diff --git a/README.md b/README.md index 91587f3..46e6ce4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ - [Binary Time](#binary-time) - [Scalar Values](#scalar-values) - [Scalar](#scalar) + - [Vector](#vector) + - [XYData](#xydata) # About @@ -141,4 +143,11 @@ API Reference. information and extended properties. Valid types for the scalar values are `bool`, `int`, `float`, and `str`. For more details, see [Scalar](https://nitypes.readthedocs.io/en/latest/autoapi/nitypes/vector/index.html#vector) in the +API Reference. + +## XYData + +`nitypes.xy_data.XYData` is a data type that represents a two axes (sequences) of numeric values +with units information. Valid types for the numeric values are `int` and `float`. For more details, +see [XYData](https://nitypes.readthedocs.io/en/latest/autoapi/nitypes/xy_data/index.html) in the API Reference. \ No newline at end of file diff --git a/docs/intro.inc b/docs/intro.inc index afe8a01..c9ac5c9 100644 --- a/docs/intro.inc +++ b/docs/intro.inc @@ -114,3 +114,9 @@ Vector :class:`Vector` is a data type that represents an array of scalar values with units information. Valid types for the scalar values are :any:`bool`, :any:`int`, :any:`float`, and :any:`str`. + +XYData +^^^^^^ + +:class:`XYData` is a data type that represents a two axes (sequences) of numeric values with +units information. Valid types for the numeric values are :any:`int` and :any:`float`. \ No newline at end of file diff --git a/src/nitypes/xy_data.py b/src/nitypes/xy_data.py new file mode 100644 index 0000000..e5b05e4 --- /dev/null +++ b/src/nitypes/xy_data.py @@ -0,0 +1,390 @@ +"""XYData type for NI Python APIs. + +XYData Data Type +================= +:class:`XYData`: An XYData object represents two axes (sequences) of numeric values with units +information. Valid types for the numeric values are :any:`int` and :any:`float`. +""" + +from __future__ import annotations + +import sys +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any, Generic, Union, overload + +import numpy as np +import numpy.typing as npt +from typing_extensions import Self, TypeVar, final + +from nitypes._arguments import validate_dtype +from nitypes._exceptions import invalid_arg_type, invalid_array_ndim +from nitypes._numpy import asarray as _np_asarray, long as _np_long, ulong as _np_ulong +from nitypes.waveform._exceptions import create_datatype_mismatch_error +from nitypes.waveform.typing import ExtendedPropertyValue + +if sys.version_info < (3, 10): + import array as std_array + +if TYPE_CHECKING: + # Import from the public package so the docs don't reference private submodules. + from nitypes.waveform import ExtendedPropertyDictionary +else: + from nitypes.waveform._extended_properties import ExtendedPropertyDictionary + +# Extended property keys for X and Y units. +_UNIT_DESCRIPTION_X = "NI_UnitDescription_X" +_UNIT_DESCRIPTION_Y = "NI_UnitDescription_Y" + +TData = TypeVar("TData", bound=Union[np.floating, np.integer]) +TOtherData = TypeVar("TOtherData", bound=Union[np.floating, np.integer]) + +# Use the C types here because np.isdtype() considers some of them to be distinct types, even when +# they have the same size (e.g. np.intc vs. np.int_). +_DATA_DTYPES = ( + # Floating point + np.single, + np.double, + # Signed integers + np.byte, + np.short, + np.intc, + np.int_, + _np_long, + np.longlong, + # Unsigned integers + np.ubyte, + np.ushort, + np.uintc, + np.uint, + _np_ulong, + np.ulonglong, +) + + +@final +class XYData(Generic[TData]): + """Two axes (sequences) of numeric values with units information. + + Constructing + ^^^^^^^^^^^^ + + To construct an XYData object, use the :class:`XYData` class: + + >>> XYData(np.array([1.1], np.float64), np.array([4.1], np.float64)) + nitypes.xy_data.XYData(x_data=array([1.1]), y_data=array([4.1]), + extended_properties={'NI_UnitDescription_X': '', 'NI_UnitDescription_Y': ''}) + >>> XYData(np.array([1, 2]), np.array([4, 5]), x_units="A", y_units="V") + nitypes.xy_data.XYData(x_data=array([1, 2]), y_data=array([4, 5]), + extended_properties={'NI_UnitDescription_X': 'A', 'NI_UnitDescription_Y': 'V'}) + + To construct an XYData object using built-in lists, use from_arrays_1d(): + + >>> XYData.from_arrays_1d([1, 2], [5, 6], np.int32) + nitypes.xy_data.XYData(x_data=array([1, 2], dtype=int32), y_data=array([5, 6], dtype=int32), + extended_properties={'NI_UnitDescription_X': '', 'NI_UnitDescription_Y': ''}) + >>> XYData.from_arrays_1d([1.0, 1.1], [1.2, 1.3], np.float64) + nitypes.xy_data.XYData(x_data=array([1. , 1.1]), y_data=array([1.2, 1.3]), + extended_properties={'NI_UnitDescription_X': '', 'NI_UnitDescription_Y': ''}) + """ + + __slots__ = [ + "_x_data", + "_y_data", + "_extended_properties", + ] + + _x_data: npt.NDArray[TData] + _y_data: npt.NDArray[TData] + _extended_properties: ExtendedPropertyDictionary + + @overload + @classmethod + def from_arrays_1d( + cls, + x_array: npt.NDArray[TOtherData], + y_array: npt.NDArray[TOtherData], + dtype: None = ..., + *, + x_units: str = ..., + y_units: str = ..., + copy: bool = ..., + extended_properties: Mapping[str, ExtendedPropertyValue] | None = ..., + ) -> XYData[TOtherData]: ... + + @overload + @classmethod + def from_arrays_1d( + cls, + x_array: npt.NDArray[Any] | Sequence[Any], + y_array: npt.NDArray[Any] | Sequence[Any], + dtype: type[TOtherData] | np.dtype[TOtherData], + *, + x_units: str = ..., + y_units: str = ..., + copy: bool = ..., + extended_properties: Mapping[str, ExtendedPropertyValue] | None = ..., + ) -> XYData[TOtherData]: ... + + @overload + @classmethod + def from_arrays_1d( + cls, + x_array: npt.NDArray[Any] | Sequence[Any], + y_array: npt.NDArray[Any] | Sequence[Any], + dtype: npt.DTypeLike = ..., + *, + x_units: str = ..., + y_units: str = ..., + copy: bool = ..., + extended_properties: Mapping[str, ExtendedPropertyValue] | None = ..., + ) -> XYData[Any]: ... + + @classmethod + def from_arrays_1d( + cls, + x_array: npt.NDArray[Any] | Sequence[Any], + y_array: npt.NDArray[Any] | Sequence[Any], + dtype: npt.DTypeLike = None, + *, + x_units: str = "", + y_units: str = "", + copy: bool = True, + extended_properties: Mapping[str, ExtendedPropertyValue] | None = None, + ) -> XYData[Any]: + """Construct an XYData from two one-dimensional arrays or sequences. + + Args: + x_array: The x-axis data as a one-dimensional array or a sequence. + y_array: The y-axis data as a one-dimensional array or a sequence. + dtype: The NumPy data type for the XYdata axes. This argument is required + when x_array and y_array are sequences. + x_units: The units string associated with x_array. + y_units: The units string associated with y_array + copy: Specifies whether to copy the arrays or save references to them. + extended_properties: The extended properties of the XYData. + + Returns: + An XYData object containing the specified data. + """ + if isinstance(x_array, np.ndarray): + if x_array.ndim != 1: + raise invalid_array_ndim( + "input array", "one-dimensional array or sequence", x_array.ndim + ) + elif isinstance(x_array, Sequence) or ( + sys.version_info < (3, 10) and isinstance(x_array, std_array.array) + ): + if dtype is None: + raise ValueError("You must specify a dtype when the input array is a sequence.") + else: + raise invalid_arg_type("input array", "one-dimensional array or sequence", x_array) + + if isinstance(y_array, np.ndarray): + if y_array.ndim != 1: + raise invalid_array_ndim( + "input array", "one-dimensional array or sequence", y_array.ndim + ) + elif isinstance(y_array, Sequence) or ( + sys.version_info < (3, 10) and isinstance(y_array, std_array.array) + ): + if dtype is None: + raise ValueError("You must specify a dtype when the input array is a sequence.") + else: + raise invalid_arg_type("input array", "one-dimensional array or sequence", y_array) + + return cls( + x_data=_np_asarray(x_array, dtype, copy=copy), + y_data=_np_asarray(y_array, dtype, copy=copy), + x_units=x_units, + y_units=y_units, + extended_properties=extended_properties, + ) + + def __init__( + self: XYData[TOtherData], + x_data: npt.NDArray[TOtherData], + y_data: npt.NDArray[TOtherData], + *, + x_units: str = "", + y_units: str = "", + extended_properties: Mapping[str, ExtendedPropertyValue] | None = None, + copy_extended_properties: bool = True, + ) -> None: + """Initialize a new XYData. + + Args: + x_data: A NumPy ndarray to use for x-axis data storage. The XYData takes ownership + of this array. If not specified, an ndarray is created based on the specified + dtype and capacity. + y_data: A NumPy ndarray to use for y-axis data storage. The XYData takes ownership + of this array. If not specified, an ndarray is created based on the specified + dtype and capacity. + x_units: The units string associated with x_data. + y_units: The units string associated with y_data. + extended_properties: The extended properties of the XYData. + copy_extended_properties: Specifies whether to copy the extended properties or take + ownership. + + Returns: + An XYData object. + """ + if x_data.dtype != y_data.dtype: + raise TypeError("x_data and y_data must have the same type.") + + if isinstance(x_data, np.ndarray) and isinstance(y_data, np.ndarray): + self._init_with_provided_arrays( + x_data, + y_data, + x_data.dtype, + ) + else: + raise invalid_arg_type("raw data", "NumPy ndarray", x_data) + + if copy_extended_properties or not isinstance( + extended_properties, ExtendedPropertyDictionary + ): + extended_properties = ExtendedPropertyDictionary(extended_properties) + self._extended_properties = extended_properties + + # If x and y units are not already in extended properties, set them. + # If the caller specifies a non-blank x or y units, overwrite the existing entry. + if _UNIT_DESCRIPTION_X not in self._extended_properties or x_units: + self._extended_properties[_UNIT_DESCRIPTION_X] = x_units + if _UNIT_DESCRIPTION_Y not in self._extended_properties or y_units: + self._extended_properties[_UNIT_DESCRIPTION_Y] = y_units + + def _init_with_provided_arrays( + self, + x_data: npt.NDArray[TData], + y_data: npt.NDArray[TData], + dtype: npt.DTypeLike = None, + ) -> None: + if not isinstance(x_data, np.ndarray): + raise invalid_arg_type("x-axis input array", "one-dimensional array", x_data) + if not isinstance(y_data, np.ndarray): + raise invalid_arg_type("y-axis input array", "one-dimensional array", y_data) + if x_data.ndim != 1: + raise invalid_array_ndim("x-axis input array", "one-dimensional array", x_data.ndim) + if y_data.ndim != 1: + raise invalid_array_ndim("y-axis input array", "one-dimensional array", y_data.ndim) + if len(x_data) != len(y_data): + raise ValueError("x_data and y_data must be the same length.") + + if dtype is None: + if x_data.dtype != y_data.dtype: + raise TypeError("x_data and y_data must be the same type.") + dtype = x_data.dtype + + validate_dtype(dtype, _DATA_DTYPES) + if dtype != x_data.dtype: + raise create_datatype_mismatch_error( + "input array", x_data.dtype, "requested", np.dtype(dtype) + ) + if dtype != y_data.dtype: + raise create_datatype_mismatch_error( + "input array", y_data.dtype, "requested", np.dtype(dtype) + ) + + self._x_data = x_data + self._y_data = y_data + + @property + def x_data(self) -> npt.NDArray[TData]: + """The x-axis data of this XYData.""" + return self._x_data + + @property + def y_data(self) -> npt.NDArray[TData]: + """The y-axis data of this XYData.""" + return self._y_data + + @property + def x_units(self) -> str: + """The unit of measurement, such as volts, of x_data.""" + value = self._extended_properties.get(_UNIT_DESCRIPTION_X, "") + assert isinstance(value, str) + return value + + @x_units.setter + def x_units(self, value: str) -> None: + if not isinstance(value, str): + raise invalid_arg_type("x_units", "str", value) + self._extended_properties[_UNIT_DESCRIPTION_X] = value + + @property + def y_units(self) -> str: + """The unit of measurement, such as volts, of y_data.""" + value = self._extended_properties.get(_UNIT_DESCRIPTION_Y, "") + assert isinstance(value, str) + return value + + @y_units.setter + def y_units(self, value: str) -> None: + if not isinstance(value, str): + raise invalid_arg_type("y_units", "str", value) + self._extended_properties[_UNIT_DESCRIPTION_Y] = value + + @property + def dtype(self) -> np.dtype[TData]: + """The NumPy dtype for the XYData.""" + return self._x_data.dtype + + @property + def extended_properties(self) -> ExtendedPropertyDictionary: + """The extended properties for the XYData. + + .. note:: + Data stored in the extended properties dictionary may not be encrypted when you send it + over the network or write it to a TDMS file. + """ + return self._extended_properties + + def __eq__(self, value: object, /) -> bool: + """Return self==value.""" + if not isinstance(value, self.__class__): + return NotImplemented + return ( + np.array_equal(self.x_data, value.x_data) + and np.array_equal(self.y_data, value.y_data) + and self.x_units == value.x_units + and self.y_units == value.y_units + ) + + def __reduce__(self) -> tuple[Any, ...]: + """Return object state for pickling.""" + ctor_args = (self._x_data, self._y_data) + ctor_kwargs: dict[str, Any] = { + "extended_properties": self._extended_properties, + "copy_extended_properties": False, + } + return (self.__class__._unpickle, (ctor_args, ctor_kwargs)) + + @classmethod + def _unpickle(cls, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Self: + return cls(*args, **kwargs) + + def __repr__(self) -> str: + """Return repr(self).""" + args = [ + f"x_data={self.x_data!r}", + f"y_data={self.y_data!r}", + f"extended_properties={self._extended_properties._properties!r}", + ] + return f"{self.__class__.__module__}.{self.__class__.__name__}({', '.join(args)})" + + def __str__(self) -> str: + """Return str(self).""" + x_str = XYData._format_values_with_units(self.x_data, self.x_units) + y_str = XYData._format_values_with_units(self.y_data, self.y_units) + return f"[{x_str}, {y_str}]" + + @staticmethod + def _format_values_with_units(values: npt.NDArray[TData], units: str) -> str: + if units: + values_with_units = [f"{value} {units}" for value in values] + values_str = ", ".join(values_with_units) + else: + values_without_units = [f"{value}" for value in values] + values_str = ", ".join(values_without_units) + + return f"[{values_str}]" diff --git a/tests/unit/xy_data/__init__.py b/tests/unit/xy_data/__init__.py new file mode 100644 index 0000000..64b4c73 --- /dev/null +++ b/tests/unit/xy_data/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the nitypes.xy_data package.""" diff --git a/tests/unit/xy_data/test_xy_data.py b/tests/unit/xy_data/test_xy_data.py new file mode 100644 index 0000000..8b92fca --- /dev/null +++ b/tests/unit/xy_data/test_xy_data.py @@ -0,0 +1,467 @@ +from __future__ import annotations + +import array +import copy +import itertools +import pickle +import sys +from typing import Any + +import numpy as np +import pytest +from packaging.version import Version +from typing_extensions import assert_type + +from nitypes.waveform._extended_properties import ExtendedPropertyDictionary +from nitypes.xy_data import _UNIT_DESCRIPTION_X, _UNIT_DESCRIPTION_Y, XYData, TData + + +############################################################################### +# create +############################################################################### +def test___data_and_dtype___create___creates_xydata_with_data_dtype_and_default_units() -> None: + data = np.array([1, 2, 3, 4, 5], np.int32) + xydata = XYData(data, data) + + assert xydata.dtype == np.int32 + assert_type(xydata, XYData[np.int32]) + assert xydata.x_units == "" + assert xydata.y_units == "" + + +def test___data_dtype_and_units___create___creates_xydata_with_data_dtype_and_units() -> None: + data = np.array([1, 2, 3, 4, 5], np.int32) + xydata = XYData(data, data, x_units="volts", y_units="seconds") + + assert xydata.dtype == np.int32 + assert_type(xydata, XYData[np.int32]) + assert xydata.x_units == "volts" + assert xydata.y_units == "seconds" + + +def test___mismatched_dtypes___create___raises_type_error() -> None: + data = np.array([1, 2, 3, 4, 5], np.int32) + data2 = np.array([1, 2, 3, 4, 5], np.float64) + + with pytest.raises(TypeError) as exc: + _ = XYData(data, data2) + + assert exc.value.args[0].startswith("x_data and y_data must have the same type.") + + +@pytest.mark.parametrize( + "data", + [ + np.array([1 + 2j, 3 - 4j, -5 + 6j, -7 - 8j], dtype=np.complex128), + np.array(["a", "b", "c"], dtype=np.str_), + np.array([b"\x01\x02", b"\x03\x04"], dtype=np.void), + np.array([(1, 2), (3, 4)], dtype="i2,i2"), + ], +) +def test___unsupported_dtype___create___raises_type_error( + data: np.ndarray, +) -> None: + with pytest.raises(TypeError) as exc: + _ = XYData(data, data) + + assert exc.value.args[0].startswith("The requested data type is not supported.") + + +############################################################################### +# from_arrays_1d +############################################################################### +def test___float64_ndarray___from_arrays_1d___creates_xydata_with_float64_dtype() -> None: + data = np.array([1.1, 2.2, 3.3, 4.4, 5.5], np.float64) + + xydata = XYData.from_arrays_1d(data, data) + + assert xydata.x_data.tolist() == data.tolist() + assert xydata.y_data.tolist() == data.tolist() + assert xydata.dtype == np.float64 + assert_type(xydata, XYData[np.float64]) + assert xydata.x_units == "" + assert xydata.y_units == "" + + +def test___float64_ndarray_with_units___from_arrays_1d___creates_xydata_with_float64_dtype_and_units() -> ( + None +): + data = np.array([1.1, 2.2, 3.3, 4.4, 5.5], np.float64) + + xydata = XYData.from_arrays_1d(data, data, x_units="amps", y_units="hours") + + assert xydata.x_data.tolist() == data.tolist() + assert xydata.y_data.tolist() == data.tolist() + assert xydata.dtype == np.float64 + assert_type(xydata, XYData[np.float64]) + assert xydata.x_units == "amps" + assert xydata.y_units == "hours" + + +def test___int32_ndarray___from_arrays_1d___creates_xydata_with_int32_dtype() -> None: + data = np.array([1, 2, 3, 4, 5], np.int32) + + xydata = XYData.from_arrays_1d(data, data) + + assert xydata.x_data.tolist() == data.tolist() + assert xydata.y_data.tolist() == data.tolist() + assert xydata.dtype == np.int32 + assert_type(xydata, XYData[np.int32]) + + +def test___int32_array_with_dtype___from_arrays_1d___creates_xydata_with_specified_dtype() -> None: + data = array.array("i", [1, 2, 3, 4, 5]) + + xydata = XYData.from_arrays_1d(data, data, np.int32) + + assert xydata.x_data.tolist() == data.tolist() + assert xydata.y_data.tolist() == data.tolist() + assert xydata.dtype == np.int32 + assert_type(xydata, XYData[np.int32]) + + +def test___int16_ndarray_with_mismatched_dtype___from_arrays_1d___creates_xydata_with_specified_dtype() -> ( + None +): + data = np.array([1, 2, 3, 4, 5], np.int16) + + xydata = XYData.from_arrays_1d(data, data, np.int32) + + assert xydata.x_data.tolist() == data.tolist() + assert xydata.y_data.tolist() == data.tolist() + assert xydata.dtype == np.int32 + assert_type(xydata, XYData[np.int32]) + + +def test___int_list_with_dtype___from_arrays_1d___creates_xydata_with_specified_dtype() -> None: + data = [1, 2, 3, 4, 5] + + xydata = XYData.from_arrays_1d(data, data, np.int32) + + assert xydata.x_data.tolist() == data + assert xydata.y_data.tolist() == data + assert xydata.dtype == np.int32 + assert_type(xydata, XYData[np.int32]) + + +def test___int_list_with_dtype_str___from_arrays_1d___creates_xydata_with_specified_dtype() -> None: + data = [1, 2, 3, 4, 5] + + xydata = XYData.from_arrays_1d(data, data, "int32") + + assert xydata.x_data.tolist() == data + assert xydata.y_data.tolist() == data + assert xydata.dtype == np.int32 + assert_type(xydata, XYData[Any]) # dtype not inferred from string + + +def test___int32_ndarray_2d___from_arrays_1d___raises_value_error() -> None: + data = np.array([[1, 2, 3], [4, 5, 6]], np.int32) + + with pytest.raises(ValueError) as exc: + _ = XYData.from_arrays_1d(data, data) + + assert exc.value.args[0].startswith( + "The input array must be a one-dimensional array or sequence." + ) + + +def test___int_list_without_dtype___from_arrays_1d___raises_value_error() -> None: + data = [1, 2, 3, 4, 5] + + with pytest.raises(ValueError) as exc: + _ = XYData.from_arrays_1d(data, data) + + assert exc.value.args[0].startswith( + "You must specify a dtype when the input array is a sequence." + ) + + +def test___bytes___from_arrays_1d___raises_value_error() -> None: + data = b"\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00" + + with pytest.raises(ValueError) as exc: + _ = XYData.from_arrays_1d(data, data, np.int32) + + assert exc.value.args[0].startswith("invalid literal for int() with base 10:") + + +def test___iterable___from_arrays_1d___raises_type_error() -> None: + data = itertools.repeat(3) + + with pytest.raises(TypeError) as exc: + _ = XYData.from_arrays_1d(data, data, np.int32) # type: ignore[call-overload] + + assert exc.value.args[0].startswith( + "The input array must be a one-dimensional array or sequence." + ) + + +def test___ndarray_with_unsupported_dtype___from_arrays_1d___raises_type_error() -> None: + data = np.zeros(3, np.str_) + + with pytest.raises(TypeError) as exc: + _ = XYData.from_arrays_1d(data, data) + + assert exc.value.args[0].startswith("The requested data type is not supported.") + + +def test___copy___from_arrays_1d___creates_xydata_linked_to_different_buffer() -> None: + x_data = np.array([1, 2, 3, 4, 5], np.int32) + y_data = np.array([6, 7, 8, 9, 10], np.int32) + + xydata = XYData.from_arrays_1d(x_data, y_data, copy=True) + + assert xydata.x_data is not x_data + assert xydata.x_data.tolist() == x_data.tolist() + x_data[:] = [5, 4, 3, 2, 1] + assert xydata.x_data.tolist() != x_data.tolist() + + assert xydata.y_data is not y_data + assert xydata.y_data.tolist() == y_data.tolist() + y_data[:] = [5, 4, 3, 2, 1] + assert xydata.y_data.tolist() != y_data.tolist() + + +def test___int32_ndarray_no_copy___from_arrays_1d___creates_xydata_linked_to_same_buffer() -> None: + x_data = np.array([1, 2, 3, 4, 5], np.int32) + y_data = np.array([6, 7, 8, 9, 10], np.int32) + + xydata = XYData.from_arrays_1d(x_data, y_data, copy=False) + + assert xydata._x_data is x_data + assert xydata.x_data.tolist() == x_data.tolist() + x_data[:] = [5, 4, 3, 2, 1] + assert xydata.x_data.tolist() == x_data.tolist() + + assert xydata._y_data is y_data + assert xydata.y_data.tolist() == y_data.tolist() + y_data[:] = [5, 4, 3, 2, 1] + assert xydata.y_data.tolist() == y_data.tolist() + + +def test___int_list_no_copy___from_arrays_1d___raises_value_error() -> None: + x_data = [1, 2, 3, 4, 5] + y_data = [6, 7, 8, 9, 10] + + with pytest.raises(ValueError) as exc: + _ = XYData.from_arrays_1d(x_data, y_data, np.int32, copy=False) + + assert exc.value.args[0].startswith( + "Unable to avoid copy while creating an array as requested." + ) + + +############################################################################### +# compare +############################################################################### +@pytest.mark.parametrize( + "left, right", + [ + ( + XYData.from_arrays_1d([1, 2], [3, 4], np.int32), + XYData.from_arrays_1d([1, 2], [3, 4], np.int32), + ), + ( + XYData.from_arrays_1d([1.0, 2.0], [3.0, 4.0], np.int32), + XYData.from_arrays_1d([1.0, 2.0], [3.0, 4.0], np.int32), + ), + ], +) +def test___same_value___comparison___equal(left: XYData[TData], right: XYData[TData]) -> None: + assert left == right + + +@pytest.mark.parametrize( + "left, right", + [ + ( + XYData.from_arrays_1d([1, 2], [5, 6], np.int32), + XYData.from_arrays_1d([1, 2], [3, 4], np.int32), + ), + ( + XYData.from_arrays_1d([1.0, 2.0], [5.0, 6.0], np.int32), + XYData.from_arrays_1d([1.0, 2.0], [3.0, 4.0], np.int32), + ), + ], +) +def test___different_values___comparison___not_equal( + left: XYData[TData], right: XYData[TData] +) -> None: + assert left != right + + +def test___different_units___comparison___not_equal() -> None: + left = XYData.from_arrays_1d([0], [0], np.int32, x_units="volts", y_units="seconds") + right = XYData.from_arrays_1d([0], [0], np.int32, x_units="amps", y_units="seconds") + + assert left != right + + +############################################################################### +# other operators +############################################################################### +if Version(np.__version__) >= Version("2.0.0") or sys.platform != "win32": + _NDARRAY_DTYPE_INT32 = ", dtype=int32" +else: + _NDARRAY_DTYPE_INT32 = "" + + +@pytest.mark.parametrize( + "value, expected_repr", + [ + ( + XYData.from_arrays_1d([10], [20], np.int32), + f"nitypes.xy_data.XYData(x_data=array([10]{_NDARRAY_DTYPE_INT32}), " + f"y_data=array([20]{_NDARRAY_DTYPE_INT32}), " + "extended_properties={'NI_UnitDescription_X': '', 'NI_UnitDescription_Y': ''})", + ), + ( + XYData.from_arrays_1d([1.0, 1.1], [1.2, 1.3], np.float64), + "nitypes.xy_data.XYData(x_data=array([1. , 1.1]), y_data=array([1.2, 1.3]), " + "extended_properties={'NI_UnitDescription_X': '', 'NI_UnitDescription_Y': ''})", + ), + ( + XYData.from_arrays_1d([10], [20], np.int32, x_units="volts", y_units="s"), + f"nitypes.xy_data.XYData(x_data=array([10]{_NDARRAY_DTYPE_INT32}), " + f"y_data=array([20]{_NDARRAY_DTYPE_INT32}), " + "extended_properties={'NI_UnitDescription_X': 'volts', 'NI_UnitDescription_Y': 's'})", + ), + ( + XYData.from_arrays_1d( + [1.0, 1.1], + [1.2, 1.3], + np.float64, + extended_properties={"NI_ChannelName": "Dev1/ai0"}, + ), + "nitypes.xy_data.XYData(x_data=array([1. , 1.1]), y_data=array([1.2, 1.3]), " + "extended_properties={'NI_ChannelName': 'Dev1/ai0', 'NI_UnitDescription_X': '', " + "'NI_UnitDescription_Y': ''})", + ), + ], +) +def test___various_values___repr___looks_ok(value: XYData[Any], expected_repr: str) -> None: + assert repr(value) == expected_repr + + +@pytest.mark.parametrize( + "value, expected_str", + [ + ( + XYData.from_arrays_1d([], [], np.int32), + "[[], []]", + ), + ( + XYData.from_arrays_1d([], [], np.int32, x_units="volts", y_units="s"), + "[[], []]", + ), + ( + XYData.from_arrays_1d([10, 20], [30, 40], np.int32), + "[[10, 20], [30, 40]]", + ), + ( + XYData.from_arrays_1d([10.0, 20.0], [30.0, 40.0], np.float64), + "[[10.0, 20.0], [30.0, 40.0]]", + ), + ( + XYData.from_arrays_1d([10], [20], np.int32, x_units="volts", y_units="s"), + "[[10 volts], [20 s]]", + ), + ( + XYData.from_arrays_1d([1, 2], [3, 4], np.int32, x_units="miles", y_units="hr"), + "[[1 miles, 2 miles], [3 hr, 4 hr]]", + ), + ], +) +def test___various_values___str___looks_ok(value: XYData[Any], expected_str: str) -> None: + assert str(value) == expected_str + + +############################################################################### +# other properties +############################################################################### +def test___xy_data_with_units___get_extended_properties___returns_correct_dictionary() -> None: + value = XYData.from_arrays_1d([20.0], [40.0], np.float64, x_units="watts", y_units="hr") + + prop_dict = value.extended_properties + + assert isinstance(prop_dict, ExtendedPropertyDictionary) + assert prop_dict.get(_UNIT_DESCRIPTION_X) == "watts" + assert prop_dict.get(_UNIT_DESCRIPTION_Y) == "hr" + + +def test___xy_data_with_units___set_units___units_updated_correctly() -> None: + value = XYData.from_arrays_1d([20.0], [40.0], np.float64, x_units="watts", y_units="hr") + + value.x_units = "volts" + value.y_units = "s" + + assert value.x_units == "volts" + assert value.y_units == "s" + + +@pytest.mark.parametrize( + "value", + [ + XYData.from_arrays_1d([10, 20], [30, 40], np.int32), + XYData.from_arrays_1d([20.0, 20.1], [20.3, 20.4], np.float64), + XYData.from_arrays_1d([10, 20], [30, 40], np.int32, x_units="A", y_units="B"), + XYData.from_arrays_1d([20.0, 20.1], [20.3, 20.4], np.float64, x_units="C", y_units="D"), + ], +) +def test___various_values___copy___makes_copy(value: XYData[TData]) -> None: + new_value = copy.copy(value) + assert new_value is not value + assert new_value == value + + +@pytest.mark.parametrize( + "value", + [ + XYData.from_arrays_1d([10, 20], [30, 40], np.int32), + XYData.from_arrays_1d([20.0, 20.1], [20.3, 20.4], np.float64), + XYData.from_arrays_1d([10, 20], [30, 40], np.int32, x_units="A", y_units="B"), + XYData.from_arrays_1d([20.0, 20.1], [20.3, 20.4], np.float64, x_units="C", y_units="D"), + ], +) +def test___various_values___pickle_unpickle___makes_copy(value: XYData[TData]) -> None: + new_value = pickle.loads(pickle.dumps(value)) + assert new_value is not value + assert new_value == value + + +def test___xy_data___pickle___references_public_modules() -> None: + value = XYData.from_arrays_1d([10, 20], [30, 40], np.int32) + value_bytes = pickle.dumps(value) + + assert b"nitypes.xy_data" in value_bytes + assert b"nitypes.xy_data._xy_data" not in value_bytes + + +def test___various_units_values___change_units___updates_units_correctly() -> None: + data = XYData.from_arrays_1d([1], [2], np.int32) + data.x_units = "volts" + + assert data.x_units == "volts" + assert data.y_units == "" + + data.y_units = "seconds" + + assert data.x_units == "volts" + assert data.y_units == "seconds" + + data.x_units = "" + data.y_units = "hours" + + assert data.x_units == "" + assert data.y_units == "hours" + + data.y_units = "" + + assert data.x_units == data.y_units == "" + + data.y_units = "A" + data.x_units = "B" + + assert data.x_units == "B" + assert data.y_units == "A"