NumPy support

Install unitx[numpy] for NumPy >= 2.1. Core imports and Python scalar arithmetic do not import NumPy.

import numpy as np
from unitx import Quantity, Unit

metre = Unit('m')
a = np.arange(6.0).reshape(2, 3)
q = a * metre
assert q.value is a and (metre * a).value is a
speed = q / Quantity(2, 's')
centimetres = q.value_in('cm')
column_means = np.mean(q, axis=0)
mask = q > Quantity(100, 'cm')

One Quantity holds one unit for its entire magnitude. Supported magnitudes are plain ndarrays and NumPy numeric scalars with integer, floating, or complex dtype. Object, boolean, string, structured, datetime, masked arrays, ndarray subclasses, and other backends are unsupported. Boolean arrays are accepted as masks and returned by predicates. Construct ndarrays explicitly: list/tuple magnitudes and implicit Fraction/Decimal object arrays are rejected.

AffineQuantity points and LogQuantity readings share point storage rules; see affine operations and logarithmic statistics. Mean/median preserve points, std returns differences, var returns squared differences, and point sums/products are rejected.

Values, storage, and precision

.value, .value_in(unit), and .to(unit).value are explicit numerical exits. np.asarray(q) and np.array(q) raise TypeError even for dimensionless q; use np.asarray(q.value_in(target_unit)).

Construction, unit-only multiplication/division, and an identity conversion preserve the magnitude object. Factor/SymbolicConstant operands change the exact unit scale. Indexing, iteration, .T, reshape, and transpose follow NumPy's scalar/view/copy rules; .flatten(order='C') copies. Zero-dimensional arrays cannot be iterated. .shape, .ndim, .dtype, and .size are available for NumPy magnitudes. Python scalars do not acquire array attributes. Exposed values and views may share writable storage.

Unit algebra stays exact. Nonidentity conversion uses np.multiply: integral rational ratios become Python int multipliers; nonintegral rational ratios become one Python float multiplier; irrational ratios use Factor's approximate value. NumPy controls dtype promotion, rounding, and overflow. Conversion allocates and never writes to the input. Integer overflow, multiplier conversion errors, and float overflow/underflow remain possible, including for extended precision inputs. Conversion never creates Fraction/Decimal arrays.

Python Decimal and Fraction scalar operands (including Quantity magnitudes such as SI.h and SI.kB) enter NumPy computations as Python floats. NumPy then controls promotion, so a float32 array need not become float64. This conversion rounds, may overflow or underflow, and does not preserve extended precision from a Decimal/Fraction scalar. Constants and scalar-only arithmetic retain their original types and Decimal context rules. Object arrays remain unsupported. Formal unit powers retain exact Fraction/Decimal exponents even though the numerical kernel uses floating exponents.

Comparisons broadcast and return bare boolean results. Equal unit factors compare native values; otherwise both operands convert to coherent SI values. Different dimensions yield False equality / True inequality masks; ordering raises DimensionError. Bare numbers/arrays are dimensionless. These numerical rules are distinct from Python scalar Quantity's exact comparison and hash rules. All NumPy magnitudes, including scalars and zero-dimensional arrays, produce unhashable Quantities. bool(q) delegates to bool(q.value).

Support matrix

Use this table as the supported API boundary. Jump to writes, differences, masked statistics, products, out, or multiple outputs for detailed keyword and storage rules.

Unit-bearing results are Quantities, including dimensionless values and scalar reductions. Predicates and indices are bare results. Unsupported functions, methods, mixed array types, and keywords fail explicitly.

ufunc / Python operation Result rule
add, subtract / +, - Align compatible dimensions to the first Quantity's unit
multiply, divide/true_divide, matmul / *, /, @ Combine units
equal, not_equal, less, less_equal, greater, greater_equal / comparisons Bare boolean results
positive, negative, absolute, fabs, conjugate / unary +, -, abs Preserve unit
square, sqrt, cbrt, reciprocal Exact unit exponents 2, 1/2, 1/3, -1
power, float_power / ** Dimensional bases require a finite real scalar exponent; dimensionless bases/exponents normalize their scales
sin, cos, tan, exp, expm1, log, log2, log10, log1p Require coherent dimensionless values; return dimensionless Quantity
arcsin, arccos, arctan Require coherent dimensionless values; return Quantity in rad
hypot Compatible input dimensions; align to the first Quantity's unit
arctan2 Compatible input dimensions; return Quantity in rad
isfinite, isnan, isinf, signbit Bare boolean results
modf Fractional and integral parts in the input unit
frexp Mantissa in the input unit; bare integer binary exponent
add.reduce, add.accumulate Preserve unit; accept axis, dtype, out, plus keepdims, initial, where for reduce
multiply.reduce Product unit rules below; accepts axis, dtype, keepdims, initial, where, out

