chronoseq 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
chronoseq/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ from .axis import IrregularTimeAxis, RegularTimeAxis, TimeAxis
2
+ from .reader import MemoryReader, Reader
3
+ from .stream import Sample, Stream, Window
4
+ from .time import Duration, Hz, Rate, TimeLike, Timestamp, micros, millis, nanos, seconds, timestamp
5
+
6
+ __all__ = [
7
+ "Duration",
8
+ "Hz",
9
+ "IrregularTimeAxis",
10
+ "MemoryReader",
11
+ "Rate",
12
+ "Reader",
13
+ "RegularTimeAxis",
14
+ "Sample",
15
+ "Stream",
16
+ "TimeAxis",
17
+ "Timestamp",
18
+ "TimeLike",
19
+ "Window",
20
+ "micros",
21
+ "millis",
22
+ "nanos",
23
+ "seconds",
24
+ "timestamp",
25
+ ]
chronoseq/axis.py ADDED
@@ -0,0 +1,333 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+ from fractions import Fraction
7
+ from typing import cast
8
+
9
+ from .time import (
10
+ Rate,
11
+ TimeLike,
12
+ Timestamp,
13
+ as_timestamp,
14
+ floor_fraction_to_int,
15
+ round_fraction_to_int,
16
+ )
17
+
18
+
19
+ class TimeAxis(ABC):
20
+ """Converts between timestamps and integer sample indices."""
21
+
22
+ @abstractmethod
23
+ def time_at(self, index: int) -> Timestamp:
24
+ raise NotImplementedError
25
+
26
+ @abstractmethod
27
+ def nearest_index(self, time: TimeLike, *, length: int | None = None) -> int:
28
+ raise NotImplementedError
29
+
30
+ @abstractmethod
31
+ def index_at_or_before(self, time: TimeLike, *, length: int | None = None) -> int:
32
+ """Return the index of the sample at or before ``time``.
33
+
34
+ If ``time`` is earlier than the first sample, return 0. If a bounded
35
+ ``length`` is provided and the axis is empty, raise ``IndexError``.
36
+ """
37
+
38
+ raise NotImplementedError
39
+
40
+ @abstractmethod
41
+ def index_at_or_after(self, time: TimeLike, *, length: int | None = None) -> int:
42
+ """Return the index of the sample at or after ``time``.
43
+
44
+ If ``time`` is later than the last bounded sample, return the final
45
+ bounded index. If a bounded ``length`` is provided and the axis is
46
+ empty, raise ``IndexError``.
47
+ """
48
+
49
+ raise NotImplementedError
50
+
51
+ @abstractmethod
52
+ def index_range(
53
+ self,
54
+ start: TimeLike,
55
+ end: TimeLike,
56
+ *,
57
+ length: int | None = None,
58
+ ) -> range:
59
+ """Return indices for a half-open time window: [start, end)."""
60
+
61
+ raise NotImplementedError
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class RegularTimeAxis(TimeAxis):
66
+ """A time axis with samples taken at a fixed rate."""
67
+
68
+ start: TimeLike
69
+ rate: Rate
70
+
71
+ def __post_init__(self) -> None:
72
+ object.__setattr__(self, "start", as_timestamp(self.start))
73
+
74
+ def time_at(self, index: int) -> Timestamp:
75
+ if index < 0:
76
+ raise IndexError("index must be non-negative")
77
+
78
+ frac_ns = Fraction(
79
+ index * 1_000_000_000 * self.rate.denominator,
80
+ self.rate.numerator,
81
+ )
82
+ return Timestamp(self.start.ns + round_fraction_to_int(frac_ns))
83
+
84
+ def nearest_index(self, time: TimeLike, *, length: int | None = None) -> int:
85
+ time = as_timestamp(time)
86
+ if length is not None and length <= 0:
87
+ raise IndexError("empty axis")
88
+
89
+ after = self._lower_bound(time, length=length)
90
+
91
+ if after == 0:
92
+ return 0
93
+ if length is not None and after >= length:
94
+ return length - 1
95
+
96
+ before = after - 1
97
+ before_time = self.time_at(before)
98
+ after_time = self.time_at(after)
99
+
100
+ if abs(before_time.ns - time.ns) <= abs(after_time.ns - time.ns):
101
+ return before
102
+ return after
103
+
104
+ def index_at_or_before(self, time: TimeLike, *, length: int | None = None) -> int:
105
+ time = as_timestamp(time)
106
+ if length is not None and length <= 0:
107
+ raise IndexError("empty axis")
108
+
109
+ after = self._upper_bound(time, length=length)
110
+ return max(0, after - 1)
111
+
112
+ def index_at_or_after(self, time: TimeLike, *, length: int | None = None) -> int:
113
+ time = as_timestamp(time)
114
+ if length is not None and length <= 0:
115
+ raise IndexError("empty axis")
116
+
117
+ index = self._lower_bound(time, length=length)
118
+ if length is not None and index >= length:
119
+ return length - 1
120
+ return index
121
+
122
+ def index_range(
123
+ self,
124
+ start: TimeLike,
125
+ end: TimeLike,
126
+ *,
127
+ length: int | None = None,
128
+ ) -> range:
129
+ """Return indices for a half-open time window: [start, end)."""
130
+
131
+ start = as_timestamp(start)
132
+ end = as_timestamp(end)
133
+ if length is not None and length <= 0:
134
+ return range(0, 0)
135
+ if end.ns <= start.ns:
136
+ return range(0, 0)
137
+
138
+ first = self._lower_bound(start, length=length)
139
+ last_exclusive = self._lower_bound(end, length=length)
140
+
141
+ if length is not None and first >= length:
142
+ return range(0, 0)
143
+
144
+ if last_exclusive <= first:
145
+ return range(0, 0)
146
+
147
+ return range(first, last_exclusive)
148
+
149
+ def _lower_bound(self, time: Timestamp, *, length: int | None = None) -> int:
150
+ """Return the first index whose rounded timestamp is >= time.
151
+
152
+ The search is performed over ``time_at(index)`` rather than the ideal
153
+ fractional index. This matters for fractional rates and for rates above
154
+ nanosecond resolution, where several adjacent samples can round to the
155
+ same integer-nanosecond timestamp.
156
+ """
157
+
158
+ if length is not None and length <= 0:
159
+ raise IndexError("empty axis")
160
+
161
+ if time.ns <= self.start.ns:
162
+ return 0
163
+
164
+ if length is not None:
165
+ lo = 0
166
+ hi = length
167
+ else:
168
+ delta_ns = time.ns - self.start.ns
169
+ frac_index = Fraction(
170
+ delta_ns * self.rate.numerator,
171
+ 1_000_000_000 * self.rate.denominator,
172
+ )
173
+ hi = max(1, floor_fraction_to_int(frac_index) + 2)
174
+ while self.time_at(hi).ns < time.ns:
175
+ hi *= 2
176
+ lo = 0
177
+
178
+ while lo < hi:
179
+ mid = (lo + hi) // 2
180
+ if self.time_at(mid).ns < time.ns:
181
+ lo = mid + 1
182
+ else:
183
+ hi = mid
184
+
185
+ return lo
186
+
187
+ def _upper_bound(self, time: Timestamp, *, length: int | None = None) -> int:
188
+ """Return the first index whose rounded timestamp is > time."""
189
+
190
+ if length is not None and length <= 0:
191
+ raise IndexError("empty axis")
192
+
193
+ if length is not None:
194
+ lo = 0
195
+ hi = length
196
+ else:
197
+ if time.ns < self.start.ns:
198
+ return 0
199
+
200
+ delta_ns = time.ns - self.start.ns
201
+ frac_index = Fraction(
202
+ delta_ns * self.rate.numerator,
203
+ 1_000_000_000 * self.rate.denominator,
204
+ )
205
+ hi = max(1, floor_fraction_to_int(frac_index) + 2)
206
+ while self.time_at(hi).ns <= time.ns:
207
+ hi *= 2
208
+ lo = 0
209
+
210
+ while lo < hi:
211
+ mid = (lo + hi) // 2
212
+ if self.time_at(mid).ns <= time.ns:
213
+ lo = mid + 1
214
+ else:
215
+ hi = mid
216
+
217
+ return lo
218
+
219
+
220
+ @dataclass(frozen=True)
221
+ class IrregularTimeAxis(TimeAxis):
222
+ """A time axis with explicitly timestamped samples."""
223
+
224
+ timestamps: Sequence[TimeLike]
225
+
226
+ def __post_init__(self) -> None:
227
+ timestamps = tuple(as_timestamp(t) for t in self.timestamps)
228
+ for earlier, later in zip(timestamps, timestamps[1:], strict=False):
229
+ if later.ns < earlier.ns:
230
+ raise ValueError("timestamps must be sorted in ascending order")
231
+ object.__setattr__(self, "timestamps", timestamps)
232
+
233
+ @property
234
+ def _timestamp_tuple(self) -> tuple[Timestamp, ...]:
235
+ return cast(tuple[Timestamp, ...], self.timestamps)
236
+
237
+ def __len__(self) -> int:
238
+ return len(self._timestamp_tuple)
239
+
240
+ def time_at(self, index: int) -> Timestamp:
241
+ timestamps = self._timestamp_tuple
242
+ if index < 0 or index >= len(timestamps):
243
+ raise IndexError(index)
244
+ return timestamps[index]
245
+
246
+ def nearest_index(self, time: TimeLike, *, length: int | None = None) -> int:
247
+ time = as_timestamp(time)
248
+ timestamps = self._timestamp_tuple
249
+ n = len(timestamps) if length is None else min(length, len(timestamps))
250
+ if n <= 0:
251
+ raise IndexError("empty axis")
252
+
253
+ pos = _lower_bound(timestamps, time, stop=n)
254
+
255
+ if pos == 0:
256
+ return 0
257
+ if pos >= n:
258
+ return n - 1
259
+
260
+ before = pos - 1
261
+ after = pos
262
+ if abs(timestamps[before].ns - time.ns) <= abs(timestamps[after].ns - time.ns):
263
+ return before
264
+ return after
265
+
266
+ def index_at_or_before(self, time: TimeLike, *, length: int | None = None) -> int:
267
+ time = as_timestamp(time)
268
+ timestamps = self._timestamp_tuple
269
+ n = len(timestamps) if length is None else min(length, len(timestamps))
270
+ if n <= 0:
271
+ raise IndexError("empty axis")
272
+ pos = _upper_bound(timestamps, time, stop=n)
273
+ return max(0, pos - 1)
274
+
275
+ def index_at_or_after(self, time: TimeLike, *, length: int | None = None) -> int:
276
+ time = as_timestamp(time)
277
+ timestamps = self._timestamp_tuple
278
+ n = len(timestamps) if length is None else min(length, len(timestamps))
279
+ if n <= 0:
280
+ raise IndexError("empty axis")
281
+ pos = _lower_bound(timestamps, time, stop=n)
282
+ return min(pos, n - 1)
283
+
284
+ def index_range(
285
+ self,
286
+ start: TimeLike,
287
+ end: TimeLike,
288
+ *,
289
+ length: int | None = None,
290
+ ) -> range:
291
+ start = as_timestamp(start)
292
+ end = as_timestamp(end)
293
+ timestamps = self._timestamp_tuple
294
+ n = len(timestamps) if length is None else min(length, len(timestamps))
295
+ if n <= 0 or end.ns <= start.ns:
296
+ return range(0, 0)
297
+ first = _lower_bound(timestamps, start, stop=n)
298
+ last_exclusive = _lower_bound(timestamps, end, stop=n)
299
+ return range(first, last_exclusive)
300
+
301
+
302
+ def _lower_bound(
303
+ timestamps: Sequence[Timestamp],
304
+ target: Timestamp,
305
+ *,
306
+ stop: int | None = None,
307
+ ) -> int:
308
+ lo = 0
309
+ hi = len(timestamps) if stop is None else stop
310
+ while lo < hi:
311
+ mid = (lo + hi) // 2
312
+ if timestamps[mid].ns < target.ns:
313
+ lo = mid + 1
314
+ else:
315
+ hi = mid
316
+ return lo
317
+
318
+
319
+ def _upper_bound(
320
+ timestamps: Sequence[Timestamp],
321
+ target: Timestamp,
322
+ *,
323
+ stop: int | None = None,
324
+ ) -> int:
325
+ lo = 0
326
+ hi = len(timestamps) if stop is None else stop
327
+ while lo < hi:
328
+ mid = (lo + hi) // 2
329
+ if timestamps[mid].ns <= target.ns:
330
+ lo = mid + 1
331
+ else:
332
+ hi = mid
333
+ return lo
chronoseq/py.typed ADDED
File without changes
chronoseq/reader.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Generic, Protocol, TypeVar
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ class Reader(Protocol[T]):
10
+ """Reads values by integer sample index."""
11
+
12
+ def __len__(self) -> int: ...
13
+
14
+ def read(self, indices: Sequence[int]) -> list[T]: ...
15
+
16
+
17
+ class MemoryReader(Generic[T]):
18
+ """Reader backed by an in-memory sequence of values."""
19
+
20
+ def __init__(self, values: Sequence[T]):
21
+ self.values = values
22
+
23
+ def __len__(self) -> int:
24
+ return len(self.values)
25
+
26
+ def read(self, indices: Sequence[int]) -> list[T]:
27
+ return [self.values[i] for i in indices]
chronoseq/stream.py ADDED
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator, Sequence, Sized
4
+ from dataclasses import dataclass
5
+ from itertools import islice
6
+ from typing import Generic, TypeVar
7
+
8
+ from .axis import IrregularTimeAxis, RegularTimeAxis, TimeAxis
9
+ from .reader import MemoryReader, Reader
10
+ from .time import Duration, Rate, TimeLike, Timestamp, as_timestamp
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Sample(Generic[T]):
17
+ index: int
18
+ time: Timestamp
19
+ value: T
20
+
21
+
22
+ class Stream(Generic[T]):
23
+ """Combines a TimeAxis and Reader into a time-queryable stream."""
24
+
25
+ def __init__(self, axis: TimeAxis, reader: Reader[T]):
26
+ axis_length = len(axis) if isinstance(axis, Sized) else None
27
+ reader_length = len(reader)
28
+ if axis_length is not None and axis_length != reader_length:
29
+ raise ValueError(
30
+ "finite axis and reader must have the same length "
31
+ f"({axis_length} != {reader_length})"
32
+ )
33
+ self.axis = axis
34
+ self.reader = reader
35
+
36
+ @classmethod
37
+ def regular(
38
+ cls,
39
+ *,
40
+ start: TimeLike,
41
+ rate: Rate,
42
+ values: Sequence[T],
43
+ ) -> Stream[T]:
44
+ return cls(
45
+ axis=RegularTimeAxis(start=start, rate=rate),
46
+ reader=MemoryReader(values),
47
+ )
48
+
49
+ @classmethod
50
+ def irregular(
51
+ cls,
52
+ *,
53
+ timestamps: Sequence[TimeLike],
54
+ values: Sequence[T],
55
+ ) -> Stream[T]:
56
+ if len(timestamps) != len(values):
57
+ raise ValueError("timestamps and values must have the same length")
58
+ return cls(
59
+ axis=IrregularTimeAxis(timestamps),
60
+ reader=MemoryReader(values),
61
+ )
62
+
63
+ def __len__(self) -> int:
64
+ return len(self.reader)
65
+
66
+ @property
67
+ def first_time(self) -> Timestamp:
68
+ """Timestamp of the first sample.
69
+
70
+ Raises ``IndexError`` for an empty stream.
71
+ """
72
+
73
+ if len(self) == 0:
74
+ raise IndexError("empty stream")
75
+ return self.time_at(0)
76
+
77
+ @property
78
+ def last_time(self) -> Timestamp:
79
+ """Timestamp of the last sample.
80
+
81
+ This is the final sample's timestamp, not an exclusive stop time.
82
+ Raises ``IndexError`` for an empty stream.
83
+ """
84
+
85
+ if len(self) == 0:
86
+ raise IndexError("empty stream")
87
+ return self.time_at(len(self) - 1)
88
+
89
+ @property
90
+ def duration(self) -> Duration:
91
+ """Duration between the first and last sample timestamps."""
92
+
93
+ if len(self) <= 1:
94
+ return Duration(0)
95
+ return self.last_time - self.first_time
96
+
97
+ def time_at(self, index: int) -> Timestamp:
98
+ self._check_index(index)
99
+ return self.axis.time_at(index)
100
+
101
+ def read(self, indices: Sequence[int]) -> list[T]:
102
+ """Read values for indices, preserving order.
103
+
104
+ Custom readers must return exactly one value per requested index. A
105
+ mismatch is treated as a reader contract violation and raises
106
+ ``ValueError`` rather than silently dropping or inventing samples.
107
+ """
108
+
109
+ index_list = list(indices)
110
+ for index in index_list:
111
+ self._check_index(index)
112
+ values = self.reader.read(index_list)
113
+ if len(values) != len(index_list):
114
+ raise ValueError(
115
+ "reader returned "
116
+ f"{len(values)} value(s) for {len(index_list)} requested index/indices"
117
+ )
118
+ return values
119
+
120
+ def read_one(self, index: int) -> T:
121
+ return self.read([index])[0]
122
+
123
+ def sample_at(self, index: int) -> Sample[T]:
124
+ return Sample(index=index, time=self.time_at(index), value=self.read_one(index))
125
+
126
+ def samples_at(self, indices: Sequence[int]) -> list[Sample[T]]:
127
+ index_list = list(indices)
128
+ values = self.read(index_list)
129
+ return [
130
+ Sample(index=index, time=self.axis.time_at(index), value=value)
131
+ for index, value in zip(index_list, values, strict=True)
132
+ ]
133
+
134
+ def nearest_index(self, time: TimeLike) -> int:
135
+ return self.axis.nearest_index(time, length=len(self))
136
+
137
+ def nearest(self, time: TimeLike) -> Sample[T]:
138
+ return self.sample_at(self.nearest_index(time))
139
+
140
+ def at_or_before_index(self, time: TimeLike) -> int:
141
+ return self.axis.index_at_or_before(time, length=len(self))
142
+
143
+ def at_or_before(self, time: TimeLike) -> Sample[T]:
144
+ return self.sample_at(self.at_or_before_index(time))
145
+
146
+ def at_or_after_index(self, time: TimeLike) -> int:
147
+ return self.axis.index_at_or_after(time, length=len(self))
148
+
149
+ def at_or_after(self, time: TimeLike) -> Sample[T]:
150
+ return self.sample_at(self.at_or_after_index(time))
151
+
152
+ def window(self, start: TimeLike, end: TimeLike) -> Window[T]:
153
+ indices = self.axis.index_range(as_timestamp(start), as_timestamp(end), length=len(self))
154
+ return Window(stream=self, indices=indices)
155
+
156
+ def window_before(self, end: TimeLike, duration: Duration) -> Window[T]:
157
+ end_ts = as_timestamp(end)
158
+ return self.window(start=end_ts - duration, end=end_ts)
159
+
160
+ def _check_index(self, index: int) -> None:
161
+ if index < 0 or index >= len(self):
162
+ raise IndexError(index)
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class Window(Generic[T]):
167
+ """Lazy view over a stream and a set of indices."""
168
+
169
+ stream: Stream[T]
170
+ indices: range | Sequence[int]
171
+
172
+ def __len__(self) -> int:
173
+ return len(self.indices)
174
+
175
+ def values(self) -> list[T]:
176
+ return self.stream.read(self.indices)
177
+
178
+ def samples(self) -> list[Sample[T]]:
179
+ return self.stream.samples_at(self.indices)
180
+
181
+ def batches(self, size: int) -> Iterator[list[Sample[T]]]:
182
+ """Yield samples from this window in bounded batches.
183
+
184
+ ``size`` must be a positive integer. Each yielded batch preserves index
185
+ order and contains at most ``size`` samples. The final batch may be
186
+ shorter. Empty windows yield no batches. This method consumes the
187
+ window indices incrementally and does not materialise the full index set
188
+ before yielding the first batch.
189
+ """
190
+
191
+ if isinstance(size, bool) or not isinstance(size, int):
192
+ raise TypeError("batch size must be an integer")
193
+ if size <= 0:
194
+ raise ValueError("batch size must be positive")
195
+
196
+ iterator = iter(self.indices)
197
+ while True:
198
+ batch_indices = list(islice(iterator, size))
199
+ if not batch_indices:
200
+ return
201
+ yield self.stream.samples_at(batch_indices)
202
+
203
+ def __iter__(self) -> Iterator[Sample[T]]:
204
+ """Yield samples one at a time without materialising the whole window."""
205
+
206
+ for index in self.indices:
207
+ yield self.stream.sample_at(index)
chronoseq/time.py ADDED
@@ -0,0 +1,292 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from decimal import Decimal, InvalidOperation
5
+ from fractions import Fraction
6
+ from functools import total_ordering
7
+ from typing import overload
8
+
9
+ NumberLike = int | float | str | Decimal
10
+
11
+ _NS_PER_SECOND = Decimal("1000000000")
12
+ _NS_PER_MILLISECOND = Decimal("1000000")
13
+ _NS_PER_MICROSECOND = Decimal("1000")
14
+
15
+
16
+ @dataclass(frozen=True, order=True)
17
+ class Duration:
18
+ """An exact duration represented as integer nanoseconds."""
19
+
20
+ ns: int
21
+
22
+ def __post_init__(self) -> None:
23
+ if isinstance(self.ns, bool) or not isinstance(self.ns, int):
24
+ raise TypeError("Duration nanoseconds must be an integer")
25
+
26
+ def __add__(self, other: Duration) -> Duration:
27
+ """Add two durations."""
28
+
29
+ if not isinstance(other, Duration):
30
+ raise TypeError("Duration can only be added to another Duration")
31
+ return Duration(self.ns + other.ns)
32
+
33
+ def __sub__(self, other: Duration) -> Duration:
34
+ """Subtract one duration from another."""
35
+
36
+ if not isinstance(other, Duration):
37
+ raise TypeError("Duration can only subtract another Duration")
38
+ return Duration(self.ns - other.ns)
39
+
40
+ def __neg__(self) -> Duration:
41
+ """Return the negated duration."""
42
+
43
+ return Duration(-self.ns)
44
+
45
+ def __mul__(self, factor: int) -> Duration:
46
+ """Multiply this duration by an integer factor."""
47
+
48
+ if isinstance(factor, bool) or not isinstance(factor, int):
49
+ raise TypeError("Duration can only be multiplied by an integer")
50
+ return Duration(self.ns * factor)
51
+
52
+ def __rmul__(self, factor: int) -> Duration:
53
+ """Multiply this duration by an integer factor."""
54
+
55
+ return self * factor
56
+
57
+ def __floordiv__(self, divisor: int) -> Duration:
58
+ """Divide this duration by an integer, rounding down to nanoseconds."""
59
+
60
+ if isinstance(divisor, bool) or not isinstance(divisor, int):
61
+ raise TypeError("Duration can only be floor-divided by an integer")
62
+ return Duration(self.ns // divisor)
63
+
64
+ def to_seconds(self) -> Decimal:
65
+ """Return this duration as Decimal seconds."""
66
+
67
+ return Decimal(self.ns) / _NS_PER_SECOND
68
+
69
+
70
+ @dataclass(frozen=True, order=True)
71
+ class Timestamp:
72
+ """An exact timestamp represented as integer nanoseconds."""
73
+
74
+ ns: int
75
+
76
+ def __post_init__(self) -> None:
77
+ if isinstance(self.ns, bool) or not isinstance(self.ns, int):
78
+ raise TypeError("Timestamp nanoseconds must be an integer")
79
+
80
+ def __add__(self, duration: Duration) -> Timestamp:
81
+ """Offset this timestamp by a duration."""
82
+
83
+ if not isinstance(duration, Duration):
84
+ raise TypeError("Timestamp can only be offset by a Duration")
85
+ return Timestamp(self.ns + duration.ns)
86
+
87
+ @overload
88
+ def __sub__(self, other: Duration) -> Timestamp: ...
89
+
90
+ @overload
91
+ def __sub__(self, other: Timestamp) -> Duration: ...
92
+
93
+ def __sub__(self, other: Duration | Timestamp) -> Duration | Timestamp:
94
+ """Subtract a duration or another timestamp.
95
+
96
+ ``timestamp - duration`` returns a ``Timestamp``.
97
+ ``timestamp - timestamp`` returns a ``Duration``.
98
+ """
99
+
100
+ if isinstance(other, Timestamp):
101
+ return Duration(self.ns - other.ns)
102
+ if isinstance(other, Duration):
103
+ return Timestamp(self.ns - other.ns)
104
+ raise TypeError("Timestamp can only subtract a Timestamp or Duration")
105
+
106
+ def to_seconds(self) -> Decimal:
107
+ """Return this timestamp as Decimal seconds."""
108
+
109
+ return Decimal(self.ns) / _NS_PER_SECOND
110
+
111
+
112
+ TimeLike = Duration | Timestamp
113
+
114
+
115
+ def _to_decimal(value: NumberLike) -> Decimal:
116
+ if isinstance(value, bool):
117
+ raise TypeError("boolean values are not valid time values")
118
+ try:
119
+ result = Decimal(str(value))
120
+ except (InvalidOperation, ValueError) as exc:
121
+ raise ValueError(f"time value is not finite: {value!r}") from exc
122
+ if not result.is_finite():
123
+ raise ValueError(f"time value is not finite: {value!r}")
124
+ return result
125
+
126
+
127
+ def _exact_scaled_int(value: NumberLike, scale: Decimal, unit: str) -> int:
128
+ scaled = _to_decimal(value) * scale
129
+ if scaled != scaled.to_integral_value():
130
+ raise ValueError(f"{unit} value is not exactly representable in nanoseconds: {value!r}")
131
+ return int(scaled)
132
+
133
+
134
+ def nanos(value: NumberLike) -> Duration:
135
+ """Create a duration in nanoseconds.
136
+
137
+ Values must be integral nanoseconds. Fractional nanosecond inputs raise
138
+ ``ValueError`` rather than being silently truncated.
139
+ """
140
+
141
+ return Duration(_exact_scaled_int(value, Decimal(1), "nanoseconds"))
142
+
143
+
144
+ def micros(value: NumberLike) -> Duration:
145
+ """Create a duration in microseconds.
146
+
147
+ Values must be exactly representable as integer nanoseconds.
148
+ """
149
+
150
+ return Duration(_exact_scaled_int(value, _NS_PER_MICROSECOND, "microseconds"))
151
+
152
+
153
+ def millis(value: NumberLike) -> Duration:
154
+ """Create a duration in milliseconds.
155
+
156
+ Values must be exactly representable as integer nanoseconds.
157
+ """
158
+
159
+ return Duration(_exact_scaled_int(value, _NS_PER_MILLISECOND, "milliseconds"))
160
+
161
+
162
+ def seconds(value: NumberLike) -> Duration:
163
+ """Create a duration in seconds.
164
+
165
+ Values must be exactly representable as integer nanoseconds.
166
+ """
167
+
168
+ return Duration(_exact_scaled_int(value, _NS_PER_SECOND, "seconds"))
169
+
170
+
171
+ def timestamp(value: NumberLike, *, unit: str = "s") -> Timestamp:
172
+ """Create a timestamp.
173
+
174
+ The default unit is seconds. Supported units are "s", "ms", "us", and "ns".
175
+ Values must be exactly representable as integer nanoseconds.
176
+ """
177
+
178
+ if unit == "s":
179
+ return Timestamp(seconds(value).ns)
180
+ if unit == "ms":
181
+ return Timestamp(millis(value).ns)
182
+ if unit == "us":
183
+ return Timestamp(micros(value).ns)
184
+ if unit == "ns":
185
+ return Timestamp(nanos(value).ns)
186
+ raise ValueError(f"unsupported timestamp unit: {unit!r}")
187
+
188
+
189
+ def as_timestamp(value: TimeLike) -> Timestamp:
190
+ """Interpret a Duration as a timestamp relative to zero."""
191
+
192
+ if isinstance(value, Timestamp):
193
+ return value
194
+ if isinstance(value, Duration):
195
+ return Timestamp(value.ns)
196
+ raise TypeError("expected a Timestamp or Duration")
197
+
198
+
199
+ def as_duration(value: TimeLike) -> Duration:
200
+ """Interpret a Timestamp's raw nanoseconds as a duration."""
201
+
202
+ if isinstance(value, Duration):
203
+ return value
204
+ if isinstance(value, Timestamp):
205
+ return Duration(value.ns)
206
+ raise TypeError("expected a Timestamp or Duration")
207
+
208
+
209
+ @total_ordering
210
+ class Rate:
211
+ """Samples per second, stored exactly as a reduced ``Fraction``."""
212
+
213
+ __slots__ = ("_fraction",)
214
+
215
+ _fraction: Fraction
216
+
217
+ def __init__(self, numerator: int | Fraction, denominator: int = 1):
218
+ if isinstance(numerator, bool) or isinstance(denominator, bool):
219
+ raise TypeError("Rate values must be integers or Fractions, not booleans")
220
+ if denominator == 0:
221
+ raise ValueError("Rate denominator must be non-zero")
222
+ value = Fraction(numerator, denominator)
223
+ if value <= 0:
224
+ raise ValueError("Rate must be positive")
225
+ object.__setattr__(self, "_fraction", value)
226
+
227
+ def __setattr__(self, name: str, value: object) -> None:
228
+ if hasattr(self, "_fraction"):
229
+ raise AttributeError("Rate is immutable")
230
+ object.__setattr__(self, name, value)
231
+
232
+ @property
233
+ def fraction(self) -> Fraction:
234
+ """Return this rate as a reduced ``fractions.Fraction``."""
235
+
236
+ return self._fraction
237
+
238
+ @property
239
+ def numerator(self) -> int:
240
+ """Numerator of the reduced samples-per-second fraction."""
241
+
242
+ return self._fraction.numerator
243
+
244
+ @property
245
+ def denominator(self) -> int:
246
+ """Denominator of the reduced samples-per-second fraction."""
247
+
248
+ return self._fraction.denominator
249
+
250
+ def __eq__(self, other: object) -> bool:
251
+ if not isinstance(other, Rate):
252
+ return NotImplemented
253
+ return self.fraction == other.fraction
254
+
255
+ def __lt__(self, other: object) -> bool:
256
+ if not isinstance(other, Rate):
257
+ return NotImplemented
258
+ return self.fraction < other.fraction
259
+
260
+ def __hash__(self) -> int:
261
+ return hash(self.fraction)
262
+
263
+ def __repr__(self) -> str:
264
+ if self.denominator == 1:
265
+ return f"Rate({self.numerator})"
266
+ return f"Rate({self.numerator}, {self.denominator})"
267
+
268
+
269
+ def Hz(numerator: int | Fraction, denominator: int = 1) -> Rate:
270
+ """Create an exact sampling rate in Hz."""
271
+
272
+ return Rate(numerator, denominator)
273
+
274
+
275
+ def round_fraction_to_int(value: Fraction) -> int:
276
+ """Round a Fraction to the nearest integer, with halves away from zero."""
277
+
278
+ if value >= 0:
279
+ return int(value + Fraction(1, 2))
280
+ return int(value - Fraction(1, 2))
281
+
282
+
283
+ def floor_fraction_to_int(value: Fraction) -> int:
284
+ """Return floor(value) for a Fraction."""
285
+
286
+ return value.numerator // value.denominator
287
+
288
+
289
+ def ceil_fraction_to_int(value: Fraction) -> int:
290
+ """Return ceil(value) for a Fraction."""
291
+
292
+ return -((-value.numerator) // value.denominator)
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: chronoseq
3
+ Version: 0.3.0
4
+ Summary: Exact time axes and time-indexed streams for Python
5
+ Author: ChronoSeq contributors
6
+ License-Expression: MIT
7
+ Keywords: time,streams,sampling,timestamps,time-series
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Provides-Extra: dev
20
+ Requires-Dist: hypothesis>=6; extra == "dev"
21
+ Requires-Dist: mypy>=1.8; extra == "dev"
22
+ Requires-Dist: pytest>=8; extra == "dev"
23
+ Requires-Dist: ruff>=0.5; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # ChronoSeq
27
+
28
+ [![CI](https://github.com/stpoular/chronoseq/actions/workflows/ci.yml/badge.svg)](https://github.com/stpoular/chronoseq/actions/workflows/ci.yml)
29
+ [![PyPI version](https://img.shields.io/pypi/v/chronoseq.svg)](https://pypi.org/project/chronoseq/)
30
+ [![Python versions](https://img.shields.io/pypi/pyversions/chronoseq.svg)](https://pypi.org/project/chronoseq/)
31
+ [![Types](https://img.shields.io/pypi/types/chronoseq.svg)](https://pypi.org/project/chronoseq/)
32
+
33
+ ChronoSeq is a Python library for time-indexed streams of sampled data. It provides exact integer-nanosecond timestamps, rational sampling rates, regular and irregular time axes, nearest-sample lookup, and half-open time-window slicing.
34
+ Data may be stored in memory, a file, a database, a video container, an external API, or any custom backend.
35
+
36
+ ChronoSeq has no time-zone, calendar, or wall-clock semantics. Timestamps are
37
+ exact integer nanosecond offsets on a user-defined timeline.
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install chronoseq
43
+ ```
44
+
45
+ ## Core model
46
+
47
+ ChronoSeq has three main concepts:
48
+
49
+ - `TimeAxis`: converts between timestamps and integer sample indices.
50
+ - `Reader`: reads values by integer sample index.
51
+ - `Stream`: combines a `TimeAxis` and `Reader` into a time-queryable stream.
52
+
53
+ The common constructors are concise:
54
+
55
+ ```python
56
+ from chronoseq import Hz, Stream, seconds
57
+
58
+ frames = Stream.regular(
59
+ start=seconds(0),
60
+ rate=Hz(30),
61
+ values=[f"frame_{i:03d}" for i in range(300)],
62
+ )
63
+
64
+ frame = frames.nearest(seconds("0.101"))
65
+
66
+ print(frame.index)
67
+ print(frame.time)
68
+ print(frame.value)
69
+ ```
70
+
71
+ For irregular sampled data:
72
+
73
+ ```python
74
+ from chronoseq import Stream, seconds
75
+
76
+ speed = Stream.irregular(
77
+ timestamps=[
78
+ seconds("0.000"),
79
+ seconds("0.047"),
80
+ seconds("0.101"),
81
+ seconds("0.153"),
82
+ ],
83
+ values=[10.0, 10.3, 10.8, 11.1],
84
+ )
85
+
86
+ sample = speed.nearest(seconds("0.100"))
87
+ ```
88
+
89
+ ## More documentation
90
+
91
+ - `docs/semantics.md`: formal timestamp, lookup, window, and reader rules.
92
+ - `docs/custom_readers.md`: custom backend and stream extension patterns.
93
+ - `docs/performance.md`: complexity notes, batching guidance, and benchmark snapshots.
94
+ - `samples/`: dependency-free usage patterns.
95
+ - `benchmarks/`: local benchmark scripts for large regular and irregular axes.
96
+
97
+ ## Licence
98
+
99
+ MIT
@@ -0,0 +1,11 @@
1
+ chronoseq/__init__.py,sha256=G9klVhhYuH5BeBZcfT9RMfT3lm4nWkz77smP2p7nMZ8,547
2
+ chronoseq/axis.py,sha256=eyzea0sa9rR-wM5o-kmFLfrJK1hnUEzIEVd1u8Px3x0,10150
3
+ chronoseq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ chronoseq/reader.py,sha256=WXXbXRvgLtyF0bkJsOKtoRJuhq8R7eeyJabO849Z6Jg,646
5
+ chronoseq/stream.py,sha256=GPqkFskJ_1wjPIdKXRWCJYMGtc23oMKF99_szXCql1E,6775
6
+ chronoseq/time.py,sha256=WYQmODOGuCvw-XfShI4kBptIy3ou8bY51p9T2m5WzEM,9156
7
+ chronoseq-0.3.0.dist-info/licenses/LICENSE,sha256=mRU2blkYDi7tJto3gWY9raxHr29RqlvxGTdLCilHkUI,1076
8
+ chronoseq-0.3.0.dist-info/METADATA,sha256=sF3u0hHVenbl-FyQd14pgELn9sFuHelViobL9H6ofTA,3177
9
+ chronoseq-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ chronoseq-0.3.0.dist-info/top_level.txt,sha256=PaSbwZzg2XtlXrTO6voHULHQrQEghE2k5icbxSRb6O0,10
11
+ chronoseq-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stergios Poularakis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ chronoseq