Skip to content

Restructure QuantSI into small modules, check NumPy functions, and test it against Brian2 - #19

Open
Legend101Zz wants to merge 72 commits into
brian-team:mainfrom
Legend101Zz:quantsi-structure
Open

Legend101Zz wants to merge 72 commits into
brian-team:mainfrom
Legend101Zz:quantsi-structure

Conversation

@Legend101Zz

@Legend101Zz Legend101Zz commented Oct 7, 2026 •

Copy link
Copy Markdown

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 containing Dimension, 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:

  • NumPy functions outside the ufunc interface could silently lose units or return incorrect ones. For example, np.concatenate could turn lengths into a dimensionless array, np.where could mix metres and seconds without an error, and np.linalg.inv could return a matrix labelled in metres instead of inverse metres.
  • Importing QuantSI took about 170 ms. Most of that time came from SymPy, which was used only to format exponents in LaTeX unit names.
  • Common operations repeated work unnecessarily: ufunc handling searched eight name lists on every call, dimension comparisons used np.allclose, and check_units repeatedly inspected the decorated function.
  • Short unit names and package-level exports were maintained by hand, leaving gaps such as mV without uV, ms without ns, and no package-level newton export.
  • The generator for allunits.py lived 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:

  • Public imports: a snapshot of the names exposed by each public module, including the private names Brian2 imports, with checks that they remain importable.
  • Pickle compatibility: fixtures written by the existing implementation using protocols 2, 4, and 5. The tests check that these still load and that new pickles retain the existing reconstruction paths.
  • Formatting: a golden file covering str, repr, format, in_unit, in_best_unit, and LaTeX output for 24 example values. Changes to this output now appear as reviewable diffs.
  • Performance: benchmarks for common operations and a CI check that compares QuantSI’s overhead with plain NumPy in the same process, reducing dependence on the speed of the CI machine.

I also pinned Ruff and selected its rules explicitly. With a fresh install, main currently fails ruff check because 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/:

Module Responsibility
dimension.py Dimension, dimension caching, and helpers such as get_dimensions and fail_for_dimension_mismatch
errors.py Exceptions
registry.py Unit registries used to choose display units
quantity.py, unit.py The Quantity and Unit classes
ufuncs.py, array_functions.py Unit handling for NumPy operations
formatting.py, parsing.py Converting quantities to and from text
decorators.py check_units
utils.py, wrappers.py Supporting helpers

The public modules remain in place: QuantSI, fundamentalunits, allunits, stdunits, constants, and unitsafefunctions. fundamentalunits.py re-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 _core in a separate commit that changes only file locations and imports. git diff --color-moved should 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, alongside conftest.py, rather than inside src/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 as q *= 2 * second. Scalar quantities continue to behave like floats.
  • Products and reductions: np.multiply.reduce over 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.
  • Other ufunc methods: accumulate, outer, reduceat, and at now 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

Quantity now implements __array_function__ (NEP 18). The roughly 300 functions NumPy can dispatch through this interface are classified into four groups:

  1. Functions whose existing NumPy implementation already handles quantities correctly.
  2. Functions with a QuantSI implementation.
  3. Functions that return results without units, such as indices, booleans, and shapes.
  4. Unsupported functions, including date, bit, and polynomial operations, which raise an explanatory error.

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:

f"{3 * mV:.2f}"  # '3.00 mV', previously '0.00'

The PR also adds parse_quantity and parse_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 repr output 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 generates allunits.py, stdunits.py, and the unit imports in __init__.py from a shared table.

This fills the gaps in the existing names: where mV exists, uV, nV, and pV are 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. Quantity and Unit therefore declare their operators inside if TYPE_CHECKING, following the approach NumPy uses for np.matrix. These declarations have no runtime cost and let type checkers recognise expressions such as 3 * mV as a Quantity and volt * amp as a Unit.

tests/typing_examples.py checks these cases with mypy, and I also checked the examples with pyright.

Changes users will see

I ran each of these examples on both main and this branch:

