STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
coolprop.py
Go to the documentation of this file.
1"""CoolProp-backed properties: Helmholtz equations of state, and tables over them.
2
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.
9
10Measured on the development host, nitrogen, per call (update + read):
11
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============= ======== =========== =========
21
22Two behaviours of the tabulated backend shape the design:
23
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`.
28
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.
33
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`.
36"""
37
38from __future__ import annotations
39
40import bisect
41import math
42from typing import TYPE_CHECKING, Any, Callable, NoReturn
43
44import CoolProp.CoolProp as CP
45
46from feedtwin.props.backend import PropertyBackend, StatePair, register_backend
47from feedtwin.props.errors import OutOfRange, UnsupportedProperty
48from feedtwin.props.state import Phase
49
50if TYPE_CHECKING: # pragma: no cover - types only
51 from feedtwin.props.species import SpeciesSpec
52
53#: Universal gas constant [J/(mol.K)], CODATA 2018 -- the value CoolProp uses.
54R_UNIVERSAL = 8.31446261815324
55
56#: Our neutral state pairs, mapped onto CoolProp's input constants.
57#: Adding a pair is an entry here, not a branch.
58#:
59#: `Any` rather than `int` because CoolProp changed what these are between the
60#: versions this package supports: 7.2 ships no type information and they are
61#: plain ints, while 8.0 gives them a nominal `input_pairs` type that
62#: `AbstractState.update` then demands. They are ints at runtime either way --
63#: declaring `int` made the 8.0 stubs reject the very call this table exists to
64#: feed, and declaring the 8.0 name would not resolve on 7.2.
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,
75}
76
77#: CoolProp orders the arguments of some pairs the other way round from how the
78#: pair is named. Listed explicitly rather than discovered, because getting it
79#: silently backwards produces a valid-looking state at the wrong point.
80_SWAPPED: frozenset[StatePair] = frozenset({StatePair.PH, StatePair.TQ, StatePair.PD})
81
82#: The pairs a table is checked for nearness to the saturation line before it
83#: answers (:class:`_Dome`). On the line itself (PQ, TQ) the tables use their
84#: saturation splines and agree with HEOS to 0.02 %; DU agreed everywhere tried.
85_GUARDED: frozenset[StatePair] = frozenset({StatePair.PT, StatePair.PH, StatePair.DT})
86
87#: CoolProp's phase integers, mapped onto our backend-neutral enum.
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,
96}
97
98#: Direct reads. Everything else in PROPERTIES is derived below.
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()),
112}
113
114#: Substrings CoolProp uses when a state point is outside a backend's envelope.
115#: Matched rather than pattern-parsed: the message is not a stable API, so the
116#: check stays broad, and anything unrecognised propagates as a real error
117#: instead of being silently reinterpreted as "just out of range".
118_OUT_OF_RANGE_MARKERS = ("not in range", "outside", "out of range")
119
120_UNSUPPORTED_MARKERS = ("is not implemented", "not implemented for this backend")
121
122
123#: How far below the saturation line a liquid state, and above it a vapour
124#: state, the tables are not trusted [K] (:class:`_Dome`). Measured against HEOS
125#: on CoolProp 7.2 and 8.0, nitrogen, oxygen, ethanol, methane and water: a
126#: table cell that straddles the line interpolates across it, so near it the
127#: tables can be off by the ratio of liquid to vapour density. Nitrogen at
128#: 1.07 bar read 83 kg/m^3 for 4.3 ten kelvin above its boiling point; oxygen
129#: vapour on its dew curve at 90 K read 0.47 bar for 0.99. Errors reached
130#: +14.5 K on the vapour side and -10 K on the liquid side (still 19 % at -10 K
131#: on water); the bands clear them with room.
132DOME_LIQUID_K = 16.0
133DOME_VAPOUR_K = 16.0
134#: And around the critical point, where the same tables are off by up to 20 %
135#: on nitrogen at 40 bar: pressures within this share of p_c, below T_c plus
136#: :data:`DOME_CRITICAL_K`. It was (0.75, 1.5) p_c below T_c + 16 K, and just
137#: outside that nitrogen read density 3.7 % off (144 K, 5 MPa), cp 9.4 % and
138#: cp/cv 14 % (148.5 K, 5 MPa), viscosity 15 % (144 K, 6 MPa), and 10 % on cp/cv
139#: at 140 K, 6 MPa (1.77 p_c) -- measured against HEOS over 142.5-230 K,
140#: 3-30 MPa. Out to 2.5 p_c and T_c + 30 K the worst left outside is density
141#: 0.22 %, cp 0.77 %, cp/cv 0.97 % (viscosity 3.9 %); 2 p_c and T_c + 25 K only
142#: moved the edge (cp/cv 3.3 % at 7.15 MPa). LE4 never comes near (its COPV is
143#: 1479 psia, 226 K at burnout).
144DOME_CRITICAL_SHARE = (0.75, 2.5)
145#: How far above T_c the critical band reaches [K]; see DOME_CRITICAL_SHARE.
146DOME_CRITICAL_K = 30.0
147#: A vapour denser than this share of the critical density, below T_c plus
148#: :data:`DOME_VAPOUR_K`, is near-critical whatever its pressure: the ideal-gas
149#: estimate a (rho, T) state is placed by overshoots there.
150DOME_DENSE_SHARE = 0.25
151#: Points on the saturation curve a guard is built from, evenly in ln p.
152DOME_POINTS = 400
153
154
155class _Dome:
156 """Where a species' tables cannot be trusted: a band about its saturation
157 line, built once from the equation of state itself (HEOS)."""
158
159 _built: dict[str, "_Dome"] = {}
160
161 def __init__(self, fluid: str) -> None:
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
168 self.ln_p: list[float] = []
169 self.T_sat: list[float] = []
170 self.h_low: list[float] = []
171 self.h_high: list[float] = []
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)
177 p = math.exp(ln_p)
178 try:
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())
183 h_low = h_bubble
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())
189 except ValueError:
190 continue
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)
195
196 @classmethod
197 def of(cls, fluid: str) -> "_Dome":
198 dome = cls._built.get(fluid)
199 if dome is None:
200 dome = cls._built[fluid] = cls(fluid)
201 return dome
202
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:
206 return None
207 x = math.log(p)
208 if x < self.ln_p[0] or x > self.ln_p[-1]:
209 return None
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])
214
215 def _critical(self, p: float, T: float) -> bool:
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
218
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:
222 p, T = v1, v2
223 if self._critical(p, T):
224 return True
225 if T > self.T_c + DOME_VAPOUR_K:
226 return False
227 T_sat = self._at(self.T_sat, p)
228 return T_sat is not None and (
229 T_sat - DOME_LIQUID_K < T < T_sat + DOME_VAPOUR_K
230 )
231 if pair is StatePair.PH:
232 p, h = v1, v2
233 low, high = self._at(self.h_low, p), self._at(self.h_high, p)
234 if low is None or high is None:
235 return self._critical(p, self.T_c)
236 return low < h < high
237 if pair is StatePair.DT:
238 rho, T = v1, v2
239 if T > self.T_c + DOME_VAPOUR_K or rho >= self.rho_c:
240 return False # warm, or a liquid: p from (rho, T) is its own matter
241 if rho > DOME_DENSE_SHARE * self.rho_c:
242 return True
243 p_ideal = rho * self.R * T
244 if self._critical(p_ideal, T):
245 return True
246 T_sat = self._at(self.T_sat, p_ideal)
247 return T_sat is not None and T < T_sat + DOME_VAPOUR_K
248 return False
249
250
252 """Properties for one species from one CoolProp backend.
253
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.
257 """
258
259 def __init__(self, species: SpeciesSpec, backend: str, name: str) -> None:
260 self._species = species
261 self._backend = backend
262 self._name = name
263 self._state = CP.AbstractState(backend, species.backend_fluid)
264 # Learned, not declared: a property is assumed available until the
265 # backend says otherwise, and then remembered. Avoids both a hardcoded
266 # per-backend capability list and a probe at construction that would
267 # need a state point known to be inside an envelope we cannot predict.
268 self._unsupported: set[str] = set()
269 # Specific gas constant, cached on first use of Z. A property of the
270 # substance, not of any state point, so it never needs recomputing.
271 self._r_specific: float | None = None
272 # The tables are not trusted near the saturation line (`_Dome`); there
273 # this backend declines and the chain's equation of state answers.
274 self._dome: _Dome | None = None
275 self._tabulated = backend.upper().startswith(("BICUBIC", "TTSE"))
276
277 @property
278 def name(self) -> str:
279 return self._name
280
281 def supports(self, prop: str) -> bool:
282 if prop in self._unsupported:
283 return False
284 return prop in _GETTERS or prop in ("Z", "gamma")
285
286 def update(self, pair: StatePair, v1: float, v2: float) -> None:
287 try:
288 inputs = _INPUT_PAIRS[pair]
289 except KeyError: # pragma: no cover - StatePair is closed
290 raise UnsupportedProperty(self._name, f"state pair {pair.value}") from None
291
292 if self._tabulated and pair in _GUARDED:
293 if self._dome is None:
294 self._dome = _Dome.of(self._species.backend_fluid)
295 if self._dome.near(pair, v1, v2):
296 raise OutOfRange(
297 self._name,
298 self._species.name,
299 f"{pair.value}=({v1:.6g}, {v2:.6g}) is near the saturation "
300 "line, where the tables interpolate across it",
301 )
302 a, b = (v2, v1) if pair in _SWAPPED else (v1, v2)
303 try:
304 self._state.update(inputs, a, b)
305 except Exception as exc:
306 self._reraise(exc, f"{pair.value}=({v1:.6g}, {v2:.6g})")
307
308 def value(self, prop: str) -> float:
309 if prop == "Z":
310 return self._compressibility()
311 if prop == "gamma":
312 return self.value("cp") / self.value("cv")
313
314 getter = _GETTERS.get(prop)
315 if getter is None or prop in self._unsupported:
316 raise UnsupportedProperty(self._name, prop)
317
318 try:
319 return getter(self._state)
320 except Exception as exc:
321 self._reraise(exc, prop, prop=prop)
322
323 def constants(self) -> dict[str, float]:
324 """Constants of the substance, not of any state point.
325
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.
330 """
331 return {
332 "p_critical": float(self._state.p_critical()),
333 "T_critical": float(self._state.T_critical()),
334 "molar_mass": float(self._state.molar_mass()),
335 }
336
337 def phase(self) -> Phase:
338 try:
339 return _PHASES.get(int(self._state.phase()), Phase.UNKNOWN)
340 except Exception:
341 return Phase.UNKNOWN
342
343 def quality(self) -> float | None:
344 """Vapour fraction, or ``None`` when the state is not two-phase.
345
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.
350 """
351 try:
352 q = float(self._state.Q())
353 except Exception:
354 return None
355 return q if 0.0 <= q <= 1.0 else None
356
357 def _compressibility(self) -> float:
358 """Z = p / (rho . R_specific . T), derived rather than read.
359
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.
363 """
364 # Read straight off the state rather than through value(): Z needs four
365 # of them, and four rounds of dict lookup and dispatch cost more than
366 # the arithmetic. Molar mass is a constant of the substance, so it is
367 # fetched once per backend rather than per state point.
368 state = self._state
369 rho = float(state.rhomass())
370 T = float(state.T())
371 if rho <= 0.0 or T <= 0.0:
372 raise UnsupportedProperty(self._name, "Z")
373 if self._r_specific is None:
374 molar_mass = float(state.molar_mass())
375 if molar_mass <= 0.0:
376 raise UnsupportedProperty(self._name, "Z")
377 self._r_specific = R_UNIVERSAL / molar_mass
378 return float(state.p()) / (rho * self._r_specific * T)
379
381 self, exc: Exception, detail: str, prop: str | None = None
382 ) -> NoReturn:
383 """Translate a CoolProp failure into the layer's own vocabulary.
384
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.
389 """
390 message = str(exc)
391 lowered = message.lower()
392
393 if any(marker in lowered for marker in _OUT_OF_RANGE_MARKERS):
394 raise OutOfRange(self._name, self._species.name, f"{detail}: {message}")
395
396 if any(marker in lowered for marker in _UNSUPPORTED_MARKERS):
397 if prop is not None:
398 self._unsupported.add(prop)
399 raise UnsupportedProperty(self._name, detail)
400
401 raise exc
402
403
404def warm_tables(species: SpeciesSpec) -> float:
405 """Build this species' interpolation tables now instead of on first use.
406
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.
409
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.
414 """
415 import time
416
417 started = time.perf_counter()
418 state = CP.AbstractState("BICUBIC&HEOS", species.backend_fluid)
419 # A state point every fluid we ship is fluid at; only the table build
420 # matters, not this particular answer.
421 state.update(CP.PT_INPUTS, 1.0e6, 300.0)
422 return time.perf_counter() - started
423
424
425def _heos(species: SpeciesSpec) -> PropertyBackend:
426 return CoolPropBackend(species, "HEOS", "heos")
427
428
429def _bicubic(species: SpeciesSpec) -> PropertyBackend:
430 return CoolPropBackend(species, "BICUBIC&HEOS", "bicubic")
431
432
433register_backend("heos", _heos)
434register_backend("bicubic", _bicubic)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
None __init__(self, SpeciesSpec species, str backend, str name)
Definition coolprop.py:259
None update(self, StatePair pair, float v1, float v2)
Definition coolprop.py:286
NoReturn _reraise(self, Exception exc, str detail, str|None prop=None)
Definition coolprop.py:382
bool _critical(self, float p, float T)
Definition coolprop.py:215
bool near(self, StatePair pair, float v1, float v2)
Definition coolprop.py:219
float|None _at(self, list[float] column, float p)
Definition coolprop.py:203
uint8_t value
float warm_tables(SpeciesSpec species)
Definition coolprop.py:404
PropertyBackend _heos(SpeciesSpec species)
Definition coolprop.py:425
PropertyBackend _bicubic(SpeciesSpec species)
Definition coolprop.py:429
static bool near(double a, double b, double tol)