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.
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:
Attribute access on a UnitSystem resolves any Pint recognized unit (including compound units like kilometer_per_hour or newton_meter) and returns a UnitDescriptor:
importstochasfromstochas.unit_systemimportUnitSystem# declare SI as the model's base unit systemus=UnitSystem.si()# float() gives the conversion factor (FROM that unit INTO the base unit)assertfloat(us.meter)==1.0assertfloat(us.inch)==0.0254assertfloat(us.kilometer)==1000.0# str() gives the unit name back as a stringassertstr(us.inch)=="inch"# base dimension names are plain attributesassertus.length=="m"assertus.mass=="kg"
us=UnitSystem.si()# attribute access: any Pint-recognized unit nameassertfloat(us.meter)==1.0assertfloat(us.inch)==0.0254assertabs(float(us.pound)-0.45359237)<1e-9# kg per pound# Pint underscore names for compound units Pint already knowsassertfloat(us.meter_per_second)==1.0assertfloat(us.kilometer_per_hour)==1/3.6# km/h -> m/s# descriptor arithmetic: *, /, ** compose name and scalevelocity=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 tooinch_per_sec=us.inch/us.second# scale = 0.0254 / 1.0 = 0.0254assertabs(float(inch_per_sec)-0.0254)<1e-12# string subscript: any Pint expression (slashes, **, spaces all OK)assertfloat(us["m/s"])==1.0assertfloat(us["inch/second"])==0.0254# us.inch_per_second raises AttributeErrorassertfloat(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.
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:
importnumpyasnpus=UnitSystem.si()# scalar multiplymass_kg=2.205*us.pound# 2.205 lb * 0.4536 kg/lb ≈ 1.0 kgprint(f"mass: {mass_kg:.4f} kg")# or since u tracks its own base dimensionsprint(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 ndarraypos_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.
The most powerful integration is tagging a distribution or design variable with a units descriptor and letting StochasBase handle the conversion automatically.
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.
us=UnitSystem.si()# sample_dist automatically converts inches -> meters and tags the result with the model base unitmodel=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 meansigma=0.1,# 0.1 inch stdunit=us.inch,# attach a UnitDescriptor to a distribution),convert_units=True,# defaults to True).squeeze()# value is now in metersprint(f"arm_length (m): {arm_length.value}")# ~ 0.305 m# unit reflects the model base unit for length (not the source inch unit)assertarm_length.unitisnotNoneassertstr(arm_length.unit)=="m"# SI length base unitassertfloat(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.
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.
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 secondunit=us.inch/us.second,# combine units with /, *, or **)vel_m_per_s=model.sample_design(link_velocity,convert_units=True,# also defaults to True)assertabs(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 unitassertstr(model.design["link_velocity"].unit)=="inch / second"# model.named holds the converted value tagged with the model's compound base unitnamed_unit=model.named["link_velocity"].unitassertnamed_unitisnotNoneassertstr(named_unit)=="m / s"# value is in meters per second; unit says so
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.
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)assertdata["dists"]["goofy_velocity"]["unit"]=={"name":"furlong / fortnight"}# when us is present in the JSON, validation restores all scale/offset values automaticallyrestored_model=stochas.StochasBase.model_validate(data)goofy_unit=restored_model.dists["goofy_velocity"].unitassertgoofy_unitisnotNoneandgoofy_unit.scaleassertabs(goofy_unit.scale-float(us["furlong / fortnight"]))<1e-12# if you serialize without us, call with_unit_system() after loadingunitless_model=stochas.StochasBase()unitless_model.dists.update(stochas.NormalDistribution(name=stochas.DistName("mass"),mu=1.0,sigma=0.05,unit=us.pound))# round tripunitless_raw=unitless_model.model_dump_json()unitless_restored=stochas.StochasBase.model_validate_json(unitless_raw)assertunitless_restored.usisNonemass_unit=unitless_restored.dists["mass"].unitassertmass_unitisnotNoneassertmass_unit.scaleisNone# scale missing until restoredhas_units_model=unitless_restored.with_unit_system(us)assertisinstance(has_units_model.us,UnitSystem)restored_mass_unit=has_units_model.dists["mass"].unitassertrestored_mass_unitisnotNoneassertabs(float(restored_mass_unit)-float(us.pound))<1e-9