STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
units.py
Go to the documentation of this file.
1"""Units, converted once at the boundary and never again.
2
3Two jobs, and the second is the important one.
4
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.
9
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.
15
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.
21
22Units are data, like everything else here::
23
24 from feedtwin.model.units import register_unit
25
26 register_unit("furlong", "length", 201.168)
27"""
28
29from __future__ import annotations
30
31from dataclasses import dataclass
32
33from fluids.fittings import Kv_to_Cv
34
35#: Cv (US gpm at 1 psi) per Kv (m3/h at 1 bar), from IEC 60534-2-1 via
36#: ``fluids``. Taken from the library rather than written as 1.156 so there is
37#: one definition of it in the process, and it is the one the valve correlations
38#: in Phase 03 will use.
39#:
40#: Imported from ``fluids.fittings``, where it is defined, rather than from
41#: ``fluids.control_valve``, which re-exports it: ``fluids`` ships py.typed, so
42#: mypy type-checks against it and rejects an implicit re-export under strict.
43_CV_PER_KV = Kv_to_Cv(1.0)
44
45_IN = 0.0254
46_PSI = 6894.757293168361
47_LBM = 0.45359237
48_GAL_US = 3.785411784e-3
49_MIN = 60.0
50
51#: What a gauge reads zero at [Pa]: one standard atmosphere, as the DAQ and every
52#: psig on the panel use (feedtwin.session.gauge.ATMOSPHERE is the same number).
53GAUGE_ZERO = 101325.0
54
55
56@dataclass(frozen=True, slots=True)
57class Unit:
58 """A unit, and how to get from it to this package's canonical one.
59
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.
63 """
64
65 name: str
66 dimension: str
67 factor: float
68 offset: float = 0.0
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."""
74
75 def to_si(self, value: float) -> float:
76 return value * self.factor + self.offset
77
78 def from_si(self, value: float) -> float:
79 return (value - self.offset) / self.factor
80
81
82def _u(
83 name: str,
84 dimension: str,
85 factor: float,
86 offset: float = 0.0,
87 canonical: bool = False,
88) -> Unit:
89 return Unit(name, dimension, factor, offset, canonical)
90
91
92#: Every known unit, keyed by the spelling used in a config file.
93#:
94#: The canonical unit of each dimension has factor 1.0. Two are not SI and say
95#: so: ``Cv`` because the valve correlations take it directly, and ``-`` for
96#: dimensionless quantities.
97_UNITS: dict[str, Unit] = {
98 u.name: u
99 for u in [
100 # dimensionless
101 _u("-", "dimensionless", 1.0),
102 _u("%", "dimensionless", 0.01),
103 # pressure ratio -- pressure per pressure. Dimensionless by arithmetic
104 # and emphatically not by intent: a supply-pressure effect written as a
105 # bare 0.017 cannot be checked against the datasheet it came from, and
106 # the reader cannot tell 17 psi/1000 psi from 1.7 psi/100 psi from a
107 # typo. Its own dimension so that `-` is rejected where one belongs,
108 # and so the vendor's own wording is what gets typed in.
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),
114 # length
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),
120 # area
121 _u("m^2", "area", 1.0),
122 _u("mm^2", "area", 1e-6),
123 _u("in^2", "area", _IN**2),
124 # volume
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),
130 # mass
131 _u("kg", "mass", 1.0),
132 _u("g", "mass", 1e-3),
133 _u("lbm", "mass", _LBM),
134 # pressure. The canonical value is absolute Pa. The bare spellings have
135 # no offset, so they convert a *difference* (a droop, a bias, a relief
136 # set pressure) exactly, and an absolute pressure only if it was written
137 # absolute. The qualified spellings say which: "-a" absolute, "-g" gauge,
138 # one standard atmosphere (GAUGE_ZERO) above. A drawing's absolute
139 # pressures written bare are read as gauge, the way a dial reads them,
140 # and a difference may never carry a reference: both rules live in
141 # feedtwin.model.pressure, which every reader of a drawing goes through.
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),
157 # temperature
158 _u("K", "temperature", 1.0),
159 _u("degC", "temperature", 1.0, 273.15),
160 _u("degR", "temperature", 5.0 / 9.0),
161 # mass flow
162 _u("kg/s", "mass_flow", 1.0),
163 _u("g/s", "mass_flow", 1e-3),
164 _u("lbm/s", "mass_flow", _LBM),
165 # volumetric flow
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),
170 # time
171 _u("s", "time", 1.0),
172 _u("ms", "time", 1e-3),
173 _u("min", "time", _MIN),
174 # velocity, density, viscosity
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),
181 # temperature *difference*, where degC and K are the same size. A
182 # separate dimension so a 20 K rise cannot be written where an absolute
183 # temperature belongs, or vice versa.
184 # Two spellings of the same size. K_diff is marked canonical so
185 # si_unit_of is deterministic; degC_diff exists so a config written in
186 # Celsius reads naturally.
187 _u("K_diff", "temperature_difference", 1.0, canonical=True),
188 _u("degC_diff", "temperature_difference", 1.0),
189 # valve capacity. Canonical is Cv, not because it is SI (it is not) but
190 # because it is what IEC 60534 sizing takes as input.
191 _u("Cv", "flow_coefficient", 1.0),
192 _u("Kv", "flow_coefficient", _CV_PER_KV),
193 # angle
194 _u("rad", "angle", 1.0),
195 _u("deg", "angle", 3.141592653589793 / 180.0),
196 # rotational / spring
197 _u("N/m", "stiffness", 1.0),
198 _u("lbf/in", "stiffness", 4.4482216152605 / _IN),
199 # thermal
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),
205 ]
206}
207
208
209class UnknownUnit(KeyError):
210 """A unit that is not registered.
211
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.
216 """
217
218 def __init__(self, unit: str) -> None:
219 import difflib
220
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 ""
223 super().__init__(
224 f"unknown unit {unit!r}.{hint} "
225 "feedtwin.model.units.registered_units() lists them all; "
226 "register_unit() adds one."
227 )
228 self.unit = unit
229
230
231class DimensionMismatch(ValueError):
232 """A value was given in a unit that measures the wrong thing."""
233
234 def __init__(self, *, expected: str, got: str, unit: str, where: str) -> None:
235 super().__init__(
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."
238 )
239 self.expected = expected
240 self.got = got
241
242
244 name: str,
245 dimension: str,
246 factor: float,
247 offset: float = 0.0,
248 canonical: bool = False,
249) -> Unit:
250 """Add a unit. ``si = value * factor + offset``.
251
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.
254
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.
257 """
258 unit = Unit(name, dimension, factor, offset, canonical)
259 _UNITS[name] = unit
260 return unit
261
262
263def get_unit(name: str) -> Unit:
264 try:
265 return _UNITS[name]
266 except KeyError:
267 raise UnknownUnit(name) from None
268
269
270def registered_units() -> list[str]:
271 return sorted(_UNITS)
272
273
274def dimension_of(unit: str) -> str:
275 """What a unit measures: ``"pressure"``, ``"length"``, ..."""
276 return get_unit(unit).dimension
277
278
279def to_si(value: float, unit: str) -> float:
280 """Convert into this package's canonical unit for that dimension."""
281 return get_unit(unit).to_si(value)
282
283
284def from_si(value: float, unit: str) -> float:
285 """Convert out of canonical units, for display."""
286 return get_unit(unit).from_si(value)
287
288
289def si_unit_of(dimension: str) -> str:
290 """The canonical unit name for a dimension, for labelling output.
291
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.
296 """
297 for name, unit in _UNITS.items():
298 if unit.dimension == dimension and unit.canonical:
299 return name
300 for name, unit in _UNITS.items():
301 if unit.dimension == dimension and unit.factor == 1.0 and unit.offset == 0.0:
302 return name
303 raise KeyError(
304 f"no canonical unit registered for dimension {dimension!r}. Register one "
305 "with register_unit(..., factor=1.0, canonical=True)."
306 )
307
308
309def check_dimension(unit: str, expected: str, where: str) -> None:
310 """Raise :class:`DimensionMismatch` unless ``unit`` measures ``expected``."""
311 actual = dimension_of(unit)
312 if actual != expected:
313 raise DimensionMismatch(expected=expected, got=actual, unit=unit, where=where)
None __init__(self, *, str expected, str got, str unit, str where)
Definition units.py:234
float to_si(self, float value)
Definition units.py:75
float from_si(self, float value)
Definition units.py:78
None __init__(self, str unit)
Definition units.py:218
Unit get_unit(str name)
Definition units.py:263
float to_si(float value, str unit)
Definition units.py:279
Unit _u(str name, str dimension, float factor, float offset=0.0, bool canonical=False)
Definition units.py:88
None check_dimension(str unit, str expected, str where)
Definition units.py:309
list[str] registered_units()
Definition units.py:270
Unit register_unit(str name, str dimension, float factor, float offset=0.0, bool canonical=False)
Definition units.py:249
str si_unit_of(str dimension)
Definition units.py:289
float from_si(float value, str unit)
Definition units.py:284
str dimension_of(str unit)
Definition units.py:274