Ufunc __call__ accepts where, dtype, casting, order, subok, and out as described below (two outputs for modf/frexp). Each kernel validates its own options. where is a bare boolean scalar or plain boolean ndarray; masked-out positions without an output buffer follow NumPy's uninitialized-result behavior. Numeric outputs must stay in the supported dtype domain. Product accumulation, at, outer, and reduceat are unsupported. Python ** also accepts Fraction/Decimal exponents, retaining exact unit exponents while passing a float exponent to the NumPy kernel. Angles retain unitx's dimensionless model: deg/rad normalize before these functions, including exp/log. Python scalar numerical semantics remain native.

NumPy function Unit/result rule Supported options
reshape, transpose Preserve unit shape/order/copy; axes
concatenate, stack Convert to first Quantity's unit; bare arrays remain dimensionless axis, dtype, casting, out; dtype and out are mutually exclusive
sum Preserve unit axis, dtype, keepdims, initial, where, out
mean Preserve unit axis, dtype, keepdims, where, out
prod Product unit rules below axis, dtype, keepdims, initial, where, out
median Preserve unit axis, keepdims, out; overwrite_input=False
std, var Preserve unit / square unit axis, dtype, ddof, keepdims, where, out
cumsum Preserve unit axis, dtype, out
argmin, argmax Bare indices axis, keepdims, out
trapezoid Multiply ordinate and coordinate units x, scalar dx, axis; no out
diff Preserve input unit n, axis, prepend, append; no out
gradient Divide input unit by each coordinate unit scalar spacings or coordinate arrays, axis, edge_order; no out
isclose, allclose Bare mask / Python bool rtol, atol, equal_nan

Supply the first magnitude argument positionally; concatenate/stack take a sequence. Options follow each supported signature, including positional out checks. Function and reduction outputs follow the rules below; overwrite_input=True is rejected. Reduction/statistical where is supported for sum, mean, std, var, add.reduce, prod, and multiply.reduce; initial is supported only for sum/add.reduce and prod/multiply.reduce. Precomputed mean, correction, and other unsupported keywords raise TypeError. Indexed assignment and augmented arithmetic on ndarrays follow the fixed-target rules below. Python and NumPy scalar magnitudes retain ordinary result/rebinding behavior for augmented arithmetic.

Indexed assignment and augmented arithmetic

q[index] = value writes into a writable plain numeric ndarray magnitude. The magnitude object, dtype, shape, and unit stay fixed. Values must be supported numeric scalars/ndarrays or Quantities with compatible dimensions; Quantity values convert to the destination unit. Bare values are coherent dimensionless values, including zero: use Quantity(0, q.unit) to clear a dimensional array. Decimal/Fraction scalars enter the same floating domain as other NumPy operations. Lists/tuples are allowed as indices, not as assigned magnitudes.

positions = Quantity(np.array([1.0, 2, 3]), 'm')
positions[0] = Quantity(50, 'cm')
positions[positions > Quantity(1, 'm')] = Quantity(0, 'm')
np.testing.assert_array_equal(positions.value, [0.5, 0, 0])

Basic slices, integer/boolean indexing, broadcasting, new axes, scalar elements, and repeated advanced indices follow NumPy indexed assignment. Array indices must be plain integer/boolean ndarrays; masked arrays and ndarray subclasses are rejected. Boolean masks are bare arrays, as returned by comparisons. Repeated indices use NumPy assignment semantics; no accumulation at repeated positions or particular winner among conflicting repeated writes is promised. NumPy scalar magnitudes are immutable; explicit zero-dimensional ndarrays are writable. Read-only arrays reject assignment, including empty selections.

