Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
6 changes: 5 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 7 additions & 9 deletions src/nitypes/_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions src/nitypes/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
129 changes: 129 additions & 0 deletions src/nitypes/complex/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""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 :mod:`nitypes.complex` submodule provides a NumPy representation of
complex integers.

: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.

For more information about NumPy structured data types, see the
:ref:`NumPy documentation on structured arrays <numpy:structured_arrays>`.

.. note::
In ``NIComplexI16``, the number 16 refers to the number of bits in each field. In
: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 :func:`numpy.array`:

>>> import numpy as np
>>> np.array([(1, 2), (3, 4)], dtype=ComplexInt32DType)
array([(1, 2), (3, 4)], dtype=[('real', '<i2'), ('imag', '<i2')])

Likewise, you can construct an array of complex integer zeros using :func:`numpy.zeros`:

>>> np.zeros(3, dtype=ComplexInt32DType)
array([(0, 0), (0, 0), (0, 0)], dtype=[('real', '<i2'), ('imag', '<i2')])

Indexing and slicing
--------------------

Indexing the array gives you a complex integer structured scalar:

>>> x = np.array([(1, 2), (3, 4), (5, 6)], dtype=ComplexInt32DType)
>>> x[0]
np.void((1, 2), dtype=[('real', '<i2'), ('imag', '<i2')])
>>> x[1]
np.void((3, 4), dtype=[('real', '<i2'), ('imag', '<i2')])

.. note:
NumPy displays :any:`numpy.void` because the :any:`ComplexInt32DType` structured data type has
a base type of :any:`numpy.void`. Using a different base type such as :any:`numpy.int32`
would have benefits, such as making it easier to convert array elements to/from
:any:`numpy.int32`, but it would also have drawbacks, such as making it harder to initialize
the array using a sequence of tuples.

You can index a complex integer structured scalar to get the real and imaginary parts:

>>> x[0][0]
np.int16(1)
>>> x[0][1]
np.int16(2)

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 the field names ``real`` and ``imag``:

>>> 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', '<i2'), ('imag', '<i2')])
>>> x[1:]
array([(3, 4), (5, 6)], dtype=[('real', '<i2'), ('imag', '<i2')])
>>> x[-1]
np.void((5, 6), dtype=[('real', '<i2'), ('imag', '<i2')])

Conversion
----------

To convert a complex integer structured scalar to a tuple, use the :any:`numpy.ndarray.item`
method:

>>> 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
: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', '<i2'), ('imag', '<i2')])

You can also use :func:`convert_complex` with NumPy scalars:

>>> convert_complex(np.complex128, x[0])
np.complex128(1+2j)
>>> convert_complex(ComplexInt32DType, np.complex128(3+4j))
np.void((3, 4), dtype=[('real', '<i2'), ('imag', '<i2')])

.. note::
As of NumPy 2.2, shape typing is still under development, so its type hints do not reflect that
many operations coerce zero-dimensional arrays to :any:`numpy.generic`. The type hints for the
scalar overloads of :func:`convert_complex` follow this precedent and return an
:any:`numpy.ndarray`. This behavior may change in a future release.

Mathematical operations
-----------------------

Structured arrays of complex integers do not support mathematical operations. Convert
them to arrays of complex floating-point numbers before doing any sort of math or analysis.
"""

from nitypes.complex._conversion import convert_complex
from nitypes.complex._dtypes import ComplexInt32Base, ComplexInt32DType

__all__ = ["convert_complex", "ComplexInt32DType", "ComplexInt32Base"]
108 changes: 108 additions & 0 deletions src/nitypes/complex/_conversion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
from __future__ import annotations

from typing import Any, TypeVar, cast, overload

import numpy as np
import numpy.typing as npt

from nitypes._arguments import validate_dtype
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,
np.complex128,
ComplexInt32DType,
)

_FIELD_DTYPE = {
np.dtype(np.complex64): np.float32,
np.dtype(np.complex128): np.float64,
ComplexInt32DType: np.int16,
}


@overload
def convert_complex(
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: np.ndarray[_Shape, Any]
) -> np.ndarray[_Shape, 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 or scalar of complex numbers to the specified dtype.

Args:
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:
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 cast(
np.ndarray[_Shape, Any],
_convert_complexint32_array(requested_dtype, value.reshape(1))[0],
)
else:
return _convert_complexint32_array(
requested_dtype, cast(np.ndarray[_Shape, Any], value)
)
else:
return value.astype(requested_dtype)


def _convert_complexint32_array(
requested_dtype: npt.DTypeLike | type[_ScalarType] | np.dtype[_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)

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)
10 changes: 10 additions & 0 deletions src/nitypes/complex/_dtypes.py
Original file line number Diff line number Diff line change
@@ -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 :any:`ComplexInt32DType`, which is :any:`numpy.void`."""

ComplexInt32DType = np.dtype((ComplexInt32Base, [("real", np.int16), ("imag", np.int16)]))
"""NumPy structured data type for a complex integer with 16-bit ``real`` and ``imag`` fields."""
1 change: 1 addition & 0 deletions tests/unit/complex/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Unit tests for the nitypes.complex package."""
Loading