STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_dewar_load.py
Go to the documentation of this file.
1"""A LOX load pushed in by a dewar (``Setup.dewar_psi``).
2
3The load is a flow through the fill line at the dewar's pressure less the
4tank's. Into a warm tank it boils on the wall and the vapour goes into the
5ullage -- the climb the stand shows during a chilldown -- and once the wall is
6at saturation it collects. Checked here against hand calculation and CoolProp,
7not against the session: the line against Crane's Cv relation and Clamond, the
8chill against ``h_fg`` per gram, and a zero dewar against the fixed-rate load
9it replaces.
10"""
11
12from __future__ import annotations
13
14import math
15from dataclasses import replace
16
17import CoolProp.CoolProp as CP
18import pytest
19from fluids.friction import Clamond
20
21from feedtwin.props import Fluid
22from feedtwin.session.core import FILL_LINE_ROUGHNESS, TankSim
23from feedtwin.session.gauge import from_psig
24from feedtwin.vessels import CylindricalTank, Tank
25
26WALL_MASS = 3.63 # kg, LE4's 8.19 L aluminium tank
27WALL_CP = 897.0 # J/(kg K)
28
29
30def _sim(
31 *, liquid_mass: float = 0.0, wall_K: float = 293.15, fuel: bool = False
32) -> TankSim:
33 liquid = Fluid("ethanol" if fuel else "oxygen")
34 tank = Tank(
35 liquid,
36 Fluid("nitrogen"),
37 CylindricalTank(diameter=0.152, barrel_length=0.40),
38 wall_mass=WALL_MASS,
39 wall_capacity=WALL_CP,
40 boiling_onset=2.0,
41 )
42 state = tank.initial_state(
43 pressure=from_psig(0.0),
44 liquid_mass=liquid_mass,
45 liquid_temperature=293.15 if fuel else 90.19,
46 gas_temperature=wall_K,
47 split_wall=True,
48 )
49 # An empty tank's wetted wall is the same metal as the rest of it.
50 state = replace(state, wetted_wall_temperature=wall_K)
51 return TankSim(
52 id="T",
53 label="T",
54 tank=tank,
55 state=state,
56 ullage_node="u",
57 outlet_node="o",
58 filling=True,
59 fill_seconds=120.0,
60 dewar_pressure=from_psig(100.0),
61 fill_line_bore=7.75e-3,
62 fill_line_length=3.0,
63 fill_cv=0.019,
64 )
65
66
68 """Liquid through the line: drop = (f L/D + K + 1) rho v^2 / 2, with K from
69 Crane's Cv relation K = 891 d^4 / Cv^2 (d in inches) and f Clamond at the
70 Reynolds number of the answer."""
71 sim = _sim()
72 drop = from_psig(100.0) - from_psig(0.0)
73 flow = sim.fill_line_flow(drop)
74 T = sim.state.liquid_temperature
75 rho = CP.PropsSI("D", "T", T, "Q", 0, "Oxygen")
76 mu = CP.PropsSI("V", "T", T, "Q", 0, "Oxygen")
77 D, L = 7.75e-3, 3.0
78 area = math.pi * D * D / 4
79 v = flow / (rho * area)
80 f = Clamond(rho * v * D / mu, FILL_LINE_ROUGHNESS / D)
81 K = 891.0 * (D / 25.4e-3) ** 4 / 0.019**2
82 assert drop == pytest.approx((f * L / D + K + 1.0) * rho * v * v / 2, rel=5e-3)
83
84
86 """The reason the dewar valve is calibrated rather than left open: a bare
87 3/8 in line from 100 psig pours ~0.7 kg/s."""
88 sim = _sim()
89 throttled = sim.fill_line_flow(from_psig(100.0) - from_psig(0.0))
90 sim.fill_cv = 0.0
91 clean = sim.fill_line_flow(from_psig(100.0) - from_psig(0.0))
92 assert 0.5 < clean < 1.0
93 assert clean > 30 * throttled
94
95
97 sim = _sim()
98 assert sim.fill_line_flow(0.0) == 0.0
99 assert sim.fill_line_flow(-1e5) == 0.0
100
101
103 """Chilling: nothing collects, the vapour goes into the ullage (where only
104 the vent can take it out), and the wall gives up h_fg per gram -- h_fg from
105 CoolProp here, not from the model."""
106 sim = _sim()
107 walls = (sim.state.ullage.wall_temperature, sim.state.wetted_wall_temperature)
108 flow = sim.fill_line_flow(sim.dewar_pressure - sim.pressure)
109 dt = 0.5
110 assert sim._dewar_load(dt)
111 assert sim.state.liquid_mass == 0.0
112 assert sim.state.vapour_mass == pytest.approx(flow * dt, rel=1e-12)
113 assert sim.chill_boiled == pytest.approx(flow * dt, rel=1e-12)
114 assert sim.fill_flow == pytest.approx(flow, rel=1e-12)
115 T = sim.state.liquid_temperature
116 h_fg = CP.PropsSI("H", "T", T, "Q", 1, "Oxygen") - CP.PropsSI(
117 "H", "T", T, "Q", 0, "Oxygen"
118 )
119 fell = [
120 w - c
121 for w, c in zip(
122 walls,
123 (sim.state.ullage.wall_temperature, sim.state.wetted_wall_temperature),
124 )
125 ]
126 assert fell[0] == pytest.approx(fell[1], rel=1e-12)
127 assert WALL_MASS * WALL_CP * fell[0] == pytest.approx(flow * dt * h_fg, rel=1e-3)
128
129
131 sim = _sim(wall_K=90.5)
132 flow = sim.fill_line_flow(sim.dewar_pressure - sim.pressure)
133 assert not sim._dewar_load(0.5)
134 assert sim.state.vapour_mass == 0.0
135 assert sim.state.liquid_mass == pytest.approx(flow * 0.5, rel=1e-12)
136
137
139 """A step that brings the wall down to saturation splits: what the wall's
140 remaining heat boils, and the rest in the liquid."""
141 sim = _sim()
142 target = CP.PropsSI("T", "P", sim.pressure, "Q", 0, "Oxygen") + 2.0
143 warm = target + 1.0
144 sim.state = replace(
145 sim.state,
146 ullage=replace(sim.state.ullage, wall_temperature=warm),
147 wetted_wall_temperature=warm,
148 )
149 sim.fill_line_bore = 30e-3 # a big pour, to finish the chill in one step
150 sim.fill_cv = 0.0
151 sim._dewar_load(0.5)
152 assert sim.state.ullage.wall_temperature == pytest.approx(target, abs=0.05)
153 T = sim.state.liquid_temperature
154 h_fg = CP.PropsSI("H", "T", T, "Q", 1, "Oxygen") - CP.PropsSI(
155 "H", "T", T, "Q", 0, "Oxygen"
156 )
157 assert sim.state.vapour_mass == pytest.approx(
158 WALL_MASS * WALL_CP * 1.0 / h_fg, rel=0.02
159 )
160 assert sim.state.liquid_mass > 1.0
161
162
164 sim = _sim(wall_K=90.5)
165 sim.state = replace(sim.state, liquid_mass=sim._wanted())
166 sim._dewar_load(0.5)
167 assert sim.fill_flow == 0.0
168 assert sim.state.liquid_mass == pytest.approx(sim._wanted())
169
170
171def _advance(sim: TankSim, dt: float) -> None:
172 sim.advance(
173 dt, mdot_liquid_out=0.0, mdot_gas_in=0.0, mdot_gas_out=0.0, enthalpy_gas_in=0.0
174 )
175
176
178 """Zero dewar pressure: the load before this existed, wanted / fill_seconds."""
179 sim = _sim(wall_K=90.5)
180 sim.dewar_pressure = 0.0
181 wanted = sim._wanted()
182 _advance(sim, 0.5)
183 assert sim.state.liquid_mass == pytest.approx(wanted * 0.5 / 120.0, rel=1e-9)
184 assert sim.state.vapour_mass == 0.0
185
186
188 """The dewar is a cryogen's. With it on, ethanol still pours at its rate."""
189 sim = _sim(fuel=True)
190 wanted = sim._wanted()
191 _advance(sim, 0.5)
192 assert sim.state.liquid_mass == pytest.approx(wanted * 0.5 / 120.0, rel=1e-9)
193 assert sim.chill_boiled == 0.0
194
195
196# ------------------------------------------------------------- on a stand
197
198
199def _le4_session(**setup: float): # type: ignore[no-untyped-def]
200 """LE4's LOX tank vented through its top disconnect, the cart's valve
201 beyond it (``test_pid_drawn_freely._le4_with_top_manifold``)."""
202 from pathlib import Path
203
204 from test_pid_drawn_freely import _le4_with_top_manifold, _machine
205
206 from feedtwin.pid import read_diagram
207 from feedtwin.session import Session, Setup, assemble_model, load_machine
208 from feedtwin.session.statemachine import bind
209
210 _machine() # skips when the tables are absent
211 tables = (
212 Path(__file__).resolve().parents[3] / "feed-twin" / "backend" / "statemachines"
213 )
214 payload = _le4_with_top_manifold()
215 for n in payload["nodes"]:
216 if n["id"] == "QD" and "vent_cv" in setup:
217 n["data"]["params"] = {
218 "Cv": {
219 "value": setup.pop("vent_cv"),
220 "unit": "Cv",
221 "source": "measured",
222 }
223 }
224 model = assemble_model(read_diagram(payload, name="le4 top"), diagram_id="t")
225 machine = load_machine(tables=tables)
226 labels = {
227 n.id: n.label for n in model.diagram.nodes if n.id in model.built.actuators
228 }
229 session = Session(
230 model,
231 machine,
232 bind(machine, labels, roles=model.built.valve_roles),
233 setup=replace(Setup(), **setup),
234 )
235 return session, model
236
237
238def _chill(seconds: float, **setup: float) -> tuple[float, float]:
239 from feedtwin.session.gauge import psig
240
241 session, model = _le4_session(**setup)
242 for state in ("Armed", "Ox Fill"):
243 session.command_state(state)
244 while session.t < seconds:
245 sample = session.step(0.5)
246 tank = session.tanks["OT"]
247 return (
248 psig(sample.pressures[model.built.tanks["OT"].ullage]),
249 tank.state.liquid_mass,
250 )
251
252
254 session, model = _le4_session(gse_vent_cv=0.37)
255 assert model.built.gse_vents == ("QD",)
256 session.command_state("Armed")
257 session.step(0.1)
258 assert model.built.network.branches["QD"].component.p["Cv"] == 0.37
259
260
262 """Turned on against a drawing that sizes the vent, the setup changes
263 nothing."""
264 session, model = _le4_session(vent_cv=1.1, gse_vent_cv=0.37)
265 assert model.built.gse_vents == ()
266 session.command_state("Armed")
267 session.step(0.1)
268 assert model.built.network.branches["QD"].component.p["Cv"] == pytest.approx(1.1)
269
270
272 """The stand: a LOX tank rises while it chills, as far as the vent makes
273 it, and the dewar's pressure sets the pour."""
274 low, low_liquid = _chill(30.0, dewar_psi=50.0)
275 high, high_liquid = _chill(30.0, dewar_psi=100.0)
276 assert low_liquid == 0.0 and high_liquid == 0.0, "still chilling: nothing collects"
277 assert 5.0 < low < high
278
279
280@pytest.mark.xfail(
281 strict=True,
282 reason="R035: the vent is IEC 60534 now; 0.019 peaks at 44.75 psig here and "
283 "~0.013 restores ~30. dewar_fill_cv is the team's calibration to redo.",
284)
286 """The default Cv (0.019, the team 2026-10-08) tops LE4 out near the stand's
287 ~30 psig. 0.013 did until the vent stopped taking back air the pressurant
288 floor re-created (2026-10-06); it then peaked near 21.
289
290 The calibration was made against the plain valve's old gas law, which
291 passed up to 1.5x IEC just short of the choke -- where a chilling tank's
292 vent runs. With the vent priced by IEC 60534-2-1 (R035) the same 0.019
293 peaks at 44.75 psig on this stand (0.014: 33.7; 0.012: 28.6; 0.010: 23.2).
294 LE4 (3) as uploaded does not move (16.4 -> 16.5 psia at 140 s of Ox Fill).
295 Recalibrating is the team's call; strict, so it turns red when they do."""
296 high, _ = _chill(30.0, dewar_psi=100.0)
297 assert high == pytest.approx(30.0, abs=5.0), "calibrated to the stand's ~30 psig"
298
299
301 p, _ = _chill(30.0, dewar_psi=0.0)
302 assert p < 5.0
303
304
306 """The vent of a tank chilling under its own boil-off carries that boil-off.
307
308 Split by mass share alone, the vent took the tank's air down to the floor
309 (an atmosphere's worth, which the pressurant keeps because it carries the
310 ullage's heat capacity) and the floor put it straight back: on LE4, ~3 g/s
311 of air that did not exist and 0.2 kg over a load, booked as a guard. The
312 vent here carries what the dewar boils into the ullage; the guards must
313 book next to nothing of it.
314 """
315 sim = _sim()
316 arrived = 0.0
317 dt = 0.05
318 for _ in range(600): # 30 s of chilldown, venting what boils
319 flow = sim.fill_line_flow(sim.dewar_pressure - sim.pressure)
320 sim.advance(
321 dt,
322 mdot_liquid_out=0.0,
323 mdot_gas_in=0.0,
324 mdot_gas_out=flow,
325 enthalpy_gas_in=0.0,
326 vent_fraction=1.0,
327 )
328 arrived += sim.fill_flow * dt
329 assert sim.chilling, "still chilling: the case being checked"
330 assert arrived > 0.1
331 assert abs(sim.fixed_kg) < 1e-3 * arrived, (sim.fixed_kg, arrived)
332
333
335 """Nobody rehearsing waits out the minutes a dewar load spends on the wall
336 (the team, 2026-10-08). Skipped, the wall is where the chill would have left
337 it and the next seconds of the pour collect; not skipped, they boil."""
338 session, _ = _le4_session()
339 for state in ("Armed", "Ox Fill"):
340 session.command_state(state)
341 for _ in range(4):
342 session.step(0.5)
343 lox = session.tanks["OT"]
344 assert lox.chilling and lox.state.liquid_mass == 0.0
345
346 assert session.skip_chilldown() == [lox.label]
347 # Where the chill ends once its boil-off has vented, not saturation at the
348 # pressure the boil-off is holding the tank at now.
349 from feedtwin.session.gauge import ATMOSPHERE
350
351 assert lox.pressure > ATMOSPHERE
352 target = lox._chill_target(ATMOSPHERE)
353 assert lox.state.ullage.wall_temperature == pytest.approx(target)
354 assert lox.chill_boiled > 0.0, "the propellant the chill would have flashed"
355 assert any("Chilldown skipped on" in a for a in session.assumptions)
356 for _ in range(4):
357 session.step(0.5)
358 assert not lox.chilling and lox.state.liquid_mass > 0.01
359 assert session.skip_chilldown() == [], "a cold wall has nothing to skip"
360
361 control, _ = _le4_session()
362 for state in ("Armed", "Ox Fill"):
363 control.command_state(state)
364 for _ in range(8):
365 control.step(0.5)
366 assert control.tanks["OT"].chilling
367 assert control.tanks["OT"].state.liquid_mass == 0.0
368
369
371 session, _ = _le4_session()
372 fuel = next(s for s in session.tanks.values() if s.id != "OT")
373 wall = fuel.state.ullage.wall_temperature
374 assert session.skip_chilldown(fuel.id) == []
375 assert fuel.state.ullage.wall_temperature == wall
376 with pytest.raises(KeyError):
377 session.skip_chilldown("no-such-tank")
@ Setup
Definition main.cpp:72
_le4_session(**float setup)
tuple[float, float] _chill(float seconds, **float setup)
None test_a_chilling_tank_vents_its_boil_off_not_air_the_floor_puts_back()
None test_the_fill_line_is_crane_and_clamond()
None test_skipping_chilldown_leaves_a_storable_and_an_unknown_tank_alone()
None test_nothing_flows_back_into_the_dewar()
None test_into_a_warm_tank_every_gram_boils_into_the_ullage()
None test_once_the_wall_is_cold_the_load_collects()
None test_a_full_tank_takes_no_more()
None test_a_skipped_chilldown_collects_at_once_where_a_chilling_one_does_not()
None test_a_fuel_load_ignores_the_dewar()
None test_a_chilling_tank_climbs_and_climbs_more_the_harder_the_dewar_pushes()
None test_no_dewar_is_the_fixed_rate_load_exactly()
None test_without_a_dewar_the_chill_vents_outside_the_ullage_as_before()
None test_a_disconnect_that_gives_its_own_cv_keeps_it()
None test_the_fill_cv_is_calibrated_to_the_stands_30_psig_chill()
TankSim _sim(*, float liquid_mass=0.0, float wall_K=293.15, bool fuel=False)
None test_the_last_of_the_wall_boils_and_the_rest_collects()
None _advance(TankSim sim, float dt)
None test_a_clean_line_pours_tens_of_times_what_the_valve_lets_through()
None test_the_carts_vent_valve_sizes_an_unsized_gse_vent()