Assignment stages unit conversion and same_kind casting before the native indexed write. Shape and index errors then follow NumPy's unchanged-target rule; casting failures occur only in temporary storage. Assignment supports RHS views overlapping the target; index arrays are snapshotted before the write. Float narrowing and integer narrowing remain allowed by same_kind, while float to integer and complex to real writes fail. This deliberately differs from NumPy's ordinary assignment, which can silently truncate floats into integers.

+=, -=, *=, /=, **=, and @= on ndarray Quantities return the same Quantity and update its original array. Each computes its ordinary result first, then uses the controlled output conversion, broadcasting, and same_kind casting rules. The natural result dimension must match the destination: multiplying a length by a time or squaring a length fails without modifying the destination. Dimensionless factors, Units, and Quantities may scale the numerical values while the destination unit stays fixed; exact Factor/SymbolicConstant operands first participate in ordinary exact unit algebra, then convert back to that fixed unit. Unit changes remain available through ordinary arithmetic and rebinding.

positions = Quantity(np.array([1.0, 2, 3]), 'm')
alias = positions
buffer = positions.value
positions += Quantity(50, 'cm')
positions *= Quantity(2, 'km/m')
assert positions is alias and positions.value is buffer
np.testing.assert_allclose(buffer, [3000, 5000, 7000])
positions[1:] += Quantity(100, 'cm')
np.testing.assert_allclose(buffer, [3000, 5001, 7001])

All aliases observe successful writes. Basic indexed augmented assignment acts on its view; advanced indexed augmented assignment computes on a copy and writes back, so repeated indices do not accumulate repeatedly. Python/NumPy scalars continue to create new Quantities and may change their magnitude type or unit. An ndarray target that cannot represent a result raises instead of silently rebinding to a wider dtype or a different unit. Use q = q / 2 when an integer array needs a floating result. __setitem__ and augmented arithmetic preserve the covariant magnitude contract because no operation replaces T, its dtype, or its storage. Concrete ndarray annotations retain their dtype after successful augmented arithmetic; a generic Array annotation may also describe an immutable NumPy scalar and therefore retains the broader result type.

These operations use temporaries; they do not promise zero allocation or general rollback against concurrent mutation or external buffer failures. Output staging reuses an already independent result when its dtype matches the target; otherwise it stages a cast or an overlapping value. Identity scaling still retains storage, and nonidentity scaling follows the algorithm documented above. Run python benchmarks/numpy_writes.py from the checkout to measure output, assignment, and augmented-operation timings and traced peak allocations. Timings are machine dependent; they are not a performance guarantee.

np.trapezoid(y, x=x) accepts a Quantity for y, x, or both and returns a Quantity with unit y.unit * x.unit; bare inputs are dimensionless. With x=None, scalar dx supplies the coordinate unit instead (default: dimensionless 1). NumPy dispatches this function on y and x, so at least one of those must be a Quantity to enter unitx; a Quantity supplied only as dx is insufficient. Explicit x makes dx irrelevant. Numeric arrays, scalar spacing, axis handling, coordinate order (including reversed or nonmonotonic x), broadcasting, and empty intervals follow NumPy trapezoid. No coordinate sorting, interpolation, dtype option, or output buffer is added.

Differences and gradients

np.diff(q, n=1, axis=-1) computes differences in q's stored coordinates and retains its unit for every order n. prepend and append may be numeric scalars/arrays or Quantities; their dimensions must match the input, and their values convert to the input unit before differencing. Bare boundaries are dimensionless, including bare zero. A bare input with a Quantity boundary is also dimensionless. Scaled dimensionless boundaries therefore normalize to the input's coordinates. Explicit list inputs/boundaries remain unsupported.

With n=0, diff returns its input object unchanged and ignores axis and boundaries. Other orders follow NumPy diff for shapes, scalar-boundary expansion, dtype promotion, unsigned wraparound, and empty results. A difference is not a derivative: its unit is not divided by a coordinate unit or raised to n.

