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.
- chronoseq-0.3.0/CHANGELOG.md +0 -0
- chronoseq-0.3.0/LICENSE +21 -0
- chronoseq-0.3.0/MANIFEST.in +7 -0
- chronoseq-0.3.0/PKG-INFO +99 -0
- chronoseq-0.3.0/README.md +74 -0
- chronoseq-0.3.0/chronoseq/__init__.py +25 -0
- chronoseq-0.3.0/chronoseq/axis.py +333 -0
- chronoseq-0.3.0/chronoseq/py.typed +0 -0
- chronoseq-0.3.0/chronoseq/reader.py +27 -0
- chronoseq-0.3.0/chronoseq/stream.py +207 -0
- chronoseq-0.3.0/chronoseq/time.py +292 -0
- chronoseq-0.3.0/chronoseq.egg-info/PKG-INFO +99 -0
- chronoseq-0.3.0/chronoseq.egg-info/SOURCES.txt +19 -0
- chronoseq-0.3.0/chronoseq.egg-info/dependency_links.txt +1 -0
- chronoseq-0.3.0/chronoseq.egg-info/requires.txt +6 -0
- chronoseq-0.3.0/chronoseq.egg-info/top_level.txt +1 -0
- chronoseq-0.3.0/docs/custom_readers.md +173 -0
- chronoseq-0.3.0/docs/performance.md +68 -0
- chronoseq-0.3.0/docs/semantics.md +193 -0
- chronoseq-0.3.0/pyproject.toml +51 -0
- chronoseq-0.3.0/setup.cfg +4 -0
|
File without changes
|
chronoseq-0.3.0/LICENSE
ADDED
|
@@ -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.
|
chronoseq-0.3.0/PKG-INFO
ADDED
|
@@ -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,74 @@
|
|
|
1
|
+
# ChronoSeq
|
|
2
|
+
|
|
3
|
+
[](https://github.com/stpoular/chronoseq/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/chronoseq/)
|
|
5
|
+
[](https://pypi.org/project/chronoseq/)
|
|
6
|
+
[](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]
|