STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_vessels.py
Go to the documentation of this file.
1"""Vessels and the gas side: does a COPV's pressure mean anything?
2
3Phase 05's exit criteria. The one that matters is the first: an adiabatic
4blowdown has a closed-form answer, and a model that cannot reproduce it has no
5business predicting how long a bottle holds regulator inlet pressure.
6
7Checked at *low* pressure, deliberately. Nitrogen is nearly ideal there, so the
8ideal-gas closed form is the right reference; at COPV pressure the model should
9depart from it, and by how much is the whole reason for using a real equation of
10state. Both directions are asserted.
11"""
12
13from __future__ import annotations
14
15import math
16
17import pytest
18
19from feedtwin.comps import (
20 InfeasibleOperatingPoint,
21 build_component,
22 choked_mass_flow,
23 conditions_from_fluid,
24 critical_pressure_ratio,
25)
26from feedtwin.model import ComponentInstance, Param, Provenance
27from feedtwin.props import Fluid
28from feedtwin.vessels import GasVolume, VesselState
29
30M = Provenance.MANUFACTURER
31
32
33def _reached(history: list[tuple[float, VesselState]], m0: float, fraction: float):
34 """The sample nearest a mass fraction, asserting it was actually reached.
35
36 Without the assertion a blowdown that ran out of time returns its last
37 sample and the test quietly checks the wrong point -- which is exactly what
38 happened while writing these.
39 """
40 _t, state = min(history, key=lambda ts: abs(ts[1].mass - m0 * fraction))
41 assert abs(state.mass / m0 - fraction) < 0.01, (
42 f"blowdown never reached m/m0={fraction}; got {state.mass / m0:.3f}. "
43 "Lengthen the run."
44 )
45 return state
46
47
48# ------------------------------------------------------------- the closed form
49
50
52 """``T/T0 = (m/m0)^(g-1)`` and ``p/p0 = (m/m0)^g``, exactly, for an ideal gas.
53
54 Phase 05's headline criterion. A vessel with no wall is adiabatic, and at
55 two bar nitrogen is ideal to within 0.04%, so the model has nowhere to hide.
56 """
57 nitrogen = Fluid("nitrogen")
58 vessel = GasVolume(nitrogen, volume=0.05)
59 start = vessel.initial_state(pressure=2.0e5, temperature=300.0)
60 gamma = nitrogen.get("gamma", p=2.0e5, T=300.0)
61
62 assert vessel.compressibility(start) == pytest.approx(
63 1.0, abs=1e-3
64 ), "the reference only holds where the gas is nearly ideal"
65
66 history = vessel.blowdown(start, mdot=1.0e-4, duration=900.0, steps=30000)
67 for fraction in (0.9, 0.7, 0.5):
68 state = _reached(history, start.mass, fraction)
69 ratio = state.mass / start.mass
70 assert vessel.temperature(state) == pytest.approx(
71 300.0 * ratio ** (gamma - 1.0), rel=3e-3
72 )
73 assert vessel.pressure(state) == pytest.approx(2.0e5 * ratio**gamma, rel=3e-3)
74
75
77 """And it must, or the real equation of state is doing nothing.
78
79 A test that only checked the ideal limit would pass on an ideal-gas model,
80 which is the model this phase exists to replace.
81 """
82 nitrogen = Fluid("nitrogen")
83 vessel = GasVolume(nitrogen, volume=0.009)
84 start = vessel.initial_state(pressure=310.0e5, temperature=293.15)
85 gamma = nitrogen.get("gamma", p=310.0e5, T=293.15)
86
87 history = vessel.blowdown(start, mdot=3.0e-3, duration=600.0, steps=20000)
88 state = _reached(history, start.mass, 0.7)
89 ratio = state.mass / start.mass
90
91 ideal = 310.0e5 * ratio**gamma
92 assert (
93 vessel.pressure(state) < 0.95 * ideal
94 ), "a 310 bar bottle is not an ideal gas and the model should say so"
95
96
98 """The number that justifies the whole property layer.
99
100 A 9 L bottle at 4500 psi holds about 2.79 kg of nitrogen. Ideal gas says
101 3.21 kg -- 15% more pressurant than is actually in there, which propagates
102 straight into how long the tanks stay up.
103 """
104 nitrogen = Fluid("nitrogen")
105 vessel = GasVolume(nitrogen, volume=0.009)
106 state = vessel.initial_state(pressure=310.0e5, temperature=293.15)
107
108 ideal_mass = 310.0e5 * 0.009 / (296.8 * 293.15)
109 assert state.mass == pytest.approx(2.788, rel=1e-2)
110 assert ideal_mass / state.mass == pytest.approx(1.15, rel=0.02)
111
112
113# ------------------------------------------------------------------- the wall
114
115
117 """EngineDesign's blowdown solver takes ``n`` as an input. Here it is output.
118
119 The vessel wall is a heat reservoir several times the gas's own capacity,
120 and how tightly it is coupled decides how much the bottle cools. Assuming an
121 exponent assumes the answer; giving the wall its real mass and conductance
122 produces one.
123 """
124 nitrogen = Fluid("nitrogen")
125 results = {}
126 for label, wall in [
127 ("adiabatic", {}),
128 ("loose", dict(wall_mass=6.0, wall_capacity=900.0, wall_conductance=5.0)),
129 ("tight", dict(wall_mass=6.0, wall_capacity=900.0, wall_conductance=40.0)),
130 ]:
131 vessel = GasVolume(nitrogen, volume=0.009, **wall) # type: ignore[arg-type]
132 start = vessel.initial_state(pressure=310.0e5, temperature=293.15)
133 history = vessel.blowdown(start, mdot=3.0e-3, duration=600.0, steps=20000)
134 state = _reached(history, start.mass, 0.5)
135 exponent = 1.0 + math.log(vessel.temperature(state) / 293.15) / math.log(0.5)
136 results[label] = (vessel.pressure(state), exponent)
137
138 # A better-coupled wall keeps the gas warmer, so the bottle holds more.
139 assert results["adiabatic"][0] < results["loose"][0] < results["tight"][0]
140 # ...and the implied exponent falls toward isothermal as coupling rises.
141 assert results["adiabatic"][1] > results["loose"][1] > results["tight"][1]
142 # The spread is large enough that assuming it is a real modelling decision.
143 assert results["tight"][0] / results["adiabatic"][0] > 1.3
144
145
147 """It decides how fast a bottle droops, so it belongs in the result."""
148 vessel = GasVolume(
149 Fluid("nitrogen"),
150 volume=0.009,
151 wall_mass=6.0,
152 wall_capacity=900.0,
153 wall_conductance=20.0,
154 )
155 state = vessel.initial_state(pressure=310.0e5, temperature=293.15)
156 cold = VesselState(state.mass * 0.6, state.energy * 0.55, wall_temperature=293.15)
157
158 rates = vessel.rates(cold, mdot_out=1.0e-3)
159 assert rates.heat_from_wall > 0.0, "a warm wall heats cooled gas"
160 assert rates.wall_temperature < 0.0, "and cools doing it"
161
162
164 """No flow, no heat: nothing should drift."""
165 vessel = GasVolume(Fluid("helium"), volume=0.02)
166 state = vessel.initial_state(pressure=200.0e5, temperature=290.0)
167 rates = vessel.rates(state)
168
169 assert rates.mass == 0.0
170 assert rates.energy == 0.0
171 assert vessel.pressure(vessel.step(state, rates, 10.0)) == pytest.approx(
172 vessel.pressure(state)
173 )
174
175
177 """Nobody fills a bottle to an internal energy."""
178 for name, pressure, temperature in [
179 ("nitrogen", 310.0e5, 293.15),
180 ("helium", 200.0e5, 290.0),
181 ("nitrogen", 2.0e5, 300.0),
182 ]:
183 vessel = GasVolume(Fluid(name), volume=0.01)
184 state = vessel.initial_state(pressure, temperature)
185 assert vessel.pressure(state) == pytest.approx(pressure, rel=1e-6)
186 assert vessel.temperature(state) == pytest.approx(temperature, rel=1e-6)
187
188
189# --------------------------------------------------------------- the gas side
190
191
192def _orifice(bore_mm: float = 1.0) -> object:
193 return build_component(
194 ComponentInstance.build(
195 "GO-01",
196 "gas_orifice",
197 {"bore": Param(bore_mm, "mm", M, "throat"), "Cd": Param(0.85, "-", M, "")},
198 )
199 )
200
201
203 """0.528 for a diatomic gas. A constant worth pinning, not deriving twice."""
204 assert critical_pressure_ratio(1.4) == pytest.approx(0.5283, rel=1e-3)
205 assert critical_pressure_ratio(1.667) == pytest.approx(0.4867, rel=1e-3)
206
207
209 """Past it, no downstream pressure exists that passes the flow.
210
211 Reported as infeasible rather than iterated on, so a solver can back off
212 rather than wander.
213 """
214 orifice = _orifice()
215 nitrogen = conditions_from_fluid(Fluid("nitrogen"), 310.0e5, 293.15)
216 ceiling = orifice.choked_flow(nitrogen) # type: ignore[attr-defined]
217
218 assert orifice.pressure_drop(0.99 * ceiling, nitrogen) > 0.0 # type: ignore[attr-defined]
219 with pytest.raises(InfeasibleOperatingPoint):
220 orifice.pressure_drop(1.05 * ceiling, nitrogen) # type: ignore[attr-defined]
221
222
224 """The two things a relief orifice is sized on."""
225 args = dict(temperature=293.15, gamma=1.4, r_specific=296.8)
226 base = choked_mass_flow(1.0e-6, 0.85, 100.0e5, **args) # type: ignore[arg-type]
227
228 assert choked_mass_flow(2.0e-6, 0.85, 100.0e5, **args) == pytest.approx( # type: ignore[arg-type]
229 2.0 * base
230 )
231 assert choked_mass_flow(1.0e-6, 0.85, 200.0e5, **args) == pytest.approx( # type: ignore[arg-type]
232 2.0 * base
233 )
234
235
237 """Same hole, same pressure, very different pressurant budget.
238
239 Helium's low molecular weight means far less mass through the same orifice,
240 which is the trade against its not condensing into a cold ullage.
241 """
242 orifice = _orifice()
243 nitrogen = conditions_from_fluid(Fluid("nitrogen"), 310.0e5, 293.15)
244 helium = conditions_from_fluid(Fluid("helium"), 310.0e5, 293.15)
245
246 ratio = orifice.choked_flow(nitrogen) / orifice.choked_flow(helium) # type: ignore[attr-defined]
247 assert 2.0 < ratio < 3.5, f"nitrogen/helium choked flow ratio was {ratio:.2f}"
248
249
251 """The relation is solved by bracketing, so check it actually inverts."""
252 from feedtwin.comps.gas import subsonic_mass_flow
253
254 orifice = _orifice()
255 nitrogen = conditions_from_fluid(Fluid("nitrogen"), 310.0e5, 293.15)
256 ceiling = orifice.choked_flow(nitrogen) # type: ignore[attr-defined]
257
258 previous = 0.0
259 for fraction in (0.2, 0.5, 0.9):
260 mdot = ceiling * fraction
261 dp = orifice.pressure_drop(mdot, nitrogen) # type: ignore[attr-defined]
262 assert dp > previous
263 previous = dp
264
265 recovered = subsonic_mass_flow(
266 math.pi * 0.001**2 / 4.0,
267 0.85,
268 310.0e5,
269 1.0 - dp / 310.0e5,
270 293.15,
271 nitrogen.gamma,
272 nitrogen.r_specific,
273 )
274 assert recovered == pytest.approx(mdot, rel=1e-6)
275
276
278 """Liquid components ignore these; gas components cannot work without them."""
279 conditions = conditions_from_fluid(Fluid("nitrogen"), 310.0e5, 293.15)
280 assert conditions.gamma > 1.0
281 assert conditions.r_specific == pytest.approx(296.8, rel=1e-3)
282 assert conditions.temperature == pytest.approx(293.15)
283
284
286 """(rho, u) is a fine state pair for a gas and a poor one for a liquid.
287
288 Liquid density is roughly 180x less sensitive to pressure, so recovering
289 pressure from density amplifies any error in it by the same factor: through
290 interpolated tables a liquid comes back 0.1-0.7% out where a gas is within
291 1e-4%. That is small enough to go unnoticed and large enough to matter, so
292 it is refused rather than tolerated.
293 """
294 vessel = GasVolume(Fluid("oxygen"), volume=0.01)
295 with pytest.raises(ValueError, match="is a liquid"):
296 vessel.initial_state(pressure=30.0e5, temperature=90.0)
297
298
300 """The other half of that claim, so the guard is not merely superstition."""
301 for name, pressure, temperature in [
302 ("nitrogen", 310.0e5, 293.15),
303 ("helium", 200.0e5, 290.0),
304 ]:
305 fluid = Fluid(name)
306 rho = fluid.get("rho", p=pressure, T=temperature)
307 u = fluid.get("u", p=pressure, T=temperature)
308 assert fluid.get("p", rho=rho, u=u) == pytest.approx(pressure, rel=1e-5)
309
310
312 """A charge jet stirs the vessel; `stirring` multiplies the still-gas
313 conductance and nothing else. At 1 it is exactly the old behaviour."""
314 from feedtwin.props import Fluid
315 from feedtwin.vessels.volume import GasVolume
316
317 vessel = GasVolume(
318 Fluid("nitrogen"),
319 0.044,
320 wall_mass=60.0,
321 wall_capacity=500.0,
322 wall_conductance=89.0,
323 )
324 state = vessel.initial_state(pressure=200e5, temperature=350.0)
325 state = state.__class__(
326 mass=state.mass, energy=state.energy, wall_temperature=300.0
327 )
328 still = vessel.rates(state)
329 same = vessel.rates(state, stirring=1.0)
330 stirred = vessel.rates(state, stirring=5.0)
331 assert same == still
332 assert stirred.heat_from_wall == pytest.approx(5.0 * still.heat_from_wall)
333 assert stirred.mass == still.mass
334 assert stirred.energy - still.energy == pytest.approx(4.0 * still.heat_from_wall)
#define min(a, b)
Definition Arduino.h:42
object _orifice(float bore_mm=1.0)
None test_gases_round_trip_through_tables_without_loss()
None test_a_real_gas_departs_from_the_ideal_result_at_copv_pressure()
None test_a_liquid_in_a_gas_volume_is_refused()
None test_an_isolated_vessel_holds_its_state()
None test_stirring_scales_only_the_wall_exchange()
None test_the_critical_pressure_ratio_is_the_textbook_value()
None test_initial_state_round_trips_through_pressure_and_temperature()
None test_gas_pressure_drop_rises_with_flow_and_inverts_correctly()
_reached(list[tuple[float, VesselState]] history, float m0, float fraction)
None test_adiabatic_blowdown_matches_the_closed_form()
None test_choked_flow_is_a_ceiling_not_an_asymptote()
None test_heat_from_the_wall_is_reported_not_buried()
None test_an_ideal_gas_would_overstate_what_a_bottle_holds()
None test_choked_flow_scales_with_area_and_upstream_pressure()
None test_helium_chokes_at_a_third_the_mass_flow_of_nitrogen()
None test_gas_conditions_carry_what_a_gas_needs()
None test_the_wall_sets_the_polytropic_exponent_rather_than_assuming_it()