Unit-safe wiring: dimensional consistency at build time¶
On 23 September 1999 the Mars Climate Orbiter fired its main engine to enter Mars orbit at an altitude 170 km lower than planned, broke up in the upper atmosphere, and ended a US$327 million mission in roughly ninety seconds. The ground-software contract specified impulse in newton-seconds; one team supplied pound-force-seconds; nobody at the boundary checked. The post-mortem (NASA 1999) is not subtle on the point. Dimensional analysis is the cheapest debugging technique in engineering, and it is the one most likely to be skipped because tools do not enforce it.
Jaxonomy enforces it. Every port on every block can carry a Unit annotation; DiagramBuilder.connect() runs a connect-time consistency check; propagate_diagram_units(diagram) walks math blocks like Integrator and Product and stamps derived units forward; the acausal connector library carries canonical SI units per domain so wiring an electrical port to a thermal port surfaces both the type error and the physical mismatch. None of this is opt-out: legacy diagrams that never declared a unit keep working byte-for-byte. None of it is opt-in lip-service either: this notebook wires Constant(units=newton) -> Integrator -> output declared as joule and watches the framework refuse the connection because $\mathrm{N{\cdot}s} \ne \mathrm{J}$.
The wedge is that Simulink explicitly drops units after computational blocks, and Jaxonomy now does not. That claim is genuine and we will demonstrate it in cell 4. The smaller, quieter beats matter too: auto-conversion of mm to m at the wire, offset-aware conversion of degC to K (the +273.15 that pure-scale unit systems silently get wrong), BusUnit for compound bus signals, and a per-field FX rate that lets usd and eur participate in the same algebra as meter and second.
Estimated reading time: 12-15 minutes. Estimated runtime on CPU: ~5 seconds (this tutorial is almost entirely build-time checks — fast).
Prerequisites: comfort with LeafSystem and DiagramBuilder (see primitives.ipynb). Familiarity with SI base dimensions at the level of a first-year physics course. The sibling multirate_controller.ipynb showcases BusCreator/BusSelector without unit annotations; this notebook is the unit-annotated counterpart.
What a Unit actually is¶
A jaxonomy.framework.units.Unit is an immutable, hashable dataclass with three numerical fields and two tag fields:
| Field | Type | Default | Meaning |
|---|---|---|---|
dims |
7-tuple of int | (0,)*7 |
Integer exponents on the seven SI base dimensions $(\mathrm{kg}, \mathrm{m}, \mathrm{s}, \mathrm{A}, \mathrm{K}, \mathrm{mol}, \mathrm{cd})$. |
scale |
float | 1.0 |
Multiplicative scale on the base unit: kilometer has $\mathrm{dims} = (0, 1, 0, 0, 0, 0, 0)$ and $\mathrm{scale} = 1000$. |
offset |
float | 0.0 |
Additive offset for affine units. The convention is $\mathrm{value\_in\_base\_SI} = \mathrm{scale} \cdot \mathrm{raw} + \mathrm{offset}$, so celsius has $\mathrm{scale} = 1$ and $\mathrm{offset} = 273.15$. |
currency |
5-tuple of int | (0,)*5 |
Independent exponents on (USD, EUR, GBP, JPY, CAD). usd carries $(1, 0, 0, 0, 0)$; pure SI units stay all-zero. |
physical_quantity |
str or None |
None |
Disambiguation tag for units that share SI dimensions but mean different things: N*m@torque vs N*m@energy. |
The whole point of carrying these as plain Python data is that they participate in algebra (newton * meter returns a new Unit), in equality, and in hashing — without ever touching a JAX tracer or a tensor value. Units are metadata; they are evaluated at build time, never at simulation time. There is zero runtime overhead.
Two helper notions sit on top of Unit:
BusUnitcarries oneUnitper named field of a compound bus signal — the unit-side counterpart of theNamedTuplepayload thatBusCreatoremits. The connect-time check verifies each field individually.UnitMismatchErroris what gets raised when two ports declare incompatible units. It is a subclass ofStaticError, so it routes through Jaxonomy'sErrorCollectormachinery if you are using it; otherwise it is a plain Python exception.
Pitfall. The
dimstuple ordering matters.Unit(dims=(0, 1, 0, 0, 0, 0, 0))is metres because the second slot is length; switching positions silently produces a different unit. Use the curated module constants (meter,second,newton, ...) whenever you can; reach for rawdims=(...)only when you have measured twice.
import json
import warnings
import jax.numpy as jnp
import numpy as np
import jaxonomy
from jaxonomy import simulate, SimulatorOptions
from jaxonomy.backend import numpy_api as npa
from jaxonomy.framework import LeafSystem
from jaxonomy.framework.unit_propagation import propagate_diagram_units
from jaxonomy.framework.units import (
# SI base + selected derived
Unit, meter, second, kilogram, newton, joule, watt, hertz, kelvin,
volt, ampere, ohm,
# Scaled & offset units
millimeter, kilometer, celsius,
# Compound + currency
BusUnit, usd, eur,
# Helpers
UnitMismatchError, are_units_compatible, assert_unit_compatible,
conversion_factor, convert_offset_aware, derived_unit,
set_fx_rate, clear_fx_rates,
)
from jaxonomy.library import Constant, Integrator, Adder, Product, BusCreator
from jaxonomy import logging as jaxlog
# Quiet the per-connection conversion log lines — they are useful at the REPL,
# noisy in a tutorial. We will inspect conversions explicitly in §3.
jaxlog.set_log_level(jaxlog.ERROR)
# No randomness in this notebook; we set a seed only for hygiene parity with
# the sibling tutorials.
SEED = 0
np.random.seed(SEED)
§1. The simplest possible mismatch — a force into a displacement¶
We need two trivial leaf systems with opinions about their port units. ForceSource emits a force in newtons; DisplacementSink accepts a displacement in metres. The compiler should refuse the wire.
We declare units on the port objects themselves, via the units= kwarg on declare_output_port and declare_input_port. The connect-time check lives at DiagramBuilder.connect() line ~331 in jaxonomy/framework/diagram_builder.py; it calls into assert_units_compatible_with_scale (the auto-conversion path; see §3) or, if the builder was constructed with unit_conversion="error", the stricter assert_unit_compatible.
class ForceSource(LeafSystem):
"""Emits a constant 1 N. The output port advertises units=newton."""
def __init__(self, value=1.0):
super().__init__()
# Single output port. The unit annotation is just a kwarg on declare_output_port.
self.declare_output_port(
lambda t, s, *u, **p: npa.array(value),
units=newton,
name="F",
requires_inputs=False,
)
class DisplacementSink(LeafSystem):
"""Takes a displacement in metres and re-emits it. Input port carries units=meter."""
def __init__(self):
super().__init__()
self.declare_input_port(units=meter, name="x")
self.declare_output_port(
lambda t, s, *u, **p: u[0],
units=meter,
name="y",
requires_inputs=True,
)
b = jaxonomy.DiagramBuilder()
src = b.add(ForceSource())
sink = b.add(DisplacementSink())
try:
b.connect(src.output_ports[0], sink.input_ports[0])
raise AssertionError("connect() should have raised UnitMismatchError")
except UnitMismatchError as exc:
print("UnitMismatchError caught at connect time:")
print(" ", exc)
UnitMismatchError caught at connect time:
Unit mismatch: 'ForceSource_1_.out[0]' (F) has units Unit('N') but 'DisplacementSink_2_.in[0]' (x) has units Unit('m').
The error message names both ports — the source port (ForceSource.out[0], units Unit('N')) and the destination (DisplacementSink.in[0], units Unit('m')) — and surfaces the actual unit objects via their __repr__. The check fires during connect(), before the diagram is built, before any context is created, before any simulation runs. The build-time wedge is the whole point: no kernel is ever launched on a dimensionally-inconsistent diagram.
Note. Ports that never declare a unit are treated as
dimensionlessand connect to anything. This is the default-off byte-equivalence rule: legacy diagrams that predate unit annotations keep working. The annotations are opt-in per port; the check is non-opt-in once both sides are annotated.
§2. The algebra: units multiply, divide, and raise to integer powers¶
Before the harder beats, a sanity check on the algebra. Unit.__mul__, Unit.__truediv__, and Unit.__pow__ add, subtract, and multiply the dimension exponents — exactly the textbook dimensional algebra. The familiar derived units are pre-built constants:
$$ \mathrm{N} = \mathrm{kg{\cdot}m{\cdot}s^{-2}}, \qquad \mathrm{J} = \mathrm{N{\cdot}m} = \mathrm{kg{\cdot}m^{2}{\cdot}s^{-2}}, \qquad \mathrm{W} = \mathrm{J/s} = \mathrm{kg{\cdot}m^{2}{\cdot}s^{-3}}. $$
Dimensional analysis: integrating force over time gives momentum, $[\mathrm{N{\cdot}s}] = [\mathrm{kg{\cdot}m/s}]$, with dimension exponents $(1, 1, -1, 0, 0, 0, 0)$ — not energy (which has $(1, 2, -2, 0, 0, 0, 0)$). The third coordinate (the time exponent) differs by a power, which is exactly why $\int F\,dt \ne E$. The algebra below catches this.
# Multiplication composes dimensions exponent-wise.
nm = newton * meter
print(f"newton * meter = {nm!r}")
print(f" dims = {nm.dims} (kg m^2 / s^2 — same shape as joule)")
print(f" same as joule? {nm == joule}")
print()
# Division.
mps = meter / second
print(f"meter / second = {mps!r}")
print(f" dims = {mps.dims}")
print()
# Integer powers.
mps2 = meter / second**2
print(f"meter / second**2 = {mps2!r} (acceleration)")
print()
# Force times velocity is power; the algebra confirms it.
fv = newton * mps
print(f"newton * (m/s) = {fv!r}")
print(f" dims = {fv.dims}")
print(f" equal to watt? {fv == watt}")
print()
# Force times time is *momentum*, not energy. The dimension-3 (time) exponent
# differs by 1 between N*s (-1) and J (-2).
ns = newton * second
print(f"newton * second = {ns!r}")
print(f" dims = {ns.dims}")
print(f" equal to joule? {ns == joule} <-- this is the §4 headline beat")
newton * meter = Unit(kg*m^2*s^-2) dims = (1, 2, -2, 0, 0, 0, 0) (kg m^2 / s^2 — same shape as joule) same as joule? True meter / second = Unit(m*s^-1) dims = (0, 1, -1, 0, 0, 0, 0) meter / second**2 = Unit(m*s^-2) (acceleration) newton * (m/s) = Unit(kg*m^2*s^-3) dims = (1, 2, -3, 0, 0, 0, 0) equal to watt? True newton * second = Unit(kg*m*s^-1) dims = (1, 1, -1, 0, 0, 0, 0) equal to joule? False <-- this is the §4 headline beat
§3. Auto-conversion when dimensions match but scales differ¶
Wiring Constant(units=millimeter) to a port declared meter is not a mismatch — the dimensions agree, only the scale differs by $10^{-3}$. By default, DiagramBuilder is constructed with unit_conversion="auto" and silently inserts the conversion factor on the destination input port. The simulation gets the right answer regardless of which side of the wire spells the unit.
Three modes are supported on the builder constructor:
unit_conversion="auto"(default) — insert the factor silently, emit only anINFOlog line.unit_conversion="warn"— insert the factor and emit aUserWarning. Useful in CI to surface unintended unit drift.unit_conversion="error"— refuse the connection, preserving the strict Phase-1 behaviour.
The conversion factor itself is computed by conversion_factor(src, dst) = src.scale / dst.scale, so $\mathrm{millimeter} \to \mathrm{meter}$ gives $10^{-3} / 1 = 10^{-3}$, and a value of $1000$ on the source becomes $1.0$ at the sink.
# --- Mode 1: "auto" (the default). Silent factor insertion. ---
b_auto = jaxonomy.DiagramBuilder()
src_mm = b_auto.add(Constant(1000.0, units=millimeter, name="src_mm"))
sink_m = b_auto.add(DisplacementSink())
b_auto.connect(src_mm.output_ports[0], sink_m.input_ports[0])
b_auto.export_output(sink_m.output_ports[0], name="y")
diag_auto = b_auto.build(name="auto_demo")
ctx_auto = diag_auto.create_context(time=0.0)
opts = SimulatorOptions(
math_backend="jax", enable_tracing=True,
ode_solver_method="dopri5", rtol=1e-7, atol=1e-9,
max_major_steps=4, buffer_length=16,
)
res_auto = simulate(
diag_auto, ctx_auto, (0.0, 1.0), options=opts,
recorded_signals={"y": sink_m.output_ports[0]},
)
y_auto = float(res_auto.outputs["y"][-1])
print(f"Source: Constant(1000.0, units=mm); sink declares units=m")
print(f"Sink output value: {y_auto:.6f} (expected: 1.0 m)")
assert np.isclose(y_auto, 1.0), "auto-conversion should scale 1000 mm to 1.0 m"
# --- The same factor surfaced explicitly via conversion_factor() ---
factor = conversion_factor(millimeter, meter)
print(f"\nconversion_factor(millimeter, meter) = {factor} (= 1e-3 as expected)")
Source: Constant(1000.0, units=mm); sink declares units=m Sink output value: 1.000000 (expected: 1.0 m) conversion_factor(millimeter, meter) = 0.001 (= 1e-3 as expected)
# --- Mode 2: "warn". Same numerical result, but a UserWarning fires. ---
b_warn = jaxonomy.DiagramBuilder(unit_conversion="warn")
src_km = b_warn.add(Constant(0.001, units=kilometer, name="src_km"))
sink_warn = b_warn.add(DisplacementSink())
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")
b_warn.connect(src_km.output_ports[0], sink_warn.input_ports[0])
assert len(captured) == 1 and issubclass(captured[0].category, UserWarning)
print(f"UserWarning emitted (as requested by unit_conversion='warn'):")
print(f" {captured[0].message}")
# --- Mode 3: "error". The connection is refused outright. ---
b_strict = jaxonomy.DiagramBuilder(unit_conversion="error")
src_strict = b_strict.add(Constant(1000.0, units=millimeter, name="src_mm"))
sink_strict = b_strict.add(DisplacementSink())
try:
b_strict.connect(src_strict.output_ports[0], sink_strict.input_ports[0])
raise AssertionError("strict mode should refuse mm -> m")
except UnitMismatchError as exc:
print(f"\nunit_conversion='error' refuses scalar mismatches too:")
print(f" {exc}")
UserWarning emitted (as requested by unit_conversion='warn'):
Unit conversion: applying factor 1000.0 to connection 'src_km.out[0]' (out_0) (Unit('km')) -> 'DisplacementSink_7_.in[0]' (x) (Unit('m')).
unit_conversion='error' refuses scalar mismatches too:
Unit mismatch: 'src_mm.out[0]' (out_0) has units Unit('mm') but 'DisplacementSink_9_.in[0]' (x) has units Unit('m').
The numerical value 1.0 at the sink — not 1000.0, not some unit-tagged opaque object — confirms that the factor is applied transparently inside the framework, with no work required from the leaf system author. The block author sees a meter on the wire because that is what the block declared; the user gets to spell their source value in whichever scale is convenient.
§4. The headline beat — math-block propagation catches N·s ≠ J¶
This is what was deferred until the math-block propagation work shipped. Before ec12231, math blocks like Integrator, Adder, Product, and Gain were unit-blind: their input ports carried no annotation, their output ports carried no annotation, and a path that wired Constant(units=newton) -> Integrator -> ... would lose all unit information at the integrator. The result was a unit-checking system that worked at the diagram edges but went silent in the middle. The wedge — that Simulink explicitly drops units after computational blocks, and Jaxonomy does not — was true in scope of intent, false in current implementation. Both shortcomings are now closed.
The way it works: per-block unit rules live in jaxonomy.framework.unit_propagation as pure functions of (input_units, params). After the diagram is built, the user calls propagate_diagram_units(diagram), which walks the leaves in source-first order, applies each block's registered rule, and stamps the result on the block's output ports — but only when the port did not already carry a user-supplied unit (so user annotations always win). The rules are:
| Block | Output unit (per registered rule) |
|---|---|
Adder |
shared input unit (raises on mismatch) |
Gain |
gain_units * input_unit (defaults to passthrough) |
Product |
$\prod_i \mathrm{input}_i$ |
Reciprocal |
$1 / \mathrm{input}$ |
Integrator |
$\mathrm{input} \cdot \mathrm{seconds}$ |
Derivative |
$\mathrm{input} / \mathrm{seconds}$ |
The integrator rule is what makes the headline beat fire. Wire $F$ in newtons through an integrator and the derived output unit is $\mathrm{N{\cdot}s} = \mathrm{kg{\cdot}m/s}$ — momentum. That is not equal to a joule. The walker will refuse to overwrite a port the user already annotated joule, but the downstream are_units_compatible(propagated, declared) check fires immediately when a downstream block sees the conflict.
# Build: Constant(N) -> Integrator -> export. Walk units. Inspect the result.
b_int = jaxonomy.DiagramBuilder()
src_F = b_int.add(Constant(1.0, units=newton, name="F_src"))
integ = b_int.add(Integrator(0.0))
b_int.connect(src_F.output_ports[0], integ.input_ports[0])
b_int.export_output(integ.output_ports[0], name="impulse")
diag_int = b_int.build(name="force_integrator")
# Before propagation, the integrator's output port has no declared unit.
print(f"Integrator output unit BEFORE propagation: {integ.output_ports[0].units}")
# Walk the diagram. The Integrator's rule stamps N*s on its output port.
stamped = propagate_diagram_units(diag_int)
print(f"propagate_diagram_units stamped {stamped} output ports.")
out_unit = integ.output_ports[0].units
print(f"Integrator output unit AFTER propagation: {out_unit!r}")
print(f" dims = {out_unit.dims} (kg^1 m^1 s^-1 — momentum)")
# Sanity: the derived unit equals newton * second per the algebra.
assert out_unit == newton * second, "Integrator rule should yield N*s"
# Now the moment of truth: a downstream consumer that thinks it's getting joules.
print()
print("Would an 'I expect joule' downstream port accept this?")
compatible = are_units_compatible(out_unit, joule)
print(f" are_units_compatible(N*s, J) = {compatible}")
try:
assert_unit_compatible(
out_unit, joule,
src_label="Integrator(F) output",
dst_label="hypothetical energy sink",
)
raise AssertionError("should have raised")
except UnitMismatchError as exc:
print(f" assert_unit_compatible raised:")
print(f" {exc}")
Integrator output unit BEFORE propagation: None
propagate_diagram_units stamped 1 output ports.
Integrator output unit AFTER propagation: Unit(kg*m*s^-1)
dims = (1, 1, -1, 0, 0, 0, 0) (kg^1 m^1 s^-1 — momentum)
Would an 'I expect joule' downstream port accept this?
are_units_compatible(N*s, J) = False
assert_unit_compatible raised:
Unit mismatch: Integrator(F) output has units Unit(kg*m*s^-1) but hypothetical energy sink has units Unit('J').
Two things to notice in the output above.
First, the integrator's output port carries no unit until the walker runs — propagation is an explicit one-line call, not a hidden side effect of build(). This is by design: a user who never annotates anything gets exactly the pre-annotation behaviour, byte-for-byte. The propagation is opt-in at the diagram level.
Second, the propagated unit prints as Unit(kg*m*s^-1) rather than Unit('N*s'). That is the canonical SI decomposition — Jaxonomy reduces every multiplicative composition to the seven-tuple of base exponents, then renders it back in compact form. kg·m/s is mathematically identical to N·s; both equal the dimension exponents (1, 1, -1, 0, 0, 0, 0). The framework's equality is on the exponents, not on the rendered name, so Unit(kg*m*s^-1) == newton * second is True even though the strings differ.
Now wire the integrator's output to a correctly-typed downstream port — momentum, not energy — and watch it pass.
class MomentumSink(LeafSystem):
"""Accepts momentum in kg*m/s (== N*s)."""
def __init__(self):
super().__init__()
# The momentum unit can be spelled either way; we pick the multiplicative
# form to emphasise that the framework compares dimension exponents,
# not rendered names.
self.declare_input_port(units=newton * second, name="p")
self.declare_output_port(
lambda t, s, *u, **p: u[0],
units=newton * second,
name="p_out",
requires_inputs=True,
)
# Rebuild with a correct downstream consumer.
b_ok = jaxonomy.DiagramBuilder()
src_F2 = b_ok.add(Constant(1.0, units=newton, name="F_src"))
integ2 = b_ok.add(Integrator(0.0))
sink_p = b_ok.add(MomentumSink())
b_ok.connect(src_F2.output_ports[0], integ2.input_ports[0])
# The integrator's output port is unannotated by default. Two equivalent ways
# to stamp it with N*s before the next connect: (a) call propagate_diagram_units
# after build() — the post-hoc walker path; (b) annotate explicitly here, then
# verify with propagation below. We pick (b) for the cleanest read.
integ2.output_ports[0].units = newton * second # explicit annotation; the user's prerogative
b_ok.connect(integ2.output_ports[0], sink_p.input_ports[0])
b_ok.export_output(sink_p.output_ports[0], name="p")
diag_ok = b_ok.build(name="force_to_momentum")
# Walk units for completeness (and to demonstrate it does not undo our annotation).
propagate_diagram_units(diag_ok)
print(f"Final integrator output unit: {integ2.output_ports[0].units!r}")
print("Connection F -> integrator -> momentum-sink passes — dimensions agree.")
# Run it briefly and check the numerical value: 1 N integrated over 0.5 s = 0.5 N*s.
ctx_ok = diag_ok.create_context(time=0.0)
res_ok = simulate(
diag_ok, ctx_ok, (0.0, 0.5), options=opts,
recorded_signals={"p": sink_p.output_ports[0]},
)
p_final = float(res_ok.outputs["p"][-1])
print(f"Simulated momentum at t=0.5 s: {p_final:.4f} N*s (expected: 0.5)")
assert np.isclose(p_final, 0.5, atol=1e-6)
Final integrator output unit: Unit(kg*m*s^-1) Connection F -> integrator -> momentum-sink passes — dimensions agree.
Simulated momentum at t=0.5 s: 0.5000 N*s (expected: 0.5)
Numeric check: $1\,\mathrm{N} \cdot 0.5\,\mathrm{s} = 0.5\,\mathrm{N{\cdot}s}$. The framework caught a $\mathrm{N{\cdot}s} \ne \mathrm{J}$ misannotation upstream; once the consumer's port is corrected to momentum, the simulation runs and produces the textbook answer.
Note.
propagate_diagram_unitsreaches a fixed point in a bounded number of passes (it iterates until no port changes, capped at $\max(8, 4N)$ passes for a diagram of $N$ leaves). Blocks with no registered rule are silently skipped, withNone(wildcard) propagated through them. New block types — including user-definedLeafSystemsubclasses — can register their own rule viaregister_unit_rule("MyBlock", my_rule).
Pitfall. The
propagate_diagram_units(diagram, overwrite=False)default will not overwrite a port the user already annotated; it stamps only blank ports. If you swap a parameter that changes the derived unit (rare, but possible — aGainwhosegain_unitsyou change at runtime), re-run withoverwrite=True.
§5. Adder and Product propagation¶
Adder is the obvious case: $v_1 + v_2$ requires $v_1$ and $v_2$ to share a unit, and the sum carries that same unit. The propagation rule raises UnitMismatchError on incompatible inputs, so a $\mathrm{m} + \mathrm{N}$ wiring is caught at propagation time even if both sides declared their units in good faith elsewhere in the diagram.
Product is more interesting: it multiplies the input units. Force times velocity is power; the algebra above already confirmed $\mathrm{N} \cdot (\mathrm{m/s}) = \mathrm{kg{\cdot}m^2/s^3} = \mathrm{W}$. The propagation rule yields exactly that — the unit dimension of mechanical power emerges automatically from the unit dimensions of the multiplicands.
# --- Adder: two velocities sum to a velocity. ---
mps = meter / second
b_add = jaxonomy.DiagramBuilder()
v1 = b_add.add(Constant(1.0, units=mps, name="v1"))
v2 = b_add.add(Constant(2.0, units=mps, name="v2"))
add = b_add.add(Adder(2, operators="++"))
b_add.connect(v1.output_ports[0], add.input_ports[0])
b_add.connect(v2.output_ports[0], add.input_ports[1])
diag_add = b_add.build(name="velocity_sum")
propagate_diagram_units(diag_add)
print(f"Adder output unit (v1 + v2): {add.output_ports[0].units!r}")
assert add.output_ports[0].units == mps
# --- Adder catches a unit mismatch at propagation time. ---
b_bad = jaxonomy.DiagramBuilder()
v3 = b_bad.add(Constant(1.0, units=mps, name="v3"))
f3 = b_bad.add(Constant(2.0, units=newton, name="f3"))
add_bad = b_bad.add(Adder(2, operators="++"))
b_bad.connect(v3.output_ports[0], add_bad.input_ports[0])
b_bad.connect(f3.output_ports[0], add_bad.input_ports[1])
diag_bad = b_bad.build(name="bad_sum")
try:
propagate_diagram_units(diag_bad)
raise AssertionError("propagation should have raised")
except UnitMismatchError as exc:
print(f"\nAdder propagation refuses v + F:")
print(f" {exc}")
Adder output unit (v1 + v2): Unit(m*s^-1)
Adder propagation refuses v + F:
Unit mismatch: Adder input has units Unit(m*s^-1) but Adder input has units Unit('N').
# --- Product: force * velocity yields the watt automatically. ---
b_prod = jaxonomy.DiagramBuilder()
F = b_prod.add(Constant(10.0, units=newton, name="F"))
v = b_prod.add(Constant(3.0, units=mps, name="v"))
prod = b_prod.add(Product(2, operators="**"))
b_prod.connect(F.output_ports[0], prod.input_ports[0])
b_prod.connect(v.output_ports[0], prod.input_ports[1])
b_prod.export_output(prod.output_ports[0], name="P")
diag_prod = b_prod.build(name="mechanical_power")
propagate_diagram_units(diag_prod)
out = prod.output_ports[0].units
print(f"Product output unit (F * v): {out!r}")
print(f" dims = {out.dims}")
print(f" equal to watt? {out == watt} <-- the analytic dimensional result")
# Numerical sanity: 10 N * 3 m/s = 30 W.
ctx_prod = diag_prod.create_context(time=0.0)
res_prod = simulate(
diag_prod, ctx_prod, (0.0, 0.1), options=opts,
recorded_signals={"P": prod.output_ports[0]},
)
print(f"Simulated power: {float(res_prod.outputs['P'][-1]):.2f} W (expected: 30.00)")
Product output unit (F * v): Unit(kg*m^2*s^-3) dims = (1, 2, -3, 0, 0, 0, 0) equal to watt? True <-- the analytic dimensional result
Simulated power: 30.00 W (expected: 30.00)
§6. BusUnit for compound bus signals¶
Bus signals from BusCreator are NamedTuples whose fields can have different shapes, dtypes, and (now) different units. The unit-side counterpart of the NamedTuple payload is BusUnit — a frozen dataclass mapping field names to per-field Unit instances. BusCreator accepts an optional field_units={...} kwarg that:
- tags each of the bus's input ports with the field's
Unit, so an upstream wire into the wrong field is caught at connect time; - tags the bus output port with a compound
BusUnit, so downstreamBusSelector(field_name=...)knows whichUnitto advertise on its scalar output.
The compatibility check for two BusUnits lives in are_units_compatible: the field name sets must match, and each shared field's Unit is checked pair-wise under the standard rules (so a downstream bus declaring (v: volt, i: ampere) accepts an upstream bus declaring (v: volt, i: ampere) and refuses one declaring (v: ampere, i: volt) — the field names are part of the type).
# A four-field measurement bus carrying position, velocity, acceleration, force.
schema = {
"x": meter,
"v": meter / second,
"a": meter / second**2,
"F": newton,
}
creator = BusCreator(tuple(schema.keys()), field_units=schema)
print("BusCreator input ports (with declared per-field units):")
for ip in creator.input_ports:
print(f" {ip.name:5s} units = {ip.units!r}")
print(f"\nBusCreator output port carries a compound BusUnit:")
bus_unit = creator.output_ports[0].units
assert isinstance(bus_unit, BusUnit), "output port should carry a BusUnit"
for field_name, field_unit in bus_unit.fields.items():
print(f" {field_name:5s} -> {field_unit!r}")
# Compatibility checks at the BusUnit level.
matching_bus = BusUnit(fields=dict(schema)) # same field names + units
swapped_bus = BusUnit(fields={"x": meter, "v": newton, "a": meter / second**2, "F": newton}) # v swapped
print(f"\nare_units_compatible(bus, matching) = {are_units_compatible(bus_unit, matching_bus)}")
print(f"are_units_compatible(bus, swapped) = {are_units_compatible(bus_unit, swapped_bus)}")
BusCreator input ports (with declared per-field units):
x units = Unit('m')
v units = Unit(m*s^-1)
a units = Unit(m*s^-2)
F units = Unit('N')
BusCreator output port carries a compound BusUnit:
x -> Unit('m')
v -> Unit(m*s^-1)
a -> Unit(m*s^-2)
F -> Unit('N')
are_units_compatible(bus, matching) = True
are_units_compatible(bus, swapped) = False
The bus's input ports are pre-tagged so an upstream connect(Constant(units=newton), bus.input_ports[v_index]) raises immediately. The compound BusUnit flows through BusSelector so downstream consumers get back the field's scalar unit, not the whole bus.
Pitfall.
BusUnitis intentionally not a subclass ofUnit— bus signals do not participate in the multiplicative algebra.meter * busis meaningless and would be a bug to allow. The connect-time check recognises the two as different types: aBusUniton one side and a scalarUniton the other is always a mismatch.
§7. Offset-aware temperature conversion (°C vs K)¶
Most physical units differ only by a scale factor — millimetres are metres divided by a thousand, kilometres are metres times a thousand. Temperatures are the textbook exception: $0\,\mathrm{°C} = 273.15\,\mathrm{K}$, not $0$ kelvin. The conversion is affine, not just multiplicative; the offset is non-negotiable. A unit system that only handles scale would silently get this wrong, treating a Celsius input as if it were already Kelvin.
Jaxonomy's Unit class carries an offset field for exactly this case. The convention is
$$
\mathrm{physical\_value\_in\_base\_SI} = \mathrm{scale} \cdot \mathrm{raw} + \mathrm{offset}, \tag{1}
$$
so celsius has $\mathrm{scale} = 1$ and $\mathrm{offset} = 273.15$; fahrenheit has $\mathrm{scale} = 5/9$ and $\mathrm{offset} = (5/9) \cdot 459.67$.
The helper convert_offset_aware(src, dst, value) applies the full affine map. The scalar-only conversion_factor(src, dst) deliberately refuses affine units — there is no single multiplicative factor that converts °C to K, and pretending otherwise would silently produce a 273.15-unit error. The connect-time check at DiagramBuilder.connect() likewise refuses to auto-insert an affine conversion (the framework has no way to know whether the value flowing along the wire is a temperature or a temperature difference, and the two demand different conversions).
# Forward + round-trip checks.
print(f" 0 degC -> K: {convert_offset_aware(celsius, kelvin, 0.0):.4f} (expected: 273.15)")
print(f"100 degC -> K: {convert_offset_aware(celsius, kelvin, 100.0):.4f} (expected: 373.15)")
print(f"273.15 K -> degC: {convert_offset_aware(kelvin, celsius, 273.15):.4f} (expected: 0.0)")
print(f"\nRound-trip K -> C -> K at T=300 K:")
rt = convert_offset_aware(celsius, kelvin, convert_offset_aware(kelvin, celsius, 300.0))
print(f" result: {rt} (expected: exactly 300.0; offsets cancel)")
assert rt == 300.0, "K -> C -> K should be exact"
# The scalar-only path refuses affine units, with a useful message.
try:
conversion_factor(celsius, kelvin)
except UnitMismatchError as exc:
print(f"\nconversion_factor refuses affine units:")
print(f" {exc}")
0 degC -> K: 273.1500 (expected: 273.15)
100 degC -> K: 373.1500 (expected: 373.15)
273.15 K -> degC: 0.0000 (expected: 0.0)
Round-trip K -> C -> K at T=300 K:
result: 300.0 (expected: exactly 300.0; offsets cancel)
conversion_factor refuses affine units:
Cannot express conversion Unit('degC') -> Unit('K') as a scalar factor: at least one unit has a non-zero offset (affine unit, e.g. Celsius / Fahrenheit). Use convert_offset_aware(src, dst, value) instead.
The 273.15 difference between Celsius and Kelvin is exactly the affine offset; the round-trip is bit-exact (the offset cancels algebraically, not numerically), so a temperature pipeline that crosses °C/K boundaries does not accumulate roundoff.
Note. A unit difference $\Delta\mathrm{°C} = \Delta\mathrm{K}$ is multiplicatively consistent — the offset cancels in subtraction. The framework can therefore represent a temperature-difference port as
kelvinand accept Celsius differences via the existing scalar-conversion path, with no affine surprise. The reason the framework cannot auto-convert temperatures themselves is that the port annotation does not (yet) distinguish "temperature" from "temperature difference" — thephysical_quantitytag from Phase 3 will help here once tutorials and real models settle on a convention. For now, the explicitconvert_offset_awareis the right call site.
§8. Currency units with FX rates¶
Currencies fit the unit algebra surprisingly well: they are independent axes, products and quotients are meaningful ($/kg is a cost density), and the only thing that does not transfer cleanly from SI is the conversion factor — FX rates are time-varying environmental facts, not properties of the unit. Jaxonomy treats this honestly: usd, eur, gbp, jpy, and cad are pre-built Unit constants with a separate currency 5-tuple axis (so they are not equal to dimensionless), and an explicit set_fx_rate("USD", "EUR", 0.92) / convert_currency(value, usd, eur) pair handles the conversion.
This is a smaller beat than the math-block propagation, but it is the demonstration that the unit algebra is genuinely general — not a hand-rolled SI-only checker dressed up as one. The whole Unit machinery, including BusUnit over compound bus signals and physical_quantity disambiguation, applies to monetary units verbatim.
from jaxonomy.framework.units import convert_currency
# FX table is module-level mutable state. Tests + production typically refresh
# it at the top of the run; clear it here so the cell is hermetic.
clear_fx_rates()
set_fx_rate("USD", "EUR", 0.92)
set_fx_rate("USD", "JPY", 156.0)
# usd and eur are distinct in the algebra; they have non-zero currency exponents
# on different axes. Equality is False, and a port-level connection between
# unconverted usd and eur would fire UnitMismatchError just like meter vs second.
print(f"usd == eur? {usd == eur} (different currency axes)")
print(f"usd dims: {usd.dims} currency: {usd.currency}")
print(f"eur dims: {eur.dims} currency: {eur.currency}")
# Conversion uses the explicit helper. The reverse direction is auto-populated
# at FX-rate set time so round-trips are exact under the floating-point
# reciprocal.
print(f"\nconvert 100 USD -> EUR: {convert_currency(100.0, usd, eur):.2f} (= 100 * 0.92)")
print(f"convert 100 USD -> JPY: {convert_currency(100.0, usd, 'jpy'):.2f} (= 100 * 156)")
# Try to convert USD to seconds — that's a category error, not a missing rate.
try:
convert_currency(100.0, usd, second)
except UnitMismatchError as exc:
print(f"\nconvert_currency(USD, second) refused (category error, not missing rate):")
print(f" {exc}")
usd == eur? False (different currency axes)
usd dims: (0, 0, 0, 0, 0, 0, 0) currency: (1, 0, 0, 0, 0)
eur dims: (0, 0, 0, 0, 0, 0, 0) currency: (0, 1, 0, 0, 0)
convert 100 USD -> EUR: 92.00 (= 100 * 0.92)
convert 100 USD -> JPY: 15600.00 (= 100 * 156)
convert_currency(USD, second) refused (category error, not missing rate):
Unit Unit('s') is not a pure currency unit (non-zero SI dimensions); cannot resolve a currency code.
$92 \approx €100 at a 0.92 rate; $15{,}600 \approx ¥1M at a 156 rate. The framework refuses to convert dollars into seconds because the SI dimensions do not match, the same way it refuses metres into newtons.
§9. Acausal multi-domain: canonical units per connector¶
This is where unit annotations earn their keep most clearly. A multi-domain acausal model — say, a battery thermal-management loop coupling electrical (V, A, Ω), thermal (K, W), and mechanical (N, m/s) subsystems — has more wires than any one engineer can manually annotate. The acausal layer's Pantelides index-reduction pass routes flow and effort variables through a symbolic equation graph, and the existing type system (per jaxonomy/acausal/diagram_processing.py:263) already catches "wired an electrical port to a thermal port" as a domain mismatch.
What was missing before ec12231 was the unit half of the story. The fix is small but high-leverage: every PortBase subclass in jaxonomy/acausal/component_library/component_base.py now exposes class-level flow_units and pot_units attributes carrying the canonical SI units for the (flow, potential) pair of that domain. Rotational uses N·m@torque for flow and rad/s@angular_velocity for potential — the physical_quantity tag is what stops a future Pantelides-pass enhancement from confusing a torque port with an energy port (both are N·m dimensionally; only the tag distinguishes them).
The unit table:
| Domain | flow_units |
pot_units |
|---|---|---|
ElecPort |
$\mathrm{A}$ (current) | $\mathrm{V}$ (voltage) |
RotationalPort |
$\mathrm{N{\cdot}m@torque}$ | $\mathrm{rad/s@angular\_velocity}$ |
TranslationalPort |
$\mathrm{N}$ (force) | $\mathrm{m/s}$ (velocity) |
ThermalPort |
$\mathrm{W}$ (heat flow) | $\mathrm{K}$ (temperature) |
HydraulicPort / FluidPort |
$\mathrm{kg/s}$ (mass flow) | $\mathrm{Pa}$ (pressure) |
Today these attributes are enumerable: the constants ACAUSAL_UNIT_AMPERE, ACAUSAL_UNIT_VOLT, ... let provenance manifests, dashboards, and tooling read off the canonical mapping per domain. Full Pantelides-pass integration (cross-domain consistency checked at compile time alongside the existing equation-count diagnostics) is filed as a follow-up — the enabling data is in place; the consumer pass is the next step.
from jaxonomy.acausal.component_library.component_base import (
ElecPort, RotationalPort, TranslationalPort, ThermalPort,
HydraulicPort, FluidPort, PortBase,
)
domain_table = [
("Electrical", ElecPort),
("Rotational", RotationalPort),
("Translational", TranslationalPort),
("Thermal", ThermalPort),
("Hydraulic", HydraulicPort),
("Fluid", FluidPort),
]
print(f"{'Domain':14s} {'flow_units':32s} {'pot_units':32s}")
print("-" * 80)
for name, cls in domain_table:
flow_repr = repr(cls.flow_units)
pot_repr = repr(cls.pot_units)
print(f"{name:14s} {flow_repr:32s} {pot_repr:32s}")
# Custom (non-standard) domain subclasses keep working — flow_units / pot_units
# default to None on the base class so unknown domains are flagged for the
# author to fill in explicitly.
print(f"\nPortBase.flow_units defaults to: {PortBase.flow_units}")
print(f"PortBase.pot_units defaults to: {PortBase.pot_units}")
Domain flow_units pot_units
--------------------------------------------------------------------------------
Electrical Unit('A') Unit('V')
Rotational Unit('N*m') Unit('rad/s')
Translational Unit('N') Unit('m/s')
Thermal Unit('W') Unit('K')
Hydraulic Unit('kg/s') Unit('Pa')
Fluid Unit('kg/s') Unit('Pa')
PortBase.flow_units defaults to: None
PortBase.pot_units defaults to: None
# The Phase-3 physical_quantity tag in action: torque vs energy disambiguation.
# Both N*m. Both (1, 2, -2, 0, 0, 0, 0) in SI dimensions. Different physical meaning.
torque = RotationalPort.flow_units
energy = Unit(dims=(1, 2, -2, 0, 0, 0, 0), physical_quantity="energy")
untagged = Unit(dims=(1, 2, -2, 0, 0, 0, 0)) # no tag at all
print(f"torque = {torque!r}")
print(f"energy = {energy!r}")
print(f"untagged = {untagged!r}")
print()
print(f"torque == energy? {torque == energy}")
print(f" same SI dims? {torque.dims == energy.dims}")
print(f" physical_quantity tag distinguishes them.")
print()
print(f"are_units_compatible(torque, energy): {are_units_compatible(torque, energy)}")
print(f"are_units_compatible(torque, untagged): {are_units_compatible(torque, untagged)}")
print(" (untagged is a wildcard, so it passes — default-off byte-equivalence)")
try:
assert_unit_compatible(torque, energy,
src_label="motor flange (torque)",
dst_label="energy bookkeeping")
except UnitMismatchError as exc:
print(f"\nassert_unit_compatible(torque, energy) raised:")
print(f" {exc}")
torque = Unit('N*m')
energy = Unit(kg*m^2*s^-2@energy)
untagged = Unit(kg*m^2*s^-2)
torque == energy? False
same SI dims? True
physical_quantity tag distinguishes them.
are_units_compatible(torque, energy): False
are_units_compatible(torque, untagged): True
(untagged is a wildcard, so it passes — default-off byte-equivalence)
assert_unit_compatible(torque, energy) raised:
Unit mismatch: motor flange (torque) has units Unit('N*m') but energy bookkeeping has units Unit(kg*m^2*s^-2@energy).
The dimensional equality $\mathrm{N{\cdot}m}_{\text{torque}} = \mathrm{N{\cdot}m}_{\text{energy}} = (1, 2, -2, 0, 0, 0, 0)$ would normally let the two units connect freely; the physical_quantity tag on the rotational port's flow_units is what blocks the connection. Untagged units stay compatible with both, preserving the byte-equivalent default for diagrams that never opted in to the disambiguation.
§10. JSON serialization (Phase 3)¶
Every Unit round-trips through JSON via Unit.to_json() / Unit.from_json(). Default fields are omitted for compactness — an all-defaults Unit() serialises to {} — and the serialised forms are stable across versions so older saved models continue to load. The full set of emittable keys is {dims, scale, offset, currency, name, physical_quantity}; new fields will always be added with defaults.
This is what lets a provenance manifest (see reproducibility_manifest.ipynb) capture the unit annotations of a model as part of its config, and what lets the dashboard exchange unit-annotated models with the simulator without parsing Python source.
# A maximally-loaded Unit: SI dims, custom scale, custom name, physical_quantity tag.
u = Unit(
dims=(1, 2, -2, 0, 0, 0, 0),
scale=1.0,
name="N*m",
physical_quantity="torque",
)
blob = u.to_json(indent=2)
print("u.to_json(indent=2):")
print(blob)
round = Unit.from_json(blob)
print(f"\nRound-trip equal? {round == u}")
print(f" physical_quantity preserved? {round.physical_quantity == u.physical_quantity}")
print(f" name preserved? {round.name == u.name}")
# Default Unit() serialises to '{}'.
print(f"\nUnit().to_dict() = {Unit().to_dict()}")
print(f"Unit.from_dict({{}}) == Unit()? {Unit.from_dict({}) == Unit()}")
# summary() gives a longer human-readable form, useful for diagnostic dumps.
print(f"\nu.summary() = {u.summary()}")
u.to_json(indent=2):
{
"dims": [
1,
2,
-2,
0,
0,
0,
0
],
"name": "N*m",
"physical_quantity": "torque"
}
Round-trip equal? True
physical_quantity preserved? True
name preserved? True
Unit().to_dict() = {}
Unit.from_dict({}) == Unit()? True
u.summary() = Unit(kg · m^2 · s^-2, name="N*m", physical_quantity="torque")
§11. Failure modes — what units do not catch¶
The unit checker is small and fast, and that has consequences. Honest list of what it does not do:
- Unit annotations are opt-in. A diagram whose blocks never declare a unit gets zero safety. This is by design — the default-off byte-equivalence rule makes the upgrade non-breaking. The mitigation is to make annotations a code-review expectation on new blocks. The
propagate_diagram_unitswalker will then carry annotations from any annotated source through the math-block graph automatically. - The checker verifies dimensional consistency, not value correctness. It can prove that a port carries metres rather than seconds; it cannot prove that you typed
meterwhen you meantkilometer. The closest defence isunit_conversion="warn"in CI: a wire that quietly inserts a $1000\times$ scaling factor is at minimum worth a human glance. - Math-block propagation reaches a fixed point in one call. It does not auto-fire on every
connect(). If you change a parameter that changes a derived unit at run time (rare), re-callpropagate_diagram_units(diagram, overwrite=True). - Affine units do not have a scalar conversion factor. A wire that crosses Celsius and Kelvin is refused by the connect-time check; the user must call
convert_offset_aware(...)explicitly. The framework cannot tell whether the value flowing along the wire is a temperature or a temperature difference — the two require different conversions, and the framework prefers a loud refusal over a silent guess. physical_quantityis a wildcard when only one side declares it. Mixing a tagged torque port with an untaggedN·mport is treated as compatible (the untagged side is a wildcard). This preserves the byte-equivalent default; the price is that a partially-tagged diagram can still mix torque and energy silently. Tag both sides or tag neither.- Acausal Pantelides-pass integration is not yet wired. The
flow_units/pot_unitsattributes are exposed on every per-domainPortBasesubclass, so external tooling can enumerate them and the unit half of a domain check is now possible. Threading the units through the index-reduction pass so a multi-domain acausal model would catch a temperature-in-K wired to a temperature-in-°C at compile time (alongside the existing equation-count diagnostics) is filed as a follow-up. - Block-level rules cover the math blocks today.
Adder,Gain,Product,Reciprocal,Integrator,Derivative, and several passthroughs. User-writtenLeafSystemsubclasses can register their own rule viaregister_unit_rule("MyBlock", my_rule); absence of a rule means the walker passesNone(wildcard) through and leaves the output port unannotated.
The right mental model: the unit checker is one layer in a stack. Above it are tests; below it is the runtime (which is unit-blind, by design — units are static metadata). The checker is the layer that catches the bug that tests would have caught if someone had written the test, and that the runtime cannot catch because the runtime does not know what the numbers mean.
§12. Exercises¶
- Annotate one of your existing diagrams. Pick a small
LeafSystemfrom your own code, addunits=annotations on every port, and runpropagate_diagram_units(diagram)afterbuild(). Inspect the output ports and verify the derived units agree with what you expect. Report any port you find ambiguous (e.g. aSumwhose two inputs you had been adding without realising they were a velocity and a velocity delta). Code modification, easy. - Define a new derived unit. Use
derived_unit("mpg", "mpg", mile / gallon)to define miles-per-gallon — you will need to also definemileandgallonvia the scaled-unit pattern (look atkilometer's definition inunits.pyline ~546 for the template). Show the new unit composes correctly throughProductandReciprocal. Code modification, moderate. Hint:gallonis a US customary unit; pick ascalefactor relative to $\mathrm{m}^3$. - Hook a non-standard FX rate. Register a Bitcoin-to-USD rate (
set_fx_rate("BTC", "USD", ...)will fail because BTC is not inCURRENCY_CODES; instead, register agbp_to_usdrate at a custom value, then write aConstant(units=gbp)and verify the propagated value at ausdsink matches your rate). Open question: should the unit system support a user-extensible currency axis, or are the five built-in codes enough? Defend your answer in one paragraph. Open-ended. - Catch a torque-vs-energy bug. Construct a tiny acausal-style diagram where a rotational source's
flow(canonicallyN*m@torque) is wired to an energy bookkeeping sink declaredUnit(dims=(1, 2, -2, 0, 0, 0, 0), physical_quantity="energy"). Watch the connection fail. Now change the sink's tag toNone— observe the connection passes silently, and discuss why this is the right default. Conceptual. - Add a unit-propagation rule for a new block. Pick a block class without a registered rule (e.g.
Saturateis registered as a passthrough, butSlicereturns one component of a vector — its rule could in principle do something more interesting; or pick a domain-specific block from your codebase). Write the rule, register it viaregister_unit_rule, and confirmpropagate_diagram_unitsnow stamps the right unit on that block's output. Code modification, harder.
Key takeaways¶
- Annotate ports with
units=...ondeclare_input_port/declare_output_port;DiagramBuilder.connect()runs a connect-time consistency check that fires before any kernel launches. - The default
unit_conversion="auto"silently inserts the conversion factor when dimensions match but scales differ; use"warn"in CI to surface unintended scaling, and"error"for strict-equal behaviour. propagate_diagram_units(diagram)is the one-line opt-in that walks math blocks (Integrator,Adder,Product, …) and stamps derived units forward. The headline beat — that wiring $F$ in newtons through an integrator does not produce joules — is now caught at build time.BusUnitcarries per-field units on compound bus signals;BusCreator(field_units={...})is the call site.- Offset-aware temperature conversion lives in
convert_offset_aware; the scalar-only path refuses affine units rather than silently dropping the 273.15. - The
physical_quantitytag (Phase 3) disambiguates units that share SI dimensions but mean different things (N·m@torquevsN·m@energy); the acausal connector library uses it for the rotational domain. - Every per-domain
PortBasesubclass in the acausal library now exposes class-levelflow_units/pot_units, enabling enumeration today and Pantelides-pass integration as the next follow-up.
Where to next¶
multirate_controller.ipynb— the sibling tutorial that usesBusCreator/BusSelectorto carry multi-field signals between rate groups; an obvious place to annotate the bus withfield_units.reproducibility_manifest.ipynb— the manifest captures the model's config, of which unit annotations are now part. The JSON round-trip in §10 is what lets the manifest serialise and replay them.jaxonomy/framework/units.py— the full algebra, includingparse_unit("kg·m/s²")(pint-backed if installed, with a curated built-in fallback) and thederived_unithelper used for one-off composite units.jaxonomy/framework/unit_propagation.py— the per-block rules and thepropagate_diagram_unitswalker; the place to look when adding a rule for a custom block class.jaxonomy/acausal/component_library/component_base.py— theflow_units/pot_unitstable for the standard acausal domains; the natural home for Pantelides-pass integration.
References¶
- NASA Mars Climate Orbiter Mishap Investigation Board (1999), Phase I Report. The canonical demonstration that dimensional analysis is cheap and skipping it is expensive: https://llis.nasa.gov/llis_lib/pdf/1009464main1_0641-mr.pdf.
- BIPM, The International System of Units (SI), 9th edition (2019). The seven base dimensions Jaxonomy's
Unit.dimsindexes into. - F. Cellier and E. Kofman, Continuous System Simulation (Springer, 2006), Chapter 7. Treatment of acausal connector flow/effort pairs that motivates the per-domain canonical-unit table in §9.