pcalc.time_scale

  1# -*- coding: utf-8 -*-
  2# Copyright (c) 2025 Krishna Kumar
  3# SPDX-License-Identifier: MIT
  4
  5from __future__ import annotations
  6
  7from dataclasses import dataclass
  8from typing import Tuple
  9import numpy as np
 10import numpy.typing as npt
 11
 12@dataclass
 13class Timescale:
 14    """
 15    Timescale represents a partitioning of a continuous time interval [t0, t1)
 16    into N = ceil((t1 - t0) / bin_width) contiguous, left-aligned, non-overlapping bins.
 17
 18    Each bin has width `bin_width` and covers a half-open interval:
 19        Bin k = [t0 + k * bin_width, t0 + (k + 1) * bin_width)
 20
 21    This class provides methods for:
 22    - Mapping real-valued timestamps to discrete bin indices
 23    - Extracting the time boundaries of any bin
 24    - Computing which bins an interval [start, end) overlaps
 25    - Estimating how much of a bin is covered by a given interval
 26
 27    **Boundary Behavior**:
 28    - All bins lie strictly within [t0, t1)
 29    - Any time `t` where t < t0 or t >= t1 is considered **outside** the defined timescale
 30    - bin_index(t) may return an out-of-range index if `t < t0` or `t >= t1`
 31
 32    This class does not perform clipping: it assumes all inputs are in-range unless explicitly clipped by the caller.
 33"""
 34
 35    t0: float
 36    """start time of the interval [t0, t1)"""
 37    t1: float
 38    """end time of the interval [t0, t1)"""
 39
 40    bin_width: float
 41    """Width of each bin"""
 42
 43    @property
 44    def num_bins(self) -> int:
 45        """Return number of bins between t0 and t1."""
 46        return int(np.ceil((self.t1 - self.t0) / self.bin_width))
 47
 48    # Mapping from continuous time to discrete bins.
 49    def bin_index(self, time: float) -> int:
 50        """Purpose:
 51            Map a real-valued timestamp to the index of the bin that contains it.
 52
 53        Contract:
 54            Returns k such that bin_start(k) ≤ time < bin_end(k)
 55            Uses floor() logic — i.e., left-aligned binning
 56            No bounds check: time < t0 may yield negative indices; time ≥ t1 may return out-of-bounds indices
 57            Caller is responsible for ensuring t0 ≤ time < t1 if range enforcement is needed
 58        """
 59        return int(np.floor((time - self.t0) / self.bin_width))
 60
 61    def bin_slice(self, start: float, end: float) -> Tuple[int, int]:
 62        """Return the bin indices [start_bin, end_bin) that overlap the interval [start, end).
 63        Contract:
 64            - Clips the interval [start, end) to [t0, t1) before binning
 65            - Computes:
 66                start_bin = floor((clipped_start - t0) / bin_width)
 67                end_bin   = ceil((clipped_end   - t0) / bin_width)
 68            - Resulting slice [start_bin, end_bin) may be empty if the interval does not overlap the timescale
 69            - start_bin ∈ [0, num_bins)
 70            - end_bin   ∈ [0, num_bins]
 71
 72        """
 73        effective_start = max(start, self.t0)
 74        effective_end = min(end, self.t1)
 75        if effective_start >= effective_end:
 76            return 0, 0
 77        start_bin = int(np.floor((effective_start - self.t0) / self.bin_width))
 78        end_bin = int(np.ceil((effective_end - self.t0) / self.bin_width))
 79        return start_bin, end_bin
 80
 81    def fractional_overlap(self, start: float, end: float, bin_idx: int) -> float:
 82        """Return the fraction of the bin at index `bin_idx` that is covered by the interval [start, end).
 83        Contract:
 84            - Computes how much of bin_idx's interval is covered by [start, end)
 85            - Returns value in [0.0, 1.0] based on normalized overlap over bin width
 86            - Clipped to bin extent: overlaps outside the bin are ignored
 87            - Used to compute partial presence contribution within a bin
 88        """
 89        # Clip to timescale
 90        start = max(start, self.t0)
 91        end = min(end, self.t1)
 92
 93        bin_start = self.bin_start(bin_idx)
 94        bin_end = self.bin_end(bin_idx)
 95
 96        overlap_start = max(start, bin_start)
 97        overlap_end = min(end, bin_end)
 98
 99        return max(0.0, overlap_end - overlap_start) / self.bin_width
