STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_tank.py
Go to the documentation of this file.
1"""Tank geometry, ullage collapse, and constant-pressure expulsion.
2
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.
9"""
10
11from __future__ import annotations
12
13import math
14from dataclasses import replace
15
16import pytest
17from scipy.integrate import quad
18
19from feedtwin.model.units import get_unit
20from feedtwin.props import Fluid
21from feedtwin.vessels import (
22 ConductionCollapse,
23 CylindricalTank,
24 Head,
25 LiquidThermal,
26 NoCollapse,
27 TabulatedGeometry,
28 Tank,
29 build_collapse_model,
30 build_geometry,
31 level_of_volume,
32 registered_collapse_models,
33 registered_geometries,
34)
35
36#: Exact, from the library's own table -- a truncated 6894.757 leaves a
37#: 0.02 Pa round-trip error that these tolerances would otherwise chase.
38PSI = get_unit("psi").factor
39R_UNIVERSAL = 8.31446261815324
40
41
42@pytest.fixture(scope="module")
43def geometry() -> CylindricalTank:
44 """6 in. tank, 600 mm barrel, 2:1 ellipsoidal heads. The team's shape."""
45 return CylindricalTank(diameter=0.152, barrel_length=0.600)
46
47
48@pytest.fixture(scope="module")
49def lox() -> Fluid:
50 return Fluid("oxygen")
51
52
53@pytest.fixture(scope="module")
54def gn2() -> Fluid:
55 return Fluid("nitrogen")
56
57
58# ---------------------------------------------------------------- geometry
59
60
61def test_cylinder_volume_matches_closed_form(geometry: CylindricalTank) -> None:
62 r, c = 0.076, 0.038
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)
65
66
68 """Zero barrel with two hemispherical heads is a sphere, exactly."""
69 r = 0.076
70 sphere = CylindricalTank(
71 diameter=2 * r,
72 barrel_length=0.0,
73 bottom=Head(radius=r, ratio=1.0),
74 top=Head(radius=r, ratio=1.0),
75 )
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
79 )
80 assert sphere.height == pytest.approx(2 * r, rel=1e-12)
81
82
84 r = 0.076
85 tube = CylindricalTank(
86 diameter=2 * r, barrel_length=0.5, bottom=Head.flat(), top=Head.flat()
87 )
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)
90
91
92def test_volume_endpoints_are_exact(geometry: CylindricalTank) -> None:
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
96 )
97
98
99def test_level_inverts_volume(geometry: CylindricalTank) -> None:
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)
103
104
105def test_cross_section_is_the_derivative_of_volume(geometry: CylindricalTank) -> None:
106 """dV/dh must equal the horizontal area, or collapse gets the wrong area."""
107 h, eps = 0.020, 1e-7
108 numeric = (geometry.volume_below(h + eps) - geometry.volume_below(h - eps)) / (
109 2 * eps
110 )
111 assert numeric == pytest.approx(geometry.cross_section(h), rel=1e-5)
112
113
114def test_interface_shrinks_in_the_head(geometry: CylindricalTank) -> None:
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)
117
118
120 table = TabulatedGeometry(levels=[0.0, 0.1, 0.2], volumes=[0.0, 1e-3, 3e-3])
121 assert table.total_volume == 3e-3
122 assert table.volume_below(0.05) == pytest.approx(0.5e-3)
123 # Cross-section is differenced from the volume table when not supplied.
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)
126
127
129 with pytest.raises(ValueError, match="non-decreasing"):
130 TabulatedGeometry(levels=[0.0, 0.1], volumes=[1e-3, 0.0])
131
132
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(
137 CylindricalTank(diameter=0.152, barrel_length=0.6).total_volume
138 )
139 with pytest.raises(KeyError, match="unknown tank geometry"):
140 build_geometry("bathtub")
141
142
143# ---------------------------------------------------------------- collapse
144
145
147 """The instantaneous flux must integrate to 2 A dT sqrt(k rho c t / pi)."""
148 liquid = LiquidThermal(
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),
152 )
153 model = ConductionCollapse()
154 area = 0.018
155 integrated, _ = quad(
156 lambda t: model.heat_rate(area, 300.0, 90.0, liquid, t), 0.0, 5.0
157 )
158 closed = model.heat_total(area, 300.0, 90.0, liquid, 5.0)
159 # The gap is entirely MIN_CONTACT_TIME clipping the integrable singularity.
160 # It must stay under 1% of a five-second burn, which is what makes the
161 # floor a numerical guard rather than a modelling choice.
162 assert integrated == pytest.approx(closed, rel=0.01)
163 assert integrated < closed
164
165
167 """The physical claim behind using a transient model at all."""
168 liquid = LiquidThermal(
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),
172 )
173 assert liquid.penetration_depth(5.0) == pytest.approx(1.1e-3, abs=0.2e-3)
174 # sqrt(t): a 12x longer soak deepens the layer by sqrt(12) ~ 3.5, not 12.
175 ratio = liquid.penetration_depth(60.0) / liquid.penetration_depth(5.0)
176 assert ratio == pytest.approx(math.sqrt(12.0), rel=1e-9)
177
178
180 """Why repressurising right before ignition helps: it resets the clock."""
181 liquid = LiquidThermal(0.15, 1140.0, 1700.0)
182 model = ConductionCollapse()
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)
186
187
189 assert (
190 NoCollapse().heat_rate(0.018, 300.0, 90.0, LiquidThermal(1, 1, 1), 5.0) == 0.0
191 )
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")
196
197
198# -------------------------------------------------------------------- tank
199
200
202 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
203) -> None:
204 tank = Tank(lox, gn2, geometry)
205 state = tank.initial_state(
206 pressure=500 * PSI,
207 liquid_mass=9.0,
208 liquid_temperature=90.0,
209 gas_temperature=293.15,
210 )
211 # 1e-6, not 1e-9: the default chain is the BICUBIC table, whose
212 # interpolation error this round trip measures. On HEOS it is exact.
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)
215
216
218 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
219) -> None:
220 tank = Tank(lox, gn2, geometry)
221 with pytest.raises(ValueError, match="does not fit"):
222 tank.initial_state(
223 pressure=500 * PSI,
224 liquid_mass=500.0,
225 liquid_temperature=90.0,
226 gas_temperature=293.15,
227 )
228
229
231 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
232) -> None:
233 tank = Tank(lox, gn2, geometry)
234 state = tank.initial_state(
235 pressure=500 * PSI,
236 liquid_mass=9.0,
237 liquid_temperature=90.0,
238 gas_temperature=293.15,
239 )
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)
243 assert head > 0.0
244
245
246@pytest.mark.parametrize("ullage_temperature", [150.0, 293.15, 400.0])
248 lox: Fluid, gn2: Fluid, geometry: CylindricalTank, ullage_temperature: float
249) -> None:
250 """Constant-pressure expulsion needs ``m = p dV / (R T_in)``.
251
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.
257 """
258 tank = Tank(lox, gn2, geometry, collapse=NoCollapse())
259 state = tank.initial_state(
260 pressure=500 * PSI,
261 liquid_mass=9.0,
262 liquid_temperature=90.0,
263 gas_temperature=ullage_temperature,
264 )
265 result = tank.pressurant_for_expulsion(
266 state, mdot_liquid=1.5, duration=5.0, inlet_temperature=293.15, steps=2000
267 )
268
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)
273
274 # Within 1%: the residual is the real-gas correction, checked below.
275 assert result["mass"] == pytest.approx(ideal, rel=0.01)
276 # And the pressure really was held, which is what makes the comparison valid.
277 assert abs(result["pressure_error"]) < 1.0
278
279
281 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
282) -> None:
283 """The ~0.5% gap from ideal is Z, not numerical slop."""
284 tank = Tank(lox, gn2, geometry, collapse=NoCollapse())
285 state = tank.initial_state(
286 pressure=500 * PSI,
287 liquid_mass=9.0,
288 liquid_temperature=90.0,
289 gas_temperature=293.15,
290 )
291 result = tank.pressurant_for_expulsion(
292 state, mdot_liquid=1.5, duration=5.0, inlet_temperature=293.15, steps=2000
293 )
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)
297
298 z = gn2.get("Z", p=500 * PSI, T=293.15)
299 assert z < 1.0 # nitrogen is attractive-dominated here
300 # Real gas needs MORE mass for the same pressure, by roughly 1/Z.
301 assert result["mass"] / ideal == pytest.approx(1.0 / z, rel=0.01)
302
303
305 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
306) -> None:
307 """The point of the whole collapse model.
308
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.
313 """
314 results = {}
315 for name, model in (("none", NoCollapse()), ("conduction", ConductionCollapse())):
316 tank = Tank(lox, gn2, geometry, collapse=model)
317 state = tank.initial_state(
318 pressure=500 * PSI,
319 liquid_mass=9.0,
320 liquid_temperature=90.0,
321 gas_temperature=293.15,
322 )
323 results[name] = tank.pressurant_for_expulsion(
324 state, mdot_liquid=1.5, duration=5.0, inlet_temperature=293.15
325 )
326
327 # Both held pressure to well under a psi: the effect is invisible there.
328 for result in results.values():
329 assert abs(result["pressure_error"]) < 1.0
330
331 extra = results["conduction"]["mass"] / results["none"]["mass"] - 1.0
332 # Several percent over a 5 s burn -- not negligible, and not the 20%+ a
333 # back-of-envelope estimate that ignores expansion work suggests.
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
337
338
340 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
341) -> None:
342 masses = []
343 for factor in (1.0, 2.0, 4.0):
344 tank = Tank(lox, gn2, geometry, collapse=ConductionCollapse(enhancement=factor))
345 state = tank.initial_state(
346 pressure=500 * PSI,
347 liquid_mass=9.0,
348 liquid_temperature=90.0,
349 gas_temperature=293.15,
350 )
351 masses.append(
352 tank.pressurant_for_expulsion(
353 state, mdot_liquid=1.5, duration=5.0, inlet_temperature=293.15
354 )["mass"]
355 )
356 assert masses[0] < masses[1] < masses[2]
357
358
360 lox: Fluid, geometry: CylindricalTank
361) -> None:
362 """Sanity on the pressurant choice: helium's R is 7x nitrogen's."""
363 masses = {}
364 for name in ("helium", "nitrogen"):
365 tank = Tank(lox, Fluid(name), geometry, collapse=NoCollapse())
366 state = tank.initial_state(
367 pressure=500 * PSI,
368 liquid_mass=9.0,
369 liquid_temperature=90.0,
370 gas_temperature=293.15,
371 )
372 masses[name] = tank.pressurant_for_expulsion(
373 state, mdot_liquid=1.5, duration=5.0, inlet_temperature=293.15
374 )["mass"]
375 assert masses["helium"] < 0.2 * masses["nitrogen"]
376
377
378def test_liquid_in_the_ullage_refuses(geometry: CylindricalTank) -> None:
379 """A pressurant that condenses at tank conditions is not an ullage gas."""
380 tank = Tank(Fluid("oxygen"), Fluid("nitrogen"), geometry)
381 with pytest.raises(ValueError, match="is a liquid"):
382 tank.initial_state(
383 pressure=500 * PSI,
384 liquid_mass=9.0,
385 liquid_temperature=90.0,
386 gas_temperature=90.0,
387 )
388
389
391 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
392) -> None:
393 tank = Tank(lox, gn2, geometry)
394 state = tank.initial_state(
395 pressure=500 * PSI,
396 liquid_mass=9.0,
397 liquid_temperature=90.0,
398 gas_temperature=293.15,
399 )
400 # Age the clock directly rather than marching: this is a test of the
401 # sqrt(t) flux and its reset, not of the integrator.
402 aged = replace(state, contact_time=600.0)
403 assert tank.rates(aged).heat_to_liquid < tank.rates(state).heat_to_liquid
404
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
408
409 # Quantitatively: flux goes as 1/sqrt(t), so 100x the age is 10x less flux.
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
414 )
415
416
418 lox: Fluid, gn2: Fluid, geometry: CylindricalTank
419) -> None:
420 """A field added to the state later rides through a step untouched.
421
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.
425 """
426 from dataclasses import dataclass, fields
427
428 from feedtwin.vessels.tank import TankState
429
430 @dataclass(frozen=True, slots=True)
431 class Grown(TankState):
432 added_later: float = 0.0
433
434 tank = Tank(lox, gn2, geometry)
435 start = tank.initial_state(
436 pressure=500 * PSI,
437 liquid_mass=9.0,
438 liquid_temperature=90.0,
439 gas_temperature=293.15,
440 )
441 state = Grown(
442 **{f.name: getattr(start, f.name) for f in fields(start)}, added_later=7.0
443 )
444 stepped = tank.step(state, tank.rates(state, mdot_liquid_out=1.0), 1e-3)
445
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)
Definition test_tank.py:166
None test_cylinder_volume_matches_closed_form(CylindricalTank geometry)
Definition test_tank.py:61
None test_enhancement_increases_demand_monotonically(Fluid lox, Fluid gn2, CylindricalTank geometry)
Definition test_tank.py:341
None test_volume_endpoints_are_exact(CylindricalTank geometry)
Definition test_tank.py:92
None test_liquid_in_the_ullage_refuses(CylindricalTank geometry)
Definition test_tank.py:378
None test_cross_section_is_the_derivative_of_volume(CylindricalTank geometry)
Definition test_tank.py:105
None test_collapse_costs_pressurant_not_pressure(Fluid lox, Fluid gn2, CylindricalTank geometry)
Definition test_tank.py:306
None test_conduction_rate_integrates_to_the_closed_form(Fluid lox)
Definition test_tank.py:146
None test_initial_state_round_trips_pressure(Fluid lox, Fluid gn2, CylindricalTank geometry)
Definition test_tank.py:203
None test_flat_heads_give_a_cylinder()
Definition test_tank.py:83
Fluid lox()
Definition test_tank.py:49
None test_expulsion_matches_closed_form(Fluid lox, Fluid gn2, CylindricalTank geometry, float ullage_temperature)
Definition test_tank.py:249
Fluid gn2()
Definition test_tank.py:54
None test_tabulated_geometry_rejects_a_shrinking_tank()
Definition test_tank.py:128
None test_level_inverts_volume(CylindricalTank geometry)
Definition test_tank.py:99
None test_outlet_pressure_includes_the_liquid_column(Fluid lox, Fluid gn2, CylindricalTank geometry)
Definition test_tank.py:232
None test_tabulated_geometry_round_trips()
Definition test_tank.py:119
None test_collapse_flux_decays_with_contact_time(Fluid lox)
Definition test_tank.py:179
CylindricalTank geometry()
Definition test_tank.py:43
None test_geometry_registry()
Definition test_tank.py:133
None test_expulsion_deviation_is_the_compressibility(Fluid lox, Fluid gn2, CylindricalTank geometry)
Definition test_tank.py:282
None test_overfull_tank_refuses(Fluid lox, Fluid gn2, CylindricalTank geometry)
Definition test_tank.py:219
None test_repressurisation_resets_the_interface_clock(Fluid lox, Fluid gn2, CylindricalTank geometry)
Definition test_tank.py:392
None test_a_step_carries_what_it_does_not_integrate(Fluid lox, Fluid gn2, CylindricalTank geometry)
Definition test_tank.py:419
None test_helium_needs_less_mass_than_nitrogen(Fluid lox, CylindricalTank geometry)
Definition test_tank.py:361
None test_interface_shrinks_in_the_head(CylindricalTank geometry)
Definition test_tank.py:114
None test_no_collapse_is_a_named_model()
Definition test_tank.py:188
None test_hemispherical_heads_give_a_sphere()
Definition test_tank.py:67