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 +25 -0
- chronoseq/axis.py +333 -0
- chronoseq/py.typed +0 -0
- chronoseq/reader.py +27 -0
- chronoseq/stream.py +207 -0
- chronoseq/time.py +292 -0
- chronoseq-0.3.0.dist-info/METADATA +99 -0
- chronoseq-0.3.0.dist-info/RECORD +11 -0
- chronoseq-0.3.0.dist-info/WHEEL +5 -0
- chronoseq-0.3.0.dist-info/licenses/LICENSE +21 -0
- chronoseq-0.3.0.dist-info/top_level.txt +1 -0
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
|
+
[](https://github.com/stpoular/chronoseq/actions/workflows/ci.yml)
|
|
29
|
+
[](https://pypi.org/project/chronoseq/)
|
|
30
|
+
[](https://pypi.org/project/chronoseq/)
|
|
31
|
+
[](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,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
|