100
101    # Mapping from discrete bins back to continuous time.
102    def bin_start(self, bin_idx: int) -> float:
103        """Return the start time of a bin given its index.
104        Contract:
105            bin_start(k) = t0 + k * bin_width
106        """
107        return self.t0 + bin_idx * self.bin_width
108
109    def bin_end(self, bin_idx: int) -> float:
110        """Return the end time of a bin given its index.
111        Contract:
112            bin_end(k) = t0 + (k + 1) * bin_width
113        """
114        return self.t0 + (bin_idx + 1) * self.bin_width
115
116    def bin_edges(self) -> np.ndarray:
117        """
118        Return an array of bin edge times from t0 to t1, spaced by bin_width.
119
120        The result has length `num_bins + 1` and is safe against floating-point rounding
121        by capping the range to include exactly the number of bins implied by `num_bins()`.
122
123        Contract:
124        - Returns `num_bins + 1` edge values such that:
125            edges = [t0, t0 + bin_width, ..., t1]
126
127        - Guarantees:
128            bin_start(k) == edges[k]
129            bin_end(k)   == edges[k + 1]
130
131        - Handles floating-point rounding safely:
132            If np.arange overproduces due to rounding, result is trimmed
133            to ensure exactly `num_bins + 1` entries.
134
135        This method defines the canonical bin boundaries used across
136        the presence matrix and all derived metrics.
137
138        Example:
139            Timescale(t0=0.0, t1=10.0, bin_width=2.0).bin_edges()
140            => array([0.0, 2.0, 4.0, 6.0, 8.0, 10.0])
141        """
142        num_edges = self.num_bins + 1
143        edges = np.arange(self.t0, self.t0 + num_edges * self.bin_width, self.bin_width)
144
145        # Ensure exactly num_edges (for safety in rare floating-point cases)
146        if len(edges) > num_edges:
147            edges = edges[:num_edges]
148        return edges
149
150
151    def time_range(self, bin_idx: int) -> Tuple[float, float]:
152        """
153            Return the (start, end) time of a bin.
154            Contract:
155                Returns tuple (bin_start(k), bin_end(k))
156                Matches exactly the continuous interval spanned by the bin
157        """
158        return self.bin_start(bin_idx), self.bin_end(bin_idx)
@dataclass
class Timescale:
 13@dataclass
 14class Timescale:
 15    """
 16    Timescale represents a partitioning of a continuous time interval [t0, t1)
 17    into N = ceil((t1 - t0) / bin_width) contiguous, left-aligned, non-overlapping bins.
 18
 19    Each bin has width `bin_width` and covers a half-open interval:
 20        Bin k = [t0 + k * bin_width, t0 + (k + 1) * bin_width)
 21
 22    This class provides methods for:
 23    - Mapping real-valued timestamps to discrete bin indices
 24    - Extracting the time boundaries of any bin
 25    - Computing which bins an interval [start, end) overlaps
 26    - Estimating how much of a bin is covered by a given interval
 27
 28    **Boundary Behavior**:
 29    - All bins lie strictly within [t0, t1)
 30    - Any time `t` where t < t0 or t >= t1 is considered **outside** the defined timescale
 31    - bin_index(t) may return an out-of-range index if `t < t0` or `t >= t1`
 32
 33    This class does not perform clipping: it assumes all inputs are in-range unless explicitly clipped by the caller.
 34"""
 35
 36    t0: float
 37    """start time of the interval [t0, t1)"""
 38    t1: float
 39    """end time of the interval [t0, t1)"""
 40
 41    bin_width: float
 42    """Width of each bin"""
 43
 44    @property
 45    def num_bins(self) -> int:
 46        """Return number of bins between t0 and t1."""
 47        return int(np.ceil((self.t1 - self.t0) / self.bin_width))
 48
 49    # Mapping from continuous time to discrete bins.
 50    def bin_index(self, time: float) -> int:
 51        """Purpose:
 52            Map a real-valued timestamp to the index of the bin that contains it.
 53
 54        Contract:
 55            Returns k such that bin_start(k) ≤ time < bin_end(k)
 56            Uses floor() logic — i.e., left-aligned binning
 57            No bounds check: time < t0 may yield negative indices; time ≥ t1 may return out-of-bounds indices
 58            Caller is responsible for ensuring t0 ≤ time < t1 if range enforcement is needed
 59        """
 60        return int(np.floor((time - self.t0) / self.bin_width))
 61
 62    def bin_slice(self, start: float, end: float) -> Tuple[int, int]:
 63        """Return the bin indices [start_bin, end_bin) that overlap the interval [start, end).
 64        Contract:
 65            - Clips the interval [start, end) to [t0, t1) before binning
 66            - Computes:
 67                start_bin = floor((clipped_start - t0) / bin_width)
 68                end_bin   = ceil((clipped_end   - t0) / bin_width)
 69            - Resulting slice [start_bin, end_bin) may be empty if the interval does not overlap the timescale
 70            - start_bin ∈ [0, num_bins)
 71            - end_bin   ∈ [0, num_bins]
 72
 73        """
 74        effective_start = max(start, self.t0)
 75        effective_end = min(end, self.t1)
 76        if effective_start >= effective_end:
 77            return 0, 0
 78        start_bin = int(np.floor((effective_start - self.t0) / self.bin_width))
 79        end_bin = int(np.ceil((effective_end - self.t0) / self.bin_width))
 80        return start_bin, end_bin
 81
 82    def fractional_overlap(self, start: float, end: float, bin_idx: int) -> float:
 83        """Return the fraction of the bin at index `bin_idx` that is covered by the interval [start, end).
 84        Contract:
 85            - Computes how much of bin_idx's interval is covered by [start, end)
 86            - Returns value in [0.0, 1.0] based on normalized overlap over bin width
 87            - Clipped to bin extent: overlaps outside the bin are ignored
 88            - Used to compute partial presence contribution within a bin
 89        """
 90        # Clip to timescale
 91        start = max(start, self.t0)
 92        end = min(end, self.t1)
 93
 94        bin_start = self.bin_start(bin_idx)
 95        bin_end = self.bin_end(bin_idx)
 96
 97        overlap_start = max(start, bin_start)
 98        overlap_end = min(end, bin_end)
 99
