Skip to content

Unit System: Working Across Unit Conventions

Abstract

The Unit System bridges the gap between the unit conventions what you think in (inches, pounds, RPM) and the coherent base units your simulation requires (meters, kilograms, radians/second). By attaching a UnitSystem to your model and tagging distributions and design variables with a unit, all sampling paths convert values automatically so your input data stays readable while your simulation receives physically consistent numbers.


When building physical simulations, engineers often think in the units that come naturally to their domain. One team may use inches and slugs, another team may work in millimeters, and a third could be using centipoise. Manually converting everything to SI before it enters your model is error-prone and obscures intent.

stochas solves this with UnitSystem and UnitDescriptor. A UnitSystem knows the base unit for each physical dimension in your model. Any named unit can be resolved against it to produce a conversion factor.


Declaring a Unit System

Four coherent presets cover the most common choices. Each uses a self-consistent mass that makes the natural force unit exactly 1.0 (no gc constant needed):

Factory Length Mass Force
si() meter kilogram newton
cgs() centimeter gram dyne
fps() foot slug lbf
ips() inch slinch lbf

You can also construct a custom system by providing explicit base unit names:

Python
1
2
3
from stochas.unit_system import UnitSystem

u = UnitSystem(length="meter", mass="kilogram", temperature="kelvin", current="ampere")

Attribute access on a UnitSystem resolves any Pint recognized unit (including compound units like kilometer_per_hour or newton_meter) and returns a UnitDescriptor:

Python
import stochas
from stochas.unit_system import UnitSystem

# declare SI as the model's base unit system
us = UnitSystem.si()

# float() gives the conversion factor (FROM that unit INTO the base unit)
assert float(us.meter) == 1.0
assert float(us.inch) == 0.0254
assert float(us.kilometer) == 1000.0

# str() gives the unit name back as a string
assert str(us.inch) == "inch"

# base dimension names are plain attributes
assert us.length == "m"
assert us.mass == "kg"

Constructing Unit Descriptors

There are four ways to get a UnitDescriptor from a UnitSystem:

Approach Example When to use
Attribute us.meter, us.inch, us.pound any Pint-recognized unit name
Underscore compound us.meter_per_second, us.kilometer_per_hour Pint already knows the compound name
Descriptor arithmetic us.m / us.s, us.m / us.s**2, us.m**2 any combination you can express with *, /, **
String subscript us["m/s"], us["inch/second"] expressions with slashes or ** that can't be Python identifiers
Python
us = UnitSystem.si()

# attribute access: any Pint-recognized unit name
assert float(us.meter) == 1.0
assert float(us.inch) == 0.0254
assert abs(float(us.pound) - 0.45359237) < 1e-9  # kg per pound

# Pint underscore names for compound units Pint already knows
assert float(us.meter_per_second) == 1.0
assert float(us.kilometer_per_hour) == 1 / 3.6  # km/h -> m/s

# descriptor arithmetic: *, /, ** compose name and scale
velocity = us.m / us.s  # UnitDescriptor("m / s",      scale=1.0)
accel = us.m / us.s**2  # UnitDescriptor("m / s ** 2", scale=1.0)
area = us.m**2  # UnitDescriptor("m ** 2",     scale=1.0)

# scales compose for non-SI too
inch_per_sec = us.inch / us.second  # scale = 0.0254 / 1.0 = 0.0254
assert abs(float(inch_per_sec) - 0.0254) < 1e-12

# string subscript: any Pint expression (slashes, **, spaces all OK)
assert float(us["m/s"]) == 1.0
assert float(us["inch/second"]) == 0.0254  # us.inch_per_second raises AttributeError
assert float(us["m/s**2"]) == 1.0

Note

Offset units (degF, degC) follow the same four approaches but have restrictions on arithmetic: raising to a power or multiplying/dividing two offset units raises ValueError because the result has no unique affine representation. Convert to an absolute unit first (e.g. us.kelvin or us.rankine) before composing.


Using Unit Descriptors in Expressions

UnitDescriptor supports the usual arithmetic operators, so it slots directly into array expressions. Multiply a value by the descriptor to convert it from that unit into the model's base unit:

Python
import numpy as np

us = UnitSystem.si()

# scalar multiply
mass_kg = 2.205 * us.pound  # 2.205 lb * 0.4536 kg/lb ≈ 1.0 kg
print(f"mass: {mass_kg:.4f} kg")

# or since u tracks its own base dimensions
print(f"mass: {2.205 * us.pound:.4f} {us.mass}")  # prints mass: 1.0 kilogram

# array multiply: descriptor on the left so pyright resolves the return type as ndarray
pos_m = us.inch * np.array([1.0, 2.0, 3.0])
print(f"pos: {pos_m} m")  # [0.0254, 0.0508, 0.0762]

Tip

This is the same pattern as Pint quantities, but without attaching a unit to the value itself. The float(descriptor) factor is computed once at UnitSystem construction time and reused for every multiply.


Automatic Conversion in Sampling

The most powerful integration is tagging a distribution or design variable with a units descriptor and letting StochasBase handle the conversion automatically.

Distributions

Set the units field on any distribution to the UnitDescriptor for the unit your parameters are expressed in. When sample_dist is called (with the default convert_units=True), the sampled array is multiplied by float(dist.units) before being stored in model.named. The distribution's own parameters (mean, std, bounds) will remain untouched, so to_tables() still reports values in the declared unit.

