Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
9487985
Add sketch of basic TimeDelta array
jfriedri-ni Jul 24, 2025
b263c5f
Do not incorrectly swap the struct packing order
jfriedri-ni Jul 24, 2025
3772dc4
Specialize the numpy storage as much as supported
jfriedri-ni Jul 25, 2025
d73ae19
Stop using Union
jfriedri-ni Jul 25, 2025
9a15209
Use len() for a 1D numpy array
jfriedri-ni Jul 25, 2025
5dda9f0
Test with negative and fractional values as well
jfriedri-ni Jul 25, 2025
25dd201
Add CVI representation helpers to TimeValueTuple
jfriedri-ni Jul 25, 2025
6626785
Fix docstring typo
jfriedri-ni Jul 25, 2025
3645d6a
Use the CVI helper when getting an item
jfriedri-ni Jul 25, 2025
d25b88f
Try using a generator when constructing the TimeDeltaArray
jfriedri-ni Jul 25, 2025
0bdac3d
Address formatter to get to the type checker
jfriedri-ni Jul 25, 2025
7af2a9a
Iterate over the input sequence when constructing the np array
jfriedri-ni Jul 25, 2025
f35b7ff
Add a test for indexing by integer
jfriedri-ni Jul 25, 2025
268351f
Use np.fromiter() to initialize the backing ndarray
jfriedri-ni Jul 25, 2025
6a6861e
Add test for len()
jfriedri-ni Jul 25, 2025
363740a
Add TimeDeltaArray constructor benchmark tests
jfriedri-ni Jul 25, 2025
56f0d7c
Promote to MutableSequence
jfriedri-ni Jul 28, 2025
27a0c91
Add signatures for remaining MutableSequence methods
jfriedri-ni Jul 28, 2025
37470da
Add impl and test for slicing
jfriedri-ni Jul 28, 2025
30dda3a
Add test for indexing with invalid indices
jfriedri-ni Jul 28, 2025
27faef7
Add test cases for negative index and out of bounds index
jfriedri-ni Jul 29, 2025
c1a2265
Implement and test __setitem__()
jfriedri-ni Jul 29, 2025
f116a78
Implement and test __delitem__()
jfriedri-ni Jul 29, 2025
13fa2a1
Implement and test insert()
jfriedri-ni Jul 29, 2025
1600306
Validate and test constructor argument
jfriedri-ni Jul 30, 2025
4f421e6
Test constructing from another TimeDeltaArray
jfriedri-ni Jul 30, 2025
67db372
Test MutableSequence mixin methods
jfriedri-ni Jul 30, 2025
dceb88c
Implement and test builtin functions
jfriedri-ni Jul 30, 2025
cc7fd8a
Address formatter
jfriedri-ni Jul 30, 2025
afa6e19
Optimize when slicing
jfriedri-ni Jul 31, 2025
57d39af
Clarify error messages
jfriedri-ni Jul 31, 2025
0228bf0
Implement and test __imul__()
jfriedri-ni Jul 31, 2025
f1d20e2
Remove antipattern from benchmark test
jfriedri-ni Jul 31, 2025
0a86f24
Update README and .gitignore to support comparing benchmarks
jfriedri-ni Jul 31, 2025
356d82e
Group the constructor benchmarks into a single test with parametrize
jfriedri-ni Jul 31, 2025
69ae183
Add docstrings from Claude Sonnet 4
jfriedri-ni Aug 1, 2025
d613342
Remove implementation details and example usage from method docstrings
jfriedri-ni Aug 1, 2025
4865b2c
Add TimeDeltaArray to module docstring
jfriedri-ni Aug 1, 2025
192ea1c
Address the analyzers
jfriedri-ni Aug 1, 2025
9f2aac4
Fix docstring tests
jfriedri-ni Aug 1, 2025
b45507b
Switch to google-style docstrings
jfriedri-ni Aug 1, 2025
55c524e
Remove unnecessary detail from descriptions, rely on default docgen b…
jfriedri-ni Aug 1, 2025
2d011fd
Remove the how-to-use narrative section
jfriedri-ni Aug 1, 2025
f280f97
Guess and check -- was it this one?
jfriedri-ni Aug 1, 2025
6935936
Allow indexing with bools
jfriedri-ni Aug 5, 2025
547d051
Use object for __eq__'s type hint on other
jfriedri-ni Aug 5, 2025
4b6e25a
Use flattened strings for test parameter declarations
jfriedri-ni Aug 5, 2025
c5b6fde
Make test section comments match other files' style
jfriedri-ni Aug 5, 2025
b3169c2
Remove __imul__ and tests
jfriedri-ni Aug 5, 2025
e504a6f
Use error factories for user input validation
jfriedri-ni Aug 5, 2025
21f3b09
Fix test name typo
jfriedri-ni Aug 6, 2025
7a9f9a2
Validate that an unpickled TimeDeltaArray has its own backing array
jfriedri-ni Aug 6, 2025
01a2aa4
Remove test for sorted
jfriedri-ni Aug 6, 2025
03ffd4e
Add test for deepcopy()
jfriedri-ni Aug 6, 2025
17c090f
Use pytest parameters for validating equals
jfriedri-ni Aug 6, 2025
7b91f69
TDD: use full truth-table coverage for setting with a slice
jfriedri-ni Aug 6, 2025
4891ab4
Make TimeDeltaArray behave like list when setting by slice
jfriedri-ni Aug 6, 2025
fdb40bb
Unpack the slice into named variables
jfriedri-ni Aug 7, 2025
9e3c67a
Prefer builtin functions over magic methods
jfriedri-ni Aug 7, 2025
8543def
Group the slice assignment tests together
jfriedri-ni Aug 7, 2025
3b198e0
Add test cases for settings mismatched-length values on contiguous sl…
jfriedri-ni Aug 7, 2025
89b857a
Raise when trying to shrink the array using a stride
jfriedri-ni Aug 7, 2025
896abc8
Do not hokey-pokey when setting a single value with a slice
jfriedri-ni Aug 7, 2025
8c87415
Group selected==incoming test cases
jfriedri-ni Aug 7, 2025
83e6bcf
Remove 'expected to raise' test cases that should pass for plain lists
jfriedri-ni Aug 7, 2025
f9ff477
Group and add mismatched slice vs incoming test cases
jfriedri-ni Aug 7, 2025
c891490
Address the analyzers
jfriedri-ni Aug 7, 2025
26a18ba
Contiguous slices accept any number of incoming entries
jfriedri-ni Aug 7, 2025
176aa33
Implement plain old list slice assignment
jfriedri-ni Aug 7, 2025
157d3e4
Use np.insert to grow the array once
jfriedri-ni Aug 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ __pycache__/
# Unit tests
.tox/
test_results/
.benchmarks/

