Units in bigraph-schema¶

bigraph-schema ships two complementary mechanisms for working with physical units:

  1. Quantity — the runtime value carries its units alongside its magnitude (backed by pint). You can call .to(other_unit), do unit-safe arithmetic, and inspect .dimensionality at runtime.
  2. Number._units — a schema-level annotation. The runtime value is a plain float; 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¶

In [1]:
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¶

In [2]:
quantity_schema = core.access({
    '_type': 'quantity',
    'units': {'mol': 1, 'L': -1},
})
quantity_schema
Out[2]:
Quantity(_default=None, units={'mol': 1, 'L': -1}, magnitude=Float(_default=None, _units='', _bits=0))
In [3]:
core.render(quantity_schema)
Out[3]:
{'_type': 'quantity', 'units': {'mol': 1, 'L': -1}, 'magnitude': 'float'}

Realize from a {magnitude, units} dict¶

In [4]:
_, q, _ = core.realize(
    quantity_schema,
    {'magnitude': 2.0, 'units': {'mol': 1, 'L': -1}},
)
q
Out[4]:
2.0 mol/L
In [5]:
type(q).__name__, q.magnitude, str(q.units)
Out[5]:
('Quantity', 2.0, 'mol / L')

Realize from a bare numeric (uses the schema's units)¶

In [6]:
_, q_bare, _ = core.realize(quantity_schema, 5.0)
q_bare
Out[6]:
5.0 mol/L

Realize from a parseable string¶

In [7]:
_, q_str, _ = core.realize(quantity_schema, '2.1 millimolar')
q_str
Out[7]:
2.1 millimolar

Unit-aware operations at runtime¶

In [8]:
q.to('mmol/L')
Out[8]:
2000.0 millimole/liter
In [9]:
q.dimensionality
Out[9]:
<UnitsContainer({'[length]': -3, '[substance]': 1})>
In [10]:
(q * 3).to('mol/L')
Out[10]:
6.0 mole/liter

Serialize roundtrip¶

In [11]:
encoded = core.serialize(quantity_schema, q)
encoded
Out[11]:
{'units': {'mol': 1, 'L': -1}, 'magnitude': 2.0}
In [12]:
_, q_back, _ = core.realize(quantity_schema, encoded)
q_back == q
Out[12]:
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¶

In [13]:
float_schema = core.access({'_type': 'float', '_units': 'mol/L'})
float_schema
Out[13]:
Float(_default=None, _units='mol/L', _bits=0)
In [14]:
core.render(float_schema)
Out[14]:
'float[mol/L]'

The runtime value is a plain float¶

In [15]:
_, x, _ = core.realize(float_schema, 2.5)
x, type(x).__name__
Out[15]:
(2.5, 'float')

Serialize is just the number¶

In [16]:
core.serialize(float_schema, x)
Out[16]:
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¶

In [17]:
core._compute_unit_scale('fg', 'g')
Out[17]:
1e-15
In [18]:
core._compute_unit_scale('mmol/L', 'mol/L')
Out[18]:
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'
In [19]:
core._compute_unit_scale('', 'g'), core._compute_unit_scale('g', 'g'), core._compute_unit_scale('dimensionless', 'g')
Out[19]:
(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.)

In [20]:
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.