1"""Phase 14: propellant vapour in the ullage, and chilldown.
3Both are opt-in. The first thing every test here establishes is that turning
4them off leaves the tank exactly as it was, because a physics option that
5perturbs the default is not an option, it is a regression.
8from __future__
import annotations
13from dataclasses
import replace
23 registered_vapour_models,
27PSI = 6894.757293168361
30@pytest.fixture(scope="module")
39def build(parts, **kwargs) -> Tank:
40 lox, helium, geometry = parts
47 wall_conductance=12.0,
52def loaded(parts, *, vapour_mass: float = 0.0, wall: float = 293.15) -> TankState:
53 lox, helium, geometry = parts
54 liquid_volume = 0.95 * geometry.total_volume
55 ullage_volume = geometry.total_volume - liquid_volume
56 rho = helium.get(
"rho", p=550 * PSI, T=200.0)
59 mass=rho * ullage_volume,
60 energy=rho * ullage_volume * helium.get(
"u", p=550 * PSI, T=200.0),
61 wall_temperature=wall,
63 liquid_mass=liquid_volume * lox.get(
"rho", T=90.0, q=0.0),
64 liquid_temperature=90.0,
66 vapour_mass=vapour_mass,
76 assert plain.pressure(state) == explicit.pressure(state)
77 assert plain.rates(state).evaporation == 0.0
78 assert plain.rates(state).latent_power == 0.0
79 assert plain.rates(state).heat_to_wetted_wall == 0.0
83 """The old behaviour, stated as a property rather than left implied."""
85 assert rates.heat_to_liquid > 0.0
86 assert rates.liquid_temperature > 0.0
87 assert rates.latent_power == 0.0
91 assert build(parts).rates(
loaded(parts)).heat_to_wetted_wall == 0.0
92 warm =
build(parts, wetted_conductance=50.0)
93 assert warm.rates(
loaded(parts, wall=293.15)).heat_to_wetted_wall > 0.0
100 """One statement: a liquid at saturation cannot warm, so heat boils it."""
103 assert boiling.evaporation > 0.0
104 assert boiling.liquid_temperature == pytest.approx(0.0, abs=1e-9)
108 """`mdot = q / h_fg`, checked against the latent heat directly."""
111 h_fg = latent_heat(
Fluid(
"oxygen"), 90.0)
112 assert rates.evaporation == pytest.approx(rates.latent_power / h_fg, rel=1e-12)
116 """LOX at its normal boiling point is about 213 kJ/kg."""
117 assert latent_heat(
Fluid(
"oxygen"), 90.0) / 1000.0 == pytest.approx(213.0, abs=2.0)
118 assert latent_heat(
Fluid(
"nitrogen"), 77.0) / 1000.0 == pytest.approx(
124 """A tank that has been boiling has less in it than the load sheet says."""
126 assert rates.liquid_mass == pytest.approx(-rates.evaporation)
127 assert rates.vapour_mass == pytest.approx(rates.evaporation)
131 """Dalton: the ullage is pressurant plus vapour, not one or the other."""
133 dry = tank.pressure(
loaded(parts))
134 wet = tank.pressure(
loaded(parts, vapour_mass=1.0e-3))
136 assert wet - dry == pytest.approx(14.16 * PSI, rel=0.05)
140 """The two features are one phase because they are one mechanism: a warm
141 wall under the liquid is what boils a cryogen off during a load."""
142 state =
loaded(parts, wall=293.15)
147 assert chilling.heat_to_wetted_wall > 0.0
148 assert chilling.evaporation > still.evaporation
149 assert chilling.ullage.wall_temperature < still.ullage.wall_temperature
156 """A microgram of oxygen in half a litre is 0.002 kg/m^3, and CoolProp's
157 Helmholtz formulation returns **-6057 psi** there. One such sample used to
158 take the whole tank pressure negative and every solve after it with it.
160 Below ~1 kg/m^3 the gas is ideal to well under a tenth of a percent, so the
161 ideal answer is the better number, not a fallback.
165 for grams
in (1e-6, 1e-4, 1e-3, 1e-2, 1e-1, 1.0, 5.0):
166 partial = tank.vapour_pressure(
loaded(parts, vapour_mass=grams / 1000.0))
167 assert partial > 0.0, f
"{grams} g gave a negative partial pressure"
168 assert partial >= previous,
"partial pressure must rise with vapour mass"
173 """`ullage.energy` is the *pressurant's* internal energy in the pressurant's
174 reference state. Saturated LOX sits at -133.7 kJ/kg while helium at ullage
175 conditions is +628.6 kJ/kg -- adding one into the other is a category error,
176 and with two grams of helium in the ullage it inverts the state in one step.
178 So boil-off contributes mass and partial pressure, never an energy term.
183 assert wet.evaporation > 0.0,
"this test is vacuous if nothing boiled"
184 assert wet.ullage.energy == pytest.approx(dry.ullage.energy, rel=1e-12)
188 """A `TankState` rebuilt field by field silently drops anything added to the
189 dataclass later, which is how this reset to zero every step."""
192 stepped = tank.step(state, tank.rates(state), 0.01)
193 assert stepped.vapour_mass > 0.0
198 state =
loaded(parts, vapour_mass=1e-9)
199 rates = tank.rates(state)
200 assert tank.step(state, rates, 1000.0).vapour_mass >= 0.0
207 assert registered_vapour_models() == [
"none",
"saturated"]
208 assert vapour_model(
"none").name ==
"none"
209 assert vapour_model(
"saturated").name ==
"saturated"
210 with pytest.raises(KeyError, match=
"no vapour model"):
211 vapour_model(
"wishful")
217def primed(parts, **kwargs) -> tuple[Tank, TankState]:
218 """A tank as the cockpit primes it: loaded, at 550 psig, held a while."""
219 lox, _, geometry = parts
220 tank =
build(parts, **kwargs)
221 state = tank.initial_state(
222 pressure=(550.0 + 14.7) * PSI,
223 liquid_mass=0.95 * geometry.total_volume * lox.get(
"rho", T=90.0, q=0.0),
224 liquid_temperature=90.0,
225 gas_temperature=293.15,
232 """No vapour model, or an interface a moment old: no vapour, as before."""
235 assert dry.vapour_mass == 0.0
and explicit == dry
237 fresh = tank.initial_state(
238 pressure=(550.0 + 14.7) * PSI,
239 liquid_mass=dry.liquid_mass,
240 liquid_temperature=90.0,
241 gas_temperature=293.15,
244 assert fresh.vapour_mass == 0.0
245 assert fresh.ullage == dry.ullage
249 """Loaded LOX has been boiling into its ullage the whole hold. The tank
250 starts with that vapour in it, and the *total* is still what was asked."""
253 p_sat = lox.get(
"p", T=90.0, q=0.0)
254 assert state.vapour_mass > 0.0
255 assert tank.vapour_pressure(state) == pytest.approx(p_sat, rel=0.02)
256 assert tank.pressure(state) == pytest.approx((550.0 + 14.7) * PSI, rel=1e-3)
259 assert state.ullage.mass < dry.ullage.mass
263 """The symptom. Primed dry, saturated LOX boils its vapour *after* the
264 regulator has locked up at the total, and the tank ends above it. Primed
265 with the interface in equilibrium, the vapour is already there and only
266 the collapse cooling moves the pressure -- downward, as it should."""
269 dry = replace(wet, vapour_mass=0.0)
270 p_sat = lox.get(
"p", T=90.0, q=0.0)
271 p_wet0, p_dry0 = tank.pressure(wet), tank.pressure(dry)
273 wet = tank.step(wet, tank.rates(wet), 0.05)
274 dry = tank.step(dry, tank.rates(dry), 0.05)
275 assert tank.vapour_pressure(wet) == pytest.approx(p_sat, rel=0.1)
276 assert tank.vapour_pressure(dry) > 0.5 * p_sat,
"the dry prime is boiling"
279 assert (tank.pressure(dry) - p_dry0) - (tank.pressure(wet) - p_wet0) > 5.0 * PSI
280 assert tank.pressure(wet) < p_wet0
291 wetted_conductance=100.0,
292 ambient_conductance=0.0,
295 assert plain.rates(state) == explicit.rates(state)
299 """A LOX tank someone loaded a while ago: chilled wetted wall, the room
300 leaking in through the skin, the wall allowed to boil what reaches it."""
304 wetted_conductance=100.0,
305 ambient_conductance=5.0,
313 """Loaded, at atmosphere with the vent just shut, wall a hair above the
314 liquid: the state a tank is in the moment somebody closes its vent."""
315 lox, gas, geometry = parts
316 state = tank.initial_state(
318 liquid_mass=0.85 * geometry.total_volume * lox.get(
"rho", T=90.0, q=0.0),
319 liquid_temperature=90.0,
320 gas_temperature=100.0,
324 return replace(state, wetted_wall_temperature=95.0)
328 """Wall a few kelvin above saturation at one atmosphere: it boils at the
329 wall and the shut tank climbs -- a few psi a second on a small ullage,
330 slowing as the rising pressure lifts the saturation temperature toward
331 the wall's. Without the leak the wall cools to the liquid and it stops."""
334 parts, vapour=
SaturatedVapour(), wetted_conductance=100.0, wall_boiling=
True
336 warm = cold =
vented(tank, parts)
337 p0 = tank.pressure(warm)
339 warm = tank.step(warm, tank.rates(warm), 0.1)
340 cold = still.step(cold, still.rates(cold), 0.1)
341 rise = (tank.pressure(warm) - p0) / PSI
342 assert rise > 5.0, f
"a shut LOX tank should climb: {rise:.1f} psi in 30 s"
347 warm = tank.step(warm, tank.rates(warm), 0.1)
348 cold = still.step(cold, still.rates(cold), 0.1)
354 assert (warm.wetted_wall_temperature
or 0.0) > (
355 cold.wetted_wall_temperature
or 0.0
357 assert tank.pressure(warm) > still.pressure(cold) + 1.0 * PSI, (
358 tank.pressure(warm) / PSI,
359 still.pressure(cold) / PSI,
361 assert 5.0 < (tank.pressure(warm) - 101325.0) / PSI < 100.0
365 """At 38 bar of helium the liquid is a hundred kelvin subcooled: a wall a
366 few kelvin warm grows no bubbles, the leak warms the bulk, and the tank
367 holds. That is why a pressed LOX tank does not run away."""
372 wetted_conductance=100.0,
373 ambient_conductance=5.0,
376 pressed = replace(pressed, wetted_wall_temperature=95.0)
377 rates = tank.rates(pressed)
378 assert rates.vapour_mass < 1e-5, rates.vapour_mass
379 assert rates.liquid_temperature > 0.0,
"the leak warms the bulk instead"
386 wetted_conductance=100.0,
387 ambient_conductance=5.0,
392 wetted_conductance=100.0,
393 ambient_conductance=5.0,
397 assert on.rates(state) == explicit.rates(state)
401 """A well-mixed ullage leaves through the vent as it is: the caller splits
402 the outflow by mass fraction and hands the vapour share here."""
405 kept = tank.rates(state)
406 vented_ = tank.rates(state, mdot_vapour_out=1.0e-4)
407 assert vented_.vapour_mass == pytest.approx(kept.vapour_mass - 1.0e-4)
408 assert vented_.ullage.mass == kept.ullage.mass
412 """R/M, not the equation of state at a state where ethanol is a liquid: a
413 fuel tank primed at 293 K holds a fraction of a gram of vapour, not a
414 hundred and twenty."""
417 eth, gn2 =
Fluid(
"ethanol"),
Fluid(
"nitrogen")
420 assert tank._vapour_r_specific() == pytest.approx(8.314462618 / 0.04607, rel=0.01)
421 state = tank.initial_state(
422 pressure=(550.0 + 14.7) * PSI,
423 liquid_mass=0.85 * geometry.total_volume * eth.get(
"rho", T=293.15, q=0.0),
424 liquid_temperature=293.15,
425 gas_temperature=293.15,
428 assert 0.0 < state.vapour_mass < 1.0e-3, state.vapour_mass
431 assert tank.vapour_pressure(state) == pytest.approx(
432 eth.get(
"p", T=293.15, q=0.0), rel=0.3
439def _exchange(q: float, partial_over_sat: float) -> object:
440 """Ethanol at 293.15 K, its vapour at ``partial_over_sat`` x saturation."""
441 eth =
Fluid(
"ethanol")
442 p_sat = eth.get(
"p", T=293.15, q=0.0)
445 liquid_temperature=293.15,
446 ullage_pressure=101325.0,
447 vapour_partial_pressure=partial_over_sat * p_sat,
448 interface_area=0.018,
454 """The case 1.5 checks: unchanged."""
456 assert out.latent_power == pytest.approx(3.0)
457 assert out.mdot == pytest.approx(3.0 / latent_heat(
Fluid(
"ethanol"), 293.15))
461 """No vapour at all over the surface, and the surface losing 3 W to a
462 colder gas: it cools. It used to condense 3.3 mg/s of vapour that was not
463 there -- liquid out of nothing (LE4 (3)'s vented ethanol tank)."""
465 assert out.mdot == 0.0
and out.latent_power == 0.0
470 assert out.mdot < 0.0
471 assert -3.0 <= out.latent_power < 0.0
475 """Heat arriving cannot pay for condensation; it warms the surface until
476 its own vapour pressure meets the partial pressure."""
478 assert out.mdot == 0.0
and out.latent_power == 0.0
482 """LE4 (3)'s fuel tank in Ox Fill: vented at atmosphere, no vapour, gas 3 K
483 under the liquid and the wall a tenth of a kelvin under it, with the
484 cockpit's surface layer. The liquid gained 3.3 mg/s and the layer warmed
485 above the bulk, the gas, the wall and the room."""
486 eth, gn2 =
Fluid(
"ethanol"),
Fluid(
"nitrogen")
494 wall_conductance=12.0,
496 wetted_conductance=100.0,
501 state = tank.initial_state(
504 liquid_temperature=293.0,
505 gas_temperature=290.0,
511 surface_temperature=293.0,
512 ullage=replace(state.ullage, wall_temperature=292.9),
514 rates = tank.rates(state)
515 assert rates.heat_to_liquid < 0.0
and rates.heat_to_wetted_wall < 0.0
516 assert rates.liquid_mass <= 0.0, rates.liquid_mass
517 assert rates.vapour_mass >= 0.0
518 assert rates.surface_temperature <= 0.0, rates.surface_temperature
519 assert rates.liquid_temperature <= 0.0
None test_a_dry_prime_boils_onto_lockup_and_a_held_one_does_not(parts)
None test_wall_boiling_off_is_the_old_tank(parts)
None test_a_pressed_lox_tank_does_not_boil_at_the_wall(parts)
None test_heat_into_a_subsaturated_surface_evaporates_it()
None test_a_trace_of_vapour_does_not_invert_the_tank_pressure(parts)
None test_a_vent_takes_the_vapour_with_the_pressurant(parts)
None test_no_ambient_is_exactly_the_old_tank(parts)
None test_a_lox_tank_at_atmosphere_with_the_vent_shut_climbs(parts)
None test_the_step_carries_vapour_forward(parts)
None test_a_vented_ethanol_tank_losing_heat_makes_no_liquid()
None test_a_subsaturated_surface_losing_heat_does_not_condense()
None test_vapour_mass_cannot_go_negative(parts)
None test_latent_heat_matches_the_handbook(parts)
None test_a_saturated_surface_boils_instead_of_warming(parts)
None test_chilldown_is_off_until_given_a_conductance(parts)
None test_with_no_vapour_all_interfacial_heat_warms_the_liquid(parts)
tuple[Tank, TankState] primed(parts, **kwargs)
None test_a_dry_prime_is_unchanged(parts)
Tank build(parts, **kwargs)
None test_a_held_interface_starts_at_its_saturation_pressure(parts)
TankState vented(tank, parts)
None test_chilldown_boils_the_propellant(parts)
TankState loaded(parts, *, float vapour_mass=0.0, float wall=293.15)
None test_vapour_carries_no_foreign_enthalpy_into_the_ullage(parts)
None test_models_are_named_and_registered()
None test_a_supersaturated_surface_losing_heat_condenses()
object _exchange(float q, float partial_over_sat)
None test_boil_off_is_lost_from_the_liquid(parts)
None test_vapour_adds_partial_pressure(parts)
None test_the_default_tank_is_unchanged(parts)
None test_the_boil_off_rate_is_the_heat_over_the_latent_heat(parts)
None test_a_supersaturated_surface_gaining_heat_warms_and_does_not_condense()
None test_ethanol_vapour_is_priced_as_a_gas()
static self_test_reply_t exchange(void *context, uint16_t id, uint8_t mode, uint32_t max_hz, self_test_spi_frame_t *frames, uint8_t count, uint32_t budget_ms)