STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_session_burn.py
Go to the documentation of this file.
1"""The marching session as library code, and a burn as one call.
2
3The session's physics is covered where it was written (feed-twin's
4``tests/test_session.py``, which now runs against this package through the
5app's re-exports) and by ``scripts/physics_benchmark.py``. What is new here is
6the surface a second caller uses: assembling without the app's artifact store,
7priming a fixed propellant load by mass, and a burn that records its probes.
8"""
9
10from __future__ import annotations
11
12import json
13from pathlib import Path
14
15import pytest
16
17from feedtwin.pid import read_diagram
18from feedtwin.session import assemble_model, load_machine, psig_from_psia, PSI
19from feedtwin.session.burn import (
20 BurnPlan,
21 burn_setup,
22 find_probes,
23 open_session,
24 run_burn,
25)
26from feedtwin.session.gauge import ATMOSPHERE, from_psig
27
28STAR = Path(__file__).resolve().parents[3]
29STAND = STAR / "feed-twin" / "backend" / "diagrams" / "ethalox_stand.json"
30TABLES = STAR / "feed-twin" / "backend" / "statemachines"
31ENGINE = STAR / "EngineDesign" / "configs" / "ethalox_doublet_7000N.yaml"
32CEA = STAR / "EngineDesign" / "output" / "cache" / "cea_cache_LOX_Ethanol_3D.npz"
33
34needs_stand = pytest.mark.skipif(
35 not (STAND.exists() and TABLES.is_dir()), reason="stand drawing or tables absent"
36)
37needs_engine = pytest.mark.skipif(
38 not (ENGINE.exists() and CEA.exists()), reason="engine config or CEA table absent"
39)
40
41
42def _model(engine: bool = False): # type: ignore[no-untyped-def]
43 diagram = read_diagram(json.loads(STAND.read_text()), name="ethalox_stand")
44 design = None
45 if engine:
46 import yaml
47
48 from feedtwin.engine.importer import engine_from_config
49
50 design = engine_from_config(yaml.safe_load(ENGINE.read_text()), name="7000N")
51 return assemble_model(
52 diagram,
53 diagram_id="ethalox_stand.json",
54 engine=design,
55 cea_cache=str(CEA) if engine else "",
56 )
57
58
60 """A caller that knows psia converts through the gauge zero without error."""
61 for psia in (14.6959, 94.07e3 / PSI, 578.0, 4514.7):
62 assert from_psig(psig_from_psia(psia)) == pytest.approx(psia * PSI, rel=1e-14)
63 assert from_psig(0.0) == ATMOSPHERE
64
65
67 setup = burn_setup(dome_psi=513.0)
68 assert setup.tick_budget == 1.0e9
69 assert setup.max_iterations == 120
70 assert setup.auto_vent is False
71 assert setup.stratification is False
72 assert setup.boiling_onset_K == 0.0 and setup.chilldown_nucleate == 0.0
73 assert setup.dome_psi == 513.0
74
75
76@needs_stand
78 model = _model()
79 assert model.engine is None and model.chamber is None
80 assert model.report.diagram == "ethalox_stand.json"
81 assert model.report.engine == ""
82 assert model.report.nodes > 0 and model.report.branches > 0
83 assert set(model.built.tanks) == {"OXT", "FUT"}
84
85
86@needs_stand
88 session = open_session(_model(), load_machine(tables=TABLES), setup=burn_setup())
89 session.prime(tank_psi=550.0, copv_psi=4500.0, loads={"OXT": 6.611})
90 assert session.tanks["OXT"].state.liquid_mass == pytest.approx(6.611, rel=1e-12)
91 # A tank the loads do not name keeps the fill fraction.
92 fuel = session.tanks["FUT"]
93 rho = fuel.tank.liquid.get("rho", T=fuel.state.liquid_temperature, q=0.0)
94 expected = fuel.tank.geometry.total_volume * 0.95 * rho
95 assert fuel.state.liquid_mass == pytest.approx(expected, rel=1e-12)
96
97
98@needs_stand
100 """Without ``loads`` every tank holds ``fill_fraction`` of its volume as liquid, by
101 hand from the volume and the density: the behaviour before ``loads`` existed."""
102 a = open_session(_model(), load_machine(tables=TABLES), setup=burn_setup())
103 a.prime(fill_fraction=0.8, tank_psi=500.0)
104 for sim in a.tanks.values():
105 rho = sim.tank.liquid.get("rho", T=sim.state.liquid_temperature, q=0.0)
106 expected = sim.tank.geometry.total_volume * 0.8 * rho
107 assert sim.state.liquid_mass == pytest.approx(expected, rel=1e-12), sim.id
108
109
110@needs_stand
112 session = open_session(_model(), load_machine(tables=TABLES), setup=burn_setup())
113 with pytest.raises(ValueError, match="L of liquid"):
114 session.prime(loads={"FUT": 50.0})
115 with pytest.raises(ValueError, match="no tank"):
116 session.prime(loads={"LOX-TANK": 1.0})
117
118
119@needs_stand
121 session = open_session(
122 _model(engine=ENGINE.exists() and CEA.exists()),
123 load_machine(tables=TABLES),
124 setup=burn_setup(),
125 )
126 probes = find_probes(session)
127 assert set(probes.tank_ullage) == {"OXT", "FUT"}
128 assert probes.tank_fluid["OXT"] and probes.tank_fluid["FUT"]
129 assert probes.regulator_outlet, "the stand's regulators were not found"
130 assert probes.bottles
131
132
133@needs_stand
134@needs_engine
136 """Fire for a third of a second and check the bookkeeping holds.
137
138 Conservation is the check worth having here: the propellant that left the
139 tanks has to be the propellant the chamber says it burned, integrated over
140 the same steps. It is independent of every correlation in the model.
141 """
142 session = open_session(
143 _model(engine=True), load_machine(tables=TABLES), setup=burn_setup()
144 )
145 loads = {"OXT": 6.0, "FUT": 4.0}
146 plan = BurnPlan(
147 tank_psi=550.0, loads=loads, settle=False, lead_in_s=0.1, horizon_s=0.3
148 )
149 trace = run_burn(session, plan)
150
151 assert trace.end is not None and not trace.end.cancelled
152 n = len(trace.t)
153 assert n == trace.end.steps
154 assert trace.firing.count(False) == 2
155 assert set(trace.probes.injector_inlet) == {"oxidiser", "fuel"}
156 for node in trace.nodes():
157 assert len(trace.pressure[node]) == n
158
159 firing = [i for i, f in enumerate(trace.firing) if f]
160 pc = trace.chamber["pressure_Pa"]
161 assert all(pc[i] > 2.0 * ATMOSPHERE for i in firing[1:])
162 assert all(trace.chamber["thrust_N"][i] > 0.0 for i in firing[1:])
163 # Injector inlet sits between the tank and the chamber.
164 for side, tank in (("oxidiser", "OXT"), ("fuel", "FUT")):
165 inlet = trace.pressure[trace.probes.injector_inlet[side]]
166 tank_p = trace.tank[tank]["pressure_Pa"]
167 for i in firing[1:]:
168 assert pc[i] < inlet[i] < tank_p[i] + 5.0 * PSI
169
170 dt = plan.dt
171 for tank, key in (("OXT", "mdot_oxidiser"), ("FUT", "mdot_fuel")):
172 left = loads[tank] - trace.tank[tank]["liquid_mass_kg"][-1]
173 burned = sum(trace.chamber[key][i] * dt for i in firing)
174 assert burned == pytest.approx(left, rel=0.03), tank
175
176
177@needs_stand
179 """A session writes its run into the model's network; a second session on the same model
180 would start from the first one's end state. open_session refuses it."""
181 model = _model()
182 open_session(model, load_machine(tables=TABLES), setup=burn_setup())
183 with pytest.raises(ValueError, match="fresh model"):
184 open_session(model, load_machine(tables=TABLES), setup=burn_setup())
185 # A fresh one is fine.
186 open_session(_model(), load_machine(tables=TABLES), setup=burn_setup())
187
188
189def _impulse(trace, plan) -> float: # type: ignore[no-untyped-def]
190 """Thrust times each step's own length, over the firing steps."""
191 total, prev = 0.0, None
192 for t, firing, f in zip(trace.t, trace.firing, trace.chamber["thrust_N"]):
193 if firing:
194 total += f * (t - (prev if prev is not None else t - plan.dt))
195 prev = t
196 return total
197
198
199@needs_stand
200@needs_engine
202 """With ``end_on_depletion`` the last step lands the first tank on ``dry_kg``, so
203 the impulse grows smoothly with the load. On whole steps it moves in jumps of a
204 step's worth as the depletion crosses a step boundary; that is the default, and
205 the study keeps it."""
206
207 def fire(ox_kg: float, end: bool): # type: ignore[no-untyped-def]
208 session = open_session(
209 _model(engine=True), load_machine(tables=TABLES), setup=burn_setup()
210 )
211 plan = BurnPlan(
212 tank_psi=550.0,
213 loads={"OXT": ox_kg, "FUT": 4.0},
214 settle=False,
215 lead_in_s=0.1,
216 horizon_s=2.0,
217 end_on_depletion=end,
218 tanks=("OXT",),
219 )
220 trace = run_burn(session, plan)
221 assert trace.end is not None and trace.end.tank == "OXT"
222 return trace, plan
223
224 # 15 g apart over 105 g: one 50 ms step burns ~90 g of LOX, so a boundary is crossed.
225 loads = [0.40 + 0.015 * k for k in range(8)]
226 smooth, stepped = [], []
227 for ox in loads:
228 trace, plan = fire(ox, True)
229 assert trace.tank["OXT"]["liquid_mass_kg"][-1] == pytest.approx(
230 plan.dry_kg, abs=0.003
231 )
232 smooth.append(_impulse(trace, plan))
233 trace, plan = fire(ox, False)
234 stepped.append(_impulse(trace, plan))
235 d_smooth = [b - a for a, b in zip(smooth, smooth[1:])]
236 d_stepped = [b - a for a, b in zip(stepped, stepped[1:])]
237 # 15 g of LOX more each time: the same few tens of N.s every time.
238 assert all(d > 0.0 for d in d_smooth), d_smooth
239 assert max(d_smooth) < 2.0 * min(d_smooth), d_smooth
240 # On whole steps the same loads jump: at least one increment is a step's worth.
241 assert max(d_stepped) > 3.0 * max(d_smooth), (d_stepped, d_smooth)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
None test_probes_are_found_on_the_drawing()
None test_gauge_from_psia_round_trips_exactly()
None test_a_model_assembles_without_an_artifact_store()
_model(bool engine=False)
None test_prime_without_loads_fills_by_volume()
None test_burn_setup_is_the_study_numerics()
None test_ending_on_depletion_is_continuous_in_the_load()
None test_prime_by_mass_loads_exactly_that_mass()
float _impulse(trace, plan)
None test_a_short_burn_records_and_conserves_propellant()
None test_a_model_runs_under_one_session_only()
None test_prime_refuses_a_load_that_does_not_fit_or_has_no_tank()