1"""The caller-facing handle: a fluid you can ask for properties.
3``Fluid`` is a thin thing over a *chain* of backends::
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
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.
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
23from __future__
import annotations
27from typing
import Any, Callable, Iterable, Sequence
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")),
59_PAIR_BY_ORDERED: dict[tuple[str, ...], tuple[StatePair, tuple[str, str]]] = {
61 for keys, entry
in _PAIR_BY_KWARGS.items()
62 for order
in ((tuple(sorted(keys))), tuple(reversed(sorted(keys))))
66_SNAPSHOT_PROPS = (
"p",
"T",
"rho",
"h",
"s",
"u",
"cp",
"cv",
"mu",
"k",
"a",
"Z")
75_STRICTLY_POSITIVE: dict[str, str] = {
77 "T":
"temperature [K]",
78 "rho":
"density [kg/m^3]",
81_UNIT_FRACTION: dict[str, str] = {
"q":
"vapour quality [-]"}
90_FAST_LOW: dict[str, float] = {name: 0.0
for name
in (*_STRICTLY_POSITIVE,
"q")}
91_FAST_HIGH: dict[str, float] = {
"q": 1.0}
94 tuple[StatePair, bool, str, float, float, str, float, float],
100 _FAST_LOW.get(order[0], -math.inf),
101 _FAST_HIGH.get(order[0], math.inf),
103 _FAST_LOW.get(order[1], -math.inf),
104 _FAST_HIGH.get(order[1], math.inf),
106 for order, (pair, names)
in _PAIR_BY_ORDERED.items()
111 if not math.isfinite(value):
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."
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}")
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}")
130ChainEntry = str | PropertyBackend
147 """One species, backed by an ordered chain of property sources.
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")``.
155 def __init__(self, name: str, chain: Sequence[ChainEntry] |
None =
None) ->
None:
157 self.
_chain: tuple[ChainEntry, ...] = tuple(
158 chain
if chain
is not None else self.
_species.chain
161 raise ValueError(f
"fluid {name!r} has an empty backend chain")
169 def species(self) -> SpeciesSpec:
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)
179 def get(self, prop: str, **state: float) -> float:
180 """One property at one state point. The solver's hot path.
182 Specify the state with exactly two of ``p``, ``T``, ``h``, ``s``, ``q``,
183 ``rho`` -- ``fluid.get("rho", p=..., T=...)``.
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
191 if prop
not in PROPERTIES:
193 f
"unknown property {prop!r}; known: {', '.join(sorted(PROPERTIES))}"
208 memo: dict[tuple[str, StatePair, float, float], float] |
None = getattr(
212 memo = self.
_local.memo = {}
213 key = (prop, pair, v1, v2)
214 cached = memo.get(key)
215 if cached
is not None:
218 backend, _ = self.
_settle(pair, v1, v2, needs=(prop,))
219 value = backend.value(prop)
225 if len(memo) >= _MEMO_ENTRIES:
230 def state(self, **state: float) -> ThermoState:
231 """A full immutable snapshot. For reports, frames and diagnostics.
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.
238 backend, _ = self.
_settle(pair, v1, v2, needs=_SNAPSHOT_PROPS)
239 values = {p: backend.value(p)
for p
in _SNAPSHOT_PROPS}
242 backend=backend.name,
243 phase=backend.phase(),
244 quality=backend.quality(),
249 """Critical point and molar mass -- properties of the substance.
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.
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.
262 cached: dict[str, float] |
None = getattr(self.
_local,
"constants",
None)
264 declined: list[str] = []
267 getter = getattr(backend,
"constants",
None)
269 declined.append(backend.name)
271 cached = dict(getter())
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."
280 self.
_local.constants = cached
285 """Thermodynamic critical pressure [Pa]."""
290 """Thermodynamic critical temperature [K]."""
293 def phase(self, **state: float) -> Phase:
295 backend, _ = self.
_settle(pair, v1, v2, needs=())
296 return backend.phase()
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.
303 rho = n2.accessor("rho", "p", "T")
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.
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.
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.
319 if prop
not in PROPERTIES:
321 f
"unknown property {prop!r}; known: {', '.join(sorted(PROPERTIES))}"
323 entry = _PAIR_BY_KWARGS.get(frozenset({first, second}))
325 options = sorted(
"+".join(sorted(k))
for k
in _PAIR_BY_KWARGS)
327 f
"{first!r} and {second!r} do not form a state pair; "
328 f
"valid pairs: {', '.join(options)}"
330 pair, (a, _b) = entry
337 def read(v1: float, v2: float) -> float:
338 x, y = (v2, v1)
if flip
else (v1, v2)
345 memo: dict[tuple[str, StatePair, float, float], float] |
None = getattr(
349 memo = self.
_local.memo = {}
350 key = (prop, pair, x, y)
351 cached = memo.get(key)
352 if cached
is not None:
364 preferred.update(pair, x, y)
365 value = preferred.value(prop)
367 backend, _ = self.
_settle(pair, x, y, needs=needs)
368 value = backend.value(prop)
369 if len(memo) >= _MEMO_ENTRIES:
374 read.__name__ = f
"{self._species.name}_{prop}_of_{first}{second}"
376 f
"{PROPERTIES[prop].description} of {self._species.name} "
377 f
"[{PROPERTIES[prop].unit}] as a function of ({first}, {second})."
383 def _resolve(self, state: dict[str, float]) -> tuple[StatePair, float, float]:
388 fast = _FAST_RESOLVE.get(tuple(state))
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:
395 if not low1 < v1 < high1:
397 return (pair, v0, v1)
if in_order
else (pair, v1, v0)
399 entry = _PAIR_BY_KWARGS.get(frozenset(state))
401 options = sorted(
"+".join(sorted(k))
for k
in _PAIR_BY_KWARGS)
403 f
"cannot form a state from {sorted(state) or '()'}; "
404 f
"give exactly two of: {', '.join(options)}"
406 for name, value
in state.items():
408 pair, (first, second) = entry
409 return pair, float(state[first]), float(state[second])
416 needs: Iterable[str],
417 ) -> tuple[PropertyBackend, list[str]]:
418 """Move the first backend that can serve this point and these properties.
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.
425 required = tuple(needs)
426 declined: list[str] = []
427 last: Exception |
None =
None
431 backend.update(pair, v1, v2)
432 except (OutOfRange, UnsupportedProperty)
as exc:
433 declined.append(f
"{backend.name}: {exc}")
436 except Exception
as exc:
443 f
"{backend.name}: unexpected {type(exc).__name__}: {exc}"
447 return backend, declined
450 f
"no backend in {list(self.chain)} could serve {self._species.name} at "
451 f
"{pair.value}=({v1:.6g}, {v2:.6g}). " +
"; ".join(declined)
454 def _eligible(self, required: tuple[str, ...]) -> tuple[PropertyBackend, ...]:
455 """This thread's chain, filtered to backends that can compute ``required``.
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.
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.
467 cache: dict[tuple[str, ...], tuple[PropertyBackend, ...]] |
None
468 cache = getattr(self.
_local,
"eligible",
None)
471 self.
_local.eligible = cache
473 found = cache.get(required)
476 found = tuple(b
for b
in backends
if all(b.supports(p)
for p
in required))
479 f
"no backend in {list(self.chain)} can compute "
480 f
"{', '.join(required)} for {self._species.name}"
482 cache[required] = found
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):
493 cache: dict[str, PropertyBackend] |
None = getattr(self.
_local,
"cache",
None)
498 backend = cache.get(entry)
500 backend = get_backend_factory(entry)(self.
_species)
501 cache[entry] = backend
505 return f
"Fluid({self._species.name!r}, chain={list(self.chain)})"
float critical_temperature(self)
dict[str, float] constants(self)
None __init__(self, str name, Sequence[ChainEntry]|None chain=None)
Callable[[float, float], float] accessor(self, str prop, str first, str second)
float get(self, str prop, **float state)
tuple[StatePair, float, float] _resolve(self, dict[str, float] state)
tuple[str,...] chain(self)
tuple[PropertyBackend,...] _eligible(self, tuple[str,...] required)
PropertyBackend _backend(self, ChainEntry entry)
Phase phase(self, **float state)
float critical_pressure(self)
tuple[ChainEntry,...] _chain
tuple[PropertyBackend, list[str]] _settle(self, StatePair pair, float v1, float v2, Iterable[str] needs)
None _validate(str name, float value)
static double read(int32_t code)