1"""CoolProp-backed properties: Helmholtz equations of state, and tables over them.
3One class serves both. ``HEOS`` evaluates the Helmholtz energy directly and
4covers everything the equation of state is defined over; ``BICUBIC&HEOS``
5interpolates precomputed tables over the same equation and is 10-30x faster
6inside a bounded region. They differ by a backend string, not by a code path,
7which is what keeps "fast where possible, exact where necessary" from turning
8into two implementations that drift.
10Measured on the development host, nitrogen, per call (update + read):
12============= ======== =========== =========
13property HEOS BICUBIC speedup
14============= ======== =========== =========
15density 3.17 us 0.128 us 24.8x
16viscosity 3.39 us 0.129 us 26.2x
17conductivity 4.25 us 0.127 us 33.5x
18cp 3.35 us 0.188 us 17.8x
19speed of sound 3.16 us 0.331 us 9.6x
20============= ======== =========== =========
22Two behaviours of the tabulated backend shape the design:
24**It refuses rather than extrapolates.** Outside its envelope it raises
25``ValueError: inputs are not in range`` instead of returning a plausible wrong
26number. That is the good failure, and it is what makes the fallback chain in
27:mod:`feedtwin.props.fluid` sound -- translated here into :class:`OutOfRange`.
29**It does not implement every property.** ``compressibility_factor`` is missing
30on BICUBIC. Rather than special-case that, Z is *derived* here for every backend
31as ``p / (rho . R_specific . T)``, which reproduces CoolProp's own value to
323e-6. Deriving it means one definition of Z regardless of backend.
34Building tables costs 2-3 s per fluid on first use and about 17 MB on disk, in
35CoolProp's own cache. See :func:`warm_tables`.
38from __future__
import annotations
42from typing
import TYPE_CHECKING, Any, Callable, NoReturn
44import CoolProp.CoolProp
as CP
54R_UNIVERSAL = 8.31446261815324
65_INPUT_PAIRS: dict[StatePair, Any] = {
66 StatePair.PT: CP.PT_INPUTS,
67 StatePair.PH: CP.HmassP_INPUTS,
68 StatePair.PS: CP.PSmass_INPUTS,
69 StatePair.PQ: CP.PQ_INPUTS,
70 StatePair.TQ: CP.QT_INPUTS,
71 StatePair.PD: CP.DmassP_INPUTS,
72 StatePair.HS: CP.HmassSmass_INPUTS,
73 StatePair.DT: CP.DmassT_INPUTS,
74 StatePair.DU: CP.DmassUmass_INPUTS,
80_SWAPPED: frozenset[StatePair] = frozenset({StatePair.PH, StatePair.TQ, StatePair.PD})
85_GUARDED: frozenset[StatePair] = frozenset({StatePair.PT, StatePair.PH, StatePair.DT})
88_PHASES: dict[int, Phase] = {
89 CP.iphase_liquid: Phase.LIQUID,
90 CP.iphase_gas: Phase.GAS,
91 CP.iphase_twophase: Phase.TWO_PHASE,
92 CP.iphase_supercritical: Phase.SUPERCRITICAL,
93 CP.iphase_supercritical_gas: Phase.SUPERCRITICAL_GAS,
94 CP.iphase_supercritical_liquid: Phase.SUPERCRITICAL_LIQUID,
95 CP.iphase_critical_point: Phase.CRITICAL_POINT,
99_GETTERS: dict[str, Callable[[Any], float]] = {
100 "p":
lambda s: float(s.p()),
101 "T":
lambda s: float(s.T()),
102 "rho":
lambda s: float(s.rhomass()),
103 "h":
lambda s: float(s.hmass()),
104 "s":
lambda s: float(s.smass()),
105 "u":
lambda s: float(s.umass()),
106 "cp":
lambda s: float(s.cpmass()),
107 "cv":
lambda s: float(s.cvmass()),
108 "mu":
lambda s: float(s.viscosity()),
109 "k":
lambda s: float(s.conductivity()),
110 "a":
lambda s: float(s.speed_sound()),
111 "molar_mass":
lambda s: float(s.molar_mass()),
118_OUT_OF_RANGE_MARKERS = (
"not in range",
"outside",
"out of range")
120_UNSUPPORTED_MARKERS = (
"is not implemented",
"not implemented for this backend")
144DOME_CRITICAL_SHARE = (0.75, 2.5)
146DOME_CRITICAL_K = 30.0
150DOME_DENSE_SHARE = 0.25
156 """Where a species' tables cannot be trusted: a band about its saturation
157 line, built once from the equation of state itself (HEOS)."""
159 _built: dict[str,
"_Dome"] = {}
162 heos = CP.AbstractState(
"HEOS", fluid)
163 self.
T_c = float(heos.T_critical())
164 self.
p_c = float(heos.p_critical())
165 self.
rho_c = float(heos.rhomass_critical())
166 self.
R = R_UNIVERSAL / float(heos.molar_mass())
167 p_low =
max(float(heos.trivial_keyed_output(CP.iP_triple)), 1.0) * 1.001
172 top = math.log(self.
p_c * 0.999)
173 bottom = math.log(p_low)
174 T_triple = float(heos.trivial_keyed_output(CP.iT_triple))
175 for i
in range(DOME_POINTS):
176 ln_p = bottom + (top - bottom) * i / (DOME_POINTS - 1)
179 heos.update(CP.PQ_INPUTS, p, 0.0)
180 T_sat, h_bubble = float(heos.T()), float(heos.hmass())
181 heos.update(CP.PQ_INPUTS, p, 1.0)
182 h_dew = float(heos.hmass())
184 if T_sat - DOME_LIQUID_K > T_triple:
185 heos.update(CP.PT_INPUTS, p, T_sat - DOME_LIQUID_K)
186 h_low = float(heos.hmass())
187 heos.update(CP.PT_INPUTS, p, T_sat + DOME_VAPOUR_K)
188 h_high = float(heos.hmass())
191 self.
ln_p.append(ln_p)
192 self.
T_sat.append(T_sat)
193 self.
h_low.append(h_low)
194 self.
h_high.append(h_high)
197 def of(cls, fluid: str) ->
"_Dome":
198 dome = cls.
_built.get(fluid)
200 dome = cls.
_built[fluid] = cls(fluid)
203 def _at(self, column: list[float], p: float) -> float |
None:
204 """``column`` at pressure ``p``, linear in ln p; None off the curve."""
205 if p <= 0.0
or not self.
ln_p:
208 if x < self.
ln_p[0]
or x > self.
ln_p[-1]:
210 i =
min(
max(bisect.bisect_left(self.
ln_p, x), 1), len(self.
ln_p) - 1)
211 x0, x1 = self.
ln_p[i - 1], self.
ln_p[i]
212 w = (x - x0) / (x1 - x0)
if x1 > x0
else 0.0
213 return column[i - 1] + w * (column[i] - column[i - 1])
216 low, high = DOME_CRITICAL_SHARE
217 return low * self.
p_c < p < high * self.
p_c and T < self.
T_c + DOME_CRITICAL_K
219 def near(self, pair: StatePair, v1: float, v2: float) -> bool:
220 """Whether this state is one the tables are not trusted at."""
221 if pair
is StatePair.PT:
225 if T > self.
T_c + DOME_VAPOUR_K:
228 return T_sat
is not None and (
229 T_sat - DOME_LIQUID_K < T < T_sat + DOME_VAPOUR_K
231 if pair
is StatePair.PH:
234 if low
is None or high
is None:
236 return low < h < high
237 if pair
is StatePair.DT:
239 if T > self.
T_c + DOME_VAPOUR_K
or rho >= self.
rho_c:
241 if rho > DOME_DENSE_SHARE * self.
rho_c:
243 p_ideal = rho * self.
R * T
246 T_sat = self.
_at(self.
T_sat, p_ideal)
247 return T_sat
is not None and T < T_sat + DOME_VAPOUR_K
252 """Properties for one species from one CoolProp backend.
254 Stateful and **not thread-safe** -- it wraps a single ``AbstractState`` that
255 :meth:`update` moves around. :class:`~feedtwin.props.fluid.Fluid` gives each
256 thread its own; never share an instance across threads.
259 def __init__(self, species: SpeciesSpec, backend: str, name: str) ->
None:
263 self.
_state = CP.AbstractState(backend, species.backend_fluid)
275 self.
_tabulated = backend.upper().startswith((
"BICUBIC",
"TTSE"))
284 return prop
in _GETTERS
or prop
in (
"Z",
"gamma")
286 def update(self, pair: StatePair, v1: float, v2: float) ->
None:
288 inputs = _INPUT_PAIRS[pair]
293 if self.
_dome is None:
299 f
"{pair.value}=({v1:.6g}, {v2:.6g}) is near the saturation "
300 "line, where the tables interpolate across it",
302 a, b = (v2, v1)
if pair
in _SWAPPED
else (v1, v2)
305 except Exception
as exc:
306 self.
_reraise(exc, f
"{pair.value}=({v1:.6g}, {v2:.6g})")
308 def value(self, prop: str) -> float:
314 getter = _GETTERS.get(prop)
319 return getter(self.
_state)
320 except Exception
as exc:
324 """Constants of the substance, not of any state point.
326 Critical pressure and temperature are needed by the IEC 60534 choked
327 liquid correlation, and they are properties of the fluid rather than of
328 where it happens to be -- so they are read once, from the same backend
329 that answers everything else, rather than through a convenience call.
332 "p_critical": float(self.
_state.p_critical()),
333 "T_critical": float(self.
_state.T_critical()),
334 "molar_mass": float(self.
_state.molar_mass()),
339 return _PHASES.get(int(self.
_state.
phase()), Phase.UNKNOWN)
344 """Vapour fraction, or ``None`` when the state is not two-phase.
346 CoolProp signals "not applicable" with an out-of-band number, and uses
347 more than one: -1 for a subcooled liquid, -1000 for a supercritical
348 state. Anything outside [0, 1] is therefore treated as "no quality"
349 rather than matched against those two values specifically.
352 q = float(self.
_state.Q())
355 return q
if 0.0 <= q <= 1.0
else None
358 """Z = p / (rho . R_specific . T), derived rather than read.
360 BICUBIC does not implement ``compressibility_factor``. Deriving it from
361 density gives one definition of Z on every backend, and it agrees with
362 CoolProp's own to ~3e-6.
369 rho = float(state.rhomass())
371 if rho <= 0.0
or T <= 0.0:
374 molar_mass = float(state.molar_mass())
375 if molar_mass <= 0.0:
378 return float(state.p()) / (rho * self.
_r_specific * T)
381 self, exc: Exception, detail: str, prop: str |
None =
None
383 """Translate a CoolProp failure into the layer's own vocabulary.
385 ``prop`` is given only when the failure came from reading a named
386 property, and is what gets remembered as unsupported -- a failed
387 ``update`` says nothing about any property's availability, so passing
388 the state-pair description here would poison the capability cache.
391 lowered = message.lower()
393 if any(marker
in lowered
for marker
in _OUT_OF_RANGE_MARKERS):
396 if any(marker
in lowered
for marker
in _UNSUPPORTED_MARKERS):
405 """Build this species' interpolation tables now instead of on first use.
407 Returns the seconds spent. Costs 2-3 s and ~17 MB per fluid the first time
408 on a machine, then loads from CoolProp's cache.
410 Worth calling explicitly in a worker process, for the same reason
411 EngineDesign's accelerator front-loads its JIT: paying it lazily means the
412 cost lands inside whatever call happens to be first, which is usually a
413 solve someone is timing.
417 started = time.perf_counter()
418 state = CP.AbstractState(
"BICUBIC&HEOS", species.backend_fluid)
421 state.update(CP.PT_INPUTS, 1.0e6, 300.0)
422 return time.perf_counter() - started
425def _heos(species: SpeciesSpec) -> PropertyBackend:
429def _bicubic(species: SpeciesSpec) -> PropertyBackend:
433register_backend(
"heos", _heos)
434register_backend(
"bicubic", _bicubic)
None __init__(self, SpeciesSpec species, str backend, str name)
dict[str, float] constants(self)
None update(self, StatePair pair, float v1, float v2)
NoReturn _reraise(self, Exception exc, str detail, str|None prop=None)
float _compressibility(self)
bool supports(self, str prop)
float value(self, str prop)
bool _critical(self, float p, float T)
bool near(self, StatePair pair, float v1, float v2)
"_Dome" of(cls, str fluid)
None __init__(self, str fluid)
float|None _at(self, list[float] column, float p)
float warm_tables(SpeciesSpec species)
PropertyBackend _heos(SpeciesSpec species)
PropertyBackend _bicubic(SpeciesSpec species)
static bool near(double a, double b, double tol)