100        return max(0.0, overlap_end - overlap_start) / self.bin_width
101
102    # Mapping from discrete bins back to continuous time.
103    def bin_start(self, bin_idx: int) -> float:
104        """Return the start time of a bin given its index.
105        Contract:
106            bin_start(k) = t0 + k * bin_width
107        """
108        return self.t0 + bin_idx * self.bin_width
109
110    def bin_end(self, bin_idx: int) -> float:
111        """Return the end time of a bin given its index.
112        Contract:
113            bin_end(k) = t0 + (k + 1) * bin_width
114        """
115        return self.t0 + (bin_idx + 1) * self.bin_width
116
117    def bin_edges(self) -> np.ndarray:
118        """
119        Return an array of bin edge times from t0 to t1, spaced by bin_width.
120
121        The result has length `num_bins + 1` and is safe against floating-point rounding
122        by capping the range to include exactly the number of bins implied by `num_bins()`.
123
124        Contract:
125        - Returns `num_bins + 1` edge values such that:
126            edges = [t0, t0 + bin_width, ..., t1]
127
128        - Guarantees:
129            bin_start(k) == edges[k]
130            bin_end(k)   == edges[k + 1]
131
132        - Handles floating-point rounding safely:
133            If np.arange overproduces due to rounding, result is trimmed
134            to ensure exactly `num_bins + 1` entries.
135
136        This method defines the canonical bin boundaries used across
137        the presence matrix and all derived metrics.
138
139        Example:
140            Timescale(t0=0.0, t1=10.0, bin_width=2.0).bin_edges()
141            => array([0.0, 2.0, 4.0, 6.0, 8.0, 10.0])
142        """
143        num_edges = self.num_bins + 1
144        edges = np.arange(self.t0, self.t0 + num_edges * self.bin_width, self.bin_width)
145
146        # Ensure exactly num_edges (for safety in rare floating-point cases)
147        if len(edges) > num_edges:
148            edges = edges[:num_edges]
149        return edges
150
151
152    def time_range(self, bin_idx: int) -> Tuple[float, float]:
153        """
154            Return the (start, end) time of a bin.
155            Contract:
156                Returns tuple (bin_start(k), bin_end(k))
157                Matches exactly the continuous interval spanned by the bin
158        """
159        return self.bin_start(bin_idx), self.bin_end(bin_idx)