Operation Behaviour after this PR
np.concatenate([q_m, q_m]) Preserves metres; combining metres and seconds raises DimensionMismatchError
np.dot(q_m, q_s) Returns metre × second
np.linalg.norm(q_m) Returns metres
np.linalg.inv(matrix_in_metres) Returns inverse metres
np.argsort(q) and q2d.argmax(axis=0) Return plain indices, without units
np.array_equal(1 * metre, 1 * second) Returns False
np.copy(q) Returns a Quantity
f"{3 * mV:.2f}" Returns '3.00 mV'
np.polyval(q_m, x) Raises an explanatory TypeError instead of silently dropping units
from QuantSI import newton, uV, ns Works
import QuantSI No longer imports SymPy
volt != ohm Returns True instead of raising DimensionMismatchError

The last issue came from Unit defining __neq__ instead of __ne__, so Python never called the intended inequality method even though volt == ohm correctly returned False.

Performance

These measurements are from my machine using Python 3.14 and NumPy 2.5.3:

Operation Before After
Import QuantSI in a fresh process 172 ms 52 ms
Compare two dimensions 6.8 µs 0.31 µs
str(3 * mV) 33 µs 16 µs
3 * mvolt 3.4 µs 1.75 µs
Add two scalars 1.62 µs 0.96 µs
Add two arrays of 1,000 values 1.75 µs 1.08 µs
Call a function decorated with check_units 5.1 µs 3.3 µs

The measurement method and CI performance checks are documented in docs_sphinx/developer/performance.rst.

Validation

  • I checked every commit individually: each passes Ruff and the full QuantSI test suite. Mypy also passes from the commit that introduces the annotations onward.
  • The final state passes Ruff, mypy, and the tests on Python 3.12 with NumPy 2.2, and on Python 3.13 and 3.14 with NumPy 2.5.3. CI now runs this matrix.
  • Against Brian2’s current master at e8ce4e2c, the doctests, code-generation-independent tests, and NumPy target pass with brian2.units replaced by thin QuantSI wrappers: 44, 426, and 295 tests respectively.
  • All workflows pass on my fork, including the three Python versions, the benchmark overhead check, and the Brian2 integration job. Running the workflows exposed the need to fetch Brian2’s full Git history for installation; the final commit addresses that.
  • The documentation build reports the same warnings as main in untouched files, with two fewer warnings because the stub pages for the old QuantSI.tests package are gone.
  • The typing examples pass both mypy and pyright.

Known limitations and possible follow-ups

I left the following behaviours unchanged or documented their limits:

  • np.hypot and np.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 return 5 * metre, and np.arctan2 should accept arguments with matching dimensions. These would make a small follow-up.
  • Plain zero values: zero is accepted for any dimension, but the existing obj == 0 check does not recognise a Python list such as [0, 0]. As a result, np.concatenate([q, np.zeros(2)]) works, while np.concatenate([q, [0, 0]]) is rejected.
  • Static typing: when dimensions cancel, as in (3 * mV) / (1 * mV), type checkers still see a Quantity even though the runtime result is a plain number. NumPy’s stubs make a similar approximation for zero-dimensional arrays. Pyright also infers array * unit as a plain array, while mypy handles it correctly; unit * array works in both.

Brian2 integration

Included in this PR

tools/brian2/make_facade.py generates thin wrappers for the modules in brian2.units. These re-export the matching QuantSI modules and forward other names to them, preserving from brian2.units import ..., from brian2 import *, and Brian2’s internal imports.

Brian2 also needs three small companion changes, provided in tools/brian2/brian2-companion.patch and explained in tools/brian2/README.md:

  1. Remove the extra * meter after np.hstack in spatialneuron/morphology.py. It compensated for hstack dropping units, which no longer happens.
  2. Update test_format_quantity, which expects the previous f"{q:g}" output.
  3. Remove test_switching_off_unit_checks, which tests the removed unit_checking setting.

