Skip to content

Commit f0a8889

Browse files
authored
Add TimeDeltaArray to bintime (#160)
* Implement plain old list slice assignment - replace and remove when assigning fewer values than the slice - replace and insert when assigning more values than the slice - replace directly when assigning same number of values as the slice * Add CVI representation helpers to TimeValueTuple * Fix docstring typo * Remove antipattern from benchmark test * Update README and .gitignore to support comparing benchmarks Signed-off-by: Joe Friedrichsen <[email protected]>
1 parent db62a56 commit f0a8889

File tree

11 files changed

+1213
-36
lines changed

11 files changed

+1213
-36
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ __pycache__/
44
# Unit tests
55
.tox/
66
test_results/
7+
.benchmarks/
78

89
# Coverage output
910
.coverage
@@ -18,4 +19,4 @@ dist/
1819
docs/_build/
1920

2021
# Common editor metadata
21-
.vscode/
22+
.vscode/

CONTRIBUTING.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ poetry run bandit -c pyproject.toml -r src/nitypes
7777
poetry run pytest -v
7878
7979
# Run the benchmarks
80+
# Compare benchmark before/after a change
81+
# see https://pytest-benchmark.readthedocs.io/en/latest/comparing.html
82+
# Run 1: --benchmark-save=some-name
83+
# Run N: --benchmark-compare=0001
8084
poetry run pytest -v tests/benchmark
8185
8286
# Build and inspect the documentation

src/nitypes/bintime/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
2525
* :class:`DateTime`: represents an NI-BTF absolute time as a Python object.
2626
* :class:`TimeDelta`: represents a NI-BTF time interval as a Python object.
27+
* :class:`TimeDeltaArray`: an array of :class:`TimeDelta` values.
2728
2829
NI-BTF NumPy Structured Data Types
2930
==================================
@@ -74,6 +75,7 @@
7475
)
7576
from nitypes.bintime._time_value_tuple import TimeValueTuple
7677
from nitypes.bintime._timedelta import TimeDelta
78+
from nitypes.bintime._timedelta_array import TimeDeltaArray
7779

7880
__all__ = [
7981
"DateTime",
@@ -82,10 +84,12 @@
8284
"CVITimeIntervalBase",
8385
"CVITimeIntervalDType",
8486
"TimeDelta",
87+
"TimeDeltaArray",
8588
"TimeValueTuple",
8689
]
8790

8891
# Hide that it was defined in a helper file
8992
DateTime.__module__ = __name__
9093
TimeDelta.__module__ = __name__
94+
TimeDeltaArray.__module__ = __name__
9195
TimeValueTuple.__module__ = __name__

src/nitypes/bintime/_dtypes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
"""Type alias for the base type of :any:`CVITimeIntervalDType`, which is :any:`numpy.void`."""
1414

1515
CVITimeIntervalDType = np.dtype((CVITimeIntervalBase, [("lsb", np.uint64), ("msb", np.int64)]))
16-
"""NumPy structured data type for a ``CVIAbsoluteTime`` C struct."""
16+
"""NumPy structured data type for a ``CVITimeInterval`` C struct."""

src/nitypes/bintime/_time_value_tuple.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,12 @@ class TimeValueTuple(NamedTuple):
1111

1212
fractional_seconds: int
1313
"""The fractional seconds portion of a binary time value. This should be a uint64."""
14+
15+
@staticmethod
16+
def from_cvi(lsb: int, msb: int) -> TimeValueTuple:
17+
"""Create a :class:`TimeValueTuple` from a ``CVIAbsoluteTime`` representation."""
18+
return TimeValueTuple(whole_seconds=msb, fractional_seconds=lsb)
19+
20+
def to_cvi(self) -> tuple[int, int]:
21+
"""Return a representation as ``CVIAbsoluteTime``."""
22+
return (self.fractional_seconds, self.whole_seconds)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Collection, Iterable, MutableSequence
4+
from typing import (
5+
TYPE_CHECKING,
6+
Any,
7+
final,
8+
overload,
9+
)
10+
11+
import numpy as np
12+
import numpy.typing as npt
13+
14+
from nitypes._exceptions import invalid_arg_value, invalid_arg_type
15+
16+
if TYPE_CHECKING:
17+
# Import from the public package so the docs don't reference private submodules.
18+
from nitypes.bintime import CVITimeIntervalDType, TimeDelta, TimeValueTuple
19+
else:
20+
from nitypes.bintime._dtypes import CVITimeIntervalDType
21+
from nitypes.bintime._timedelta import TimeDelta
22+
from nitypes.bintime._time_value_tuple import TimeValueTuple
23+
24+
25+
@final
26+
class TimeDeltaArray(MutableSequence[TimeDelta]):
27+
"""A mutable array of :class:`TimeDelta` values in NI Binary Time Format (NI-BTF).
28+
29+
Raises:
30+
TypeError: If any item in value is not a TimeDelta instance.
31+
"""
32+
33+
__slots__ = ["_array"]
34+
35+
_array: npt.NDArray[np.void]
36+
37+
def __init__(
38+
self,
39+
value: Collection[TimeDelta] | None = None,
40+
) -> None:
41+
"""Initialize a new TimeDeltaArray."""
42+
if value is None:
43+
value = []
44+
if not all(isinstance(item, TimeDelta) for item in value):
45+
raise invalid_arg_type("value", "iterable of TimeDelta", value)
46+
self._array = np.fromiter(
47+
(entry.to_tuple().to_cvi() for entry in value),
48+
dtype=CVITimeIntervalDType,
49+
count=len(value),
50+
)
51+
52+
@overload
53+
def __getitem__( # noqa: D105 - missing docstring in magic method
54+
self, index: int
55+
) -> TimeDelta: ...
56+
57+
@overload
58+
def __getitem__( # noqa: D105 - missing docstring in magic method
59+
self, index: slice
60+
) -> TimeDeltaArray: ...
61+
62+
def __getitem__(self, index: int | slice) -> TimeDelta | TimeDeltaArray:
63+
"""Return self[index].
64+
65+
Raises:
66+
TypeError: If index is an invalid type.
67+
IndexError: If index is out of range.
68+
"""
69+
if isinstance(index, int):
70+
entry = self._array[index].item()
71+
as_tuple = TimeValueTuple.from_cvi(*entry)
72+
return TimeDelta.from_tuple(as_tuple)
73+
elif isinstance(index, slice):
74+
sliced_entries = self._array[index]
75+
new_array = TimeDeltaArray()
76+
new_array._array = sliced_entries
77+
return new_array
78+
else:
79+
raise invalid_arg_type("index", "int or slice", index)
80+
81+
def __len__(self) -> int:
82+
"""Return len(self)."""
83+
return len(self._array)
84+
85+
@overload
86+
def __setitem__( # noqa: D105 - missing docstring in magic method
87+
self, index: int, value: TimeDelta
88+
) -> None: ...
89+
90+
@overload
91+
def __setitem__( # noqa: D105 - missing docstring in magic method
92+
self, index: slice, value: Iterable[TimeDelta]
93+
) -> None: ...
94+
95+
def __setitem__(self, index: int | slice, value: TimeDelta | Iterable[TimeDelta]) -> None:
96+
"""Set a new value for TimeDelta at the specified location or slice.
97+
98+
Raises:
99+
TypeError: If index is an invalid type, or slice value is not iterable.
100+
ValueError: If slice assignment length doesn't match the selected range.
101+
IndexError: If index is out of range.
102+
"""
103+
if isinstance(index, int):
104+
if not isinstance(value, TimeDelta):
105+
raise invalid_arg_type("value", "TimeDelta", value)
106+
self._array[index] = value.to_tuple().to_cvi()
107+
elif isinstance(index, slice):
108+
if not isinstance(value, Iterable):
109+
raise invalid_arg_type("value", "iterable of TimeDelta", value)
110+
if not all(isinstance(item, TimeDelta) for item in value):
111+
raise invalid_arg_type("value", "iterable of TimeDelta", value)
112+
113+
start, stop, step = index.indices(len(self))
114+
selected_count = len(range(start, stop, step))
115+
values = list(value)
116+
new_entry_count = len(values)
117+
if step > 1 and new_entry_count != selected_count:
118+
raise invalid_arg_value(
119+
"value", "iterable with the same length as the slice", value
120+
)
121+
122+
if new_entry_count < selected_count:
123+
# Shrink
124+
replaced = slice(start, start + new_entry_count)
125+
removed = slice(start + new_entry_count, stop)
126+
self._array[replaced] = [item.to_tuple().to_cvi() for item in values]
127+
del self[removed]
128+
elif new_entry_count > selected_count:
129+
# Grow
130+
replaced = slice(start, stop)
131+
self._array[replaced] = [
132+
item.to_tuple().to_cvi() for item in values[:selected_count]
133+
]
134+
self._array = np.insert(
135+
self._array,
136+
stop,
137+
[item.to_tuple().to_cvi() for item in values[selected_count:]],
138+
)
139+
else:
140+
# Replace, accounting for strides
141+
self._array[index] = [item.to_tuple().to_cvi() for item in values]
142+
else:
143+
raise invalid_arg_type("index", "int or slice", index)
144+
145+
@overload
146+
def __delitem__(self, index: int) -> None: ... # noqa: D105 - missing docstring in magic method
147+
148+
@overload
149+
def __delitem__( # noqa: D105 - missing docstring in magic method
150+
self, index: slice
151+
) -> None: ...
152+
153+
def __delitem__(self, index: int | slice) -> None:
154+
"""Delete the value at the specified location or slice.
155+
156+
Raises:
157+
TypeError: If index is an invalid type.
158+
IndexError: If index is out of range.
159+
"""
160+
if isinstance(index, (int, slice)):
161+
self._array = np.delete(self._array, index)
162+
else:
163+
raise invalid_arg_type("index", "int or slice", index)
164+
165+
def insert(self, index: int, value: TimeDelta) -> None:
166+
"""Insert the TimeDelta value before the specified index.
167+
168+
Raises:
169+
TypeError: If index is not int or value is not TimeDelta.
170+
"""
171+
if not isinstance(index, int):
172+
raise invalid_arg_type("index", "int", index)
173+
if not isinstance(value, TimeDelta):
174+
raise invalid_arg_type("value", "TimeDelta", value)
175+
lower = -len(self._array)
176+
upper = len(self._array)
177+
index = min(max(index, lower), upper)
178+
as_cvi = value.to_tuple().to_cvi()
179+
self._array = np.insert(self._array, index, as_cvi)
180+
181+
def __eq__(self, other: object) -> bool:
182+
"""Return self == other."""
183+
if not isinstance(other, TimeDeltaArray):
184+
return NotImplemented
185+
return np.array_equal(self._array, other._array)
186+
187+
def __reduce__(self) -> tuple[Any, ...]:
188+
"""Return object state for pickling."""
189+
return (self.__class__, (list(iter(self)),))
190+
191+
def __repr__(self) -> str:
192+
"""Return repr(self)."""
193+
ctor_args = list(iter(self))
194+
return f"{self.__class__.__module__}.{self.__class__.__name__}({ctor_args})"
195+
196+
def __str__(self) -> str:
197+
"""Return str(self)."""
198+
values = list(iter(self))
199+
return f"[{'; '.join(str(v) for v in values)}]"

tests/benchmark/bintime/test_timedelta.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99

1010
import nitypes.bintime as bt
1111

12-
pytestmark = pytest.mark.benchmark
13-
1412

1513
@pytest.mark.benchmark(group="timedelta_construct")
1614
def test___bt_timedelta___construct(
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from __future__ import annotations
2+
3+
import numpy as np
4+
import pytest
5+
from pytest_benchmark.fixture import BenchmarkFixture
6+
7+
import nitypes.bintime as bt
8+
9+
10+
LIST_10: list[bt.TimeDelta] = [bt.TimeDelta(float(value)) for value in np.arange(-10, 10, 0.3)]
11+
LIST_100: list[bt.TimeDelta] = [bt.TimeDelta(float(value)) for value in np.arange(-100, 100, 0.3)]
12+
LIST_1000: list[bt.TimeDelta] = [
13+
bt.TimeDelta(float(value)) for value in np.arange(-1000, 1000, 0.3)
14+
]
15+
LIST_10000: list[bt.TimeDelta] = [
16+
bt.TimeDelta(float(value)) for value in np.arange(-10000, 10000, 0.3)
17+
]
18+
19+
20+
@pytest.mark.benchmark(group="timedelta_array_construct", min_rounds=100)
21+
@pytest.mark.parametrize("constructor_list", (LIST_10, LIST_100, LIST_1000, LIST_10000))
22+
def test___bt_timedelta_array___construct(
23+
benchmark: BenchmarkFixture,
24+
constructor_list: list[bt.TimeDelta],
25+
) -> None:
26+
benchmark(bt.TimeDeltaArray, constructor_list)

tests/unit/bintime/test_datetime.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
from nitypes.bintime._timedelta import _BITS_PER_SECOND, _FRACTIONAL_SECONDS_MASK
1515

1616

17-
#############
17+
###############################################################################
1818
# Constructor
19-
#############
19+
###############################################################################
2020
def test___no_args___construct___returns_epoch() -> None:
2121
value = DateTime()
2222

@@ -124,9 +124,9 @@ def test___local_unit_args___construct___raises_value_error() -> None:
124124
assert exc.value.args[0].startswith("The tzinfo must be datetime.timezone.utc.")
125125

126126

127-
############
127+
###############################################################################
128128
# from_ticks
129-
############
129+
###############################################################################
130130
def test___int_ticks___from_ticks___returns_time_value() -> None:
131131
value = DateTime.from_ticks(0x12345678_90ABCDEF_FEDCBA09_87654321)
132132

@@ -135,9 +135,9 @@ def test___int_ticks___from_ticks___returns_time_value() -> None:
135135
assert value._offset._ticks == 0x12345678_90ABCDEF_FEDCBA09_87654321
136136

137137

138-
#############
138+
###############################################################################
139139
# from_offset
140-
#############
140+
###############################################################################
141141
def test___time_value___from_offset___returns_time_value() -> None:
142142
value = DateTime.from_offset(TimeDelta.from_ticks(0x12345678_90ABCDEF_FEDCBA09_87654321))
143143

@@ -146,9 +146,9 @@ def test___time_value___from_offset___returns_time_value() -> None:
146146
assert value._offset._ticks == 0x12345678_90ABCDEF_FEDCBA09_87654321
147147

148148

149-
##############################################
149+
###############################################################################
150150
# year, month, day, hour, minute, second, etc.
151-
##############################################
151+
###############################################################################
152152
@pytest.mark.parametrize(
153153
"other, expected",
154154
[
@@ -210,9 +210,9 @@ def test___various_values___unit_properties___return_unit_values(
210210
) == expected
211211

212212

213-
###################
213+
###############################################################################
214214
# Binary arithmetic
215-
###################
215+
###############################################################################
216216
@pytest.mark.parametrize(
217217
"left, right, expected",
218218
[
@@ -304,9 +304,9 @@ def test___datetime___sub___returns_time_value(
304304
assert left - right == expected
305305

306306

307-
############
307+
###############################################################################
308308
# Comparison
309-
############
309+
###############################################################################
310310
@pytest.mark.parametrize(
311311
"left, right",
312312
[
@@ -395,9 +395,9 @@ def test___lesser_value___comparison___lesser(
395395
assert not (left >= right)
396396

397397

398-
###############
398+
###############################################################################
399399
# Miscellaneous
400-
###############
400+
###############################################################################
401401
_VARIOUS_VALUES = [
402402
DateTime(dt.MINYEAR, 1, 1, 0, 0, 0, 0, 0, 0, dt.timezone.utc),
403403
DateTime(1850, 12, 25, 8, 15, 30, 123_456, 234_567_789, 345_567_890, dt.timezone.utc),

0 commit comments

Comments
 (0)