Repository navigation
Restructure QuantSI into small modules, check NumPy functions, and test it against Brian2 - #19
Legend101Zz wants to merge 72 commits into
Conversation
ruff 0.16 turned on more rules by default, so with a fresh install of the test dependencies `ruff check` fails on main (31 errors). This lists the rules we were already using, plus import sorting, and pins ruff to 0.16.x in both the test extra and pre-commit, so a new ruff release can't break CI on its own. allunits.py is generated, so ruff skips it. The changes in test_units.py and fundamentalunits.py are only ruff's formatting and import sorting.
Brian2 already needs Python >= 3.12 and NumPy >= 2.2, so supporting older versions here doesn't help anyone (NumPy 1.25, the old minimum, doesn't even have wheels for Python 3.12). With the same minimum we can rely on NumPy 2 behaviour and use newer ufuncs like np.matvec.
Use the one-line description we agreed on in brian-team#18 ("pragmatic dimensional checking for NumPy-based SI calculations") in the README and in pyproject.toml, add a short example, and list what QuantSI deliberately doesn't do, with pointers to pint and astropy.units for those cases.
- run doctests by default: CI already passed --doctest-modules, now a plain `pytest` does the same locally - turn warnings into errors, so that deprecations show up early - fail on unknown markers or config keys, and on xfail tests that unexpectedly pass These are the settings the Scientific Python development guide recommends. The two skipped tests are doctests on the existing skip list in conftest.py.
We're about to move most of fundamentalunits.py into smaller modules, and a name is easy to lose on the way. Users and Brian2 import from these modules directly, including a few private names that Brian2 uses. tools/snapshot_public_api.py writes every name of the public modules to tests/data/public_api.json, and the new tests check that: - every name in the snapshot can still be imported - every name in __all__ exists - no new public module appears by accident - a name exported from several modules is the same object everywhere
Brian2 saves simulations with pickle, and a pickle stores the import path of the function that rebuilds the object (for example QuantSI.fundamentalunits.quantity_with_dimensions). If that function moves, files people have already saved stop loading. tools/make_pickle_fixtures.py saved pickles of a few quantities, units, dimensions and an error, made with the current code (protocols 2, 4 and 5), to tests/data/pickles.json. The tests check that they still load and give back the shared Dimension objects, and that new pickles still use the old paths.
Later commits rework how quantities are turned into text, and Brian2's doctests compare that text exactly. tests/data/display_golden.txt stores the str, repr, format, in_unit, in_best_unit and LaTeX output for 24 example values, and a test compares the current output with it. If the output changes on purpose, regenerate the file with tools/make_display_golden.py and the change shows up in the diff. The test runs in a separate Python process, because registering units changes how values are displayed, and other tests register units.
5f7b70f to
8b2b85a
Compare
Before making anything faster we need numbers. These benchmarks cover what
every user pays for: arithmetic and comparisons on scalars and arrays, sums,
indexing, Dimension handling, check_units, pickling, str/repr, and how long
`import QuantSI` takes.
In a normal test run each benchmark runs once, as a plain test, so they
can't break without anyone noticing. A separate workflow runs them for real
and saves the timings as an artifact. To measure locally:
pytest src/QuantSI/tests/benchmarks --benchmark-enable --benchmark-only
Timings on CI runners are too noisy to compare against fixed numbers, but timing a QuantSI operation and the same operation on plain NumPy arrays back to back, in the same process, gives a ratio that is much more stable. test_overhead_ratio.py does this for the most common operations and fails if a ratio goes over its limit. The limits are about twice the measured ratios, so noise doesn't fail the build, but a real slowdown does. When a later commit makes something faster, it lowers the limit too. These tests only run in the benchmark workflow (pytest -m perf_guard), not in normal test runs.
First step of splitting up fundamentalunits.py. The Dimension class, the cache that keeps one Dimension object per set of exponents, DIMENSIONLESS, get_or_create_dimension and the SI name tables move unchanged to _dimension.py, and two small helpers move to _utils.py. fundamentalunits imports everything back under the old names, written as `import x as x`, so ruff knows these imports are on purpose and doesn't remove them. get_or_create_dimension still reports QuantSI.fundamentalunits as its module (set_module), because pickles store that path.
DimensionMismatchError goes to _errors.py. get_dimensions, is_dimensionless, have_same_dimensions, is_scalar_type and fail_for_dimension_mismatch go to _dimension.py, next to the Dimension class. A few of them need Quantity or Unit, but only on rare paths (building an error message, converting a list). They import it inside the function, so there's no import cycle. Otherwise the code is unchanged.
UnitRegistry, the three registries, register_new_unit, get_unit and get_unit_for_display move unchanged to _registry.py. fundamentalunits re-exports them.
Quantity, quantity_with_dimensions, wrap_function_keep_dimensions and the UFUNCS_* lists that __array_ufunc__ uses move unchanged to _quantity.py. The module-level in_unit and in_best_unit go to a new _formatting.py. The three Quantity methods that need Unit import it inside the method, to avoid an import cycle. quantity_with_dimensions keeps its old module path for pickles. The doctest skip list in conftest.py now matches on the method name, since the module path changed.
Unit moves unchanged to _unit.py. The imports inside functions that used to get Unit and Quantity from fundamentalunits now import them from their new modules directly.
check_units moves unchanged to _decorators.py. Its module docstring notes that Brian2's code generation reads the attributes the decorator sets (_arg_units, _return_unit, ...), so they shouldn't be renamed.
fundamentalunits only re-exports things now, so its docstring says so, and asks not to add code there. A new developer page describes the public and private modules, which module may import which, and the few rules that keep things working: one shared object per dimension and per registry, explicit re-exports, pickle paths, and the check_units metadata.
The private modules are layered: at the top of a file, a module may only import modules from lower layers. The new test reads each module's top-level imports (with ast, without importing anything) and fails if one points to a higher layer, or if a new private module hasn't been given a layer yet. That keeps the modules free of import cycles as the package grows.
QuantSI imported SymPy for one thing: printing unit exponents in LaTeX (the 3
in metre ** 3). That made SymPy a required dependency and about 80% of
QuantSI's import time.
A small function, _latex_number, now writes numbers the way SymPy did, and
QuantSI calls its own _latex hooks directly. Importing QuantSI on top of
NumPy goes from ~100 ms to ~12 ms. SymPy can still print quantities through
the hooks, and it stays in the test extra to check exactly that.
One visible change: NumPy integer exponents now print as plain digits
instead of SymPy's \mathtt{\text{3}}.
Dimension.__eq__ called np.allclose on two tuples of seven numbers, which takes about 7 us, mostly to create the arrays. Dimensions are shared objects, so equal dimensions are nearly always the same object: check that first. Otherwise apply np.allclose's own formula, |a - b| <= atol + rtol * |b|, in plain Python. That's about 0.3 us. A test compares the new __eq__ with np.allclose on 300 random pairs close to the tolerance limit.
Every multiplication, division or power of quantities computes a new Dimension: build a list, turn it into a tuple, look it up in the cache of Dimension objects. Now the result is remembered per pair of operands, keyed by their id(). Each cache entry also keeps the operands themselves, so an id can't be reused by another object while the entry exists. The caches have a size limit. dim1 * dim2 goes from ~0.32 us to ~0.1 us.
Quantity.__new__ checks that the data is numeric and works out the dimensions. For a result that NumPy has just computed from valid quantities, neither check can fail. The new _new_quantity only does the wrapping (including turning dimensionless results into plain arrays or numbers) and is used for ufunc results and indexing. A test checks that it gives the same result as Quantity(values, dim=...) for scalars, arrays and several dtypes.
check_units worked out the function's argument names, and how to check its result, on every call, including a slow isinstance(x, typing.Callable). All of that only depends on the function, so it now happens once, when the function is decorated. The call overhead drops by about 15%. The decorator also uses functools.wraps now, so decorated functions keep their signature and qualified name (Brian2's version of the decorator already does this). New tests pin down the attributes that Brian2's code generation reads, including for stacked decorators.
__array_ufunc__ decided what to do by checking the ufunc's name against eight lists of names, joining some of them on every call. The rules now live in one table, _ufuncs.RULES, keyed by the ufunc objects themselves. Aliases like np.divide and np.true_divide are the same object, so they can't disagree. Behaviour doesn't change. The old UFUNCS_* name lists stay exactly as they were, because Brian2's tests import them, and a new test checks that they agree with the table.
The chain of if/elif branches in __array_ufunc__ becomes one small function per rule, looked up in a dict that's built once. The handlers compare dimensions with `is` first and only build an error message when a check fails (before, the message was formatted on every call), and they wrap results with the fast constructor. Adding two Quantity arrays goes from ~1.9 us to ~1.2 us (plain NumPy takes ~0.3 us), and adding two scalars from ~1.7 us to ~1.1 us. The limits of the overhead check go down accordingly.
For 3 * mvolt, Unit.__array_ufunc__ copied every operand into a new Quantity and called the ufunc again. Operations that aren't unit-by-unit now go straight to Quantity's handler, which gives the same result. 3 * mvolt goes from ~2.9 us to ~2.0 us. New tests pin down which operations on units give units and which give quantities.
With out=, __array_ufunc__ wrote the result into the output array but kept the output's old dimensions, so np.add(metres, metres, out=seconds) left "seconds" holding lengths. It also returned a new view instead of the output array itself. Now an output Quantity array gets the result's dimensions (as in astropy) and is returned. That keeps in-place operators that change the dimensions correct (q *= 2 * second, and the in-place squaring inside np.var). Mismatches are still refused before anything is written. Scalar quantities keep behaving like floats (x += y gives x a new object), plain ndarray outputs still get the values in base SI units, and units, which are shared constants, can't be used as outputs anymore.
np.multiply.reduce of three lengths returned a length instead of length**3. Quantity.prod patched this separately, but counted initial= as a factor, so q.prod(initial=2) of three lengths gave metre**5. The ufunc handler now counts the factors per result once, taking axis, keepdims and where into account and ignoring initial, and prod goes through the same code. A where mask that multiplies a different number of factors into different results is refused, since one Quantity can't hold several dimensions.
Only calling a ufunc and reduce were handled; every other ufunc method failed with NumPy's generic "returned NotImplemented" error. Now: - outer works like a normal call - accumulate and reduceat work when all partial results have the same dimensions (sums, minimum/maximum, logical operations, or dimensionless input) - ufunc.at works when it doesn't change any element's dimensions Everything else is refused with an error that says why, before any element is changed.
25 ufuncs in the numpy namespace had no rule yet: - fmax, fmin and nextafter need matching dimensions - fabs and spacing keep them - vecdot, matvec and vecmat multiply them - float_power is a power, and cbrt takes the cube root - the angle conversions need dimensionless input - copysign keeps the first argument's dimensions - gcd, lcm and bitwise_count only take integers - divmod, frexp, modf, ldexp, heaviside and isnat are refused, with an error that explains why A test now fails whenever a NumPy release adds a ufunc that has no rule.
The expected dimensions in test_array_functions were computed with unit arithmetic (metre**2) at import time. That registers display units before any test runs, outside the per-test isolation added earlier. They're now computed with Dimension arithmetic, which gives the same shared objects.
A new user page covers str, repr, in_unit and format specs, the exact language that parse_quantity accepts, the round-trip guarantee and its limits, and what isn't promised. Its examples run as part of the test suite.
allunits.py could only be regenerated with a template and a script that live in Brian2 (dev/tools/static_codegen). Both are now one script, tools/generate_units.py, without the 'celsius' placeholder that QuantSI dropped. It reproduces the committed allunits.py exactly, apart from the header, which now points to the new script. That includes the odd-looking order of the SI prefixes, which decides ties when a display unit is chosen. A test fails whenever allunits.py and the generator disagree.
stdunits was a hand-picked list with gaps: mV but no uV, ms but no ns, pF but no mF. It's now generated from one table, SHORT_NAMES in tools/generate_units.py: - <prefix><symbol> for V, A, F, S, s and M (molar), with the prefixes m, u, n and p - Hz on its own and with k, M and G - m (metre) with c, m and u, and their squares and cubes - never a single letter Every existing name keeps its meaning, and nine are new: uV, nV, pV, mF, pS, ns, ps, pM and GHz. New tests check the rule, the "uV exists because mV does" example from the meeting, and that no public module exports a single-letter name. The generated module also lists mS in its stdunits mapping, which the hand-written one forgot.
Which units QuantSI exported from its root was also a hand-picked list: volt and watt with prefixes, but no newton, tesla, henry, gray, ... at all. The generator now writes that list too: every unit of the catalogue, without prefix and with p, n, u, m, k, M, G and T (and c for metre), plus every short name. That's 365 names instead of 216, and all the old ones stay. The generator writes two marked blocks in __init__.py, and a test keeps them up to date. A new docs page states the naming rules, including why there are no single-letter names.
Brian2's code generation uses unit-aware versions of sin, exp, log, exprel, ... that carry _arg_units/_return_unit metadata, and np.arange can't check units by itself (NumPy converts its arguments before any hook is called). Brian2's unitsafefunctions module comes over unchanged except for its imports, together with the three wrap_function_* helpers it's built from (in _wrappers.py) and tests from Brian2's suite for every function and for the metadata. Their docstrings are NumPy's, so their doctests are deselected, using the _do_not_run_doctests marker that Brian2 already sets. That also replaces the hard-coded list of skipped doctests in conftest.py.
Brian2's brian2.units modules will become thin wrappers around QuantSI, so QuantSI needs every name they offer today. tools/snapshot_brian2_surface.py records those names (from Brian2's main branch; the commit is in the file) in tests/data/brian2_units_surface.json, and a test checks that each one exists in the QuantSI module it comes from. This brings back the three wrap_function_* helpers in fundamentalunits, which Brian2's unitsafefunctions used to import from there. The names QuantSI leaves out on purpose (unit_checking, celsius, and Brian2's own bookkeeping) are listed in the test, with the reason.
A new workflow checks out Brian2's main branch, turns its brian2.units modules into thin wrappers around this QuantSI (tools/brian2/make_facade.py), applies the three small changes Brian2 needs alongside this (tools/brian2/brian2-companion.patch, explained in tools/brian2/README.md), and runs Brian2's tests without code generation and with the numpy target. Locally, all of them pass.
QuantSI's functions and classes are now annotated (get_dimensions, fail_for_dimension_mismatch, Unit.create, parse_quantity, check_units, ...), and the py.typed marker tells type checkers to use them (decision 7 from the meeting). CI runs mypy on the package and on tests/typing_examples.py, which uses assert_type to state what users see: volt is a Unit, get_dimensions returns a Dimension, and so on. The generated unit catalogue is excluded from mypy's errors (NumPy's stubs type unit arithmetic as plain arrays), but its names keep their types. Helpers that attach attributes to functions use a typed decorator (numpy_docstring), and the in-place operators of units and dimensions, which raise on purpose, are marked as such.
The tests now run on Python 3.12 with NumPy 2.2, the oldest versions that pyproject.toml allows, and on Python 3.13 and 3.14 with the newest NumPy. The oldest setup found a gap straight away: NumPy 2.2 still has np.in1d (the old name of np.isin, removed in NumPy 2.4), and the completeness test reported it as unclassified. It now uses np.isin's implementation wherever it exists.
The README examples now run as part of the test suite, and show parse_quantity and format specs. A new developer page explains how performance is measured and checked in CI, and what got faster and why, with before/after timings. The architecture page lists the modules and tools added since it was written.
The Brian2 job failed while installing Brian2: actions/checkout only fetches the latest commit by default, and Brian2 works out its version number from its git tags (setuptools_scm), so the build stopped with "Invalid version: 'unknown'". Fetch the whole history, as the QuantSI checkout already does.
The tests lived in src/QuantSI/tests, so every installed copy of QuantSI carried them, together with their data files: about a third of the wheel (233 of 700 KB). They now live in tests/ at the top of the repository. conftest.py (the doctest and unit-registry setup) moves to the top as well, so that it still applies to the doctests inside the package. The scripts in tools/, the benchmark workflow and the docs point to the new paths, and the two Sphinx stub pages for the QuantSI.tests package are gone. Brian2 ships its tests on purpose, so that users can run brian2.test(). If we want the same for QuantSI, this is the commit to revert.
NumPy's type stubs say that arithmetic on any array gives a plain ndarray,
so type checkers saw `3 * mV` as an ndarray, and code like
def needs_quantity(q: Quantity): ...
needs_quantity(3 * mV)
failed mypy with "incompatible type ndarray; expected Quantity".
Quantity now declares its arithmetic operators inside `if TYPE_CHECKING:`,
which only type checkers read, so nothing changes or costs anything at run
time. This is how NumPy types its own subclass np.matrix. Unit adds
overloads so that a unit combined with a unit is a Unit (volt * amp) and
anything else a Quantity. Where a declaration conflicts with ndarray's
overloads in NumPy's stubs, it has a type: ignore[override], as np.matrix
does; the ones that only NumPy 2.5's stubs need also allow being unused, so
mypy is clean with NumPy 2.2 too.
The types are approximate in two places, both documented: a result whose
dimensions cancel is a plain number at run time, and pyright (unlike mypy)
types `array * unit` as a plain array. typing_examples.py checks the new
types with assert_type.
No code changes: whole definitions move (git diff --color-moved shows it), and section headings are renamed so that each file reads top to bottom. - _ufuncs.py: the handlers in the same order as the Rule enum, then the dispatch table, the checks for the other ufunc methods, and last the old UFUNCS_* name lists, which are only kept for compatibility - _dimension.py: get_or_create_dimension right after the cache it fills - _registry.py: the three registries before register_new_unit, which uses them - Quantity and Unit: methods grouped under one style of heading (Quantity: creating quantities, NumPy hooks, dimensions, units and text, items, comparisons, copying and pickling, NumPy methods that need help with units; Unit: creating units, names, text, arithmetic, comparison and hashing) instead of a mix of two styles, and Unit.__array_ufunc__ is no longer filed under "representation"
Each private module's docstring now ends with what the file contains, in order, so that a reader knows where to look before scrolling (the order matches the previous commit). Also fixes a stale comment in _dimension.py that pointed to a prefix table "at the end of the file", which stayed behind in the old fundamentalunits.py, and explains what _dimensions holds.
Unit defined its "not equal" method as __neq__, which Python never calls (the method is __ne__). So != on two units fell through to Quantity's comparison: `volt != ohm` raised DimensionMismatchError although `volt == ohm` is False, and `volt != mV` gave a NumPy bool where == gives a Python bool. Renaming it to __ne__ makes != the exact opposite of ==, and a new test checks that for units of the same and of different kinds.
The twelve private modules now live in one folder, src/QuantSI/_core/, so the top of the package shows only what users import (QuantSI, fundamentalunits, allunits, stdunits, constants, unitsafefunctions). Inside the folder the files drop their leading underscore, since the folder name already marks them as private; NumPy did the same when numpy.core became numpy._core. _core/__init__.py lists the modules in their layer order. Only files move and import lines change. Public import paths, and pickles of quantities and dimensions, are unaffected: their reconstructors keep reporting QuantSI.fundamentalunits. Classes now report QuantSI._core.quantity and so on as their module, which shows up in tracebacks and in pickles of DimensionMismatchError, so this is done before the first release that has the split. The layer test now reads _core, and also checks that no private module is added outside it.
|
Hi @Legend101Zz, I did not yet look into things in detail, but here a few quick thoughts on your questions
I think the formatting should stay as it was in this case. The situation with
Yes, I agree!
At some point it might be interesting to have an interface to sympy's unit system, but if it is no longer used in the current implementation, it can definitely go.
Sorry, no clear answer yet, I'll need to have a closer look at this first.
Yes, I think it should follow SPEC-0 (which actually means Python 3.13 and Numpy 2.3 either already or very soon).
I think having them outside is more common these days and makes sense.
CeCILL-2.1 is explicitly GPL-compatible, so there is no worry. I'd rather go with GPL, since CeCILL is not much known outside France, and not even in the French government's list of recommend open source licenses |
|
@mstimberg thanks for approving the workflows, all three pass on the PR's merge commit:
And thanks for the answers above, I'll go through them and reply separately. |
Overview
This PR implements the plan discussed in #18 and agreed on at our 11 September meeting. QuantSI remains a NumPy array subclass, but its internals are now split into smaller modules, its handling of NumPy operations is explicit and tested, and several common operations are faster. It also adds a CI job that runs a subset of Brian2’s test suite against QuantSI.
I’ve organised the work into small commits so the refactoring, behaviour changes, and integration work can be reviewed separately. Below is a description of what changed, how I tested it, and what I suggest doing next for Brian2.
Why this needed attention
Most of QuantSI lived in
fundamentalunits.py, a file of roughly 2,500 lines containingDimension,Quantity,Unit, the unit registries,check_units, formatting, and ufunc handling. These pieces depended heavily on one another, which made even small changes difficult to reason about.There were also several issues beyond the organisation of the code:
np.concatenatecould turn lengths into a dimensionless array,np.wherecould mix metres and seconds without an error, andnp.linalg.invcould return a matrix labelled in metres instead of inverse metres.np.allclose, andcheck_unitsrepeatedly inspected the decorated function.mVwithoutuV,mswithoutns, and no package-levelnewtonexport.allunits.pylived only in Brian2’s repository.Refactoring and compatibility
Safety checks before moving code
Before splitting the implementation, I added tests to capture the behaviour users already rely on:
str,repr,format,in_unit,in_best_unit, and LaTeX output for 24 example values. Changes to this output now appear as reviewable diffs.I also pinned Ruff and selected its rules explicitly. With a fresh install,
maincurrently failsruff checkbecause Ruff 0.16 enables additional rules by default. The supported minimum versions are now Python 3.12 and NumPy 2.2, matching Brian2, and pytest runs doctests by default and treats warnings as errors.A smaller internal structure
The implementation now lives in
src/QuantSI/_core/:dimension.pyDimension, dimension caching, and helpers such asget_dimensionsandfail_for_dimension_mismatcherrors.pyregistry.pyquantity.py,unit.pyQuantityandUnitclassesufuncs.py,array_functions.pyformatting.py,parsing.pydecorators.pycheck_unitsutils.py,wrappers.pyThe public modules remain in place:
QuantSI,fundamentalunits,allunits,stdunits,constants, andunitsafefunctions.fundamentalunits.pyre-exports the implementation under the existing names, preserving imports and saved pickles.The extraction commits move code without changing its behaviour. The modules were first split alongside the public files, then moved into
_corein a separate commit that changes only file locations and imports.git diff --color-movedshould make those commits easier to review.A test checks the allowed import direction between internal modules to prevent cycles. The layout and dependency rules are documented in
docs_sphinx/developer/architecture.rst.Within each module, definitions are arranged in reading order, related methods use consistent headings, and module docstrings include a short contents list. Those cleanup commits move whole definitions and rename headings without changing their implementation.
Test location
Tests now live in the repository-level
tests/directory, alongsideconftest.py, rather than insidesrc/QuantSI/tests. Previously, the tests and their data accounted for about a third of the installed wheel: 233 KB out of 700 KB.Brian2 deliberately ships its tests so users can run
brian2.test(). If we want the same approach for QuantSI, this move is isolated in one commit and can be reverted.Unit handling
NumPy ufuncs
Ufunc rules now live in one table, keyed by the ufunc objects themselves, with a small handler for each rule. Every ufunc exposed in NumPy’s namespace has an explicit rule, and a completeness test catches additions in future NumPy releases.
This also fixes several existing problems:
out=dimensions: previously, an output array retained its old dimensions. For example,np.add(metres, metres, out=seconds)could leave an array labelled as seconds holding lengths. The output now takes the result’s dimensions, following Astropy’s approach. This also handles in-place operations such asq *= 2 * second. Scalar quantities continue to behave like floats.np.multiply.reduceover three lengths now returns a length cubed.q.prod(initial=2)also counts dimension-bearing factors correctly, without treating a dimensionless initial value as an extra unit factor.accumulate,outer,reduceat, andatnow work when their results can be represented with one set of dimensions. Unsupported cases raise an explanatory error instead of NumPy’s generic error.Other NumPy functions
Quantitynow implements__array_function__(NEP 18). The roughly 300 functions NumPy can dispatch through this interface are classified into four groups:Each classified function has a test comparing its behaviour on quantities with its behaviour on plain arrays. An unclassified function introduced by a future NumPy release retains the previous fallback behaviour but emits a
QuantSIWarning; the completeness test then fails CI until the function is reviewed.Formatting and parsing
Quantity formatting now goes through a shared implementation. Format specifications apply to the value in its chosen display unit, so:
The PR also adds
parse_quantityandparse_dimensions. They parse an abstract syntax tree and accept only numbers, unit names, multiplication, division, powers, signs, parentheses, and lists of numbers. The input is never executed as code.Quantity
reproutput can also be parsed back, within NumPy’s printed precision. This does not cover arrays abbreviated with..., or values containing NaN or infinity.Unit names and generation
The generator now lives in
tools/generate_units.py. It generatesallunits.py,stdunits.py, and the unit imports in__init__.pyfrom a shared table.This fills the gaps in the existing names: where
mVexists,uV,nV, andpVare available too. Every unit is available from the package root with the common prefixes. Existing names remain available, and the package still introduces no single-letter names.Type annotations
The public API is annotated, the package ships
py.typed, and mypy runs in CI.Arithmetic needs a little extra support because NumPy’s stubs generally describe array arithmetic as returning a plain
ndarray.QuantityandUnittherefore declare their operators insideif TYPE_CHECKING, following the approach NumPy uses fornp.matrix. These declarations have no runtime cost and let type checkers recognise expressions such as3 * mVas aQuantityandvolt * ampas aUnit.tests/typing_examples.pychecks these cases with mypy, and I also checked the examples with pyright.Changes users will see
I ran each of these examples on both
mainand this branch:np.concatenate([q_m, q_m])DimensionMismatchErrornp.dot(q_m, q_s)np.linalg.norm(q_m)np.linalg.inv(matrix_in_metres)np.argsort(q)andq2d.argmax(axis=0)np.array_equal(1 * metre, 1 * second)Falsenp.copy(q)Quantityf"{3 * mV:.2f}"'3.00 mV'np.polyval(q_m, x)TypeErrorinstead of silently dropping unitsfrom QuantSI import newton, uV, nsimport QuantSIvolt != ohmTrueinstead of raisingDimensionMismatchErrorThe last issue came from
Unitdefining__neq__instead of__ne__, so Python never called the intended inequality method even thoughvolt == ohmcorrectly returnedFalse.Performance
These measurements are from my machine using Python 3.14 and NumPy 2.5.3:
str(3 * mV)3 * mvoltcheck_unitsThe measurement method and CI performance checks are documented in
docs_sphinx/developer/performance.rst.Validation
masterate8ce4e2c, the doctests, code-generation-independent tests, and NumPy target pass withbrian2.unitsreplaced by thin QuantSI wrappers: 44, 426, and 295 tests respectively.mainin untouched files, with two fewer warnings because the stub pages for the oldQuantSI.testspackage are gone.Known limitations and possible follow-ups
I left the following behaviours unchanged or documented their limits:
np.hypotandnp.arctan2: both still require dimensionless arguments because that is how Brian2’s original rule lists classify them.np.hypot(3 * metre, 4 * metre)should physically return5 * metre, andnp.arctan2should accept arguments with matching dimensions. These would make a small follow-up.obj == 0check does not recognise a Python list such as[0, 0]. As a result,np.concatenate([q, np.zeros(2)])works, whilenp.concatenate([q, [0, 0]])is rejected.(3 * mV) / (1 * mV), type checkers still see aQuantityeven though the runtime result is a plain number. NumPy’s stubs make a similar approximation for zero-dimensional arrays. Pyright also infersarray * unitas a plain array, while mypy handles it correctly;unit * arrayworks in both.Brian2 integration
Included in this PR
tools/brian2/make_facade.pygenerates thin wrappers for the modules inbrian2.units. These re-export the matching QuantSI modules and forward other names to them, preservingfrom brian2.units import ...,from brian2 import *, and Brian2’s internal imports.Brian2 also needs three small companion changes, provided in
tools/brian2/brian2-companion.patchand explained intools/brian2/README.md:* meterafternp.hstackinspatialneuron/morphology.py. It compensated forhstackdropping units, which no longer happens.test_format_quantity, which expects the previousf"{q:g}"output.test_switching_off_unit_checks, which tests the removedunit_checkingsetting.The new
.github/workflows/brian2.yamlworkflow checks out Brian2’smasteron every push, generates the wrappers, applies the companion changes, and runs the test groups listed above. This gives us an ongoing compatibility check for those parts of Brian2.Suggested next steps
Once this PR is merged, I suggest proceeding in this order:
brian2/units/*.pywith the generated wrappers, retain Brian2’scelsiusplaceholder, and apply the three companion changes.brian2.units.fundamentalunits.quantity_with_dimensions, which the wrappers preserve, so they should load. That still needs a test using real pickles from a Brian2 release. New pickles will refer to QuantSI’s path and will not load in Brian2 versions from before the switch; I think that is reasonable, but we should agree on it explicitly.brian2.unitswould remain the public compatibility layer while internal modules move to direct QuantSI imports in small follow-up PRs, each checked against the full test suite.unit_checking, and additional unit names.Questions for review
@mstimberg I’d particularly appreciate feedback on these decisions:
f"{q:.2f}"to format the value in the best unit and retain the unit, as Astropy and Pint do, or should scalar formatting preserve the previous base-SI output?unit_checking: are we agreed that it should remain removed and that Brian2 should drop the corresponding test?uV,nV,pV,mF,pS,ns,ps,pM, andGHz—and roughly 150 package-level names, includingnewtonandtesla. Are there any we should exclude from Brian2’sfrom brian2 import *?unitsafefunctions, which licence should QuantSI use?