# Coverage output
.coverage
Expand All @@ -18,4 +19,4 @@ dist/
docs/_build/

# Common editor metadata
.vscode/
.vscode/
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ poetry run bandit -c pyproject.toml -r src/nitypes
poetry run pytest -v

# Run the benchmarks
# Compare benchmark before/after a change
# see https://pytest-benchmark.readthedocs.io/en/latest/comparing.html
# Run 1: --benchmark-save=some-name
# Run N: --benchmark-compare=0001
poetry run pytest -v tests/benchmark

# Build and inspect the documentation
Expand Down
4 changes: 4 additions & 0 deletions src/nitypes/bintime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

* :class:`DateTime`: represents an NI-BTF absolute time as a Python object.
* :class:`TimeDelta`: represents a NI-BTF time interval as a Python object.
* :class:`TimeDeltaArray`: an array of :class:`TimeDelta` values.

NI-BTF NumPy Structured Data Types
==================================
Expand Down Expand Up @@ -74,6 +75,7 @@
)
from nitypes.bintime._time_value_tuple import TimeValueTuple
from nitypes.bintime._timedelta import TimeDelta
from nitypes.bintime._timedelta_array import TimeDeltaArray

__all__ = [
"DateTime",
Expand All @@ -82,10 +84,12 @@
"CVITimeIntervalBase",
"CVITimeIntervalDType",
"TimeDelta",
"TimeDeltaArray",
"TimeValueTuple",
]

