Units in bigraph-schema¶
bigraph-schema ships two complementary mechanisms for working with physical units:
Quantity— the runtime value carries its units alongside its magnitude (backed bypint). You can call.to(other_unit), do unit-safe arithmetic, and inspect.dimensionalityat runtime.Number._units— a schema-level annotation. The runtime value is a plainfloat; the unit string lives only on the schema.
A third feature ties them together: when a state's schema declares _units and connects to a port that declares a different _units, bigraph-schema computes a scalar conversion factor at compile time so the value seen through the port is in the port's units.
This notebook walks through each of these.
Setup¶
from bigraph_schema import allocate_core
core = allocate_core()
1. Quantity — units carried at runtime¶
Use 'quantity' when you want unit-aware arithmetic and conversion on the value itself. realize accepts several encodings; serialize always produces {units, magnitude}.
Defining a quantity schema¶
quantity_schema = core.access({
'_type': 'quantity',
'units': {'mol': 1, 'L': -1},
})
quantity_schema
Quantity(_default=None, units={'mol': 1, 'L': -1}, magnitude=Float(_default=None, _units='', _bits=0))
core.render(quantity_schema)
{'_type': 'quantity', 'units': {'mol': 1, 'L': -1}, 'magnitude': 'float'}
Realize from a {magnitude, units} dict¶
_, q, _ = core.realize(
quantity_schema,
{'magnitude': 2.0, 'units': {'mol': 1, 'L': -1}},
)
q
type(q).__name__, q.magnitude, str(q.units)
('Quantity', 2.0, 'mol / L')
Realize from a bare numeric (uses the schema's units)¶
_, q_bare, _ = core.realize(quantity_schema, 5.0)
q_bare
Realize from a parseable string¶
_, q_str, _ = core.realize(quantity_schema, '2.1 millimolar')
q_str
Unit-aware operations at runtime¶
q.to('mmol/L')
q.dimensionality
<UnitsContainer({'[length]': -3, '[substance]': 1})>
(q * 3).to('mol/L')
Serialize roundtrip¶
encoded = core.serialize(quantity_schema, q)
encoded
{'units': {'mol': 1, 'L': -1}, 'magnitude': 2.0}
_, q_back, _ = core.realize(quantity_schema, encoded)
q_back == q
True
2. Number._units — units as metadata¶
Use _units on a numeric type when you want to record the unit but keep the runtime value as a plain float (or numpy array). The unit string lives only on the schema — at runtime the value has no awareness of its units.
Defining a unit-annotated float schema¶
float_schema = core.access({'_type': 'float', '_units': 'mol/L'})
float_schema
Float(_default=None, _units='mol/L', _bits=0)
core.render(float_schema)
'float[mol/L]'
The runtime value is a plain float¶
_, x, _ = core.realize(float_schema, 2.5)
x, type(x).__name__
(2.5, 'float')
Serialize is just the number¶
core.serialize(float_schema, x)
2.5
When to use which¶
| Approach | Runtime value | Unit info lives in | Use when |
|---|---|---|---|
Number._units |
plain float |
schema annotation | hot numerical paths; downstream code expects floats |
Quantity |
pint.Quantity |
the value itself | unit-safe arithmetic / introspection at runtime |
_units is the right default for high-throughput numerics — no per-value pint overhead, and unit-aware wiring (Section 3) still works. Reach for Quantity when you need .to() or unit-checked arithmetic on individual values.
3. Unit conversion across wires¶
When a state's schema declares _units and a port's schema declares a different _units, bigraph-schema doesn't silently mismatch — it computes a scalar conversion factor at compile time (using pint) and applies it during view/projection. The relevant logic is Core._compute_unit_scale in bigraph_schema/core.py.
You can call it directly to see what happens for a given pair of units.
Compatible units: a real scale factor¶
core._compute_unit_scale('fg', 'g')
1e-15
core._compute_unit_scale('mmol/L', 'mol/L')
0.001
No-op cases¶
_compute_unit_scale returns 1.0 (a true pass-through) in three cases:
- one side has no unit declared (
'') - the units match exactly
- one side is
'dimensionless'
core._compute_unit_scale('', 'g'), core._compute_unit_scale('g', 'g'), core._compute_unit_scale('dimensionless', 'g')
(1.0, 1.0, 1.0)
Incompatible units raise¶
Asking pint to convert fg to mol is meaningless; the call raises. (In wire resolution this is caught and falls back to 1.0, but it's intended to surface as a wire validation error — see the docstring in core.py.)
try:
core._compute_unit_scale('fg', 'mol')
except Exception as exc:
print(type(exc).__name__, exc)
DimensionalityError Cannot convert from 'femtogram' ([mass]) to 'mole' ([substance])
How this plays out in a schema¶
Picture a composite where a state at path x is stored in fg and a process reads it through a port declared in g:
schema = {
'x': {'_type': 'float', '_units': 'fg'},
'proc': {
'_type': 'process',
'inputs': {'mass': {'_type': 'float', '_units': 'g'}},
...
},
}
When the wire from proc.inputs.mass resolves down to x, bigraph-schema computes 1e-15 once and caches it. Each view of the port multiplies the underlying float by 1e-15 so the process always sees grams, while storage stays in femtograms.
The full machinery — _collect_view_scales, view, project, apply — is internal to Core and is exercised end-to-end in process-bigraph. For this notebook we stop at showing the scale computation itself.