1"""Vessels and the gas side: does a COPV's pressure mean anything?
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.
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.
13from __future__
import annotations
20 InfeasibleOperatingPoint,
23 conditions_from_fluid,
24 critical_pressure_ratio,
30M = Provenance.MANUFACTURER
33def _reached(history: list[tuple[float, VesselState]], m0: float, fraction: float):
34 """The sample nearest a mass fraction, asserting it was actually reached.
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.
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}. "
52 """``T/T0 = (m/m0)^(g-1)`` and ``p/p0 = (m/m0)^g``, exactly, for an ideal gas.
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.
57 nitrogen =
Fluid(
"nitrogen")
59 start = vessel.initial_state(pressure=2.0e5, temperature=300.0)
60 gamma = nitrogen.get(
"gamma", p=2.0e5, T=300.0)
62 assert vessel.compressibility(start) == pytest.approx(
64 ),
"the reference only holds where the gas is nearly ideal"
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
73 assert vessel.pressure(state) == pytest.approx(2.0e5 * ratio**gamma, rel=3e-3)
77 """And it must, or the real equation of state is doing nothing.
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.
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)
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
91 ideal = 310.0e5 * ratio**gamma
93 vessel.pressure(state) < 0.95 * ideal
94 ),
"a 310 bar bottle is not an ideal gas and the model should say so"
98 """The number that justifies the whole property layer.
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.
104 nitrogen =
Fluid(
"nitrogen")
105 vessel =
GasVolume(nitrogen, volume=0.009)
106 state = vessel.initial_state(pressure=310.0e5, temperature=293.15)
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)
117 """EngineDesign's blowdown solver takes ``n`` as an input. Here it is output.
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
124 nitrogen =
Fluid(
"nitrogen")
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)),
131 vessel =
GasVolume(nitrogen, volume=0.009, **wall)
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)
139 assert results[
"adiabatic"][0] < results[
"loose"][0] < results[
"tight"][0]
141 assert results[
"adiabatic"][1] > results[
"loose"][1] > results[
"tight"][1]
143 assert results[
"tight"][0] / results[
"adiabatic"][0] > 1.3
147 """It decides how fast a bottle droops, so it belongs in the result."""
153 wall_conductance=20.0,
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)
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"
164 """No flow, no heat: nothing should drift."""
166 state = vessel.initial_state(pressure=200.0e5, temperature=290.0)
167 rates = vessel.rates(state)
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)
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),
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)
193 return build_component(
194 ComponentInstance.build(
197 {
"bore":
Param(bore_mm,
"mm", M,
"throat"),
"Cd":
Param(0.85,
"-", M,
"")},
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)
209 """Past it, no downstream pressure exists that passes the flow.
211 Reported as infeasible rather than iterated on, so a solver can back off
215 nitrogen = conditions_from_fluid(
Fluid(
"nitrogen"), 310.0e5, 293.15)
216 ceiling = orifice.choked_flow(nitrogen)
218 assert orifice.pressure_drop(0.99 * ceiling, nitrogen) > 0.0
219 with pytest.raises(InfeasibleOperatingPoint):
220 orifice.pressure_drop(1.05 * ceiling, nitrogen)
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)
228 assert choked_mass_flow(2.0e-6, 0.85, 100.0e5, **args) == pytest.approx(
231 assert choked_mass_flow(1.0e-6, 0.85, 200.0e5, **args) == pytest.approx(
237 """Same hole, same pressure, very different pressurant budget.
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.
243 nitrogen = conditions_from_fluid(
Fluid(
"nitrogen"), 310.0e5, 293.15)
244 helium = conditions_from_fluid(
Fluid(
"helium"), 310.0e5, 293.15)
246 ratio = orifice.choked_flow(nitrogen) / orifice.choked_flow(helium)
247 assert 2.0 < ratio < 3.5, f
"nitrogen/helium choked flow ratio was {ratio:.2f}"
251 """The relation is solved by bracketing, so check it actually inverts."""
255 nitrogen = conditions_from_fluid(
Fluid(
"nitrogen"), 310.0e5, 293.15)
256 ceiling = orifice.choked_flow(nitrogen)
259 for fraction
in (0.2, 0.5, 0.9):
260 mdot = ceiling * fraction
261 dp = orifice.pressure_drop(mdot, nitrogen)
265 recovered = subsonic_mass_flow(
266 math.pi * 0.001**2 / 4.0,
274 assert recovered == pytest.approx(mdot, rel=1e-6)
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)
286 """(rho, u) is a fine state pair for a gas and a poor one for a liquid.
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.
295 with pytest.raises(ValueError, match=
"is a liquid"):
296 vessel.initial_state(pressure=30.0e5, temperature=90.0)
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),
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)
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."""
322 wall_conductance=89.0,
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
328 still = vessel.rates(state)
329 same = vessel.rates(state, stirring=1.0)
330 stirred = vessel.rates(state, stirring=5.0)
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)
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()