STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_vapour.py
Go to the documentation of this file.
1"""Phase 14: propellant vapour in the ullage, and chilldown.
2
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.
6"""
7
8from __future__ import annotations
9
10from typing import Any
11
12import pytest
13from dataclasses import replace
14
15from feedtwin.props import Fluid
16from feedtwin.vessels.geometry import CylindricalTank
17from feedtwin.vessels.tank import Tank, TankState
18from feedtwin.vessels.volume import VesselState
19from feedtwin.vessels.vapour import (
20 NoVapour,
21 SaturatedVapour,
22 latent_heat,
23 registered_vapour_models,
24 vapour_model,
25)
26
27PSI = 6894.757293168361
28
29
30@pytest.fixture(scope="module")
31def parts():
32 return (
33 Fluid("oxygen"),
34 Fluid("helium"),
35 CylindricalTank(diameter=0.1524, barrel_length=0.531),
36 )
37
38
39def build(parts, **kwargs) -> Tank:
40 lox, helium, geometry = parts
41 return Tank(
42 lox,
43 helium,
44 geometry,
45 wall_mass=8.0,
46 wall_capacity=900.0,
47 wall_conductance=12.0,
48 **kwargs,
49 )
50
51
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)
57 return TankState(
58 ullage=VesselState(
59 mass=rho * ullage_volume,
60 energy=rho * ullage_volume * helium.get("u", p=550 * PSI, T=200.0),
61 wall_temperature=wall,
62 ),
63 liquid_mass=liquid_volume * lox.get("rho", T=90.0, q=0.0),
64 liquid_temperature=90.0,
65 contact_time=1.0,
66 vapour_mass=vapour_mass,
67 )
68
69
70# ------------------------------------------------------------------- the off switch
71
72
74 plain, explicit = build(parts), build(parts, vapour=NoVapour())
75 state = loaded(parts)
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
80
81
83 """The old behaviour, stated as a property rather than left implied."""
84 rates = build(parts).rates(loaded(parts))
85 assert rates.heat_to_liquid > 0.0
86 assert rates.liquid_temperature > 0.0
87 assert rates.latent_power == 0.0
88
89
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
94
95
96# ------------------------------------------------------------------- the physics
97
98
100 """One statement: a liquid at saturation cannot warm, so heat boils it."""
101 state = loaded(parts)
102 boiling = build(parts, vapour=SaturatedVapour()).rates(state)
103 assert boiling.evaporation > 0.0
104 assert boiling.liquid_temperature == pytest.approx(0.0, abs=1e-9)
105
106
108 """`mdot = q / h_fg`, checked against the latent heat directly."""
109 state = loaded(parts)
110 rates = build(parts, vapour=SaturatedVapour()).rates(state)
111 h_fg = latent_heat(Fluid("oxygen"), 90.0)
112 assert rates.evaporation == pytest.approx(rates.latent_power / h_fg, rel=1e-12)
113
114
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(
119 199.0, abs=2.0
120 )
121
122
124 """A tank that has been boiling has less in it than the load sheet says."""
125 rates = build(parts, vapour=SaturatedVapour()).rates(loaded(parts))
126 assert rates.liquid_mass == pytest.approx(-rates.evaporation)
127 assert rates.vapour_mass == pytest.approx(rates.evaporation)
128
129
131 """Dalton: the ullage is pressurant plus vapour, not one or the other."""
132 tank = build(parts, vapour=SaturatedVapour())
133 dry = tank.pressure(loaded(parts))
134 wet = tank.pressure(loaded(parts, vapour_mass=1.0e-3))
135 assert wet > dry
136 assert wet - dry == pytest.approx(14.16 * PSI, rel=0.05)
137
138
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)
143 still = build(parts, vapour=SaturatedVapour()).rates(state)
144 chilling = build(parts, vapour=SaturatedVapour(), wetted_conductance=50.0).rates(
145 state
146 )
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
150
151
152# ------------------------------------------------- the two traps this fell into
153
154
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.
159
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.
162 """
163 tank = build(parts, vapour=SaturatedVapour())
164 previous = 0.0
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"
169 previous = partial
170
171
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.
177
178 So boil-off contributes mass and partial pressure, never an energy term.
179 """
180 state = loaded(parts)
181 dry = build(parts, vapour=NoVapour()).rates(state)
182 wet = build(parts, vapour=SaturatedVapour()).rates(state)
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)
185
186
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."""
190 tank = build(parts, vapour=SaturatedVapour())
191 state = loaded(parts)
192 stepped = tank.step(state, tank.rates(state), 0.01)
193 assert stepped.vapour_mass > 0.0
194
195
197 tank = build(parts, vapour=SaturatedVapour())
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
201
202
203# ------------------------------------------------------------------- the registry
204
205
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")
212
213
214# ------------------------------------------------------ the primed initial state
215
216
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,
226 contact_time=300.0,
227 )
228 return tank, state
229
230
232 """No vapour model, or an interface a moment old: no vapour, as before."""
233 _, dry = primed(parts)
234 _, explicit = primed(parts, vapour=NoVapour())
235 assert dry.vapour_mass == 0.0 and explicit == dry
236 tank = build(parts, vapour=SaturatedVapour())
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,
242 contact_time=0.0,
243 )
244 assert fresh.vapour_mass == 0.0
245 assert fresh.ullage == dry.ullage
246
247
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."""
251 lox, _, _ = parts
252 tank, state = primed(parts, vapour=SaturatedVapour())
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)
257 # And the pressurant made up the remainder, not the whole.
258 _, dry = primed(parts)
259 assert state.ullage.mass < dry.ullage.mass
260
261
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."""
267 lox, _, _ = parts
268 tank, wet = primed(parts, vapour=SaturatedVapour())
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)
272 for _ in range(40):
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"
277 # The dry tank gained the vapour's partial pressure that the wet one
278 # started with; the wet one only saw the collapse.
279 assert (tank.pressure(dry) - p_dry0) - (tank.pressure(wet) - p_wet0) > 5.0 * PSI
280 assert tank.pressure(wet) < p_wet0
281
282
283# ------------------------------------------------------------- the heat leak
284
285
287 plain = build(parts, vapour=SaturatedVapour(), wetted_conductance=100.0)
288 explicit = build(
289 parts,
290 vapour=SaturatedVapour(),
291 wetted_conductance=100.0,
292 ambient_conductance=0.0,
293 )
294 _, state = primed(parts, vapour=SaturatedVapour(), wetted_conductance=100.0)
295 assert plain.rates(state) == explicit.rates(state)
296
297
298def leaky(parts, **kwargs):
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."""
301 tank = build(
302 parts,
303 vapour=SaturatedVapour(),
304 wetted_conductance=100.0,
305 ambient_conductance=5.0,
306 wall_boiling=True,
307 **kwargs,
308 )
309 return tank
310
311
312def vented(tank, parts) -> TankState:
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(
317 pressure=101325.0,
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,
321 contact_time=300.0,
322 split_wall=True,
323 )
324 return replace(state, wetted_wall_temperature=95.0)
325
326
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."""
332 tank = leaky(parts)
333 still = build(
334 parts, vapour=SaturatedVapour(), wetted_conductance=100.0, wall_boiling=True
335 )
336 warm = cold = vented(tank, parts)
337 p0 = tank.pressure(warm)
338 for _ in range(300):
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"
343 # Both climb at first -- the wall had 5 K of heat in it either way. Two
344 # minutes on, the wall with no room behind it has cooled to the liquid
345 # and stopped; the leaking one is still boiling.
346 for _ in range(900):
347 warm = tank.step(warm, tank.rates(warm), 0.1)
348 cold = still.step(cold, still.rates(cold), 0.1)
349 # It is self-limiting: the wall boils only while it is above saturation
350 # at the tank's own pressure, so the climb slows as the pressure lifts
351 # the saturation temperature toward the wall's -- the reason a shut LOX
352 # tank settles in the tens of psig rather than running away. The leaking
353 # wall is the warmer one, so it settles higher.
354 assert (warm.wetted_wall_temperature or 0.0) > (
355 cold.wetted_wall_temperature or 0.0
356 ) + 2.0
357 assert tank.pressure(warm) > still.pressure(cold) + 1.0 * PSI, (
358 tank.pressure(warm) / PSI,
359 still.pressure(cold) / PSI,
360 )
361 assert 5.0 < (tank.pressure(warm) - 101325.0) / PSI < 100.0
362
363
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."""
368 tank = leaky(parts)
369 _, pressed = primed(
370 parts,
371 vapour=SaturatedVapour(),
372 wetted_conductance=100.0,
373 ambient_conductance=5.0,
374 wall_boiling=True,
375 )
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"
380
381
383 on = build(
384 parts,
385 vapour=SaturatedVapour(),
386 wetted_conductance=100.0,
387 ambient_conductance=5.0,
388 )
389 explicit = build(
390 parts,
391 vapour=SaturatedVapour(),
392 wetted_conductance=100.0,
393 ambient_conductance=5.0,
394 wall_boiling=False,
395 )
396 state = vented(on, parts)
397 assert on.rates(state) == explicit.rates(state)
398
399
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."""
403 tank = build(parts, vapour=SaturatedVapour())
404 _, state = primed(parts, vapour=SaturatedVapour())
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
409
410
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."""
415 from feedtwin.vessels.geometry import CylindricalTank
416
417 eth, gn2 = Fluid("ethanol"), Fluid("nitrogen")
418 geometry = CylindricalTank(diameter=0.1524, barrel_length=0.531)
419 tank = Tank(eth, gn2, geometry, vapour=SaturatedVapour())
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,
426 contact_time=300.0,
427 )
428 assert 0.0 < state.vapour_mass < 1.0e-3, state.vapour_mass
429 # The EOS at 0.1 kg/m^3 is rough (it reads 20% low here); the point is the
430 # order of magnitude -- a fraction of a psi, not 14.
431 assert tank.vapour_pressure(state) == pytest.approx(
432 eth.get("p", T=293.15, q=0.0), rel=0.3
433 )
434
435
436# ------------------------------------------- which way the mass goes (quadrants)
437
438
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)
443 return SaturatedVapour().exchange(
444 liquid=eth,
445 liquid_temperature=293.15,
446 ullage_pressure=101325.0,
447 vapour_partial_pressure=partial_over_sat * p_sat,
448 interface_area=0.018,
449 heat_to_interface=q,
450 )
451
452
454 """The case 1.5 checks: unchanged."""
455 out: Any = _exchange(3.0, 0.0)
456 assert out.latent_power == pytest.approx(3.0)
457 assert out.mdot == pytest.approx(3.0 / latent_heat(Fluid("ethanol"), 293.15))
458
459
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)."""
464 out: Any = _exchange(-3.0, 0.0)
465 assert out.mdot == 0.0 and out.latent_power == 0.0
466
467
469 out: Any = _exchange(-3.0, 1.5)
470 assert out.mdot < 0.0
471 assert -3.0 <= out.latent_power < 0.0
472
473
475 """Heat arriving cannot pay for condensation; it warms the surface until
476 its own vapour pressure meets the partial pressure."""
477 out: Any = _exchange(3.0, 1.5)
478 assert out.mdot == 0.0 and out.latent_power == 0.0
479
480
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")
487 geometry = CylindricalTank(diameter=0.1524, barrel_length=0.531)
488 tank = Tank(
489 eth,
490 gn2,
491 geometry,
492 wall_mass=6.9,
493 wall_capacity=900.0,
494 wall_conductance=12.0,
495 vapour=SaturatedVapour(),
496 wetted_conductance=100.0,
497 surface_layer=0.01,
498 surface_mixing=5.0,
499 wall_boiling=True,
500 )
501 state = tank.initial_state(
502 pressure=101325.0,
503 liquid_mass=4.40,
504 liquid_temperature=293.0,
505 gas_temperature=290.0,
506 contact_time=300.0,
507 )
508 state = replace(
509 state,
510 vapour_mass=0.0,
511 surface_temperature=293.0,
512 ullage=replace(state.ullage, wall_temperature=292.9),
513 )
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
leaky(parts, **kwargs)
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)