chronoseq 0.3.0__tar.gz

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.
File without changes
@@ -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,7 @@
1
+ include CHANGELOG.md
2
+ recursive-include docs *.md
3
+ prune benchmarks
4
+ prune samples
5
+ prune tests
6
+ prune .github
7
+ global-exclude __pycache__ *.py[cod] .DS_Store
@@ -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,74 @@
1
+ # ChronoSeq
2
+
3
+ [![CI](https://github.com/stpoular/chronoseq/actions/workflows/ci.yml/badge.svg)](https://github.com/stpoular/chronoseq/actions/workflows/ci.yml)
4
+ [![PyPI version](https://img.shields.io/pypi/v/chronoseq.svg)](https://pypi.org/project/chronoseq/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/chronoseq.svg)](https://pypi.org/project/chronoseq/)
6
+ [![Types](https://img.shields.io/pypi/types/chronoseq.svg)](https://pypi.org/project/chronoseq/)
7
+
8
+ 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.
9
+ Data may be stored in memory, a file, a database, a video container, an external API, or any custom backend.
10
+
11
+ ChronoSeq has no time-zone, calendar, or wall-clock semantics. Timestamps are
12
+ exact integer nanosecond offsets on a user-defined timeline.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install chronoseq
18
+ ```
19
+
20
+ ## Core model
21
+
22
+ ChronoSeq has three main concepts:
23
+
24
+ - `TimeAxis`: converts between timestamps and integer sample indices.
25
+ - `Reader`: reads values by integer sample index.
26
+ - `Stream`: combines a `TimeAxis` and `Reader` into a time-queryable stream.
27
+
28
+ The common constructors are concise:
29
+
30
+ ```python
31
+ from chronoseq import Hz, Stream, seconds
32
+
33
+ frames = Stream.regular(
34
+ start=seconds(0),
35
+ rate=Hz(30),
36
+ values=[f"frame_{i:03d}" for i in range(300)],
37
+ )
38
+
39
+ frame = frames.nearest(seconds("0.101"))
40
+
41
+ print(frame.index)
42
+ print(frame.time)
43
+ print(frame.value)
44
+ ```
45
+
46
+ For irregular sampled data:
47
+
48
+ ```python
49
+ from chronoseq import Stream, seconds
50
+
51
+ speed = Stream.irregular(
52
+ timestamps=[
53
+ seconds("0.000"),
54
+ seconds("0.047"),
55
+ seconds("0.101"),
56
+ seconds("0.153"),
57
+ ],
58
+ values=[10.0, 10.3, 10.8, 11.1],
59
+ )
60
+
61
+ sample = speed.nearest(seconds("0.100"))
62
+ ```
63
+
64
+ ## More documentation
65
+
66
+ - `docs/semantics.md`: formal timestamp, lookup, window, and reader rules.
67
+ - `docs/custom_readers.md`: custom backend and stream extension patterns.
68
+ - `docs/performance.md`: complexity notes, batching guidance, and benchmark snapshots.
69
+ - `samples/`: dependency-free usage patterns.
70
+ - `benchmarks/`: local benchmark scripts for large regular and irregular axes.
71
+
72
+ ## Licence
73
+
74
+ MIT
@@ -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
+ ]
@@ -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
File without changes
@@ -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]