np.gradient(f, *spacing, axis=None, edge_order=1) accepts numeric or Quantity input and spacings. Each spacing is a scalar step or one-dimensional coordinate array; bare inputs and spacings are dimensionless. A single scalar step applies to every selected axis. Otherwise supply one spacing per selected axis, in the order given by axis. Each component has unit f.unit / spacing.unit, including exact Unit factors. No spacing means dimensionless unit steps. The kernels use stored numerical coordinates; no implicit SI conversion is needed because each result retains the corresponding unit ratio.

One selected axis returns one Quantity; multiple axes return a tuple of Quantities, which may have different dimensions. No selected axes returns an empty tuple. Output shapes, nonuniform/decreasing coordinates, edge_order, dtype, and numerical warnings follow NumPy gradient. Coordinates are not sorted or interpolated. Neither function accepts out, where, or dtype. These NumPy entry points require at least one Quantity among their dispatched inputs; the existing NumPy stub limitations still apply.

time = Quantity(np.array([0.0, 1, 3, 6]), 's')
distance = Quantity(np.array([0.0, 2, 6, 12]), 'm')
increments = np.diff(distance)
assert increments.unit == Unit('m')
velocity = np.gradient(distance, time, edge_order=2)
assert velocity.unit == Unit('m/s')
np.testing.assert_allclose(velocity.value, 2)
assert np.trapezoid(velocity, time).isclose(distance[-1] - distance[0])

Masked sums and statistics

np.sum(q) and np.add.reduce(q) preserve q's unit, including Unit and scaled dimensionless units. Their default axes are all axes and axis 0, respectively. Both accept initial, a numeric scalar or scalar Quantity, including NumPy scalars and zero-dimensional arrays. A Quantity initial must have the input's dimension and is converted to the input unit, then added once to every result slice. Bare nonzero initial values are coherent dimensionless values; they do not implicitly acquire the input unit. Bare zero is accepted as the additive identity in any dimension, while a Quantity zero retains its declared dimension. Empty or fully masked sums return zero in the input unit, or the converted initial when supplied. Explicit initial=None uses NumPy's first-element semantics and fails on empty reductions.

The input and explicit dtype select the accumulator; initial does not promote it. NumPy casts the converted initial into that dtype before summation, so an integer accumulator can truncate a fractional initial. Decimal/Fraction initial values enter the NumPy floating domain. Complex initial values follow NumPy's casting rules, even for complex zero. Unsupported initial arrays, bools, or incompatible dimensions fail before writing an output.

where on sum, add.reduce, mean, std, and var is a bare boolean scalar or plain boolean ndarray broadcast to the input shape. It selects input elements; all result positions are written when out is supplied. Different slices may have different selected counts. Mean and standard deviation retain the input unit; variance has its square. Statistics use stored coordinates, including for scaled dimensionless input. NumPy controls dtype, complex statistics, NaN/Inf, empty-sample warnings, and degrees of freedom (N in N - ddof is the selected count). Masks do not imply automatic NaN omission. These operations use the same staged output rules as other reductions, including exact shape and unchanged targets on computation, unit, shape, or casting errors. add.accumulate, cumsum, and median do not gain where or initial.

samples = Quantity(np.array([[1.0, 2, 3], [4, 5, 6]]), 'm')
selected = np.array([[True, False, True], [False, True, True]])
totals = np.sum(samples, axis=1, where=selected, initial=Quantity(50, 'cm'))
assert totals.unit == samples.unit
np.testing.assert_allclose(totals.value, [4.5, 11.5])
means = np.mean(samples, axis=1, where=selected)
np.testing.assert_allclose(means.value, [2, 5.5])

The numerical rules follow NumPy sum, mean, and var, with the unit and output constraints above.

Product reductions

np.prod(q) reduces all axes by default; np.multiply.reduce(q) defaults to axis 0, following NumPy. Both accept scalar, negative, or tuple axes, axis=None, axis=(), and keepdims. For dimensional input with unit U, each result has unit U**N, where N is the number of selected input elements in that slice. Zero-valued elements still count. All output slices must have the same N; otherwise unitx raises DimensionError because a single Quantity cannot hold different dimensions. where accepts the same bare boolean masks as ufunc calls and broadcasts to the input shape. It selects input elements; every output position is written when an output buffer is supplied.