# Hide that it was defined in a helper file
DateTime.__module__ = __name__
TimeDelta.__module__ = __name__
TimeDeltaArray.__module__ = __name__
TimeValueTuple.__module__ = __name__
2 changes: 1 addition & 1 deletion src/nitypes/bintime/_dtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
"""Type alias for the base type of :any:`CVITimeIntervalDType`, which is :any:`numpy.void`."""

CVITimeIntervalDType = np.dtype((CVITimeIntervalBase, [("lsb", np.uint64), ("msb", np.int64)]))
"""NumPy structured data type for a ``CVIAbsoluteTime`` C struct."""
"""NumPy structured data type for a ``CVITimeInterval`` C struct."""
9 changes: 9 additions & 0 deletions src/nitypes/bintime/_time_value_tuple.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,12 @@ class TimeValueTuple(NamedTuple):

fractional_seconds: int
"""The fractional seconds portion of a binary time value. This should be a uint64."""

@staticmethod
def from_cvi(lsb: int, msb: int) -> TimeValueTuple:
"""Create a :class:`TimeValueTuple` from a ``CVIAbsoluteTime`` representation."""
return TimeValueTuple(whole_seconds=msb, fractional_seconds=lsb)

def to_cvi(self) -> tuple[int, int]:
"""Return a representation as ``CVIAbsoluteTime``."""
return (self.fractional_seconds, self.whole_seconds)
199 changes: 199 additions & 0 deletions src/nitypes/bintime/_timedelta_array.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
from __future__ import annotations

from collections.abc import Collection, Iterable, MutableSequence
from typing import (
TYPE_CHECKING,
Any,
final,
overload,
)

import numpy as np
import numpy.typing as npt

from nitypes._exceptions import invalid_arg_value, invalid_arg_type

if TYPE_CHECKING:
# Import from the public package so the docs don't reference private submodules.
from nitypes.bintime import CVITimeIntervalDType, TimeDelta, TimeValueTuple
else:
from nitypes.bintime._dtypes import CVITimeIntervalDType
from nitypes.bintime._timedelta import TimeDelta
from nitypes.bintime._time_value_tuple import TimeValueTuple


@final
class TimeDeltaArray(MutableSequence[TimeDelta]):
"""A mutable array of :class:`TimeDelta` values in NI Binary Time Format (NI-BTF).

Raises:
TypeError: If any item in value is not a TimeDelta instance.
"""

__slots__ = ["_array"]

_array: npt.NDArray[np.void]

def __init__(
self,
value: Collection[TimeDelta] | None = None,
) -> None:
"""Initialize a new TimeDeltaArray."""
if value is None:
value = []
if not all(isinstance(item, TimeDelta) for item in value):
raise invalid_arg_type("value", "iterable of TimeDelta", value)
self._array = np.fromiter(
(entry.to_tuple().to_cvi() for entry in value),
dtype=CVITimeIntervalDType,
count=len(value),
)

@overload
def __getitem__( # noqa: D105 - missing docstring in magic method
self, index: int
) -> TimeDelta: ...

@overload
def __getitem__( # noqa: D105 - missing docstring in magic method
self, index: slice
) -> TimeDeltaArray: ...

