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)
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
ort >= t1
This class does not perform clipping: it assumes all inputs are in-range unless explicitly clipped by the caller.
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.
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
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]
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
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
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
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])
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