An empty or fully masked product has value 1 and unit U**0, before applying initial. If the output itself has no elements, the unit uses the nominal reduced-axis extent; scalar where=False sets that extent to zero. Dimensionless inputs, including deg and km/m, first convert to coherent dimensionless values and produce unit Unit(). Their masked slices may have different counts because the result still has a single coherent unit.

initial is a bare numeric scalar or a scalar Quantity, including NumPy scalars and zero-dimensional arrays. A bare initial is dimensionless; a Quantity initial contributes its own unit once, so the result unit is U**N * initial.unit. An explicit zero still contributes its declared unit. The default is dimensionless 1. initial=None uses NumPy's first-element semantics and fails for empty reductions. NumPy controls the accumulator dtype, including casting the initial value to that dtype; initial does not select a wider numerical domain. Decimal/Fraction initial values enter NumPy as floats.

lengths = Quantity(np.array([[2.0, 3, 4], [5, 6, 7]]), 'm')
selected = np.array([True, False, True])
areas = np.prod(lengths, axis=1, where=selected)
assert areas.unit == Unit('m2')
np.testing.assert_array_equal(areas.value, [8, 35])
weighted = np.multiply.reduce(lengths, axis=1, where=selected, initial=Quantity(2, 's'))
assert weighted.unit == Unit('m2 s')

Both entry points support the output rules below, including exact result shape and unchanged targets on computation, unit, shape, or casting errors. cumprod and multiply.accumulate remain unsupported: partial products generally require different units at different positions. The supported axis, empty-product, initial, and mask behavior follows NumPy prod and ufunc.reduce, with the additional single-unit and explicit-output rules described here.

Output buffers

Supported single-output ufunc calls, add.reduce/accumulate, multiply.reduce, and the numeric functions listed with out above accept a Quantity whose magnitude is a writable plain numeric ndarray. out=target, out=(target,), and positional outputs for ufuncs return that same target; functions accept out=target or the positional output argument in their NumPy signature, without a tuple. The target's magnitude object, dtype, and unit stay fixed. Zero-dimensional, empty, and noncontiguous output arrays are supported. NumPy scalars and Python scalars cannot serve as output buffers.

target = Quantity(np.full((2, 3), -1.0), 'cm')
assert np.add(q, q, out=target, where=a > 0) is target

The operation first computes its ordinary result in temporary storage, then converts that result to the target unit and casts it into a staging buffer. The numerical kernel uses the input dtypes and any explicit dtype; the target dtype is used for the final cast. This is not a promise of NumPy's output-dependent kernel selection or allocation-free execution. Both the result and where mask must broadcast to the target shape for ufunc calls. Function and reduction outputs must have exactly the ordinary result's shape, including axes retained by keepdims; they do not broadcast into a larger target. Only true mask positions are written; false positions retain their original target values. Unit conversion also skips false positions. Input/output overlap and shared slices are handled through temporaries; a mask sharing output storage is copied.

The natural result dimension must match the target dimension. For example, multiplying lengths can write to an area target, but cannot write back to a length target. Unit factors participate in the conversion. Every numeric result, including a coherent or scaled dimensionless result, requires a Quantity target; writing it to a bare ndarray would discard its unit. Boolean predicates and comparisons instead accept only a bare boolean ndarray output. argmin and argmax accept only a bare integer ndarray output, with the same exact shape requirement as other function outputs.

Final casting uses casting='same_kind' by default, or the explicitly requested NumPy casting policy. It does not silently enable unsafe; nonintegral unit conversion into an integer target normally fails. same_kind can narrow float precision, whereas safe rejects such narrowing. Dimension, broadcast, read-only, casting, and temporary computation/conversion failures leave the target unchanged. The final copy is not a general transaction against concurrent changes or external buffer failures. Temporaries cost memory proportional to the result and target; out provides controlled writes, not a zero-allocation guarantee.

Functions and supported reductions/accumulations use same_kind for the final cast and do not accept a new casting keyword. Concatenate and stack use their existing casting option for the final write as well as the kernel; their dtype and out options cannot be combined. Index outputs also use same_kind, including its allowance for integer narrowing. These policies intentionally do not reproduce every function's native output casting rule. An explicit dtype controls the temporary numerical computation for supported reductions; the target dtype does not select the accumulator or kernel dtype.