Timescale represents a partitioning of a continuous time interval [t0, t1) into N = ceil((t1 - t0) / bin_width) contiguous, left-aligned, non-overlapping bins.

Each bin has width bin_width and covers a half-open interval: Bin k = [t0 + k * bin_width, t0 + (k + 1) * bin_width)

This class provides methods for:

  • Mapping real-valued timestamps to discrete bin indices
  • Extracting the time boundaries of any bin
  • Computing which bins an interval [start, end) overlaps
  • Estimating how much of a bin is covered by a given interval

Boundary Behavior:

  • All bins lie strictly within [t0, t1)
  • Any time t where t < t0 or t >= t1 is considered outside the defined timescale
  • bin_index(t) may return an out-of-range index if t < t0 or t >= t1

This class does not perform clipping: it assumes all inputs are in-range unless explicitly clipped by the caller.

Timescale(t0: float, t1: float, bin_width: float)
t0: float

start time of the interval [t0, t1)

t1: float

end time of the interval [t0, t1)

bin_width: float

Width of each bin

num_bins: int
44    @property
45    def num_bins(self) -> int:
46        """Return number of bins between t0 and t1."""
47        return int(np.ceil((self.t1 - self.t0) / self.bin_width))

Return number of bins between t0 and t1.

def bin_index(self, time: float) -> int:
50    def bin_index(self, time: float) -> int:
51        """Purpose:
52            Map a real-valued timestamp to the index of the bin that contains it.
53
54        Contract:
55            Returns k such that bin_start(k) ≤ time < bin_end(k)
56            Uses floor() logic — i.e., left-aligned binning
57            No bounds check: time < t0 may yield negative indices; time ≥ t1 may return out-of-bounds indices
58            Caller is responsible for ensuring t0 ≤ time < t1 if range enforcement is needed
59        """
60        return int(np.floor((time - self.t0) / self.bin_width))
Purpose:

Map a real-valued timestamp to the index of the bin that contains it.

Contract:

Returns k such that bin_start(k) ≤ time < bin_end(k) Uses floor() logic — i.e., left-aligned binning No bounds check: time < t0 may yield negative indices; time ≥ t1 may return out-of-bounds indices Caller is responsible for ensuring t0 ≤ time < t1 if range enforcement is needed

def bin_slice(self, start: float, end: float) -> Tuple[int, int]:
62    def bin_slice(self, start: float, end: float) -> Tuple[int, int]:
63        """Return the bin indices [start_bin, end_bin) that overlap the interval [start, end).
64        Contract:
65            - Clips the interval [start, end) to [t0, t1) before binning
66            - Computes:
67                start_bin = floor((clipped_start - t0) / bin_width)
68                end_bin   = ceil((clipped_end   - t0) / bin_width)
69            - Resulting slice [start_bin, end_bin) may be empty if the interval does not overlap the timescale
70            - start_bin ∈ [0, num_bins)
71            - end_bin   ∈ [0, num_bins]
72
73        """
74        effective_start = max(start, self.t0)
75        effective_end = min(end, self.t1)
76        if effective_start >= effective_end:
77            return 0, 0
78        start_bin = int(np.floor((effective_start - self.t0) / self.bin_width))
79        end_bin = int(np.ceil((effective_end - self.t0) / self.bin_width))
80        return start_bin, end_bin

Return the bin indices [start_bin, end_bin) that overlap the interval [start, end).

Contract:
  • Clips the interval [start, end) to [t0, t1) before binning
  • Computes: start_bin = floor((clipped_start - t0) / bin_width) end_bin = ceil((clipped_end - t0) / bin_width)
  • Resulting slice [start_bin, end_bin) may be empty if the interval does not overlap the timescale
  • start_bin ∈ [0, num_bins)
  • end_bin ∈ [0, num_bins]
def fractional_overlap(self, start: float, end: float, bin_idx: int) -> float:
 82    def fractional_overlap(self, start: float, end: float, bin_idx: int) -> float:
 83        """Return the fraction of the bin at index `bin_idx` that is covered by the interval [start, end).
 84        Contract:
 85            - Computes how much of bin_idx's interval is covered by [start, end)
 86            - Returns value in [0.0, 1.0] based on normalized overlap over bin width
 87            - Clipped to bin extent: overlaps outside the bin are ignored
 88            - Used to compute partial presence contribution within a bin
 89        """
 90        # Clip to timescale
 91        start = max(start, self.t0)
 92        end = min(end, self.t1)
 93
 94        bin_start = self.bin_start(bin_idx)
 95        bin_end = self.bin_end(bin_idx)
 96
 97        overlap_start = max(start, bin_start)
 98        overlap_end = min(end, bin_end)
 99