The new .github/workflows/brian2.yaml workflow checks out Brian2’s master on 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:

  1. Release QuantSI, so Brian2 can depend on a published version rather than a Git branch.
  2. Open the Brian2 integration PR: add the dependency, replace brian2/units/*.py with the generated wrappers, retain Brian2’s celsius placeholder, and apply the three companion changes.
  3. Expand validation: run the Cython and C++ standalone targets and long tests, build the documentation, run the examples and tutorials, and benchmark representative simulations. The current validation covers the doctests, code-generation-independent tests, and NumPy target.
  4. Test pickle compatibility with released Brian2 versions. Existing pickles refer to 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.
  5. Migrate Brian2’s internal imports gradually. As agreed at the meeting, brian2.units would 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.
  6. Write release notes covering the behaviour changes, removal of unit_checking, and additional unit names.

Questions for review

@mstimberg I’d particularly appreciate feedback on these decisions:

  • Formatting: is it reasonable for 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?
  • SymPy: is everyone happy to remove the dependency now that LaTeX exponent formatting no longer needs it?
  • Exports: this adds nine short names—uV, nV, pV, mF, pS, ns, ps, pM, and GHz—and roughly 150 package-level names, including newton and tesla. Are there any we should exclude from Brian2’s from brian2 import *?
  • Minimum versions: are Python 3.12 and NumPy 2.2 acceptable, matching Brian2?
  • Installed tests: should QuantSI keep its tests outside the installed package, or ship them as Brian2 does?
  • Licensing: QuantSI currently specifies GPLv3, while Brian2 uses CeCILL-2.1. Since this PR brings over code from Brian2, including the unit generator and unitsafefunctions, which licence should QuantSI use?

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.
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.
@Legend101Zz
Legend101Zz marked this pull request as ready for review October 7, 2026 13:33
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.
@mstimberg

Copy link
Copy Markdown
Member

Hi @Legend101Zz, I did not yet look into things in detail, but here a few quick thoughts on your questions

  • Formatting: is it reasonable for 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?

I think the formatting should stay as it was in this case. The situation with pint is not exactly comparable : the two values 0.001*second and 1*ms are represented exactly the same in quantSI, so we cannot make a difference between the two. Using best unit would display both of them as 1*ms. In contrast, pint stores the two values differently, the f"{q:.2f}" would give something like 0.00 secondfor the first and1.00 millisecond` for the latter.

  • unit_checking: are we agreed that it should remain removed and that Brian2 should drop the corresponding test?

Yes, I agree!

  • SymPy: is everyone happy to remove the dependency now that LaTeX exponent formatting no longer needs it?

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.

  • Exports: this adds nine short names—uV, nV, pV, mF, pS, ns, ps, pM, and GHz—and roughly 150 package-level names, including newton and tesla. Are there any we should exclude from Brian2’s from brian2 import *?

Sorry, no clear answer yet, I'll need to have a closer look at this first.

  • Minimum versions: are Python 3.12 and NumPy 2.2 acceptable, matching Brian2?

Yes, I think it should follow SPEC-0 (which actually means Python 3.13 and Numpy 2.3 either already or very soon).

  • Installed tests: should QuantSI keep its tests outside the installed package, or ship them as Brian2 does?

I think having them outside is more common these days and makes sense.

  • Licensing: QuantSI currently specifies GPLv3, while Brian2 uses CeCILL-2.1. Since this PR brings over code from Brian2, including the unit generator and unitsafefunctions, which licence should QuantSI use?

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

@Legend101Zz

Copy link
Copy Markdown
Author

@mstimberg thanks for approving the workflows, all three pass on the PR's merge commit:

  • Tests: ruff, mypy and pytest on Python 3.12 + NumPy 2.2.6, and on 3.13 and 3.14 with NumPy 2.5.3 (1,222-1,224 tests per job).
  • Brian2: Brian2's own test suite against this branch, with brian2.units turned into a facade over QuantSI plus the small patch in tools/brian2/. 768 tests pass across its three suites (doctests, codegen-independent, numpy target).
  • Benchmarks: the 9 overhead checks against plain NumPy pass. The raw timings are only saved as an artifact (benchmarks.json), since shared runners are too noisy to compare absolute numbers between runs.

And thanks for the answers above, I'll go through them and reply separately.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants