1"""Units, converted once at the boundary and never again.
3Two jobs, and the second is the important one.
5**Convert.** A team authors line bores in inches, pressures in psi and valve
6capacities in Cv, because that is what the datasheets say. The solver works
7entirely in SI. Conversion happens exactly once, when a parameter is read, and
8what circulates afterwards is a plain float.
10**Catch a dimension error.** Every unit declares what it measures, and every
11parameter declares what it expects. A pressure written where a length belongs
12fails at load with both names in the message. This is the single cheapest guard
13in the package: unit confusion is the most expensive class of mistake in this
14field, and it is trivially preventable at a schema boundary.
16Deliberately not ``pint``. Unit-carrying objects are the right tool for
17interactive analysis and the wrong one for a Newton loop -- a Jacobian assembly
18that allocates a Quantity per arithmetic operation is orders of magnitude slower
19than the physics it is wrapping. Converting at the boundary gets the safety
20where it matters and pays nothing where it does not.
22Units are data, like everything else here::
24 from feedtwin.model.units import register_unit
26 register_unit("furlong", "length", 201.168)
29from __future__
import annotations
31from dataclasses
import dataclass
33from fluids.fittings
import Kv_to_Cv
43_CV_PER_KV = Kv_to_Cv(1.0)
46_PSI = 6894.757293168361
48_GAL_US = 3.785411784e-3
56@dataclass(frozen=True, slots=True)
58 """A unit, and how to get from it to this package's canonical one.
60 ``si = value * factor + offset``. The offset exists for temperature and is
61 zero for everything else; keeping it in the general form is cheaper than a
62 special case that someone later forgets.
69 canonical: bool =
False
70 """Whether this is the spelling :func:`si_unit_of` reports for its
71 dimension. Needed only where two units of a dimension have factor 1.0 --
72 K and degC differences are the same size -- but declaring it beats
73 depending on declaration order, which is not something a reader can see."""
75 def to_si(self, value: float) -> float:
76 return value * self.factor + self.
offset
79 return (value - self.
offset) / self.factor
87 canonical: bool =
False,
89 return Unit(name, dimension, factor, offset, canonical)
97_UNITS: dict[str, Unit] = {
101 _u(
"-",
"dimensionless", 1.0),
102 _u(
"%",
"dimensionless", 0.01),
109 _u(
"psi/psi",
"pressure_ratio", 1.0),
110 _u(
"psi/100psi",
"pressure_ratio", 1e-2),
111 _u(
"psi/1000psi",
"pressure_ratio", 1e-3),
112 _u(
"psi/kpsi",
"pressure_ratio", 1e-3),
113 _u(
"bar/bar",
"pressure_ratio", 1.0),
115 _u(
"m",
"length", 1.0),
116 _u(
"mm",
"length", 1e-3),
117 _u(
"cm",
"length", 1e-2),
118 _u(
"in",
"length", _IN),
119 _u(
"ft",
"length", 12.0 * _IN),
121 _u(
"m^2",
"area", 1.0),
122 _u(
"mm^2",
"area", 1e-6),
123 _u(
"in^2",
"area", _IN**2),
125 _u(
"m^3",
"volume", 1.0),
126 _u(
"L",
"volume", 1e-3),
127 _u(
"mL",
"volume", 1e-6),
128 _u(
"in^3",
"volume", _IN**3),
129 _u(
"gal",
"volume", _GAL_US),
131 _u(
"kg",
"mass", 1.0),
132 _u(
"g",
"mass", 1e-3),
133 _u(
"lbm",
"mass", _LBM),
142 _u(
"Pa",
"pressure", 1.0),
143 _u(
"kPa",
"pressure", 1e3),
144 _u(
"MPa",
"pressure", 1e6),
145 _u(
"bar",
"pressure", 1e5),
146 _u(
"psi",
"pressure", _PSI),
147 _u(
"atm",
"pressure", 101325.0),
148 _u(
"psia",
"pressure", _PSI),
149 _u(
"bara",
"pressure", 1e5),
150 _u(
"kPaa",
"pressure", 1e3),
151 _u(
"MPaa",
"pressure", 1e6),
152 _u(
"psig",
"pressure", _PSI, GAUGE_ZERO),
153 _u(
"barg",
"pressure", 1e5, GAUGE_ZERO),
154 _u(
"kPag",
"pressure", 1e3, GAUGE_ZERO),
155 _u(
"MPag",
"pressure", 1e6, GAUGE_ZERO),
156 _u(
"Pag",
"pressure", 1.0, GAUGE_ZERO),
158 _u(
"K",
"temperature", 1.0),
159 _u(
"degC",
"temperature", 1.0, 273.15),
160 _u(
"degR",
"temperature", 5.0 / 9.0),
162 _u(
"kg/s",
"mass_flow", 1.0),
163 _u(
"g/s",
"mass_flow", 1e-3),
164 _u(
"lbm/s",
"mass_flow", _LBM),
166 _u(
"m^3/s",
"volume_flow", 1.0),
167 _u(
"L/s",
"volume_flow", 1e-3),
168 _u(
"L/min",
"volume_flow", 1e-3 / _MIN),
169 _u(
"gpm",
"volume_flow", _GAL_US / _MIN),
171 _u(
"s",
"time", 1.0),
172 _u(
"ms",
"time", 1e-3),
173 _u(
"min",
"time", _MIN),
175 _u(
"m/s",
"velocity", 1.0),
176 _u(
"ft/s",
"velocity", 12.0 * _IN),
177 _u(
"kg/m^3",
"density", 1.0),
178 _u(
"g/cm^3",
"density", 1e3),
179 _u(
"Pa.s",
"viscosity", 1.0),
180 _u(
"cP",
"viscosity", 1e-3),
187 _u(
"K_diff",
"temperature_difference", 1.0, canonical=
True),
188 _u(
"degC_diff",
"temperature_difference", 1.0),
191 _u(
"Cv",
"flow_coefficient", 1.0),
192 _u(
"Kv",
"flow_coefficient", _CV_PER_KV),
194 _u(
"rad",
"angle", 1.0),
195 _u(
"deg",
"angle", 3.141592653589793 / 180.0),
197 _u(
"N/m",
"stiffness", 1.0),
198 _u(
"lbf/in",
"stiffness", 4.4482216152605 / _IN),
200 _u(
"W",
"power", 1.0),
201 _u(
"W/K",
"thermal_conductance", 1.0),
202 _u(
"W/(m.K)",
"conductivity", 1.0),
203 _u(
"J/(kg.K)",
"specific_heat", 1.0),
204 _u(
"J/kg",
"specific_energy", 1.0),
210 """A unit that is not registered.
212 Suggests the nearest registered spellings rather than listing all fifty.
213 ``"inches"`` should point at ``"in"``, not bury it in a wall of text --
214 the list is what you read when you do not know what exists, and a typo is
215 the far more common case.
221 close = difflib.get_close_matches(unit, _UNITS, n=3, cutoff=0.4)
222 hint = f
" Did you mean {', '.join(repr(c) for c in close)}?" if close
else ""
224 f
"unknown unit {unit!r}.{hint} "
225 "feedtwin.model.units.registered_units() lists them all; "
226 "register_unit() adds one."
232 """A value was given in a unit that measures the wrong thing."""
234 def __init__(self, *, expected: str, got: str, unit: str, where: str) ->
None:
236 f
"{where}: expected a {expected} but {unit!r} is a {got}. "
237 "This is a unit error, not a rounding one -- check the source."
248 canonical: bool =
False,
250 """Add a unit. ``si = value * factor + offset``.
252 Replacing an existing name is allowed and deliberate: a project that means
253 something specific by a spelling should be able to say so.
255 Set ``canonical`` when adding the first unit of a new dimension, or the
256 reverse lookup used to label output will have nothing to report.
258 unit =
Unit(name, dimension, factor, offset, canonical)
271 return sorted(_UNITS)
275 """What a unit measures: ``"pressure"``, ``"length"``, ..."""
279def to_si(value: float, unit: str) -> float:
280 """Convert into this package's canonical unit for that dimension."""
285 """Convert out of canonical units, for display."""
290 """The canonical unit name for a dimension, for labelling output.
292 Prefers a unit explicitly marked canonical; otherwise the unscaled,
293 unoffset one. Both passes are needed: most dimensions have exactly one unit
294 with factor 1.0 and need no marking, while a few have two of the same size
295 and would otherwise resolve by declaration order.
297 for name, unit
in _UNITS.items():
298 if unit.dimension == dimension
and unit.canonical:
300 for name, unit
in _UNITS.items():
301 if unit.dimension == dimension
and unit.factor == 1.0
and unit.offset == 0.0:
304 f
"no canonical unit registered for dimension {dimension!r}. Register one "
305 "with register_unit(..., factor=1.0, canonical=True)."
310 """Raise :class:`DimensionMismatch` unless ``unit`` measures ``expected``."""
312 if actual != expected:
None __init__(self, *, str expected, str got, str unit, str where)
float to_si(self, float value)
float from_si(self, float value)
None __init__(self, str unit)
float to_si(float value, str unit)
Unit _u(str name, str dimension, float factor, float offset=0.0, bool canonical=False)
None check_dimension(str unit, str expected, str where)
list[str] registered_units()
Unit register_unit(str name, str dimension, float factor, float offset=0.0, bool canonical=False)
str si_unit_of(str dimension)
float from_si(float value, str unit)
str dimension_of(str unit)