def __getitem__(self, index: int | slice) -> TimeDelta | TimeDeltaArray:
"""Return self[index].

Raises:
TypeError: If index is an invalid type.
IndexError: If index is out of range.
"""
if isinstance(index, int):
entry = self._array[index].item()
as_tuple = TimeValueTuple.from_cvi(*entry)
return TimeDelta.from_tuple(as_tuple)
elif isinstance(index, slice):
sliced_entries = self._array[index]
new_array = TimeDeltaArray()
new_array._array = sliced_entries
return new_array
else:
raise invalid_arg_type("index", "int or slice", index)

def __len__(self) -> int:
"""Return len(self)."""
return len(self._array)

@overload
def __setitem__( # noqa: D105 - missing docstring in magic method
self, index: int, value: TimeDelta
) -> None: ...

@overload
def __setitem__( # noqa: D105 - missing docstring in magic method
self, index: slice, value: Iterable[TimeDelta]
) -> None: ...

def __setitem__(self, index: int | slice, value: TimeDelta | Iterable[TimeDelta]) -> None:
"""Set a new value for TimeDelta at the specified location or slice.

Raises:
TypeError: If index is an invalid type, or slice value is not iterable.
ValueError: If slice assignment length doesn't match the selected range.
IndexError: If index is out of range.
"""
if isinstance(index, int):
if not isinstance(value, TimeDelta):
raise invalid_arg_type("value", "TimeDelta", value)
self._array[index] = value.to_tuple().to_cvi()
elif isinstance(index, slice):
if not isinstance(value, Iterable):
raise invalid_arg_type("value", "iterable of TimeDelta", value)
if not all(isinstance(item, TimeDelta) for item in value):
raise invalid_arg_type("value", "iterable of TimeDelta", value)

start, stop, step = index.indices(len(self))
selected_count = len(range(start, stop, step))
values = list(value)
new_entry_count = len(values)
if step > 1 and new_entry_count != selected_count:
raise invalid_arg_value(
"value", "iterable with the same length as the slice", value
)

if new_entry_count < selected_count:
# Shrink
replaced = slice(start, start + new_entry_count)
removed = slice(start + new_entry_count, stop)
self._array[replaced] = [item.to_tuple().to_cvi() for item in values]
del self[removed]
elif new_entry_count > selected_count:
# Grow
replaced = slice(start, stop)
self._array[replaced] = [
item.to_tuple().to_cvi() for item in values[:selected_count]
]
self._array = np.insert(
self._array,
stop,
[item.to_tuple().to_cvi() for item in values[selected_count:]],
)
else:
# Replace, accounting for strides
self._array[index] = [item.to_tuple().to_cvi() for item in values]
else:
raise invalid_arg_type("index", "int or slice", index)

@overload
def __delitem__(self, index: int) -> None: ... # noqa: D105 - missing docstring in magic method

@overload
def __delitem__( # noqa: D105 - missing docstring in magic method
self, index: slice
) -> None: ...

def __delitem__(self, index: int | slice) -> None:
"""Delete the value at the specified location or slice.

Raises:
TypeError: If index is an invalid type.
IndexError: If index is out of range.
"""
if isinstance(index, (int, slice)):
self._array = np.delete(self._array, index)
else:
raise invalid_arg_type("index", "int or slice", index)

def insert(self, index: int, value: TimeDelta) -> None:
"""Insert the TimeDelta value before the specified index.

Raises:
TypeError: If index is not int or value is not TimeDelta.
"""
if not isinstance(index, int):
raise invalid_arg_type("index", "int", index)
if not isinstance(value, TimeDelta):
raise invalid_arg_type("value", "TimeDelta", value)
lower = -len(self._array)
upper = len(self._array)
index = min(max(index, lower), upper)
as_cvi = value.to_tuple().to_cvi()
self._array = np.insert(self._array, index, as_cvi)

def __eq__(self, other: object) -> bool:
"""Return self == other."""
if not isinstance(other, TimeDeltaArray):
return NotImplemented
return np.array_equal(self._array, other._array)

def __reduce__(self) -> tuple[Any, ...]:
"""Return object state for pickling."""
return (self.__class__, (list(iter(self)),))