Python
us = UnitSystem.si()

# sample_dist automatically converts inches -> meters and tags the result with the model base unit
model = stochas.StochasBase().with_seed(42).with_trial_num(1).with_unit_system(us)
arm_length = model.sample_dist(
    stochas.NormalDistribution(
        name=stochas.DistName("arm_length"),
        mu=12.0,  # 12 inches mean
        sigma=0.1,  # 0.1 inch std
        unit=us.inch,  # attach a UnitDescriptor to a distribution
    ),
    convert_units=True,  # defaults to True
).squeeze()

# value is now in meters
print(f"arm_length (m): {arm_length.value}")  # ~ 0.305 m

# unit reflects the model base unit for length (not the source inch unit)
assert arm_length.unit is not None
assert str(arm_length.unit) == "m"  # SI length base unit
assert float(arm_length.unit) == 1.0  # scale=1 means no further conversion needed

Note

Conversion is skipped automatically for non-numeric distributions (e.g. CategoricalDistribution with string choices). A np.issubdtype guard ensures no attempt is made to multiply strings or object arrays.

Design Variables

The same units field is available on DesignFloat and DesignInt. sample_design converts the value before returning it and stores the converted NamedValue in model.named, while model.design always retains the original declared value for optimizer feedback and reporting.

Python
us = UnitSystem.si()

model = stochas.StochasBase(us=us)

link_velocity = stochas.DesignFloat(
    name=stochas.ValueName("link_velocity"),
    low=0.5,
    high=4.0,
    stored_value=2.0,  # declared in inches per second
    unit=us.inch / us.second,  # combine units with /, *, or **
)

vel_m_per_s = model.sample_design(
    link_velocity,
    convert_units=True,  # also defaults to True
)
assert abs(vel_m_per_s - 0.0508) < 1e-9  # 2.0 in/s * 0.0254 m/in = 0.0508 m/s

# model.design keeps the original declared unit
assert str(model.design["link_velocity"].unit) == "inch / second"

# model.named holds the converted value tagged with the model's compound base unit
named_unit = model.named["link_velocity"].unit
assert named_unit is not None
assert str(named_unit) == "m / s"  # value is in meters per second; unit says so

Serialization and Restoration

UnitDescriptor serializes as {"name": "inch"} only since the conversion factor is excluded because it is context-dependent (the same name maps to a different factor in SI vs IPS). This keeps serialized models portable.

When deserializing, there are two paths:

  • With us in the JSON (recommended): include us=UnitSystem.si() on the model before serializing. On model_validate_json, the built-in model_validator detects a non-None us and calls update_unit_system automatically, all factors are restored with no extra code.
  • Without us in the JSON: call model.update_unit_system(us) explicitly after loading.
Python
us = UnitSystem.si()
model = stochas.StochasBase(us=us)

dist = stochas.NormalDistribution(
    name=stochas.DistName("goofy_velocity"),
    mu=10.0,
    sigma=0.5,
    unit=us["furlong / fortnight"],  # string subscript accepts any Pint expression
)

model.dists.update(dist)

data = model.model_dump()

# the unit descriptor serializes as {"name": "furlong / fortnight"} (scale/offset are excluded)
assert data["dists"]["goofy_velocity"]["unit"] == {"name": "furlong / fortnight"}

# when us is present in the JSON, validation restores all scale/offset values automatically
restored_model = stochas.StochasBase.model_validate(data)
goofy_unit = restored_model.dists["goofy_velocity"].unit
assert goofy_unit is not None and goofy_unit.scale
assert abs(goofy_unit.scale - float(us["furlong / fortnight"])) < 1e-12

# if you serialize without us, call with_unit_system() after loading
unitless_model = stochas.StochasBase()
unitless_model.dists.update(
    stochas.NormalDistribution(
        name=stochas.DistName("mass"), mu=1.0, sigma=0.05, unit=us.pound
    )
)

# round trip
unitless_raw = unitless_model.model_dump_json()
unitless_restored = stochas.StochasBase.model_validate_json(unitless_raw)

assert unitless_restored.us is None
mass_unit = unitless_restored.dists["mass"].unit
assert mass_unit is not None
assert mass_unit.scale is None  # scale missing until restored

has_units_model = unitless_restored.with_unit_system(us)
assert isinstance(has_units_model.us, UnitSystem)
restored_mass_unit = has_units_model.dists["mass"].unit
assert restored_mass_unit is not None
assert abs(float(restored_mass_unit) - float(us.pound)) < 1e-9

Built-in Unit Systems at a Glance

Python
1
2
3
4
5
6
from stochas.unit_system import UnitSystem

si  = UnitSystem.si()   # meter / kilogram / second / kelvin / ampere / mole / candela
cgs = UnitSystem.cgs()  # centimeter / gram / second
fps = UnitSystem.fps()  # foot / slug / second
ips = UnitSystem.ips()  # inch / slinch / second

Any additional SI base dimensions (temperature, current, amount, luminosity) default to None on cgs, fps, and ips. Add them via model_copy:

Python
ips_thermal = UnitSystem.ips().model_copy(update={"temperature": "rankine"})