numpy integration: design & stability¶
This page explains how physipy plugs into numpy, why it is built that way,
and whether that approach is future-proof. For the concrete, always-up-to-date
list of what is supported, see the
numpy support page and its generated
physipy.numpy_coverage() report.
How physipy plugs into numpy¶
A Quantity is a duck array: a thin wrapper holding one ndarray of
magnitudes plus one shared Dimension. It is not a subclass of ndarray, and
units are not encoded in a custom dtype. Instead, physipy implements numpy's
two override protocols so that calling a numpy function on a Quantity dispatches
into unit-aware logic:
__array_ufunc__(NEP 13) handles ufuncs — element-wise operations (np.add,np.sin,np.sqrt, ...) and theirreduce/accumulate/out=variants. physipy uses it to enforce/propagate dimensions (e.g.sinrequires a dimensionless or angle input;sqrthalves the dimension exponents).__array_function__(NEP 18) handles the rest of the high-level API (np.concatenate,np.unique,np.linalg.norm, thenp.fftfamily, ...), function by function.
When neither protocol applies — for instance np.asarray(quantity) — physipy
falls back to converting to a plain ndarray, which strips the unit and emits a
warning. This is the deliberate escape hatch: leaving physipy's world is
allowed, but never silent.
Is this approach future-proof?¶
Yes. For a library that wraps numpy arrays, the two override protocols are the right, stable mechanism:
__array_ufunc__(NEP 13) is Final. Ufuncs are the foundation of numpy; the protocol is a permanent part of the API.__array_function__(NEP 18) is Final and enabled by default since numpy 1.17. It is not deprecated. (Older NEP text about "removing the checks in the next major release" refers to the historical 1.16 → 1.17 transition that dropped the opt-in environment variable, not to removing the protocol.)
There is one caveat worth knowing, stated by NEP 18 itself: while the protocol
is stable, its use on any particular function is considered experimental and
may change with little warning. In other words, churn — if any — lives in the
long tail of individual functions on a numpy upgrade, not in the dispatch
mechanism. physipy mitigates this with physipy.numpy_coverage() (which compares
the running numpy against what is implemented) plus the numpy test suite, so a
behavioural change in a specific function surfaces quickly.
What about the Array API standard?¶
The Python Array API standard
(entry point __array_namespace__) is sometimes assumed to supersede these
protocols. It does not — it is complementary and solves the opposite
problem:
- The Array API standard lets you write portable consumer code that runs unchanged across numpy, CuPy, JAX, PyTorch, etc.
- physipy's job is the reverse: make
np.func(quantity)dispatch into unit-aware logic. That is exactly the duck-array override use case that NEP 13 / NEP 18 were designed for.
numpy 2.0 removed the experimental numpy.array_api submodule and folded full
Array API support into the main namespace, so the two concerns now live side by
side. Adopting __array_namespace__ would only matter if physipy wanted to be
consumed as a backend by Array-API-generic code — a different goal from making
numpy work on quantities.
Why not subclass ndarray or use a custom dtype?¶
Two alternative architectures were considered and rejected:
- Subclassing
ndarray(the route taken bynp.matrix,unyt, and in part astropy) is more fragile — it pulls in__array_finalize__/__array_wrap__semantics — and is now generally discouraged in favour of duck-array wrappers like physipy's. - Encoding units in a custom dtype (numpy's user-DType API) is a poor fit for
physipy's data model: a dtype is per element, whereas a
Quantityis one array sharing a singleDimension. The user-DType API is also still maturing.
The duck-array wrapper keeps the model simple ("one array + one dimension") and matches numpy's current recommendation for array-like libraries.
Directions¶
No change to the core approach is planned — it is the right one. The areas worth keeping an eye on are:
- Track numpy releases. Run the suite against the supported numpy floor and
the latest release; pair it with
numpy_coverage()so a function whose dispatch changes upstream is caught early. - Keep the conversion escape hatch explicit and loud, so dropping a unit on the way out to plain numpy is always an intentional, visible step.
__array_namespace__is optional and low priority — only relevant if backend-agnostic (CuPy/JAX-backed) quantities ever become a goal.