STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
fluid.py
Go to the documentation of this file.
1"""The caller-facing handle: a fluid you can ask for properties.
2
3``Fluid`` is a thin thing over a *chain* of backends::
4
5 n2 = Fluid("nitrogen") # ["bicubic", "heos"]
6 n2.get("rho", p=3.1e7, T=293.0) # ~0.13 us, from tables
7 n2.get("rho", p=5.0e9, T=293.0) # tables refuse; the EOS answers
8
9The chain is the whole design. "Fast where possible, exact where necessary,
10measured where we have data" is a list, not a branch -- so adding a source means
11adding an element, and no call site learns about it.
12
13Threading
14---------
15Backends wrap mutable cursors: ``update`` moves them, ``value`` reads wherever
16they were last moved. Two threads sharing one would interleave into nonsense
17that never raises -- each would read the other's state point. Every backend is
18therefore **thread-confined**: built per thread, on demand, and never handed
19across. A ``Fluid`` is safe to share; the backends underneath it are not, and
20never leave.
21"""
22
23from __future__ import annotations
24
25import math
26import threading
27from typing import Any, Callable, Iterable, Sequence
28
29from feedtwin.props.backend import (
30 PropertyBackend,
31 StatePair,
32 get_backend_factory,
33)
34from feedtwin.props.errors import OutOfRange, PropertyError, UnsupportedProperty
35from feedtwin.props.species import SpeciesSpec, get_species
36from feedtwin.props.state import PROPERTIES, Phase, ThermoState
37
38#: Which keyword arguments name which state pair, and in what order to pass
39#: them. Data, so supporting a new pair is an entry here plus one in the
40#: backend's own table -- not a chain of ``if "h" in kwargs``.
41_PAIR_BY_KWARGS: dict[frozenset[str], tuple[StatePair, tuple[str, str]]] = {
42 frozenset({"p", "T"}): (StatePair.PT, ("p", "T")),
43 frozenset({"p", "h"}): (StatePair.PH, ("p", "h")),
44 frozenset({"p", "s"}): (StatePair.PS, ("p", "s")),
45 frozenset({"p", "q"}): (StatePair.PQ, ("p", "q")),
46 frozenset({"T", "q"}): (StatePair.TQ, ("T", "q")),
47 frozenset({"p", "rho"}): (StatePair.PD, ("p", "rho")),
48 frozenset({"h", "s"}): (StatePair.HS, ("h", "s")),
49 frozenset({"rho", "T"}): (StatePair.DT, ("rho", "T")),
50 frozenset({"rho", "u"}): (StatePair.DU, ("rho", "u")),
51}
52
53#: The same table keyed by the *ordered* keyword names, both ways round.
54#:
55#: Derived, never hand-maintained. Building a frozenset to identify the pair
56#: costs about as much as the property read it precedes, and ``tuple(state)`` on
57#: a two-key dict is far cheaper -- so the ordered form is tried first and the
58#: frozenset kept only as the fallback for anything unusual.
59_PAIR_BY_ORDERED: dict[tuple[str, ...], tuple[StatePair, tuple[str, str]]] = {
60 order: entry
61 for keys, entry in _PAIR_BY_KWARGS.items()
62 for order in ((tuple(sorted(keys))), tuple(reversed(sorted(keys))))
63}
64
65#: Fields of a ThermoState that are read from the backend, in order.
66_SNAPSHOT_PROPS = ("p", "T", "rho", "h", "s", "u", "cp", "cv", "mu", "k", "a", "Z")
67
68#: Physically impossible inputs, rejected before any backend is touched.
69#:
70#: Not defensive clutter: an equation of state handed a negative pressure does
71#: not say "negative pressure", it fails somewhere inside a root find and
72#: reports that its bracket does not contain a root. Catching it here turns an
73#: unreadable numerical failure into the actual mistake -- and it is cheap,
74#: because the chain would otherwise pay a failed solve on every backend.
75_STRICTLY_POSITIVE: dict[str, str] = {
76 "p": "pressure [Pa]",
77 "T": "temperature [K]",
78 "rho": "density [kg/m^3]",
79}
80
81_UNIT_FRACTION: dict[str, str] = {"q": "vapour quality [-]"}
82
83#: Everything `Fluid._resolve` needs for one ordered pair of keywords, worked
84#: out once: the pair, whether the caller gave its two values in the pair's
85#: order, and for each keyword in the caller's order the open bounds inside
86#: which a value needs no closer look. The bounds sit strictly inside what
87#: `_validate` accepts, so every value on or past one -- NaN, an infinity, a
88#: zero pressure, a quality of exactly 0 or 1 -- still goes to `_validate`,
89#: which raises or lets it through exactly as before.
90_FAST_LOW: dict[str, float] = {name: 0.0 for name in (*_STRICTLY_POSITIVE, "q")}
91_FAST_HIGH: dict[str, float] = {"q": 1.0}
92_FAST_RESOLVE: dict[
93 tuple[str, ...],
94 tuple[StatePair, bool, str, float, float, str, float, float],
95] = {
96 order: (
97 pair,
98 order == names,
99 order[0],
100 _FAST_LOW.get(order[0], -math.inf),
101 _FAST_HIGH.get(order[0], math.inf),
102 order[1],
103 _FAST_LOW.get(order[1], -math.inf),
104 _FAST_HIGH.get(order[1], math.inf),
105 )
106 for order, (pair, names) in _PAIR_BY_ORDERED.items()
107}
108
109
110def _validate(name: str, value: float) -> None:
111 if not math.isfinite(value):
112 raise ValueError(
113 f"{name}={value} is not a finite number. A NaN reaching the property "
114 "layer usually means an earlier solve diverged; it is rejected here "
115 "rather than propagated as a plausible-looking density."
116 )
117
118 label = _STRICTLY_POSITIVE.get(name)
119 if label is not None and value <= 0.0:
120 raise ValueError(f"{label} must be > 0, got {name}={value:.6g}")
121
122 label = _UNIT_FRACTION.get(name)
123 if label is not None and not 0.0 <= value <= 1.0:
124 raise ValueError(f"{label} must be within [0, 1], got {name}={value:.6g}")
125
126
127#: A chain element: either a registered backend's name, or a ready instance
128#: (which is how a measured-data backend, parameterised by its own data rather
129#: than by a species, joins a chain).
130ChainEntry = str | PropertyBackend
131
132
133#: Property results held per fluid, per thread, before the cache is dropped.
134#:
135#: A solve asks the same handful of state points over and over -- one Newton
136#: iteration evaluates every branch, several branches share a node, and each
137#: node is re-asked across the four vessel sub-steps. Measured over a second of
138#: burn: 466,000 calls, 63,000 of them distinct, and **a 256-entry cache hits
139#: as often as an unbounded one** (86.5%). So the bound is not a compromise
140#: between memory and hit rate; past a couple of hundred entries there is
141#: nothing left to gain. Doubled from the measured knee for headroom on a
142#: stand with more branches than this one.
143_MEMO_ENTRIES = 512
144
145
146class Fluid:
147 """One species, backed by an ordered chain of property sources.
148
149 Args:
150 name: Species name or alias -- ``"nitrogen"``, ``"gn2"``, ``"LOX"``.
151 chain: Backends to try in order. Defaults to the species' own chain
152 from ``species.toml``, which defaults to ``("bicubic", "heos")``.
153 """
154
155 def __init__(self, name: str, chain: Sequence[ChainEntry] | None = None) -> None:
156 self._species: SpeciesSpec = get_species(name)
157 self._chain: tuple[ChainEntry, ...] = tuple(
158 chain if chain is not None else self._species.chain
159 )
160 if not self._chain:
161 raise ValueError(f"fluid {name!r} has an empty backend chain")
162 self._local = threading.local()
163
164 @property
165 def name(self) -> str:
166 return self._species.name
167
168 @property
169 def species(self) -> SpeciesSpec:
170 return self._species
171
172 @property
173 def chain(self) -> tuple[str, ...]:
174 """Names of the backends in this fluid's chain, in order."""
175 return tuple(e if isinstance(e, str) else e.name for e in self._chain)
176
177 # ---------------------------------------------------------------- queries
178
179 def get(self, prop: str, **state: float) -> float:
180 """One property at one state point. The solver's hot path.
181
182 Specify the state with exactly two of ``p``, ``T``, ``h``, ``s``, ``q``,
183 ``rho`` -- ``fluid.get("rho", p=..., T=...)``.
184
185 Tries each backend in order, moving to the next on
186 :class:`OutOfRange` or :class:`UnsupportedProperty`. Raises
187 :class:`PropertyError` if the whole chain declines, with what each one
188 said -- so a failure names the reason rather than the last exception to
189 survive.
190 """
191 if prop not in PROPERTIES:
192 raise KeyError(
193 f"unknown property {prop!r}; known: {', '.join(sorted(PROPERTIES))}"
194 )
195 pair, v1, v2 = self._resolve(state)
196
197 # Memoised on the *resolved* state: a flat `(str, pair, float, float)`
198 # tuple, which is both canonical -- `get("rho", p=.., T=..)` and
199 # `get("rho", T=.., p=..)` are one entry -- and cheap to build.
200 #
201 # Keying *before* the resolve was tried, to skip its validation on a
202 # hit as well, and measured slower: `tuple(sorted(state.items()))`
203 # allocates a nested tuple per call and costs more than `_resolve`
204 # saves. 1.61 s of GN2 burn became 1.67 s. Left as it is.
205 #
206 # Exact: no rounding and no tolerance, so a hit returns the number the
207 # backend would have returned, and the only thing saved is asking again.
208 memo: dict[tuple[str, StatePair, float, float], float] | None = getattr(
209 self._local, "memo", None
210 )
211 if memo is None:
212 memo = self._local.memo = {}
213 key = (prop, pair, v1, v2)
214 cached = memo.get(key)
215 if cached is not None:
216 return cached
217
218 backend, _ = self._settle(pair, v1, v2, needs=(prop,))
219 value = backend.value(prop)
220 # Cleared wholesale rather than evicted one at a time. Measured, the
221 # repeats all live inside a Newton iteration and a vessel sub-step, so
222 # the working set is tiny: a 256-entry cache captures the same 86.5% of
223 # calls as an unbounded one, over 466k calls in a second of burn. An LRU
224 # would spend more on bookkeeping than the eviction policy is worth.
225 if len(memo) >= _MEMO_ENTRIES:
226 memo.clear()
227 memo[key] = value
228 return value
229
230 def state(self, **state: float) -> ThermoState:
231 """A full immutable snapshot. For reports, frames and diagnostics.
232
233 About eight backend reads rather than one, so prefer :meth:`get` inside
234 a solve. Backends are moved once and read repeatedly, so this is
235 cheaper than eight separate :meth:`get` calls.
236 """
237 pair, v1, v2 = self._resolve(state)
238 backend, _ = self._settle(pair, v1, v2, needs=_SNAPSHOT_PROPS)
239 values = {p: backend.value(p) for p in _SNAPSHOT_PROPS}
240 return ThermoState(
241 fluid=self._species.name,
242 backend=backend.name,
243 phase=backend.phase(),
244 quality=backend.quality(),
245 **values,
246 )
247
248 def constants(self) -> dict[str, float]:
249 """Critical point and molar mass -- properties of the substance.
250
251 Cached: they do not depend on a state point, so they are read once per
252 fluid and never again. Components need the critical pressure for the
253 IEC 60534 choking correlation, and this is how they get it without
254 reaching past the property layer.
255
256 Sought along the chain rather than taken from its head, for the same
257 reason every other query is: a measured table has no critical point to
258 report, and asking only the first backend meant that putting measured
259 data in front -- the entire point of the feature -- broke every
260 component evaluation downstream.
261 """
262 cached: dict[str, float] | None = getattr(self._local, "constants", None)
263 if cached is None:
264 declined: list[str] = []
265 for entry in self._chain:
266 backend = self._backend(entry)
267 getter = getattr(backend, "constants", None)
268 if getter is None:
269 declined.append(backend.name)
270 continue
271 cached = dict(getter())
272 break
273 else:
274 raise PropertyError(
275 f"no backend in {list(self.chain)} reports substance constants "
276 f"for {self._species.name} (asked: {', '.join(declined)}). A "
277 "measured table cannot supply a critical point; keep an "
278 "equation-of-state backend in the chain behind it."
279 )
280 self._local.constants = cached
281 return cached
282
283 @property
284 def critical_pressure(self) -> float:
285 """Thermodynamic critical pressure [Pa]."""
286 return self.constants()["p_critical"]
287
288 @property
289 def critical_temperature(self) -> float:
290 """Thermodynamic critical temperature [K]."""
291 return self.constants()["T_critical"]
292
293 def phase(self, **state: float) -> Phase:
294 pair, v1, v2 = self._resolve(state)
295 backend, _ = self._settle(pair, v1, v2, needs=())
296 return backend.phase()
297
299 self, prop: str, first: str, second: str
300 ) -> Callable[[float, float], float]:
301 """A bound, positional getter for one property over one state pair.
302
303 rho = n2.accessor("rho", "p", "T")
304 rho(1.0e7, 300.0)
305
306 Same answer as :meth:`get`, roughly three times faster, because
307 everything that does not change between calls is done once: which state
308 pair the inputs form, which properties the chain's backends support,
309 and the keyword unpacking. In a solve those are loop invariants, and
310 :meth:`get` re-derives all of them on every evaluation.
311
312 Use this wherever a property is read in a loop -- assembling a Jacobian,
313 marching a transient. Use :meth:`get` for one-off queries where clarity
314 beats a few hundred nanoseconds.
315
316 The returned callable is safe to share across threads; it resolves its
317 backend through the same per-thread cache the rest of the class uses.
318 """
319 if prop not in PROPERTIES:
320 raise KeyError(
321 f"unknown property {prop!r}; known: {', '.join(sorted(PROPERTIES))}"
322 )
323 entry = _PAIR_BY_KWARGS.get(frozenset({first, second}))
324 if entry is None:
325 options = sorted("+".join(sorted(k)) for k in _PAIR_BY_KWARGS)
326 raise TypeError(
327 f"{first!r} and {second!r} do not form a state pair; "
328 f"valid pairs: {', '.join(options)}"
329 )
330 pair, (a, _b) = entry
331 # The caller's argument order, not the table's: accessor("rho", "T", "p")
332 # must mean f(T, p), or a positional API is a trap.
333 flip = first != a
334
335 needs = (prop,)
336
337 def read(v1: float, v2: float) -> float:
338 x, y = (v2, v1) if flip else (v1, v2)
339
340 # The same per-thread memo :meth:`get` uses, and the same key, so
341 # the two share entries: a state point one of them has paid for is
342 # free to the other. Without this the accessor would be the
343 # *slower* of the two for a repeated point, which would make this
344 # method's own advice -- use it in loops -- wrong.
345 memo: dict[tuple[str, StatePair, float, float], float] | None = getattr(
346 self._local, "memo", None
347 )
348 if memo is None:
349 memo = self._local.memo = {}
350 key = (prop, pair, x, y)
351 cached = memo.get(key)
352 if cached is not None:
353 return cached
354
355 _validate(first, v1)
356 _validate(second, v2)
357 # Straight at the preferred backend, which serves essentially every
358 # call: the tables were chosen to cover the operating envelope, so
359 # falling through is the exception. Anything it declines -- or any
360 # surprise -- goes to the full chain walk, which reproduces the
361 # attempt and reports properly if nothing can serve the point.
362 preferred = self._eligible(needs)[0]
363 try:
364 preferred.update(pair, x, y)
365 value = preferred.value(prop)
366 except Exception:
367 backend, _ = self._settle(pair, x, y, needs=needs)
368 value = backend.value(prop)
369 if len(memo) >= _MEMO_ENTRIES:
370 memo.clear()
371 memo[key] = value
372 return value
373
374 read.__name__ = f"{self._species.name}_{prop}_of_{first}{second}"
375 read.__doc__ = (
376 f"{PROPERTIES[prop].description} of {self._species.name} "
377 f"[{PROPERTIES[prop].unit}] as a function of ({first}, {second})."
378 )
379 return read
380
381 # ---------------------------------------------------------------- internals
382
383 def _resolve(self, state: dict[str, float]) -> tuple[StatePair, float, float]:
384 # The ordinary call: two keywords in a known order, each value passing
385 # one comparison. The general path below did two table reads and a
386 # call per value, which was a sixth of a console tick -- the solve asks
387 # ~440,000 times a stand-second.
388 fast = _FAST_RESOLVE.get(tuple(state))
389 if fast is not None:
390 pair, in_order, n0, low0, high0, n1, low1, high1 = fast
391 v0 = float(state[n0])
392 v1 = float(state[n1])
393 if not low0 < v0 < high0:
394 _validate(n0, v0)
395 if not low1 < v1 < high1:
396 _validate(n1, v1)
397 return (pair, v0, v1) if in_order else (pair, v1, v0)
398
399 entry = _PAIR_BY_KWARGS.get(frozenset(state))
400 if entry is None:
401 options = sorted("+".join(sorted(k)) for k in _PAIR_BY_KWARGS)
402 raise TypeError(
403 f"cannot form a state from {sorted(state) or '()'}; "
404 f"give exactly two of: {', '.join(options)}"
405 )
406 for name, value in state.items():
407 _validate(name, float(value))
408 pair, (first, second) = entry
409 return pair, float(state[first]), float(state[second])
410
412 self,
413 pair: StatePair,
414 v1: float,
415 v2: float,
416 needs: Iterable[str],
417 ) -> tuple[PropertyBackend, list[str]]:
418 """Move the first backend that can serve this point and these properties.
419
420 ``needs`` is checked *before* updating so a backend that could reach the
421 state point but not answer the question is skipped rather than moved and
422 then found wanting -- which would leave the chain's answer depending on
423 the order the caller asked for properties.
424 """
425 required = tuple(needs)
426 declined: list[str] = []
427 last: Exception | None = None
428
429 for backend in self._eligible(required):
430 try:
431 backend.update(pair, v1, v2)
432 except (OutOfRange, UnsupportedProperty) as exc:
433 declined.append(f"{backend.name}: {exc}")
434 last = exc
435 continue
436 except Exception as exc:
437 # An error this layer does not recognise. The next backend still
438 # deserves a try -- one library's numerical failure says nothing
439 # about another's -- but it is never swallowed: it lands in
440 # `declined` and, if the chain runs out, is chained onto the
441 # PropertyError as __cause__ so the traceback survives.
442 declined.append(
443 f"{backend.name}: unexpected {type(exc).__name__}: {exc}"
444 )
445 last = exc
446 continue
447 return backend, declined
448
449 raise PropertyError(
450 f"no backend in {list(self.chain)} could serve {self._species.name} at "
451 f"{pair.value}=({v1:.6g}, {v2:.6g}). " + "; ".join(declined)
452 ) from last
453
454 def _eligible(self, required: tuple[str, ...]) -> tuple[PropertyBackend, ...]:
455 """This thread's chain, filtered to backends that can compute ``required``.
456
457 Cached per thread and per property set. Which backends *can* compute a
458 property depends only on the backends and the property -- never on the
459 state point -- so this is a loop invariant, and recomputing it per call
460 cost more than the property read it guarded.
461
462 Filtering here rather than mid-chain also keeps the answer independent
463 of the order properties were asked for: a backend that can reach the
464 state point but not answer the question is skipped before it is moved,
465 so it can never become the one that serves a later, easier property.
466 """
467 cache: dict[tuple[str, ...], tuple[PropertyBackend, ...]] | None
468 cache = getattr(self._local, "eligible", None)
469 if cache is None:
470 cache = {}
471 self._local.eligible = cache
472
473 found = cache.get(required)
474 if found is None:
475 backends = [self._backend(e) for e in self._chain]
476 found = tuple(b for b in backends if all(b.supports(p) for p in required))
477 if not found:
478 raise PropertyError(
479 f"no backend in {list(self.chain)} can compute "
480 f"{', '.join(required)} for {self._species.name}"
481 )
482 cache[required] = found
483 return found
484
485 def _backend(self, entry: ChainEntry) -> PropertyBackend:
486 """This thread's instance of one chain entry, built on first use."""
487 if not isinstance(entry, str):
488 # A ready instance -- shared across threads by construction, so the
489 # instance itself is responsible for being safe. TabulatedProperties
490 # is; anything a project supplies must be too.
491 return entry
492
493 cache: dict[str, PropertyBackend] | None = getattr(self._local, "cache", None)
494 if cache is None:
495 cache = {}
496 self._local.cache = cache
497
498 backend = cache.get(entry)
499 if backend is None:
500 backend = get_backend_factory(entry)(self._species)
501 cache[entry] = backend
502 return backend
503
504 def __repr__(self) -> str:
505 return f"Fluid({self._species.name!r}, chain={list(self.chain)})"
float critical_temperature(self)
Definition fluid.py:289
dict[str, float] constants(self)
Definition fluid.py:248
None __init__(self, str name, Sequence[ChainEntry]|None chain=None)
Definition fluid.py:155
Callable[[float, float], float] accessor(self, str prop, str first, str second)
Definition fluid.py:300
float get(self, str prop, **float state)
Definition fluid.py:179
tuple[StatePair, float, float] _resolve(self, dict[str, float] state)
Definition fluid.py:383
tuple[str,...] chain(self)
Definition fluid.py:173
tuple[PropertyBackend,...] _eligible(self, tuple[str,...] required)
Definition fluid.py:454
PropertyBackend _backend(self, ChainEntry entry)
Definition fluid.py:485
Phase phase(self, **float state)
Definition fluid.py:293
float critical_pressure(self)
Definition fluid.py:284
tuple[ChainEntry,...] _chain
Definition fluid.py:157
tuple[PropertyBackend, list[str]] _settle(self, StatePair pair, float v1, float v2, Iterable[str] needs)
Definition fluid.py:417
None _validate(str name, float value)
Definition fluid.py:110
static double read(int32_t code)