From 6c2373acd27b76dec654e4b1c96e724d09932c0f Mon Sep 17 00:00:00 2001 From: Brad Keryan Date: Tue, 6 May 2025 17:23:49 -0500 Subject: [PATCH 1/5] nitypes: Add complex integer support --- src/nitypes/_arguments.py | 16 ++-- src/nitypes/_exceptions.py | 17 ++++ src/nitypes/complex/__init__.py | 121 ++++++++++++++++++++++++++ src/nitypes/complex/_conversion.py | 83 ++++++++++++++++++ src/nitypes/complex/_dtypes.py | 10 +++ tests/unit/complex/__init__.py | 1 + tests/unit/complex/test_conversion.py | 96 ++++++++++++++++++++ tests/unit/complex/test_dtypes.py | 53 +++++++++++ 8 files changed, 388 insertions(+), 9 deletions(-) create mode 100644 src/nitypes/complex/__init__.py create mode 100644 src/nitypes/complex/_conversion.py create mode 100644 src/nitypes/complex/_dtypes.py create mode 100644 tests/unit/complex/__init__.py create mode 100644 tests/unit/complex/test_conversion.py create mode 100644 tests/unit/complex/test_dtypes.py diff --git a/src/nitypes/_arguments.py b/src/nitypes/_arguments.py index 5afbe94a..ba2f3d1a 100644 --- a/src/nitypes/_arguments.py +++ b/src/nitypes/_arguments.py @@ -6,7 +6,12 @@ import numpy as np import numpy.typing as npt -from nitypes._exceptions import invalid_arg_type, invalid_arg_value, unsupported_arg +from nitypes._exceptions import ( + invalid_arg_type, + invalid_arg_value, + unsupported_arg, + unsupported_dtype, +) def arg_to_float( @@ -144,14 +149,7 @@ def validate_dtype(dtype: npt.DTypeLike, supported_dtypes: tuple[npt.DTypeLike, if not isinstance(dtype, (type, np.dtype)): dtype = np.dtype(dtype) if not np.isdtype(dtype, supported_dtypes): - # Remove duplicate names because distinct types (e.g. int vs. long) may have the same name - # ("int32"). - supported_dtype_names = {np.dtype(d).name: None for d in supported_dtypes}.keys() - raise TypeError( - "The requested data type is not supported.\n\n" - f"Data type: {np.dtype(dtype)}\n" - f"Supported data types: {', '.join(supported_dtype_names)}" - ) + raise unsupported_dtype("requested data type", dtype, supported_dtypes) def validate_unsupported_arg(arg_description: str, value: object) -> None: diff --git a/src/nitypes/_exceptions.py b/src/nitypes/_exceptions.py index 890bf6e2..784ad1ac 100644 --- a/src/nitypes/_exceptions.py +++ b/src/nitypes/_exceptions.py @@ -3,6 +3,9 @@ import reprlib import sys +import numpy as np +import numpy.typing as npt + def add_note(exception: Exception, note: str) -> None: """Add a note to an exception. @@ -66,6 +69,20 @@ def unsupported_arg(arg_description: str, value: object) -> ValueError: ) +def unsupported_dtype( + arg_description: str, dtype: npt.DTypeLike, supported_dtypes: tuple[npt.DTypeLike, ...] +) -> TypeError: + """Create a TypeError for an unsupported dtype.""" + # Remove duplicate names because distinct types (e.g. int vs. long) may have the same name + # ("int32"). + supported_dtype_names = {np.dtype(d).name: None for d in supported_dtypes}.keys() + return TypeError( + f"The {arg_description} is not supported.\n\n" + f"Data type: {np.dtype(dtype)}\n" + f"Supported data types: {', '.join(supported_dtype_names)}" + ) + + # English-specific hack. This is why we prefer "Key: value" for localizable errors. TODO: consider # moving the full strings into a string table instead of building them out of English noun phrases. def _a(noun: str) -> str: diff --git a/src/nitypes/complex/__init__.py b/src/nitypes/complex/__init__.py new file mode 100644 index 00000000..3410c300 --- /dev/null +++ b/src/nitypes/complex/__init__.py @@ -0,0 +1,121 @@ +"""Complex number data types for NI Python APIs. + +================ +Complex Integers +================ + +Some NI driver APIs (such as NI-FGEN, NI-SCOPE, NI-RFSA, and NI-RFSG) use complex numbers to +represent I/Q data. Python and NumPy have native support for complex floating-point numbers, but +not complex integers, so the ``nityeps.complex`` submodule provides a NumPy representation of +complex integers. + +``ComplexInt32DType`` is a NumPy structured data type object representing a complex integer with +16-bit ``real`` and ``imag`` fields. This structured data type has the same memory layout as the +``NIComplexI16`` C struct used by NI driver APIs. + +For more information about NumPy structured data types, see the +:ref:`NumPy documentation on structured arrays `. + +.. note:: + In ``NIComplexI16``, the number 16 refers to the number of bits in each field. In + ``ComplexInt32DType``, the number 32 refers to the total number of bits, following the precedent + set by NumPy's other complex types. For example, ``np.complex128`` contains 64-bit ``real`` and + ``imag`` fields. + +Constructing arrays of complex integers +--------------------------------------- + +You can construct an array of complex integers from a sequence of tuples using ``np.array``: + +>>> import numpy as np +>>> np.array([(1, 2), (3, 4)], dtype=ComplexInt32DType) +array([(1, 2), (3, 4)], dtype=[('real', '>> np.zeros(3, dtype=ComplexInt32DType) +array([(0, 0), (0, 0), (0, 0)], dtype=[('real', '>> x = np.array([(1, 2), (3, 4), (5, 6)], dtype=ComplexInt32DType) +>>> x[0] +np.void((1, 2), dtype=[('real', '>> x[1] +np.void((3, 4), dtype=[('real', '>> x[0][0] +np.int16(1) +>>> x[0][1] +np.int16(2) + +You can also index by field names ``real`` and ``imag``: + +>>> x[0]['real'] +np.int16(1) +>>> x[0]['imag'] +np.int16(2) + +Or you can index the entire array by field names: + +>>> x['real'] +array([1, 3, 5], dtype=int16) +>>> x['imag'] +array([2, 4, 6], dtype=int16) + +Arrays of complex integers support slicing and negative indices like any other array: + +>>> x[0:2] +array([(1, 2), (3, 4)], dtype=[('real', '>> x[1:] +array([(3, 4), (5, 6)], dtype=[('real', '>> x[-1] +np.void((5, 6), dtype=[('real', '>> x[0].item() +(1, 2) +>>> [y.item() for y in x] +[(1, 2), (3, 4), (5, 6)] + +To convert NumPy arrays between between different complex number data types, use the +`convert_complex` function: + +>>> convert_complex(np.complex128, x) +array([1.+2.j, 3.+4.j, 5.+6.j]) +>>> convert_complex(ComplexInt32DType, np.array([1.23+4.56j])) +array([(1, 4)], dtype=[('real', '>> convert_complex(np.complex128, x[0]) +np.complex128(1+2j) +>>> convert_complex(ComplexInt32DType, np.complex128(3+4j)) +np.void((3, 4), dtype=[('real', ' npt.NDArray[_ScalarType]: ... + + +@overload +def convert_complex( + requested_dtype: npt.DTypeLike, value: npt.NDArray[Any] +) -> npt.NDArray[Any]: ... + + +def convert_complex(requested_dtype: npt.DTypeLike, value: npt.NDArray[Any]) -> npt.NDArray[Any]: + """Convert a NumPy array of complex numbers to the specified dtype. + + Args: + requested_dtype: The NumPy data type to convert to. This must be a complex number data type. + value: The NumPy array or scalar to convert. + """ + validate_dtype(requested_dtype, _COMPLEX_DTYPES) + if requested_dtype == value.dtype: + return value + elif requested_dtype == ComplexInt32DType or value.dtype == ComplexInt32DType: + if value.shape == (): + return _convert_complexint32_scalar(requested_dtype, value) + else: + return _convert_complexint32_array(requested_dtype, value) + else: + return value.astype(requested_dtype) + + +def _convert_complexint32_scalar( + requested_dtype: npt.DTypeLike | type[_ScalarType] | np.dtype[_ScalarType], + value: npt.NDArray[Any], +) -> npt.NDArray[_ScalarType]: + # ndarray.view on scalars requires the source and destination types to have the same size, so + # reshape the scalar into an 1-element array before converting and index it afterwards. + # Mypy currently thinks that the index operator returns Any. + return _convert_complexint32_array(requested_dtype, value.reshape(1))[0] # type: ignore[no-any-return] + + +def _convert_complexint32_array( + requested_dtype: npt.DTypeLike | type[_ScalarType] | np.dtype[_ScalarType], + value: npt.NDArray[Any], +) -> npt.NDArray[_ScalarType]: + if not isinstance(requested_dtype, np.dtype): + requested_dtype = np.dtype(requested_dtype) + + requested_field_dtype = _FIELD_DTYPE.get(requested_dtype) + if requested_field_dtype is None: + raise unsupported_dtype("requested data type", requested_dtype, _COMPLEX_DTYPES) + + value_field_dtype = _FIELD_DTYPE.get(value.dtype) + if value_field_dtype is None: + raise unsupported_dtype("array data type", value.dtype, _COMPLEX_DTYPES) + + return value.view(value_field_dtype).astype(requested_field_dtype).view(requested_dtype) diff --git a/src/nitypes/complex/_dtypes.py b/src/nitypes/complex/_dtypes.py new file mode 100644 index 00000000..0e56a68f --- /dev/null +++ b/src/nitypes/complex/_dtypes.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import numpy as np +from typing_extensions import TypeAlias + +ComplexInt32Base: TypeAlias = np.void +"""Type alias for the base type of `ComplexInt32DType`, which is `np.void`.""" + +ComplexInt32DType = np.dtype((ComplexInt32Base, [("real", np.int16), ("imag", np.int16)])) +"""NumPy structured data type defining a complex integer with 16-bit `real` and `imag` fields.""" diff --git a/tests/unit/complex/__init__.py b/tests/unit/complex/__init__.py new file mode 100644 index 00000000..3986ed07 --- /dev/null +++ b/tests/unit/complex/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the nitypes.complex package.""" diff --git a/tests/unit/complex/test_conversion.py b/tests/unit/complex/test_conversion.py new file mode 100644 index 00000000..5d40375b --- /dev/null +++ b/tests/unit/complex/test_conversion.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import numpy as np +import numpy.typing as npt +from typing_extensions import assert_type + +from nitypes.complex import ComplexInt32Base, ComplexInt32DType, convert_complex + + +def test___complexint32_to_complex64___convert_complex_array___converts_array() -> None: + value_in = np.array([(1, 2), (3, -4), (-5, 6), (-7, -8)], ComplexInt32DType) + + value_out = convert_complex(np.complex64, value_in) + + assert_type(value_out, npt.NDArray[np.complex64]) + assert isinstance(value_out, np.ndarray) and value_out.dtype == np.complex64 + assert list(value_out) == [1 + 2j, 3 - 4j, -5 + 6j, -7 - 8j] + + +def test___complexint32_to_complex128___convert_complex_array___converts_array() -> None: + value_in = np.array([(1, 2), (3, -4), (-5, 6), (-7, -8)], ComplexInt32DType) + + value_out = convert_complex(np.complex128, value_in) + + assert_type(value_out, npt.NDArray[np.complex128]) + assert isinstance(value_out, np.ndarray) and value_out.dtype == np.complex128 + assert list(value_out) == [1 + 2j, 3 - 4j, -5 + 6j, -7 - 8j] + + +def test___complexint32_to_complexint32___convert_complex_array___returns_original_array() -> None: + value_in = np.array([(1, 2), (3, -4), (-5, 6), (-7, -8)], ComplexInt32DType) + + value_out = convert_complex(ComplexInt32DType, value_in) + + assert_type(value_out, npt.NDArray[ComplexInt32Base]) + assert value_out is value_in + + +def test___complex64_to_complexint32___convert_complex_array___converts_array() -> None: + value_in = np.array([1 + 2j, 3 - 4j, -5 + 6j, -7 - 8j], np.complex64) + + value_out = convert_complex(ComplexInt32DType, value_in) + + assert_type(value_out, npt.NDArray[ComplexInt32Base]) + assert isinstance(value_out, np.ndarray) and value_out.dtype == ComplexInt32DType + assert [x.item() for x in value_out] == [(1, 2), (3, -4), (-5, 6), (-7, -8)] + + +def test___complex64_to_complex64___convert_complex_array___returns_original_array() -> None: + value_in = np.array([1 + 2j, 3 - 4j, -5 + 6j, -7 - 8j], np.complex64) + + value_out = convert_complex(np.complex64, value_in) + + assert_type(value_out, npt.NDArray[np.complex64]) + assert value_out is value_in + + +def test___complex64_to_complex128___convert_complex_array___converts_array() -> None: + value_in = np.array([1.23 + 4.56j, 6.78 - 9.01j], np.complex64) + + value_out = convert_complex(np.complex128, value_in) + + assert_type(value_out, npt.NDArray[np.complex128]) + assert isinstance(value_out, np.ndarray) and value_out.dtype == np.complex128 + # complex64 bruises the numbers (e.g. np.complex128(1.2300000190734863+4.559999942779541j)) so + # round to 3 decimal places. + assert list(np.round(value_out, 3)) == [1.23 + 4.56j, 6.78 - 9.01j] + + +def test___complex128_to_complexint32___convert_complex_array___converts_array() -> None: + value_in = np.array([1 + 2j, 3 - 4j, -5 + 6j, -7 - 8j], np.complex128) + + value_out = convert_complex(ComplexInt32DType, value_in) + + assert_type(value_out, npt.NDArray[ComplexInt32Base]) + assert isinstance(value_out, np.ndarray) and value_out.dtype == ComplexInt32DType + assert [x.item() for x in value_out] == [(1, 2), (3, -4), (-5, 6), (-7, -8)] + + +def test___complex128_to_complex64___convert_complex_array___converts_array() -> None: + value_in = np.array([1.23 + 4.56j, 6.78 - 9.01j], np.complex128) + + value_out = convert_complex(np.complex64, value_in) + + assert_type(value_out, npt.NDArray[np.complex64]) + assert isinstance(value_out, np.ndarray) and value_out.dtype == np.complex64 + assert list(value_out) == [1.23 + 4.56j, 6.78 - 9.01j] + + +def test___complex128_to_complex128___convert_complex_array___returns_original_array() -> None: + value_in = np.array([1.23 + 4.56j, 6.78 - 9.01j], np.complex128) + + value_out = convert_complex(np.complex128, value_in) + + assert_type(value_out, npt.NDArray[np.complex128]) + assert value_out is value_in diff --git a/tests/unit/complex/test_dtypes.py b/tests/unit/complex/test_dtypes.py new file mode 100644 index 00000000..6151ae6b --- /dev/null +++ b/tests/unit/complex/test_dtypes.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Any + +import numpy as np +import numpy.typing as npt +import pytest +from typing_extensions import assert_type + +from nitypes.complex import ComplexInt32Base, ComplexInt32DType + + +def test___complexint32_dtype___np_array___constructs_array_with_dtype() -> None: + value = np.array([(1, 2), (3, -4), (-5, 6), (-7, -8)], ComplexInt32DType) + + assert_type(value, npt.NDArray[ComplexInt32Base]) + assert isinstance(value, np.ndarray) and value.dtype == ComplexInt32DType + assert [x.item() for x in value] == [(1, 2), (3, -4), (-5, 6), (-7, -8)] + + +def test___complexint32_dtype___np_zeros___constructs_array_with_dtype() -> None: + value = np.zeros(3, ComplexInt32DType) + + assert_type(value, np.ndarray[tuple[int], np.dtype[ComplexInt32Base]]) + assert isinstance(value, np.ndarray) and value.dtype == ComplexInt32DType + assert [x.item() for x in value] == [(0, 0), (0, 0), (0, 0)] + + +def test___complexint32_array___index___returns_complexint32_scalar() -> None: + array = np.array([(1, 2), (3, -4)], ComplexInt32DType) + + value = array[1] + + assert_type(value, Any) # ¯\_(ツ)_/¯ + assert isinstance(value, np.void) # _ComplexInt32Base only exists for typing + assert value["real"] == 3 + assert value["imag"] == -4 + + +def test___complexint32_arrays___add___raises_type_error() -> None: + left = np.array([(1, 2), (3, -4)], ComplexInt32DType) + right = np.array([(-5, 6), (-7, -8)], ComplexInt32DType) + + with pytest.raises(TypeError): + _ = left + right # type: ignore[operator] + + +def test___complexint32_array_and_int16_array___add___raises_type_error() -> None: + left = np.array([(1, 2), (3, -4)], ComplexInt32DType) + right = np.array([5, -6], np.int16) + + with pytest.raises(TypeError): + _ = left + right # type: ignore[operator] From 2d89b8bc1c27dd74b720a890261378b8a4137817 Mon Sep 17 00:00:00 2001 From: Brad Keryan Date: Tue, 6 May 2025 20:56:48 -0500 Subject: [PATCH 2/5] complex: Add scalar overloads and tests --- src/nitypes/complex/_conversion.py | 63 ++++++++++++++-------- tests/unit/complex/test_conversion.py | 75 +++++++++++++++++++++++---- 2 files changed, 108 insertions(+), 30 deletions(-) diff --git a/src/nitypes/complex/_conversion.py b/src/nitypes/complex/_conversion.py index 8fab4a7d..345b85f7 100644 --- a/src/nitypes/complex/_conversion.py +++ b/src/nitypes/complex/_conversion.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, TypeVar, overload +from typing import Any, TypeVar, cast, overload import numpy as np import numpy.typing as npt @@ -9,7 +9,9 @@ from nitypes._exceptions import unsupported_dtype from nitypes.complex._dtypes import ComplexInt32DType +_Item_co = TypeVar("_Item_co", bound=Any) _ScalarType = TypeVar("_ScalarType", bound=np.generic) +_Shape = TypeVar("_Shape", bound=tuple[int, ...]) _COMPLEX_DTYPES = ( np.complex64, @@ -26,17 +28,37 @@ @overload def convert_complex( - requested_dtype: type[_ScalarType] | np.dtype[_ScalarType], value: npt.NDArray[Any] -) -> npt.NDArray[_ScalarType]: ... + requested_dtype: type[_ScalarType] | np.dtype[_ScalarType], + value: np.ndarray[_Shape, Any], +) -> np.ndarray[_Shape, np.dtype[_ScalarType]]: ... @overload def convert_complex( - requested_dtype: npt.DTypeLike, value: npt.NDArray[Any] -) -> npt.NDArray[Any]: ... + requested_dtype: npt.DTypeLike, value: np.ndarray[_Shape, Any] +) -> np.ndarray[_Shape, Any]: ... -def convert_complex(requested_dtype: npt.DTypeLike, value: npt.NDArray[Any]) -> npt.NDArray[Any]: +# https://numpy.org/doc/2.2/reference/typing.html#d-arrays +# "While thus not strictly correct, all operations are that can potentially perform a 0D-array -> +# scalar cast are currently annotated as exclusively returning an ndarray." +@overload +def convert_complex( + requested_dtype: type[_ScalarType] | np.dtype[_ScalarType], + value: np.generic[Any], +) -> np.ndarray[tuple[()], np.dtype[_ScalarType]]: ... + + +@overload +def convert_complex( + requested_dtype: npt.DTypeLike, + value: np.generic[Any], +) -> np.ndarray[tuple[()], Any]: ... + + +def convert_complex( + requested_dtype: npt.DTypeLike, value: np.ndarray[_Shape, Any] | np.generic[Any] +) -> np.ndarray[_Shape, Any]: """Convert a NumPy array of complex numbers to the specified dtype. Args: @@ -45,30 +67,29 @@ def convert_complex(requested_dtype: npt.DTypeLike, value: npt.NDArray[Any]) -> """ validate_dtype(requested_dtype, _COMPLEX_DTYPES) if requested_dtype == value.dtype: - return value + return cast(np.ndarray[_Shape, Any], value) elif requested_dtype == ComplexInt32DType or value.dtype == ComplexInt32DType: + # ndarray.view on scalars requires the source and destination types to have the same size, + # so reshape the scalar into an 1-element array before converting and index it afterwards. + # shape == () means this is either a scalar (np.generic) or a 0-dimension array, but mypy + # doesn't know that. if value.shape == (): - return _convert_complexint32_scalar(requested_dtype, value) + return cast( + np.ndarray[_Shape, Any], + _convert_complexint32_array(requested_dtype, value.reshape(1))[0], + ) else: - return _convert_complexint32_array(requested_dtype, value) + return _convert_complexint32_array( + requested_dtype, cast(np.ndarray[_Shape, Any], value) + ) else: return value.astype(requested_dtype) -def _convert_complexint32_scalar( - requested_dtype: npt.DTypeLike | type[_ScalarType] | np.dtype[_ScalarType], - value: npt.NDArray[Any], -) -> npt.NDArray[_ScalarType]: - # ndarray.view on scalars requires the source and destination types to have the same size, so - # reshape the scalar into an 1-element array before converting and index it afterwards. - # Mypy currently thinks that the index operator returns Any. - return _convert_complexint32_array(requested_dtype, value.reshape(1))[0] # type: ignore[no-any-return] - - def _convert_complexint32_array( requested_dtype: npt.DTypeLike | type[_ScalarType] | np.dtype[_ScalarType], - value: npt.NDArray[Any], -) -> npt.NDArray[_ScalarType]: + value: np.ndarray[_Shape, Any], +) -> np.ndarray[_Shape, np.dtype[_ScalarType]]: if not isinstance(requested_dtype, np.dtype): requested_dtype = np.dtype(requested_dtype) diff --git a/tests/unit/complex/test_conversion.py b/tests/unit/complex/test_conversion.py index 5d40375b..27020430 100644 --- a/tests/unit/complex/test_conversion.py +++ b/tests/unit/complex/test_conversion.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + import numpy as np import numpy.typing as npt from typing_extensions import assert_type @@ -7,7 +9,10 @@ from nitypes.complex import ComplexInt32Base, ComplexInt32DType, convert_complex -def test___complexint32_to_complex64___convert_complex_array___converts_array() -> None: +############################################################################### +# convert arrays +############################################################################### +def test___complexint32_array_to_complex64_array___convert_complex___converts_array() -> None: value_in = np.array([(1, 2), (3, -4), (-5, 6), (-7, -8)], ComplexInt32DType) value_out = convert_complex(np.complex64, value_in) @@ -17,7 +22,7 @@ def test___complexint32_to_complex64___convert_complex_array___converts_array() assert list(value_out) == [1 + 2j, 3 - 4j, -5 + 6j, -7 - 8j] -def test___complexint32_to_complex128___convert_complex_array___converts_array() -> None: +def test___complexint32_array_to_complex128_array___convert_complex___converts_array() -> None: value_in = np.array([(1, 2), (3, -4), (-5, 6), (-7, -8)], ComplexInt32DType) value_out = convert_complex(np.complex128, value_in) @@ -27,7 +32,9 @@ def test___complexint32_to_complex128___convert_complex_array___converts_array() assert list(value_out) == [1 + 2j, 3 - 4j, -5 + 6j, -7 - 8j] -def test___complexint32_to_complexint32___convert_complex_array___returns_original_array() -> None: +def test___complexint32_array_to_complexint32_array___convert_complex___returns_original_array() -> ( + None +): value_in = np.array([(1, 2), (3, -4), (-5, 6), (-7, -8)], ComplexInt32DType) value_out = convert_complex(ComplexInt32DType, value_in) @@ -36,7 +43,7 @@ def test___complexint32_to_complexint32___convert_complex_array___returns_origin assert value_out is value_in -def test___complex64_to_complexint32___convert_complex_array___converts_array() -> None: +def test___complex64_array_to_complexint32_array___convert_complex___converts_array() -> None: value_in = np.array([1 + 2j, 3 - 4j, -5 + 6j, -7 - 8j], np.complex64) value_out = convert_complex(ComplexInt32DType, value_in) @@ -46,7 +53,7 @@ def test___complex64_to_complexint32___convert_complex_array___converts_array() assert [x.item() for x in value_out] == [(1, 2), (3, -4), (-5, 6), (-7, -8)] -def test___complex64_to_complex64___convert_complex_array___returns_original_array() -> None: +def test___complex64_array_to_complex64_array___convert_complex___returns_original_array() -> None: value_in = np.array([1 + 2j, 3 - 4j, -5 + 6j, -7 - 8j], np.complex64) value_out = convert_complex(np.complex64, value_in) @@ -55,7 +62,7 @@ def test___complex64_to_complex64___convert_complex_array___returns_original_arr assert value_out is value_in -def test___complex64_to_complex128___convert_complex_array___converts_array() -> None: +def test___complex64_array_to_complex128_array___convert_complex___converts_array() -> None: value_in = np.array([1.23 + 4.56j, 6.78 - 9.01j], np.complex64) value_out = convert_complex(np.complex128, value_in) @@ -67,7 +74,7 @@ def test___complex64_to_complex128___convert_complex_array___converts_array() -> assert list(np.round(value_out, 3)) == [1.23 + 4.56j, 6.78 - 9.01j] -def test___complex128_to_complexint32___convert_complex_array___converts_array() -> None: +def test___complex128_array_to_complexint32_array___convert_complex___converts_array() -> None: value_in = np.array([1 + 2j, 3 - 4j, -5 + 6j, -7 - 8j], np.complex128) value_out = convert_complex(ComplexInt32DType, value_in) @@ -77,7 +84,7 @@ def test___complex128_to_complexint32___convert_complex_array___converts_array() assert [x.item() for x in value_out] == [(1, 2), (3, -4), (-5, 6), (-7, -8)] -def test___complex128_to_complex64___convert_complex_array___converts_array() -> None: +def test___complex128_array_to_complex64_array___convert_complex___converts_array() -> None: value_in = np.array([1.23 + 4.56j, 6.78 - 9.01j], np.complex128) value_out = convert_complex(np.complex64, value_in) @@ -87,10 +94,60 @@ def test___complex128_to_complex64___convert_complex_array___converts_array() -> assert list(value_out) == [1.23 + 4.56j, 6.78 - 9.01j] -def test___complex128_to_complex128___convert_complex_array___returns_original_array() -> None: +def test___complex128_array_to_complex128_array___convert_complex___returns_original_array() -> ( + None +): value_in = np.array([1.23 + 4.56j, 6.78 - 9.01j], np.complex128) value_out = convert_complex(np.complex128, value_in) assert_type(value_out, npt.NDArray[np.complex128]) assert value_out is value_in + + +############################################################################### +# convert scalars +############################################################################### +def test___complexint32_scalar_to_complex128_scalar___convert_complex___converts_scalar() -> None: + value_in = np.array([(1, 2)], ComplexInt32DType)[0] + assert_type(value_in, Any) # ¯\_(ツ)_/¯ + + value_out = convert_complex(np.complex128, value_in) + + assert_type(value_out, np.ndarray[Any, Any]) # This is less than ideal. + assert isinstance(value_out, np.complex128) + assert value_out == (1 + 2j) + + +def test___complex128_scalar_to_complexint32_scalar___convert_complex___converts_scalar() -> None: + value_in = np.complex128(1 + 2j) + + value_out = convert_complex(ComplexInt32DType, value_in) + + assert_type(value_out, np.ndarray[tuple[()], np.dtype[ComplexInt32Base]]) + assert isinstance(value_out, ComplexInt32Base) + assert value_out.item() == (1, 2) + + +def test___complexint32_scalar_to_complexint32_scalar___convert_complex___returns_original_scalar() -> ( + None +): + value_in = np.array([(1, 2)], ComplexInt32DType)[0] + assert_type(value_in, Any) # ¯\_(ツ)_/¯ + + value_out = convert_complex(ComplexInt32DType, value_in) + + assert_type(value_out, np.ndarray[Any, Any]) # This is less than ideal. + assert value_out is value_in + + +def test___complex64_scalar_to_complex128_scalar___convert_complex___converts_scalar() -> None: + value_in = np.complex64(1.23 + 4.56j) + + value_out = convert_complex(np.complex128, value_in) + + assert_type(value_out, np.ndarray[tuple[()], np.dtype[np.complex128]]) + assert isinstance(value_out, np.complex128) + # complex64 bruises the numbers (e.g. np.complex128(1.2300000190734863+4.559999942779541j)) so + # round to 3 decimal places. + assert np.round(value_out, 3) == (1.23 + 4.56j) From 6f6c5dff423084296972f0f805eacd7a3389b9e7 Mon Sep 17 00:00:00 2001 From: Brad Keryan Date: Tue, 6 May 2025 21:30:11 -0500 Subject: [PATCH 3/5] complex: Revise docs --- docs/conf.py | 6 ++++- src/nitypes/complex/__init__.py | 40 ++++++++++++++++++------------ src/nitypes/complex/_conversion.py | 8 ++++-- src/nitypes/complex/_dtypes.py | 4 +-- 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 665dc4a5..c6095044 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -56,7 +56,11 @@ def skip_aliases(app, what, name, obj, skip, options): """Skip documentation for classes that are exported from multiple modules.""" # For names that are defined in a private sub-module and aliased into a # public package, hide the definition. - if name.startswith("nitypes.time._") or name.startswith("nitypes.waveform._"): + if ( + name.startswith("nitypes.complex._") + or name.startswith("nitypes.time._") + or name.startswith("nitypes.waveform._") + ): skip = True return skip diff --git a/src/nitypes/complex/__init__.py b/src/nitypes/complex/__init__.py index 3410c300..14e9fcdd 100644 --- a/src/nitypes/complex/__init__.py +++ b/src/nitypes/complex/__init__.py @@ -6,10 +6,10 @@ Some NI driver APIs (such as NI-FGEN, NI-SCOPE, NI-RFSA, and NI-RFSG) use complex numbers to represent I/Q data. Python and NumPy have native support for complex floating-point numbers, but -not complex integers, so the ``nityeps.complex`` submodule provides a NumPy representation of +not complex integers, so the :mod:`nitypes.complex` submodule provides a NumPy representation of complex integers. -``ComplexInt32DType`` is a NumPy structured data type object representing a complex integer with +:any:`ComplexInt32DType` is a NumPy structured data type object representing a complex integer with 16-bit ``real`` and ``imag`` fields. This structured data type has the same memory layout as the ``NIComplexI16`` C struct used by NI driver APIs. @@ -18,20 +18,20 @@ .. note:: In ``NIComplexI16``, the number 16 refers to the number of bits in each field. In - ``ComplexInt32DType``, the number 32 refers to the total number of bits, following the precedent - set by NumPy's other complex types. For example, ``np.complex128`` contains 64-bit ``real`` and - ``imag`` fields. + :any:`ComplexInt32DType`, the number 32 refers to the total number of bits, following the + precedent set by NumPy's other complex types. For example, :any:`numpy.complex128` contains + 64-bit ``real`` and ``imag`` fields. Constructing arrays of complex integers --------------------------------------- -You can construct an array of complex integers from a sequence of tuples using ``np.array``: +You can construct an array of complex integers from a sequence of tuples using :func:`numpy.array`: >>> import numpy as np >>> np.array([(1, 2), (3, 4)], dtype=ComplexInt32DType) array([(1, 2), (3, 4)], dtype=[('real', '>> np.zeros(3, dtype=ComplexInt32DType) array([(0, 0), (0, 0), (0, 0)], dtype=[('real', '>> x[0][1] np.int16(2) -You can also index by field names ``real`` and ``imag``: +You can also index by the field names ``real`` and ``imag``: >>> x[0]['real'] np.int16(1) >>> x[0]['imag'] np.int16(2) -Or you can index the entire array by field names: +Or you can index the entire array by the field names ``real`` and ``imag``: >>> x['real'] array([1, 3, 5], dtype=int16) @@ -86,7 +87,8 @@ Conversion ---------- -To convert a complex integer structured scalar to a tuple, use the ``item`` method: +To convert a complex integer structured scalar to a tuple, use the :any:`numpy.ndarray.item` +method: >>> x[0].item() (1, 2) @@ -94,20 +96,26 @@ [(1, 2), (3, 4), (5, 6)] To convert NumPy arrays between between different complex number data types, use the -`convert_complex` function: +:func:`convert_complex` function: >>> convert_complex(np.complex128, x) array([1.+2.j, 3.+4.j, 5.+6.j]) >>> convert_complex(ComplexInt32DType, np.array([1.23+4.56j])) array([(1, 4)], dtype=[('real', '>> convert_complex(np.complex128, x[0]) np.complex128(1+2j) >>> convert_complex(ComplexInt32DType, np.complex128(3+4j)) np.void((3, 4), dtype=[('real', ' np.ndarray[_Shape, Any]: - """Convert a NumPy array of complex numbers to the specified dtype. + """Convert a NumPy array or scalar of complex numbers to the specified dtype. Args: - requested_dtype: The NumPy data type to convert to. This must be a complex number data type. + requested_dtype: The NumPy data type to convert to. Supported data types: + :any:`numpy.complex64`, :any:`numpy.complex128`, :any:`ComplexInt32DType`. value: The NumPy array or scalar to convert. + + Returns: + The value converted to the specified dtype. """ validate_dtype(requested_dtype, _COMPLEX_DTYPES) if requested_dtype == value.dtype: diff --git a/src/nitypes/complex/_dtypes.py b/src/nitypes/complex/_dtypes.py index 0e56a68f..a07e58f7 100644 --- a/src/nitypes/complex/_dtypes.py +++ b/src/nitypes/complex/_dtypes.py @@ -4,7 +4,7 @@ from typing_extensions import TypeAlias ComplexInt32Base: TypeAlias = np.void -"""Type alias for the base type of `ComplexInt32DType`, which is `np.void`.""" +"""Type alias for the base type of :any:`ComplexInt32DType`, which is :any:`numpy.void`.""" ComplexInt32DType = np.dtype((ComplexInt32Base, [("real", np.int16), ("imag", np.int16)])) -"""NumPy structured data type defining a complex integer with 16-bit `real` and `imag` fields.""" +"""NumPy structured data type for a complex integer with 16-bit ``real`` and ``imag`` fields.""" From 7f58acd33fa3f4ecd24e14b615cb76234f747d8c Mon Sep 17 00:00:00 2001 From: Brad Keryan Date: Tue, 6 May 2025 21:39:50 -0500 Subject: [PATCH 4/5] tests: Use the type alias --- tests/unit/complex/test_dtypes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/complex/test_dtypes.py b/tests/unit/complex/test_dtypes.py index 6151ae6b..f9e4df2e 100644 --- a/tests/unit/complex/test_dtypes.py +++ b/tests/unit/complex/test_dtypes.py @@ -32,7 +32,7 @@ def test___complexint32_array___index___returns_complexint32_scalar() -> None: value = array[1] assert_type(value, Any) # ¯\_(ツ)_/¯ - assert isinstance(value, np.void) # _ComplexInt32Base only exists for typing + assert isinstance(value, ComplexInt32Base) # alias for np.void assert value["real"] == 3 assert value["imag"] == -4 From 1eaa28d7ae49ed728861303f901b63ee32b3191c Mon Sep 17 00:00:00 2001 From: Brad Keryan Date: Wed, 7 May 2025 13:48:08 -0500 Subject: [PATCH 5/5] tests: Add test cases for array shape --- tests/unit/complex/test_conversion.py | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/unit/complex/test_conversion.py b/tests/unit/complex/test_conversion.py index 27020430..1e783631 100644 --- a/tests/unit/complex/test_conversion.py +++ b/tests/unit/complex/test_conversion.py @@ -105,6 +105,58 @@ def test___complex128_array_to_complex128_array___convert_complex___returns_orig assert value_out is value_in +def test___2d_complexint32_array_to_complex128_array___convert_complex___preserves_shape() -> None: + value_in = np.array( + [[(1, 2), (3, -4)], [(-5, 6), (-7, -8)], [(9, 10), (11, 12)]], ComplexInt32DType + ) + + value_out = convert_complex(np.complex128, value_in) + + # Use npt.NDArray because np.array() can't infer the array shape when type checking. + assert_type(value_out, npt.NDArray[np.complex128]) + assert isinstance(value_out, np.ndarray) and value_out.shape == (3, 2) + assert [list(x) for x in value_out] == [ + [1 + 2j, 3 - 4j], + [-5 + 6j, -7 - 8j], + [9 + 10j, 11 + 12j], + ] + + +def test___2d_complex64_array_to_complex128_array___convert_complex___preserves_shape() -> None: + value_in = np.array([[1 + 2j, 3 - 4j], [-5 + 6j, -7 - 8j], [9 + 10j, 11 + 12j]], np.complex64) + + value_out = convert_complex(np.complex128, value_in) + + # Use npt.NDArray because np.array() can't infer the array shape when type checking. + assert_type(value_out, npt.NDArray[np.complex128]) + assert isinstance(value_out, np.ndarray) and value_out.shape == (3, 2) + assert [list(x) for x in value_out] == [ + [1 + 2j, 3 - 4j], + [-5 + 6j, -7 - 8j], + [9 + 10j, 11 + 12j], + ] + + +def test___arrays_with_static_shape___convert_complex___preserves_static_and_runtime_shape() -> ( + None +): + # np.zeros() can infer the array shape when type checking because it takes a shape argument. + value_in_1d = np.zeros(3, np.complex64) + value_in_2d = np.zeros((4, 5), np.complex128) + value_in_3d = np.zeros((6, 7, 8), ComplexInt32DType) + + value_out_1d = convert_complex(np.complex128, value_in_1d) + value_out_2d = convert_complex(ComplexInt32DType, value_in_2d) + value_out_3d = convert_complex(np.complex64, value_in_3d) + + assert_type(value_out_1d, np.ndarray[tuple[int], np.dtype[np.complex128]]) + assert_type(value_out_2d, np.ndarray[tuple[int, int], np.dtype[ComplexInt32Base]]) + assert_type(value_out_3d, np.ndarray[tuple[int, int, int], np.dtype[np.complex64]]) + assert isinstance(value_out_1d, np.ndarray) and value_out_1d.shape == (3,) + assert isinstance(value_out_2d, np.ndarray) and value_out_2d.shape == (4, 5) + assert isinstance(value_out_3d, np.ndarray) and value_out_3d.shape == (6, 7, 8) + + ############################################################################### # convert scalars ###############################################################################