100        return max(0.0, overlap_end - overlap_start) / self.bin_width

Return the fraction of the bin at index bin_idx that is covered by the interval [start, end).

Contract:
  • Computes how much of bin_idx's interval is covered by [start, end)
  • Returns value in [0.0, 1.0] based on normalized overlap over bin width
  • Clipped to bin extent: overlaps outside the bin are ignored
  • Used to compute partial presence contribution within a bin
def bin_start(self, bin_idx: int) -> float:
103    def bin_start(self, bin_idx: int) -> float:
104        """Return the start time of a bin given its index.
105        Contract:
106            bin_start(k) = t0 + k * bin_width
107        """
108        return self.t0 + bin_idx * self.bin_width

Return the start time of a bin given its index.

Contract:

bin_start(k) = t0 + k * bin_width

def bin_end(self, bin_idx: int) -> float:
110    def bin_end(self, bin_idx: int) -> float:
111        """Return the end time of a bin given its index.
112        Contract:
113            bin_end(k) = t0 + (k + 1) * bin_width
114        """
115        return self.t0 + (bin_idx + 1) * self.bin_width

Return the end time of a bin given its index.

Contract:

bin_end(k) = t0 + (k + 1) * bin_width

def bin_edges(self) -> numpy.ndarray:
117    def bin_edges(self) -> np.ndarray:
118        """
119        Return an array of bin edge times from t0 to t1, spaced by bin_width.
120
121        The result has length `num_bins + 1` and is safe against floating-point rounding
122        by capping the range to include exactly the number of bins implied by `num_bins()`.
123
124        Contract:
125        - Returns `num_bins + 1` edge values such that:
126            edges = [t0, t0 + bin_width, ..., t1]
127
128        - Guarantees:
129            bin_start(k) == edges[k]
130            bin_end(k)   == edges[k + 1]
131
132        - Handles floating-point rounding safely:
133            If np.arange overproduces due to rounding, result is trimmed
134            to ensure exactly `num_bins + 1` entries.
135
136        This method defines the canonical bin boundaries used across
137        the presence matrix and all derived metrics.
138
139        Example:
140            Timescale(t0=0.0, t1=10.0, bin_width=2.0).bin_edges()
141            => array([0.0, 2.0, 4.0, 6.0, 8.0, 10.0])
142        """
143        num_edges = self.num_bins + 1
144        edges = np.arange(self.t0, self.t0 + num_edges * self.bin_width, self.bin_width)
145
146        # Ensure exactly num_edges (for safety in rare floating-point cases)
147        if len(edges) > num_edges:
148            edges = edges[:num_edges]
149        return edges

Return an array of bin edge times from t0 to t1, spaced by bin_width.

The result has length num_bins + 1 and is safe against floating-point rounding by capping the range to include exactly the number of bins implied by num_bins().

Contract:

  • Returns num_bins + 1 edge values such that: edges = [t0, t0 + bin_width, ..., t1]
  • Guarantees: bin_start(k) == edges[k] bin_end(k) == edges[k + 1]

  • Handles floating-point rounding safely: If np.arange overproduces due to rounding, result is trimmed to ensure exactly num_bins + 1 entries.

This method defines the canonical bin boundaries used across the presence matrix and all derived metrics.

Example:

Timescale(t0=0.0, t1=10.0, bin_width=2.0).bin_edges() => array([0.0, 2.0, 4.0, 6.0, 8.0, 10.0])

def time_range(self, bin_idx: int) -> Tuple[float, float]:
152    def time_range(self, bin_idx: int) -> Tuple[float, float]:
153        """
154            Return the (start, end) time of a bin.
155            Contract:
156                Returns tuple (bin_start(k), bin_end(k))
157                Matches exactly the continuous interval spanned by the bin
158        """
159        return self.bin_start(bin_idx), self.bin_end(bin_idx)

Return the (start, end) time of a bin.

Contract:

Returns tuple (bin_start(k), bin_end(k)) Matches exactly the continuous interval spanned by the bin