1"""Tank geometry, ullage collapse, and constant-pressure expulsion.
3The headline check is :func:`test_expulsion_matches_closed_form`: for an ideal
4gas with no interfacial loss, holding a tank at constant pressure while liquid
5leaves needs exactly ``m = p dV / (R T_in)``, *independent of the ullage's
6starting temperature*. That independence is a strong test -- it fails loudly if
7the expansion-work term is missing or mis-signed, which is the single easiest
8thing to get wrong in a tank model.
11from __future__
import annotations
14from dataclasses
import replace
17from scipy.integrate
import quad
32 registered_collapse_models,
33 registered_geometries,
38PSI = get_unit(
"psi").factor
39R_UNIVERSAL = 8.31446261815324
42@pytest.fixture(scope="module")
44 """6 in. tank, 600 mm barrel, 2:1 ellipsoidal heads. The team's shape."""
48@pytest.fixture(scope="module")
50 return Fluid(
"oxygen")
53@pytest.fixture(scope="module")
55 return Fluid(
"nitrogen")
63 expected = 2.0 * (2.0 / 3.0) * math.pi * r * r * c + math.pi * r * r * 0.600
64 assert geometry.total_volume == pytest.approx(expected, rel=1e-12)
68 """Zero barrel with two hemispherical heads is a sphere, exactly."""
73 bottom=
Head(radius=r, ratio=1.0),
74 top=
Head(radius=r, ratio=1.0),
76 assert sphere.total_volume == pytest.approx(4.0 / 3.0 * math.pi * r**3, rel=1e-12)
77 assert sphere.wetted_area(sphere.height) == pytest.approx(
78 4.0 * math.pi * r * r, rel=1e-12
80 assert sphere.height == pytest.approx(2 * r, rel=1e-12)
86 diameter=2 * r, barrel_length=0.5, bottom=Head.flat(), top=Head.flat()
88 assert tube.total_volume == pytest.approx(math.pi * r * r * 0.5, rel=1e-12)
89 assert tube.height == pytest.approx(0.5, rel=1e-12)
93 assert geometry.volume_below(0.0) == 0.0
94 assert geometry.volume_below(geometry.height) == pytest.approx(
95 geometry.total_volume, rel=1e-14
100 for level
in (0.005, 0.02, 0.038, 0.3, 0.62, 0.66):
101 volume = geometry.volume_below(level)
102 assert level_of_volume(geometry, volume) == pytest.approx(level, abs=1e-9)
106 """dV/dh must equal the horizontal area, or collapse gets the wrong area."""
108 numeric = (geometry.volume_below(h + eps) - geometry.volume_below(h - eps)) / (
111 assert numeric == pytest.approx(geometry.cross_section(h), rel=1e-5)
115 """A nearly-empty tank has far less interface than a half-full one."""
116 assert geometry.cross_section(0.010) < 0.5 * geometry.cross_section(0.3)
121 assert table.total_volume == 3e-3
122 assert table.volume_below(0.05) == pytest.approx(0.5e-3)
124 assert table.cross_section(0.15) == pytest.approx(2e-2)
125 assert level_of_volume(table, 1e-3) == pytest.approx(0.1, abs=1e-9)
129 with pytest.raises(ValueError, match=
"non-decreasing"):
134 assert "cylindrical" in registered_geometries()
135 built = build_geometry(
"cylindrical", diameter=0.152, barrel_length=0.6)
136 assert built.total_volume == pytest.approx(
139 with pytest.raises(KeyError, match=
"unknown tank geometry"):
140 build_geometry(
"bathtub")
147 """The instantaneous flux must integrate to 2 A dT sqrt(k rho c t / pi)."""
149 conductivity=lox.get(
"k", T=90.0, q=0.0),
150 density=lox.get(
"rho", T=90.0, q=0.0),
151 heat_capacity=lox.get(
"cp", T=90.0, q=0.0),
155 integrated, _ = quad(
156 lambda t: model.heat_rate(area, 300.0, 90.0, liquid, t), 0.0, 5.0
158 closed = model.heat_total(area, 300.0, 90.0, liquid, 5.0)
162 assert integrated == pytest.approx(closed, rel=0.01)
163 assert integrated < closed
167 """The physical claim behind using a transient model at all."""
169 conductivity=lox.get(
"k", T=90.0, q=0.0),
170 density=lox.get(
"rho", T=90.0, q=0.0),
171 heat_capacity=lox.get(
"cp", T=90.0, q=0.0),
173 assert liquid.penetration_depth(5.0) == pytest.approx(1.1e-3, abs=0.2e-3)
175 ratio = liquid.penetration_depth(60.0) / liquid.penetration_depth(5.0)
176 assert ratio == pytest.approx(math.sqrt(12.0), rel=1e-9)
180 """Why repressurising right before ignition helps: it resets the clock."""
183 fresh = model.heat_rate(0.018, 300.0, 90.0, liquid, 1.0)
184 stale = model.heat_rate(0.018, 300.0, 90.0, liquid, 100.0)
185 assert fresh == pytest.approx(10.0 * stale, rel=1e-9)
192 assert set(registered_collapse_models()) >= {
"none",
"conduction"}
193 assert build_collapse_model(
"conduction", enhancement=2.0).name ==
"conduction"
194 with pytest.raises(KeyError, match=
"unknown ullage collapse model"):
195 build_collapse_model(
"magic")
202 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
204 tank =
Tank(lox, gn2, geometry)
205 state = tank.initial_state(
208 liquid_temperature=90.0,
209 gas_temperature=293.15,
213 assert tank.pressure(state) == pytest.approx(500 * PSI, rel=1e-6)
214 assert tank.gas_temperature(state) == pytest.approx(293.15, rel=1e-6)
218 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
220 tank =
Tank(lox, gn2, geometry)
221 with pytest.raises(ValueError, match=
"does not fit"):
225 liquid_temperature=90.0,
226 gas_temperature=293.15,
231 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
233 tank =
Tank(lox, gn2, geometry)
234 state = tank.initial_state(
237 liquid_temperature=90.0,
238 gas_temperature=293.15,
240 head = tank.outlet_pressure(state) - tank.pressure(state)
241 expected = tank.liquid_density(state) * 9.80665 * tank.level(state)
242 assert head == pytest.approx(expected, rel=1e-12)
246@pytest.mark.parametrize("ullage_temperature", [150.0, 293.15, 400.0])
248 lox: Fluid, gn2: Fluid, geometry: CylindricalTank, ullage_temperature: float
250 """Constant-pressure expulsion needs ``m = p dV / (R T_in)``.
252 From the energy balance with ``p`` held: ``(cv/R) p Vdot = mdot cp T_in -
253 p Vdot`` reduces to ``mdot = p Vdot / (R T_in)``, which does **not** contain
254 the ullage's own temperature. Getting the same answer from a 150 K ullage
255 and a 400 K one is the real content of this test; a missing or mis-signed
256 expansion-work term breaks that independence immediately.
259 state = tank.initial_state(
262 liquid_temperature=90.0,
263 gas_temperature=ullage_temperature,
265 result = tank.pressurant_for_expulsion(
266 state, mdot_liquid=1.5, duration=5.0, inlet_temperature=293.15, steps=2000
269 expelled = 9.0 - result[
"liquid_remaining"]
270 volume = expelled / tank.liquid_density(state)
271 r_specific = R_UNIVERSAL / gn2.constants()[
"molar_mass"]
272 ideal = 500 * PSI * volume / (r_specific * 293.15)
275 assert result[
"mass"] == pytest.approx(ideal, rel=0.01)
277 assert abs(result[
"pressure_error"]) < 1.0
281 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
283 """The ~0.5% gap from ideal is Z, not numerical slop."""
285 state = tank.initial_state(
288 liquid_temperature=90.0,
289 gas_temperature=293.15,
291 result = tank.pressurant_for_expulsion(
292 state, mdot_liquid=1.5, duration=5.0, inlet_temperature=293.15, steps=2000
294 volume = (9.0 - result[
"liquid_remaining"]) / tank.liquid_density(state)
295 r_specific = R_UNIVERSAL / gn2.constants()[
"molar_mass"]
296 ideal = 500 * PSI * volume / (r_specific * 293.15)
298 z = gn2.get(
"Z", p=500 * PSI, T=293.15)
301 assert result[
"mass"] / ideal == pytest.approx(1.0 / z, rel=0.01)
305 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
307 """The point of the whole collapse model.
309 With the regulator holding tank pressure, interfacial heat loss does not
310 show up in the pressure trace at all -- both runs hold 500 psi exactly. It
311 shows up as extra pressurant, which drains the COPV faster, which lowers
312 regulator inlet, which moves the outlet through the supply coefficient.
316 tank =
Tank(lox, gn2, geometry, collapse=model)
317 state = tank.initial_state(
320 liquid_temperature=90.0,
321 gas_temperature=293.15,
323 results[name] = tank.pressurant_for_expulsion(
324 state, mdot_liquid=1.5, duration=5.0, inlet_temperature=293.15
328 for result
in results.values():
329 assert abs(result[
"pressure_error"]) < 1.0
331 extra = results[
"conduction"][
"mass"] / results[
"none"][
"mass"] - 1.0
334 assert 0.03 < extra < 0.12
335 assert results[
"conduction"][
"heat_to_liquid"] > 0.0
336 assert results[
"none"][
"heat_to_liquid"] == 0.0
340 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
343 for factor
in (1.0, 2.0, 4.0):
345 state = tank.initial_state(
348 liquid_temperature=90.0,
349 gas_temperature=293.15,
352 tank.pressurant_for_expulsion(
353 state, mdot_liquid=1.5, duration=5.0, inlet_temperature=293.15
356 assert masses[0] < masses[1] < masses[2]
360 lox: Fluid, geometry: CylindricalTank
362 """Sanity on the pressurant choice: helium's R is 7x nitrogen's."""
364 for name
in (
"helium",
"nitrogen"):
366 state = tank.initial_state(
369 liquid_temperature=90.0,
370 gas_temperature=293.15,
372 masses[name] = tank.pressurant_for_expulsion(
373 state, mdot_liquid=1.5, duration=5.0, inlet_temperature=293.15
375 assert masses[
"helium"] < 0.2 * masses[
"nitrogen"]
379 """A pressurant that condenses at tank conditions is not an ullage gas."""
381 with pytest.raises(ValueError, match=
"is a liquid"):
385 liquid_temperature=90.0,
386 gas_temperature=90.0,
391 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
393 tank =
Tank(lox, gn2, geometry)
394 state = tank.initial_state(
397 liquid_temperature=90.0,
398 gas_temperature=293.15,
402 aged = replace(state, contact_time=600.0)
403 assert tank.rates(aged).heat_to_liquid < tank.rates(state).heat_to_liquid
405 fresh = aged.repressurised()
406 assert fresh.contact_time == 0.0
407 assert tank.rates(fresh).heat_to_liquid > tank.rates(aged).heat_to_liquid
410 old_state = replace(state, contact_time=100.0)
411 young = replace(state, contact_time=1.0)
412 assert tank.rates(young).heat_to_liquid == pytest.approx(
413 10.0 * tank.rates(old_state).heat_to_liquid, rel=1e-9
418 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
420 """A field added to the state later rides through a step untouched.
422 Built field by field, the step returned a plain :class:`TankState` and
423 reset anything it did not list -- how ``vapour_mass`` was once zeroed every
424 step (docs/PHYSICS-BENCHMARK.md 4.3). A subclass stands in for that field.
426 from dataclasses
import dataclass, fields
430 @dataclass(frozen=True, slots=True)
431 class Grown(TankState):
432 added_later: float = 0.0
434 tank =
Tank(lox, gn2, geometry)
435 start = tank.initial_state(
438 liquid_temperature=90.0,
439 gas_temperature=293.15,
442 **{f.name: getattr(start, f.name)
for f
in fields(start)}, added_later=7.0
444 stepped = tank.step(state, tank.rates(state, mdot_liquid_out=1.0), 1e-3)
446 assert isinstance(stepped, Grown)
and stepped.added_later == 7.0
447 plain = tank.step(start, tank.rates(start, mdot_liquid_out=1.0), 1e-3)
448 assert stepped.liquid_mass == plain.liquid_mass < start.liquid_mass
449 assert stepped.ullage == plain.ullage
None test_penetration_depth_is_millimetres_over_a_burn(Fluid lox)
None test_cylinder_volume_matches_closed_form(CylindricalTank geometry)
None test_enhancement_increases_demand_monotonically(Fluid lox, Fluid gn2, CylindricalTank geometry)
None test_volume_endpoints_are_exact(CylindricalTank geometry)
None test_liquid_in_the_ullage_refuses(CylindricalTank geometry)
None test_cross_section_is_the_derivative_of_volume(CylindricalTank geometry)
None test_collapse_costs_pressurant_not_pressure(Fluid lox, Fluid gn2, CylindricalTank geometry)
None test_conduction_rate_integrates_to_the_closed_form(Fluid lox)
None test_initial_state_round_trips_pressure(Fluid lox, Fluid gn2, CylindricalTank geometry)
None test_flat_heads_give_a_cylinder()
None test_expulsion_matches_closed_form(Fluid lox, Fluid gn2, CylindricalTank geometry, float ullage_temperature)
None test_tabulated_geometry_rejects_a_shrinking_tank()
None test_level_inverts_volume(CylindricalTank geometry)
None test_outlet_pressure_includes_the_liquid_column(Fluid lox, Fluid gn2, CylindricalTank geometry)
None test_tabulated_geometry_round_trips()
None test_collapse_flux_decays_with_contact_time(Fluid lox)
CylindricalTank geometry()
None test_geometry_registry()
None test_expulsion_deviation_is_the_compressibility(Fluid lox, Fluid gn2, CylindricalTank geometry)
None test_overfull_tank_refuses(Fluid lox, Fluid gn2, CylindricalTank geometry)
None test_repressurisation_resets_the_interface_clock(Fluid lox, Fluid gn2, CylindricalTank geometry)
None test_a_step_carries_what_it_does_not_integrate(Fluid lox, Fluid gn2, CylindricalTank geometry)
None test_helium_needs_less_mass_than_nitrogen(Fluid lox, CylindricalTank geometry)
None test_interface_shrinks_in_the_head(CylindricalTank geometry)
None test_no_collapse_is_a_named_model()
None test_hemispherical_heads_give_a_sphere()