def __repr__(self) -> str:
"""Return repr(self)."""
ctor_args = list(iter(self))
return f"{self.__class__.__module__}.{self.__class__.__name__}({ctor_args})"

def __str__(self) -> str:
"""Return str(self)."""
values = list(iter(self))
return f"[{'; '.join(str(v) for v in values)}]"
2 changes: 0 additions & 2 deletions tests/benchmark/bintime/test_timedelta.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@

import nitypes.bintime as bt

pytestmark = pytest.mark.benchmark


@pytest.mark.benchmark(group="timedelta_construct")
def test___bt_timedelta___construct(
Expand Down
26 changes: 26 additions & 0 deletions tests/benchmark/bintime/test_timedelta_array.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from __future__ import annotations

import numpy as np
import pytest
from pytest_benchmark.fixture import BenchmarkFixture

import nitypes.bintime as bt


LIST_10: list[bt.TimeDelta] = [bt.TimeDelta(float(value)) for value in np.arange(-10, 10, 0.3)]
LIST_100: list[bt.TimeDelta] = [bt.TimeDelta(float(value)) for value in np.arange(-100, 100, 0.3)]
LIST_1000: list[bt.TimeDelta] = [
bt.TimeDelta(float(value)) for value in np.arange(-1000, 1000, 0.3)
]
LIST_10000: list[bt.TimeDelta] = [
bt.TimeDelta(float(value)) for value in np.arange(-10000, 10000, 0.3)
]


@pytest.mark.benchmark(group="timedelta_array_construct", min_rounds=100)
@pytest.mark.parametrize("constructor_list", (LIST_10, LIST_100, LIST_1000, LIST_10000))
def test___bt_timedelta_array___construct(
benchmark: BenchmarkFixture,
constructor_list: list[bt.TimeDelta],
) -> None:
benchmark(bt.TimeDeltaArray, constructor_list)
28 changes: 14 additions & 14 deletions tests/unit/bintime/test_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
from nitypes.bintime._timedelta import _BITS_PER_SECOND, _FRACTIONAL_SECONDS_MASK


#############
###############################################################################
# Constructor
#############
###############################################################################
def test___no_args___construct___returns_epoch() -> None:
value = DateTime()

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


############
###############################################################################
# from_ticks
############
###############################################################################
def test___int_ticks___from_ticks___returns_time_value() -> None:
value = DateTime.from_ticks(0x12345678_90ABCDEF_FEDCBA09_87654321)

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


#############
###############################################################################
# from_offset
#############
###############################################################################
def test___time_value___from_offset___returns_time_value() -> None:
value = DateTime.from_offset(TimeDelta.from_ticks(0x12345678_90ABCDEF_FEDCBA09_87654321))

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


##############################################
###############################################################################
# year, month, day, hour, minute, second, etc.
##############################################
###############################################################################
@pytest.mark.parametrize(
"other, expected",
[
Expand Down Expand Up @@ -210,9 +210,9 @@ def test___various_values___unit_properties___return_unit_values(
) == expected


###################
###############################################################################
# Binary arithmetic
###################
###############################################################################
@pytest.mark.parametrize(
"left, right, expected",
[
Expand Down Expand Up @@ -304,9 +304,9 @@ def test___datetime___sub___returns_time_value(
assert left - right == expected


############
###############################################################################
# Comparison
############
###############################################################################
@pytest.mark.parametrize(
"left, right",
[
Expand Down Expand Up @@ -395,9 +395,9 @@ def test___lesser_value___comparison___lesser(
assert not (left >= right)


###############
###############################################################################
# Miscellaneous
###############
###############################################################################
_VARIOUS_VALUES = [
DateTime(dt.MINYEAR, 1, 1, 0, 0, 0, 0, 0, 0, dt.timezone.utc),
DateTime(1850, 12, 25, 8, 15, 30, 123_456, 234_567_789, 345_567_890, dt.timezone.utc),
Expand Down
Loading
Loading