STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_props_chain.py
Go to the documentation of this file.
1"""The fallback chain, and refusing to answer when there is no honest answer.
2
3The chain is the design (see :mod:`feedtwin.props.fluid`): fast tables first,
4the equation of state behind them, measured data in front when there is any.
5These tests hold it to the two promises that make it safe to rely on --
6
7* a state point the fast backend cannot reach is **served by the next one**,
8 not failed and not guessed at; and
9* a state point *nothing* can serve **raises**, naming what each backend said,
10 rather than returning a number.
11
12The second matters more than it looks. Every backend in this package refuses
13outside its envelope, so the only way a wrong number gets out is if this layer
14invents one.
15"""
16
17from __future__ import annotations
18
19import pytest
20
21from feedtwin.props import Fluid, OutOfRange, PropertyError
22
23IN_TABLES = dict(p=1.0e7, T=300.0)
24
25#: Points outside the tabulated envelope, verified against the backend rather
26#: than assumed -- and every one of them chosen to be off the *table* while
27#: still inside the *equation of state*, which is a narrower window than it
28#: looks.
29#:
30#: This list used to reach past the tables by going to 5 GPa at 300 K and down
31#: to 20 K at 1 MPa. Both of those are solid nitrogen: above the melting line
32#: and below the triple point. CoolProp 7.2 answered anyway, extrapolating the
33#: fluid equation of state into the solid region and returning a density for
34#: it -- the accessor test below pinned one of those numbers, 1579.7 kg/m3, as
35#: an expectation. CoolProp 8.0 added a melting-line check and refuses, so the
36#: chain has nothing to fall through *to* and the test failed on an upgrade.
37#:
38#: The refusal is the correct behaviour, so the points moved rather than the
39#: check. The two edges that remain genuine fall-throughs -- the table stops,
40#: the equation of state continues -- are low pressure and high temperature.
41#: Both agree to 15 significant figures across the two CoolProp versions.
42PAST_THE_TABLES = [
43 ("above the temperature ceiling", dict(p=1.0e7, T=3000.0)),
44 ("below the pressure floor", dict(p=1.0, T=300.0)),
45 ("far below the pressure floor", dict(p=1.0e-3, T=300.0)),
46]
47
48
50 assert Fluid("nitrogen").state(**IN_TABLES).backend == "bicubic"
51
52
53@pytest.mark.parametrize("label,state", PAST_THE_TABLES)
55 label: str, state: dict[str, float]
56) -> None:
57 """Past the tables, the equation of state answers -- and says it did."""
58 snapshot = Fluid("nitrogen").state(**state)
59 assert snapshot.backend == "heos", label
60 assert snapshot.rho > 0.0
61
62
63@pytest.mark.parametrize("label,state", PAST_THE_TABLES)
65 label: str, state: dict[str, float]
66) -> None:
67 """Pinned on the backend itself, because the whole design leans on it.
68
69 If the tabulated backend ever started extrapolating instead of raising,
70 every test above would still pass -- the chain would simply never fall
71 through, and would return quietly wrong numbers at the edges. This is the
72 test that would notice.
73
74 Asserted one level below :class:`Fluid` deliberately: through a fluid, an
75 exhausted chain reports :class:`PropertyError` summarising every backend's
76 reason, which is right for a caller but too coarse to prove *this* backend
77 refused for *this* reason.
78 """
79 from feedtwin.props import CoolPropBackend, StatePair, get_species
80
81 backend = CoolPropBackend(get_species("nitrogen"), "BICUBIC&HEOS", "bicubic")
82 with pytest.raises(OutOfRange):
83 backend.update(StatePair.PT, state["p"], state["T"])
84
85
86@pytest.mark.parametrize("label,state", PAST_THE_TABLES)
87def test_an_exhausted_chain_preserves_why(label: str, state: dict[str, float]) -> None:
88 """A chain of only the tables fails, and the OutOfRange survives as the cause."""
89 with pytest.raises(PropertyError) as excinfo:
90 Fluid("nitrogen", chain=["bicubic"]).get("rho", **state)
91
92 assert isinstance(excinfo.value.__cause__, OutOfRange), label
93
94
96 """The error names every backend and what it said."""
97 absurd = Fluid("nitrogen", chain=["bicubic"])
98 with pytest.raises(PropertyError) as excinfo:
99 absurd.get("rho", p=5.0e9, T=300.0)
100
101 message = str(excinfo.value)
102 assert "bicubic" in message
103 assert "nitrogen" in message
104
105
106@pytest.mark.parametrize(
107 "state,expected",
108 [
109 (dict(p=-5.0, T=300.0), "must be > 0"),
110 (dict(p=1.0e6, T=0.0), "must be > 0"),
111 (dict(p=5.0e5, q=1.7), r"within \‍[0, 1\‍]"),
112 (dict(p=float("nan"), T=300.0), "not a finite number"),
113 ],
114)
116 state: dict[str, float], expected: str
117) -> None:
118 """A negative pressure should say so, not fail inside a root find.
119
120 Handed p < 0, CoolProp reports that a Brent bracket does not contain a
121 root -- true, useless, and three layers below the mistake. NaN matters even
122 more: it is what a diverging solve produces, and propagating it would turn
123 one bad iteration into a plausible-looking density.
124 """
125 with pytest.raises(ValueError, match=expected):
126 Fluid("nitrogen").get("rho", **state)
127
128
130 with pytest.raises(KeyError, match="unknown property"):
131 Fluid("nitrogen").get("enthalpy_but_misspelled", p=1e6, T=300.0)
132
133 from feedtwin.props import UnknownFluid
134
135 with pytest.raises(UnknownFluid, match="Registered"):
136 Fluid("unobtanium")
137
138
140 with pytest.raises(TypeError, match="exactly two of"):
141 Fluid("nitrogen").get("rho", p=1.0e6)
142
143
145 """A positional API must honour the order the caller declared."""
146 n2 = Fluid("nitrogen")
147 expected = n2.get("rho", p=1.0e7, T=300.0)
148
149 assert n2.accessor("rho", "p", "T")(1.0e7, 300.0) == pytest.approx(expected)
150 assert n2.accessor("rho", "T", "p")(300.0, 1.0e7) == pytest.approx(expected)
151
152
154 """The fast path must not lose the fallback it was optimised around.
155
156 The point is off the table and served by the equation of state, so the
157 number is the equation of state's -- not, as it was, a density
158 extrapolated into solid nitrogen. See :data:`PAST_THE_TABLES`.
159 """
160 rho = Fluid("nitrogen").accessor("rho", "p", "T")
161 assert rho(1.0e7, 3000.0) == pytest.approx(11.0908, rel=1e-4)
162
163
165 """CoolProp reports -1 or -1000 for "not two-phase"; neither may escape.
166
167 A sentinel that survives into a mass balance is read as a vapour fraction,
168 and -1000 kg of vapour does not raise anything.
169 """
170 n2 = Fluid("nitrogen")
171 assert n2.state(p=5.0e5, T=80.0).quality is None # subcooled liquid
172 assert n2.state(p=1.0e7, T=300.0).quality is None # supercritical
173 assert n2.state(p=5.0e5, q=0.5).quality == pytest.approx(0.5)
174
175
177 """Which sources a fluid is using is inspectable, not implicit."""
178 assert Fluid("nitrogen").chain == ("bicubic", "heos")
179 assert Fluid("nitrogen", chain=["heos"]).chain == ("heos",)
180
181 with pytest.raises(ValueError, match="empty backend chain"):
182 Fluid("nitrogen", chain=[])
183
184
185# ------------------------------------------------- phase, and not guessing it
186
187
189 """The default is *not* to infer phase from ``(p, T)``.
190
191 Asking the property layer to decide has failed twice here from two
192 different causes, and both times silently: oxygen at an ambient default is
193 a gas at 40 kg/m^3 where LOX is 1140, so the leg solves at a thirtieth of
194 the density and every number after it stays plausible. Below the critical
195 point a declared fluid is evaluated on its saturated-liquid line, which
196 cannot come back as a gas whatever pressure the solve is guessing.
197 """
198 from feedtwin.comps.elements import conditions_from_fluid
199 from feedtwin.props import Fluid
200
201 lox = conditions_from_fluid(Fluid("oxygen"), 3.4e6, 90.0)
202 assert lox.rho == pytest.approx(1142.0, abs=15.0)
203
204 ethanol = conditions_from_fluid(Fluid("ethanol"), 3.4e6, 293.15)
205 assert ethanol.rho == pytest.approx(789.0, abs=20.0)
206
207
209 """It has to be reachable, and it has to actually do something -- a flag
210 that quietly does nothing is worse than no flag."""
211 from feedtwin.comps.elements import conditions_from_fluid
212 from feedtwin.props import Fluid
213
214 forced = conditions_from_fluid(Fluid("oxygen"), 3.4e6, 90.0, multiphase=False)
215 inferred = conditions_from_fluid(Fluid("oxygen"), 3.4e6, 90.0, multiphase=True)
216 # Subcooled LOX is a little denser than saturated; both are liquid.
217 assert inferred.rho > forced.rho
218 assert forced.rho > 1000.0
219
220
222 """Nitrogen at 4500 psi and room temperature is supercritical -- there is no
223 liquid line to sit on, and forcing one would be nonsense."""
224 from feedtwin.comps.elements import conditions_from_fluid
225 from feedtwin.props import Fluid
226
227 gas = conditions_from_fluid(Fluid("nitrogen"), 3.1e7, 293.15)
228 assert 250.0 < gas.rho < 400.0
229
230
232 from feedtwin.solve.network import Network
233
234 assert Network().multiphase is False
235 assert Network(multiphase=True).multiphase is True
static ActuatorControllerState state
Definition main.cpp:57
None test_the_tables_refuse_rather_than_extrapolate(str label, dict[str, float] state)
None test_a_declared_liquid_is_solved_as_a_liquid()
None test_ordinary_states_are_served_by_the_tables()
None test_a_state_nothing_can_serve_raises_and_explains()
None test_an_unformable_state_lists_the_valid_pairs()
None test_unknown_property_and_unknown_fluid_say_what_is_available()
None test_a_pressurant_is_untouched_by_the_liquid_rule()
None test_a_network_defaults_to_single_phase()
None test_accessor_and_get_agree_including_argument_order()
None test_an_exhausted_chain_preserves_why(str label, dict[str, float] state)
None test_multiphase_is_opt_in_and_changes_the_answer()
None test_accessor_falls_through_the_chain_too()
None test_impossible_inputs_are_rejected_before_any_backend(dict[str, float] state, str expected)
None test_quality_is_none_outside_the_dome_not_a_sentinel()
None test_the_chain_falls_through_to_the_equation_of_state(str label, dict[str, float] state)