totals = Quantity(np.empty(3), 'cm')
assert np.sum(q, axis=0, out=totals) is totals
indices = np.empty(3, dtype=np.intp)
assert np.argmax(q, axis=0, out=indices) is indices

out=None and ufunc out=(None,) allocate ordinary single-output results. The supported multiple-output calls are described below; other multiple-output ufuncs and product accumulations remain unsupported. Output writes preserve the magnitude's type and dtype, so the covariant Quantity[T] contract remains valid; no arbitrary replacement of T is introduced. NumPy's own stubs do not accept Quantity outputs or infer wrapped return types; these runtime calls may require a local type-checker suppression.

Multiple-output decompositions

np.modf(q) returns (fractional, integral), both Quantities with q's unit. np.frexp(q) returns (mantissa, exponent), with q's unit on the mantissa and a bare integer ndarray or NumPy scalar for the binary exponent. These functions decompose stored coordinates, including for Unit and scaled dimensionless inputs: convert q to the desired unit before decomposing it. Changing the input unit can change both numerical outputs. There is no implicit conversion to coherent dimensionless values. Thus fractional + integral reconstructs q. For frexp, reconstruct with Quantity(np.ldexp(mantissa.value, exponent), mantissa.unit); separately computing np.exp2(exponent) can overflow for large finite inputs. Dtype, signed-zero, NaN/Inf, and real-input restrictions follow NumPy modf and frexp. Only their ordinary __call__ operation is supported.

Both accept out=(first, second) or positional output arguments. Each None allocates that output; (None, None) allocates both. Every unit-bearing output requires a Quantity with a writable plain numeric ndarray; frexp's exponent requires a bare integer ndarray. The existing conversion, casting, and where rules apply independently to each output. Supplied buffers must all have the common broadcast shape of the inputs, mask, and outputs. An allocated output also has that shape. False mask positions retain supplied values; without a buffer those positions are unspecified.

parts = Quantity(np.array([1.25, -2.5]), 'm')
fractional = Quantity(np.empty(2), 'cm')
integral = Quantity(np.empty(2), 'm')
f, i = np.modf(parts, out=(fractional, integral))
assert f is fractional and i is integral
np.testing.assert_array_equal(f.value, [25, -50])
np.testing.assert_array_equal(i.value, [1, -2])

All supplied outputs are converted and staged before any is written. A shape, unit, casting, read-only, computation, or conversion failure leaves all targets unchanged. Input/output overlap and masks sharing output storage are supported. The two output buffers may share storage only when their elements do not overlap; overlapping outputs raise ValueError before writing. This avoids one result overwriting another. Final writes have the same concurrent mutation and external-buffer limitations as single outputs, and require temporary storage. The covariance and NumPy-stub limitations above still apply.

Close comparisons

q.isclose(other, rel_tol=1e-9, abs_tol=0) uses the symmetric formula in coherent SI coordinates: abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol). np.isclose instead uses abs(a-b) <= atol + rtol*abs(b) with defaults rtol=1e-5 and atol=1e-8; np.allclose reduces that mask. Tolerances broadcast and must be finite, nonnegative, and real. Relative tolerance is dimensionless; absolute tolerance matches the operands' dimension. Nonzero bare absolute tolerance is dimensionless, so dimensional np.isclose calls need a Quantity atol or zero. Matching infinities are close; NaNs require equal_nan=True. The NumPy path enters at least float64 before subtraction, preserving longdouble when present; large integers may lose precision. Mixed NumPy operations accept supported arrays/scalars or Python int/float/complex, Decimal, and Fraction scalars under the conversion rule above.

np.isclose(q, Quantity(a * 100, 'cm'), atol=Quantity(1e-8, 'm'))
q.isclose(Quantity(a * 100, 'cm'), abs_tol=Quantity(1e-8, 'm'))

See the typing guide for magnitude annotations, optional-dependency checks, and NumPy stub limitations. Use .value or .value_in(unit) at plotting boundaries. .to() returns a new Quantity and has no inplace option.

References: NumPy dispatch, promotion, isclose.

Documentation index ยท Typing