Skip to content

Commit 0d515bc

Browse files
authored
waveform: Implement append (#10)
* waveform: Implement append() * waveform: Simplify _append_timing() * tests: Add tests for _append_timing() and monotonicity checks * tests: Add a test case for other timestamp sequence type * waveform: Fix _base_timing initialization * waveform: Fix irregular timestamp validation * tests: Add waveform append tests * waveform: Add sample interval strategy * waveform: Add abstract named constructors to BaseTiming This required disabling covariance for the datetime/timedelta type variables. * waveform: Use override decorator and remove duplicated docstrings * waveform: Remove runtime dependency on typing_extensions * waveform: Refactor timing to make it clear that there is a single source of truth * waveform: Add enum for timestamp direction * waveform: Move sample interval strategies to a subpackage and make them type-safe * github: Rerun checks * tests: Add a couple test cases for timing caching * waveform: Add TimingMismatchError and TimingMismatchWarning * waveform: Document and extend behavior for appending waveform
1 parent f33ea2f commit 0d515bc

File tree

20 files changed

+1531
-276
lines changed

20 files changed

+1531
-276
lines changed

src/nitypes/_exceptions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,23 +44,23 @@ def invalid_arg_type(arg_description: str, type_description: str, value: object)
4444

4545
def invalid_array_ndim(arg_description: str, valid_value_description: str, ndim: int) -> ValueError:
4646
"""Create a ValueError for an array with an invalid number of dimensions."""
47-
raise ValueError(
47+
return ValueError(
4848
f"The {arg_description} must be {_a(valid_value_description)}.\n\n"
4949
f"Number of dimensions: {ndim}"
5050
)
5151

5252

5353
def invalid_requested_type(type_description: str, requested_type: type) -> TypeError:
5454
"""Create a TypeError for an invalid requested type."""
55-
raise TypeError(
55+
return TypeError(
5656
f"The requested type must be {_a(type_description)} type.\n\n"
5757
f"Requested type: {requested_type}"
5858
)
5959

6060

6161
def unsupported_arg(arg_description: str, value: object) -> ValueError:
6262
"""Create a ValueError for an unsupported argument."""
63-
raise ValueError(
63+
return ValueError(
6464
f"The {arg_description} argument is not supported.\n\n"
6565
f"Provided value: {reprlib.repr(value)}"
6666
)

src/nitypes/waveform/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Waveform data types for NI Python APIs."""
22

33
from nitypes.waveform._analog_waveform import AnalogWaveform
4+
from nitypes.waveform._exceptions import TimingMismatchError
45
from nitypes.waveform._extended_properties import (
56
ExtendedPropertyDictionary,
67
ExtendedPropertyValue,
@@ -11,7 +12,13 @@
1112
NoneScaleMode,
1213
ScaleMode,
1314
)
14-
from nitypes.waveform._timing import BaseTiming, PrecisionTiming, SampleIntervalMode, Timing
15+
from nitypes.waveform._timing import (
16+
BaseTiming,
17+
PrecisionTiming,
18+
SampleIntervalMode,
19+
Timing,
20+
)
21+
from nitypes.waveform._warnings import ScalingMismatchWarning, TimingMismatchWarning
1522

1623
__all__ = [
1724
"AnalogWaveform",
@@ -24,7 +31,10 @@
2431
"PrecisionTiming",
2532
"SampleIntervalMode",
2633
"ScaleMode",
34+
"ScalingMismatchWarning",
2735
"Timing",
36+
"TimingMismatchError",
37+
"TimingMismatchWarning",
2838
]
2939

3040
# Hide that it was defined in a helper file
@@ -33,8 +43,12 @@
3343
ExtendedPropertyDictionary.__module__ = __name__
3444
# ExtendedPropertyValue is a TypeAlias
3545
LinearScaleMode.__module__ = __name__
46+
# NO_SCALING is a constant
3647
NoneScaleMode.__module__ = __name__
3748
PrecisionTiming.__module__ = __name__
3849
SampleIntervalMode.__module__ = __name__
3950
ScaleMode.__module__ = __name__
51+
ScalingMismatchWarning.__module__ = __name__
4052
Timing.__module__ = __name__
53+
TimingMismatchError.__module__ = __name__
54+
TimingMismatchWarning.__module__ = __name__

src/nitypes/waveform/_analog_waveform.py

Lines changed: 173 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,37 @@
11
from __future__ import annotations
22

3+
import datetime as dt
34
import sys
5+
import warnings
46
from collections.abc import Sequence
5-
from typing import (
6-
Any,
7-
Generic,
8-
SupportsIndex,
9-
TypeVar,
10-
overload,
11-
)
7+
from typing import Any, Generic, SupportsIndex, TypeVar, Union, cast, overload
128

9+
import hightime as ht
1310
import numpy as np
1411
import numpy.typing as npt
1512

16-
from nitypes._arguments import arg_to_uint, validate_dtype
13+
from nitypes._arguments import arg_to_uint, validate_dtype, validate_unsupported_arg
1714
from nitypes._exceptions import invalid_arg_type, invalid_array_ndim
15+
from nitypes._typing import TypeAlias
1816
from nitypes.waveform._extended_properties import (
1917
CHANNEL_NAME,
2018
UNIT_DESCRIPTION,
2119
ExtendedPropertyDictionary,
2220
)
2321
from nitypes.waveform._scaling import NO_SCALING, ScaleMode
24-
from nitypes.waveform._timing import PrecisionTiming, Timing, convert_timing
22+
from nitypes.waveform._timing import BaseTiming, PrecisionTiming, Timing, convert_timing
23+
from nitypes.waveform._warnings import scale_mode_mismatch
2524

2625
if sys.version_info < (3, 10):
2726
import array as std_array
2827

28+
2929
_ScalarType = TypeVar("_ScalarType", bound=np.generic)
3030
_ScalarType_co = TypeVar("_ScalarType_co", bound=np.generic, covariant=True)
3131

32+
_AnyTiming: TypeAlias = Union[BaseTiming[Any, Any], Timing, PrecisionTiming]
33+
_TTiming = TypeVar("_TTiming", bound=BaseTiming[Any, Any])
34+
3235
# Use the C types here because np.isdtype() considers some of them to be distinct types, even when
3336
# they have the same size (e.g. np.intc vs. np.int_ vs. np.long).
3437
_ANALOG_DTYPES = (
@@ -57,7 +60,6 @@
5760
np.double,
5861
)
5962

60-
6163
# Note about NumPy type hints:
6264
# - At time of writing (April 2025), shape typing is still under development, so we do not
6365
# distinguish between 1D and 2D arrays in type hints.
@@ -229,7 +231,7 @@ def from_array_2d(
229231
"_sample_count",
230232
"_extended_properties",
231233
"_timing",
232-
"_precision_timing",
234+
"_converted_timing_cache",
233235
"_scale_mode",
234236
"__weakref__",
235237
]
@@ -238,8 +240,8 @@ def from_array_2d(
238240
_start_index: int
239241
_sample_count: int
240242
_extended_properties: ExtendedPropertyDictionary
241-
_timing: Timing | None
242-
_precision_timing: PrecisionTiming | None
243+
_timing: BaseTiming[Any, Any]
244+
_converted_timing_cache: dict[type[_AnyTiming], _AnyTiming]
243245
_scale_mode: ScaleMode
244246

245247
# If neither dtype nor _data is specified, the type parameter defaults to np.float64.
@@ -357,7 +359,7 @@ def _init_with_new_array(
357359
self._sample_count = sample_count
358360
self._extended_properties = ExtendedPropertyDictionary()
359361
self._timing = Timing.empty
360-
self._precision_timing = None
362+
self._converted_timing_cache = {}
361363
self._scale_mode = NO_SCALING
362364

363365
def _init_with_provided_array(
@@ -414,7 +416,7 @@ def _init_with_provided_array(
414416
self._sample_count = sample_count
415417
self._extended_properties = ExtendedPropertyDictionary()
416418
self._timing = Timing.empty
417-
self._precision_timing = None
419+
self._converted_timing_cache = {}
418420
self._scale_mode = NO_SCALING
419421

420422
@property
@@ -579,32 +581,47 @@ def unit_description(self, value: str) -> None:
579581
raise invalid_arg_type("unit description", "str", value)
580582
self._extended_properties[UNIT_DESCRIPTION] = value
581583

584+
def _get_timing(self, requested_type: type[_TTiming]) -> _TTiming:
585+
if isinstance(self._timing, requested_type):
586+
return self._timing
587+
value = cast(_TTiming, self._converted_timing_cache.get(requested_type))
588+
if value is None:
589+
value = convert_timing(requested_type, self._timing)
590+
self._converted_timing_cache[requested_type] = value
591+
return value
592+
593+
def _set_timing(self, value: _TTiming) -> None:
594+
if self._timing is not value:
595+
self._timing = value
596+
self._converted_timing_cache.clear()
597+
598+
def _validate_timing(self, value: _TTiming) -> None:
599+
if value._timestamps is not None and len(value._timestamps) != self._sample_count:
600+
raise ValueError(
601+
"The number of irregular timestamps is not equal to the number of samples in the waveform.\n\n"
602+
f"Number of timestamps: {len(value._timestamps)}\n"
603+
f"Number of samples in the waveform: {self._sample_count}"
604+
)
605+
582606
@property
583607
def timing(self) -> Timing:
584608
"""The timing information of the analog waveform.
585609
586610
The default value is Timing.empty.
587611
"""
588-
if self._timing is None:
589-
if self._precision_timing is PrecisionTiming.empty:
590-
self._timing = Timing.empty
591-
elif self._precision_timing is not None:
592-
self._timing = convert_timing(Timing, self._precision_timing)
593-
else:
594-
raise RuntimeError("The waveform has no timing information.")
595-
return self._timing
612+
return self._get_timing(Timing)
596613

597614
@timing.setter
598615
def timing(self, value: Timing) -> None:
599616
if not isinstance(value, Timing):
600617
raise invalid_arg_type("timing information", "Timing object", value)
601-
self._timing = value
602-
self._precision_timing = None
618+
self._validate_timing(value)
619+
self._set_timing(value)
603620

604621
@property
605622
def is_precision_timing_initialized(self) -> bool:
606-
"""Indicates whether the waveform's precision timing information is initialized."""
607-
return self._precision_timing is not None
623+
"""Indicates whether the waveform's timing information was set using precision timing."""
624+
return isinstance(self._timing, PrecisionTiming)
608625

609626
@property
610627
def precision_timing(self) -> PrecisionTiming:
@@ -622,21 +639,14 @@ def precision_timing(self) -> PrecisionTiming:
622639
set using AnalogWaveform.timing. Use AnalogWaveform.is_precision_timing_initialized to
623640
determine if AnalogWaveform.precision_timing has been initialized.
624641
"""
625-
if self._precision_timing is None:
626-
if self._timing is Timing.empty:
627-
self._precision_timing = PrecisionTiming.empty
628-
elif self._timing is not None:
629-
self._precision_timing = convert_timing(PrecisionTiming, self._timing)
630-
else:
631-
raise RuntimeError("The waveform has no timing information.")
632-
return self._precision_timing
642+
return self._get_timing(PrecisionTiming)
633643

634644
@precision_timing.setter
635645
def precision_timing(self, value: PrecisionTiming) -> None:
636646
if not isinstance(value, PrecisionTiming):
637647
raise invalid_arg_type("precision timing information", "PrecisionTiming object", value)
638-
self._precision_timing = value
639-
self._timing = None
648+
self._validate_timing(value)
649+
self._set_timing(value)
640650

641651
@property
642652
def scale_mode(self) -> ScaleMode:
@@ -648,3 +658,129 @@ def scale_mode(self, value: ScaleMode) -> None:
648658
if not isinstance(value, ScaleMode):
649659
raise invalid_arg_type("scale mode", "ScaleMode object", value)
650660
self._scale_mode = value
661+
662+
def append(
663+
self,
664+
other: (
665+
npt.NDArray[_ScalarType_co]
666+
| AnalogWaveform[_ScalarType_co]
667+
| Sequence[AnalogWaveform[_ScalarType_co]]
668+
),
669+
/,
670+
timestamps: Sequence[dt.datetime] | Sequence[ht.datetime] | None = None,
671+
) -> None:
672+
"""Append data to the analog waveform.
673+
674+
Args:
675+
other: The array or waveform(s) to append.
676+
timestamps: A sequence of timestamps. When the current waveform has
677+
SampleIntervalMode.IRREGULAR, you must provide a sequence of timestamps with the
678+
same length as the array.
679+
680+
Raises:
681+
TimingMismatchError: The current and other waveforms have incompatible timing.
682+
ValueError: The other array has the wrong number of dimensions or the length of the
683+
timestamps argument does not match the length of the other array.
684+
TypeError: The data types of the current waveform and other array or waveform(s) do not
685+
match, or an argument has the wrong data type.
686+
687+
Warnings:
688+
TimingMismatchWarning: The sample intervals of the waveform(s) do not match.
689+
ScalingMismatchWarning: The scale modes of the waveform(s) do not match.
690+
691+
When appending waveforms:
692+
693+
* Timing information is merged based on the sample interval mode of the current
694+
waveform:
695+
696+
* SampleIntervalMode.NONE or SampleIntervalMode.REGULAR: The other waveform(s) must also
697+
have SampleIntervalMode.NONE or SampleIntervalMode.REGULAR. If the sample interval does
698+
not match, a TimingMismatchWarning is generated. Otherwise, the timing information of
699+
the other waveform(s) is discarded.
700+
701+
* SampleIntervalMode.IRREGULAR: The other waveforms(s) must also have
702+
SampleIntervalMode.IRREGULAR. The timestamps of the other waveforms(s) are appended to
703+
the current waveform's timing information.
704+
705+
* Extended properties of the other waveform(s) are merged into the current waveform if they
706+
are not already set in the current waveform.
707+
708+
* If the scale mode of other waveform(s) does not match the scale mode of the current
709+
waveform, a ScalingMismatchWarning is generated. Otherwise, the scaling information of the
710+
other waveform(s) is discarded.
711+
"""
712+
if isinstance(other, np.ndarray):
713+
self._append_array(other, timestamps)
714+
elif isinstance(other, AnalogWaveform):
715+
validate_unsupported_arg("timestamps", timestamps)
716+
self._append_waveform(other)
717+
elif isinstance(other, Sequence) and all(isinstance(x, AnalogWaveform) for x in other):
718+
validate_unsupported_arg("timestamps", timestamps)
719+
self._append_waveforms(other)
720+
else:
721+
raise invalid_arg_type("input", "array or waveform(s)", other)
722+
723+
def _append_array(
724+
self,
725+
array: npt.NDArray[_ScalarType_co],
726+
timestamps: Sequence[dt.datetime] | Sequence[ht.datetime] | None = None,
727+
) -> None:
728+
if array.dtype != self.dtype:
729+
raise TypeError(
730+
"The data type of the input array must match the waveform data type.\n\n"
731+
f"Input array data type: {array.dtype}\n"
732+
f"Waveform data type: {self.dtype}"
733+
)
734+
if array.ndim != 1:
735+
raise ValueError(
736+
"The input array must be a one-dimensional array.\n\n"
737+
f"Number of dimensions: {array.ndim}"
738+
)
739+
if timestamps is not None and len(array) != len(timestamps):
740+
raise ValueError(
741+
"The number of irregular timestamps must be equal to the input array length.\n\n"
742+
f"Number of timestamps: {len(timestamps)}\n"
743+
f"Array length: {len(array)}"
744+
)
745+
746+
new_timing = self._timing._append_timestamps(timestamps)
747+
748+
self._increase_capacity(len(array))
749+
self._set_timing(new_timing)
750+
751+
offset = self._start_index + self._sample_count
752+
self._data[offset : offset + len(array)] = array
753+
self._sample_count += len(array)
754+
755+
def _append_waveform(self, waveform: AnalogWaveform[_ScalarType_co]) -> None:
756+
self._append_waveforms([waveform])
757+
758+
def _append_waveforms(self, waveforms: Sequence[AnalogWaveform[_ScalarType_co]]) -> None:
759+
for waveform in waveforms:
760+
if waveform.dtype != self.dtype:
761+
raise TypeError(
762+
"The data type of the input waveform must match the waveform data type.\n\n"
763+
f"Input waveform data type: {waveform.dtype}\n"
764+
f"Waveform data type: {self.dtype}"
765+
)
766+
if waveform._scale_mode != self._scale_mode:
767+
warnings.warn(scale_mode_mismatch())
768+
769+
new_timing = self._timing
770+
for waveform in waveforms:
771+
new_timing = new_timing._append_timing(waveform._timing)
772+
773+
self._increase_capacity(sum(waveform.sample_count for waveform in waveforms))
774+
self._set_timing(new_timing)
775+
776+
offset = self._start_index + self._sample_count
777+
for waveform in waveforms:
778+
self._data[offset : offset + waveform.sample_count] = waveform.raw_data
779+
offset += waveform.sample_count
780+
self._sample_count += waveform.sample_count
781+
self._extended_properties._merge(waveform._extended_properties)
782+
783+
def _increase_capacity(self, amount: int) -> None:
784+
new_capacity = self._start_index + self._sample_count + amount
785+
if new_capacity > self.capacity:
786+
self.capacity = new_capacity

0 commit comments

Comments
 (0)