STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_feeddiag.py
Go to the documentation of this file.
1"""Feed diagnostics (engine/layerx/diag: ladder, regulator, solenoids, pressurant, saturation,
2cavitation, injector, thrust shape, ledger) against hand calculations, CoolProp and ``fluids``.
3
4Most tests run on a synthetic result with a contract-shaped ``network`` (DATA-CONTRACT 2), whose
5numbers are chosen so each answer can be worked by hand in the test. One test burns the LE4 helium
6drawing and is skipped unless ``LAYERX_GOLDEN=1`` and lib/feedtwin records the network.
7"""
8
9from __future__ import annotations
10
11import copy
12import math
13import os
14from types import SimpleNamespace
15
16import pytest
17
18from engine.layerx.diag import ladder as L
19
20PSI = 6894.757293168361
21
22
23# ---- a small synthetic stand ----------------------------------------------------------------------
24#
25# bottle KB1 -l_kb-> MF1 -l_reg-> PR_D.in =PR_D=> PR_D.out -l_reg_out-> MF2
26# MF2 -l_oxpress_in-> SV_LOX_PRESS.in =SV_LOX_PRESS=> .out -l_oxpress-> OXT (ullage)
27# OXT.out -l_ox1-> MVO.in =MVO=> MVO.out -l_ox2-> ENG.oxidiser =injector=> ENG.chamber
28# and the same for fuel. Four steps: two before Fire (mains shut), two firing.
29
30N = 4
31T = [-0.1, 0.0, 0.05, 0.10]
32FIRING = [False, False, True, True]
33
34
35def _lin(a, b):
36 return [a + (b - a) * k / (N - 1) for k in range(N)]
37
38
40 nodes = {}
41 branches = {}
42
43 def node(nid, label, kind, side, phase, p, T_K):
44 nodes[nid] = {"label": label, "kind": kind, "side": side, "phase": phase, "p_psia": p, "T_K": T_K}
45
46 def branch(bid, label, kind, frm, to, side, mdot, cv=None):
47 branches[bid] = {"label": label, "kind": kind, "from": frm, "to": to, "side": side,
48 "mdot": mdot, "dp_psi": [a - b for a, b in zip(nodes[frm]["p_psia"], nodes[to]["p_psia"])],
49 "cv": cv, "state": None}
50
51 bottle = _lin(4000.0, 3000.0)
52 node("KB1", "COPV", "bottle", "gas", "gas", bottle, [293.15] * N)
53 node("MF1", "MAN-GN2", "manifold", "gas", "gas", [p - 1.0 for p in bottle], [293.0] * N)
54 node("PR_D.in", "PR-DOME.in", "junction", "gas", "gas", [p - 5.0 for p in bottle], [292.9] * N)
55 reg_out = [600.0, 600.0, 598.0, 601.0]
56 node("PR_D.out", "PR-DOME.out", "junction", "gas", "gas", reg_out, [300.0] * N)
57 node("MF2", "MAN-REG", "manifold", "gas", "gas", [p - 1.0 for p in reg_out], [300.0] * N)
58 gas = [0.0, 0.0, 0.02, 0.03]
59 branch("l_kb", "l_kb", "line", "KB1", "MF1", "gas", gas)
60 branch("l_reg", "l_reg", "line", "MF1", "PR_D.in", "gas", gas)
61 branch("PR_D", "PR-DOME", "regulator", "PR_D.in", "PR_D.out", "gas", gas, cv=0.8)
62 branch("l_reg_out", "l_reg_out", "line", "PR_D.out", "MF2", "gas", gas)
63 for side, tank, pre, mv, eng, T_liq, head in (("ox", "OXT", "SV_LOX_PRESS", "MVO", "ENG.oxidiser", 90.0, 0.5),
64 ("fuel", "FUT", "SV_FUEL_PRESS", "MVF", "ENG.fuel", 293.15, 0.3)):
65 mf2 = nodes["MF2"]["p_psia"]
66 sv_in = [p - 0.3 for p in mf2]
67 sv_drop = [0.0, 0.0, 1.5, 2.0] if side == "ox" else [0.0, 0.0, 1.2, 1.7]
68 sv_out = [a - b for a, b in zip(sv_in, sv_drop)]
69 ull = [p - 0.3 for p in sv_out]
70 node(f"{pre}.in", f"{pre}.in", "junction", side, "gas", sv_in, [300.0] * N)
71 node(f"{pre}.out", f"{pre}.out", "junction", side, "gas", sv_out, [300.0] * N)
72 node(tank, f"TK-{side}", "tank", side, "gas", ull, [293.0] * N)
73 node(f"{tank}.out", f"TK-{side}.out", "tank_outlet", side, "liquid", [p + head for p in ull], [T_liq] * N)
74 mdot = [0.0, 0.0, 1.8, 1.9] if side == "ox" else [0.0, 0.0, 1.2, 1.25]
75 line1 = [0.0, 0.0, 15.0, 16.0] if side == "ox" else [0.0, 0.0, 31.0, 33.0]
76 mv_in = [a - b for a, b in zip(nodes[f"{tank}.out"]["p_psia"], line1)]
77 mv_out = [p - (564.0 if not f else 1.1) for p, f in zip(mv_in, FIRING)]
78 node(f"{mv}.in", f"{mv}.in", "junction", side, "liquid", mv_in, [T_liq] * N)
79 node(f"{mv}.out", f"{mv}.out", "junction", side, "liquid", mv_out, [T_liq] * N)
80 exit_p = [p - (0.0 if not f else 2.2) for p, f in zip(mv_out, FIRING)]
81 node(eng, eng, "junction", side, "liquid", exit_p, [T_liq] * N)
82 pv = 0.0 if side == "ox" else 0.0
83 gas_side = [g * (0.55 if side == "ox" else 0.45) for g in gas]
84 branch(f"l_{side}press_in", f"l_{side}press_in", "line", "MF2", f"{pre}.in", side, gas_side)
85 branch(pre, pre, "solenoid", f"{pre}.in", f"{pre}.out", side, gas_side, cv=1.7)
86 branch(f"l_{side}press", f"l_{side}press", "line", f"{pre}.out", tank, side, gas_side)
87 branch(f"l_{side}1", f"l_{side}1", "line", f"{tank}.out", f"{mv}.in", side, mdot)
88 branch(mv, mv, "valve", f"{mv}.in", f"{mv}.out", side, mdot, cv=26.1)
89 branch(f"l_{side}2", f"l_{side}2", "line", f"{mv}.out", eng, side, mdot)
90 del pv
91 node("ENG.chamber", "ENG.chamber", "chamber", "ox", "gas", [14.7, 14.7, 400.0, 401.0], [85.0] * N)
92 branch("ENG.oxidiser.injector", "ENG.oxidiser.injector", "injector", "ENG.oxidiser", "ENG.chamber", "ox",
93 branches["l_ox2"]["mdot"])
94 branch("ENG.fuel.injector", "ENG.fuel.injector", "injector", "ENG.fuel", "ENG.chamber", "fuel",
95 branches["l_fuel2"]["mdot"])
96 paths = {side: ["l_kb", "l_reg", "PR_D", "l_reg_out", f"l_{side}press_in", pre, f"l_{side}press",
97 f"l_{side}1", mv, f"l_{side}2", f"{eng}.injector"]
98 for side, pre, mv, eng in (("ox", "SV_LOX_PRESS", "MVO", "ENG.oxidiser"),
99 ("fuel", "SV_FUEL_PRESS", "MVF", "ENG.fuel"))}
100 network = {"t": list(T), "nodes": nodes, "branches": branches, "paths": paths}
101
102 def side_series(side, eng, tank):
103 inlet = nodes[eng]["p_psia"]
104 pc = nodes["ENG.chamber"]["p_psia"]
105 dump = [0.0, 0.0, 25.0, 26.0] if side == "ox" else [0.0, 0.0, 15.5, 16.0]
106 manifold = [a - b for a, b in zip(inlet, dump)]
107 dpi = [max(m - c, 0.0) if f else 0.0 for m, c, f in zip(manifold, pc, FIRING)]
108 # before Fire the injector branch carries the whole inlet-to-chamber difference
109 dump = [d if f else 0.0 for d, f in zip(dump, FIRING)]
110 dpi = [x if f else a - c for x, a, c, f in zip(dpi, inlet, pc, FIRING)]
111 return {"tank_psia": nodes[tank]["p_psia"], "outlet_psia": nodes[f"{tank}.out"]["p_psia"],
112 "inlet_psia": inlet, "dump_psi": dump, "manifold_psia": manifold,
113 "dp_injector_psi": dpi, "mdot": branches[f"l_{side}1"]["mdot"],
114 "liquid_K": [90.0 if side == "ox" else 293.15] * N}
115
116 series = {"t": list(T), "firing": list(FIRING),
117 "ox": side_series("ox", "ENG.oxidiser", "OXT"),
118 "fuel": side_series("fuel", "ENG.fuel", "FUT"),
119 "chamber": {"pc_psia": nodes["ENG.chamber"]["p_psia"]}}
120 return {"series": series, "network": network}
121
122
123# ---- 1. ladder ------------------------------------------------------------------------------------
124
126 res = _synthetic()
127 lad = L.build_ladder(res)
128 assert "error" not in lad, lad
129 for side, head in (("ox", 0.5), ("fuel", 0.3)):
130 s = lad[side]
131 kinds = [e["kind"] for e in s["elements"]]
132 # bottle -> regulator -> solenoid -> tank head -> lines/valve -> dump -> orifices -> chamber
133 assert kinds == ["line", "line", "regulator", "line", "line", "solenoid", "line", "tank_head",
134 "line", "valve", "line", "dump", "orifice"]
135 assert s["start"] == "KB1" and s["end"] == "ENG.chamber"
136 # end to end, by hand: bottle minus chamber
137 hand = [b - c for b, c in zip(res["network"]["nodes"]["KB1"]["p_psia"],
138 res["network"]["nodes"]["ENG.chamber"]["p_psia"])]
139 assert s["total_psi"] == pytest.approx(hand, abs=1e-9)
140 assert s["sum_psi"] == pytest.approx(hand, abs=1e-9)
141 assert s["closes"] and s["max_abs_residual_psi"] < 1e-9
142 tank_head = s["elements"][7]
143 assert tank_head["dp_psi"] == pytest.approx([-head] * N, abs=1e-12)
144 # shares add to one at every step
145 for k in range(N):
146 assert sum(e["share"][k] for e in s["elements"]) == pytest.approx(1.0, abs=1e-12)
147 # the injector split: dump + orifices = inlet - Pc, firing
148 ox = {e["id"]: e for e in lad["ox"]["elements"]}
149 assert ox["ENG.oxidiser.injector:dump"]["dp_psi"][2] == pytest.approx(25.0)
150 inlet = res["series"]["ox"]["inlet_psia"][2]
151 assert ox["ENG.oxidiser.injector:orifice"]["dp_psi"][2] == pytest.approx(inlet - 25.0 - 400.0)
152 assert ox["ENG.oxidiser.injector:orifice"]["check_psi"] == pytest.approx(0.0, abs=1e-9)
153 # the solenoid's own drop is what the drawing's branch recorded
154 assert ox["SV_LOX_PRESS"]["dp_psi"] == pytest.approx([0.0, 0.0, 1.5, 2.0])
155
156
158 res = _synthetic()
159 # a recorder writing the solenoid's loss without, say, a static term: off by 0.7 psi
160 res["network"]["branches"]["SV_LOX_PRESS"]["dp_psi"] = [x + 0.7 for x in
161 res["network"]["branches"]["SV_LOX_PRESS"]["dp_psi"]]
162 lad = L.build_ladder(res)
163 assert not lad["ox"]["closes"]
164 assert lad["ox"]["max_abs_residual_psi"] == pytest.approx(0.7)
165 assert lad["fuel"]["closes"]
166
167
169 res = _synthetic()
170 del res["network"]
171 lad = L.build_ladder(res)
172 assert lad["available"] is False and "network" in lad["error"]
173
174
175# ---- 2. regulator and solenoids -------------------------------------------------------------------
176
177from engine.layerx.diag import regulator as R # noqa: E402
178
179
180def _he(p_psia, T_K):
181 import CoolProp.CoolProp as CP
182 p = p_psia * PSI
183 rho = CP.PropsSI("D", "P", p, "T", T_K, "Helium")
184 gamma = CP.PropsSI("CPMASS", "P", p, "T", T_K, "Helium") / CP.PropsSI("CVMASS", "P", p, "T", T_K, "Helium")
185 Z = CP.PropsSI("Z", "P", p, "T", T_K, "Helium")
186 mu = CP.PropsSI("V", "P", p, "T", T_K, "Helium")
187 return p, rho, gamma, Z, mu
188
189
190@pytest.mark.parametrize("p2_psia, choked", [(400.0, True), (2500.0, False)])
192 from fluids.control_valve import size_control_valve_g
193 p1, rho, gamma, Z, mu = _he(3000.0, 293.15)
194 p2 = p2_psia * PSI
195 got = R.iec_gas_flow(p1, p2, rho, gamma, 0.8, 0.7)
196 # by hand, IEC 60534-2-1: Kv = Cv / 1.156, N6 = 3.16 (kg/h, kPa)
197 f_gamma = gamma / 1.40
198 x = (p1 - p2) / p1
199 x_eff = min(x, f_gamma * 0.7)
200 Y = 1 - x_eff / (3 * f_gamma * 0.7)
201 hand = 3.16 * (0.8 / 1.156099) * Y * math.sqrt(x_eff * p1 / 1e3 * rho) / 3600.0
202 assert got["choked"] is choked
203 assert got["mdot"] == pytest.approx(hand, rel=2e-5)
204 # the library, sizing the other way: the valve that passes this flow is Cv 0.8. fluids takes
205 # the flow at 0 degC and 1 atm and the Z form of the same equation; IEC's N9 is rounded.
206 q_std = got["mdot"] / (101325.0 * 4.002602e-3 / (8.314462618 * 273.15))
207 kv = size_control_valve_g(T=293.15, MW=4.002602, mu=mu, gamma=gamma, Z=Z, P1=p1, P2=p2, Q=q_std,
208 allow_laminar=False)
209 assert kv * 1.156099 == pytest.approx(0.8, rel=3e-3)
210
211
213 # AUDIT 9.6 D: He, LOX press solenoid at 0.5 s: 0.01121 kg/s, 581.6 psia, 298 K, Cv 1.7.
214 # Twin (incompressible Cv law) 1.720 psi, IEC with Y 1.724 psi.
215 p1, rho, gamma, Z, mu = _he(581.6, 298.0)
216 dp = R.iec_gas_drop(0.01121, p1, rho, gamma, 1.7, 0.7)
217 assert dp / PSI == pytest.approx(1.724, abs=0.004)
218 # it inverts the flow function
219 assert R.iec_gas_flow(p1, p1 - dp, rho, gamma, 1.7, 0.7)["mdot"] == pytest.approx(0.01121, rel=1e-6)
220 # incompressible Cv law (Y = 1) by hand: dp = rho (W / (N6' Kv rho))^2 -> smaller than with Y < 1
221 kv = 1.7 / 1.156099
222 dp_inc = ((0.01121 * 3600.0) / (3.16 * kv)) ** 2 / rho * 1e3
223 assert dp_inc / PSI == pytest.approx(1.720, abs=0.004)
224 assert dp > dp_inc
225 # twice the Cv, a quarter of the drop (to the Y correction)
226 dp2 = R.iec_gas_drop(0.01121, p1, rho, gamma, 3.4, 0.7)
227 assert dp2 / dp == pytest.approx(0.25, rel=0.01)
228 # more than the choked capacity has no drop that passes it
229 assert math.isnan(R.iec_gas_drop(10.0, p1, rho, gamma, 1.7, 0.7))
230
231
232REG_PARAMS = {
233 "Cv": (0.8, "manufacturer: TB 1031"),
234 "bore": (0.005842, "manufacturer: TB 1031"),
235 "supply_coefficient": (0.017, "manufacturer: TB 1031"),
236 "flow_droop": (8.3 * PSI, "measured"),
237 "rated_flow": (0.09646, "measured"),
238 "dome_bias": (50.0 * PSI, "manufacturer"),
239 "dial": (528.25 * PSI, "Layer X dial"),
240}
241
242
243#: Gauge zero [psia]: the supply effect is measured from zero inlet.
244ATM_PSIA = 101325.0 / PSI
245
246
247def _reg_target(p_in, mdot):
248 return 528.25 + 50.0 + 0.017 * (ATM_PSIA - p_in) - 8.3 * mdot / 0.09646
249
250
252 res = _synthetic()
253 net = res["network"]
254 p_in = net["nodes"]["PR_D.in"]["p_psia"]
255 mdot = net["branches"]["PR_D"]["mdot"]
256 out_p = [_reg_target(p_in[k], mdot[k]) for k in range(N)]
257 out_p[3] -= 11.0 # starved: 11 psi under its law while flowing
258 net["nodes"]["PR_D.out"]["p_psia"] = out_p
259 reg = R.build_regulator(res, None, params=REG_PARAMS, gas="helium")
260 assert "error" not in reg, reg
261 # supply effect and droop at step 2, by hand
262 assert reg["spe_psi"][2] == pytest.approx(0.017 * (ATM_PSIA - p_in[2]))
263 assert reg["droop_psi"][2] == pytest.approx(8.3 * 0.02 / 0.09646)
264 assert reg["target_psia"][2] == pytest.approx(out_p[2])
265 assert reg["residual_psi"][:3] == pytest.approx([0.0, 0.0, 0.0], abs=1e-9)
266 assert reg["residual_psi"][3] == pytest.approx(-11.0)
267 assert reg["wide_open"] == [False, False, False, True]
268 assert reg["dome_psia"] == pytest.approx([528.25] * N)
269 # capacity: IEC at the inlet node's own state, and the use fraction is flow over it
270 p1, rho, gamma, _, _ = _he(p_in[2], 292.9)
271 cap = R.iec_gas_flow(p1, out_p[2] * PSI, rho, gamma, 0.8, 0.7)["mdot"]
272 assert reg["capacity_mdot"][2] == pytest.approx(cap, rel=1e-9)
273 assert reg["use_frac"][2] == pytest.approx(0.02 / cap, rel=1e-9)
274 assert reg["choked"][2] is True
275 # the outlet's rise over the firing steps is the supply effect minus the droop's growth
276 rise = reg["rise"]
277 assert rise["outlet_rise_psi"] == pytest.approx(rise["spe_psi"] + rise["droop_psi"] + rise["residual_psi"])
278 assert rise["residual_psi"] == pytest.approx(-11.0)
279 # the model block names its inputs with provenance
280 assert reg["model"]["inputs"]["supply_coefficient"]["provenance"].startswith("manufacturer")
281 assert reg["model"]["inputs"]["xT"]["provenance"].startswith("assumed")
282
283
285 res = _synthetic()
286 sol = R.build_solenoids(res, None, gas="helium")
287 assert isinstance(sol, list) and len(sol) == 2, sol
288 ox = next(s for s in sol if s["side"] == "ox")
289 assert ox["id"] == "SV_LOX_PRESS" and ox["cv"] == 1.7
290 # regulator outlet 598 -> MF2 597 -> SV.in 596.7 -> SV.out 595.2 -> tank 594.9: 3.1 psi, 1.5 of it the valve
291 assert ox["reg_to_tank_psi"][2] == pytest.approx(3.1)
292 assert ox["share_of_reg_to_tank"][2] == pytest.approx(1.5 / 3.1)
293 assert ox["dp_iec_psi"][0] == 0.0
294 p1, rho, gamma, _, _ = _he(596.7, 300.0)
295 assert ox["dp_iec_psi"][2] == pytest.approx(R.iec_gas_drop(0.011, p1, rho, gamma, 1.7) / PSI, rel=1e-9)
296
297
298# ---- 3. pressurant --------------------------------------------------------------------------------
299
300from engine.layerx.diag import pressurant as P # noqa: E402
301
302
303def _jt_by_integration(fluid, p1, T1, p2, steps=400):
304 """dT along the isenthalp by integrating CoolProp's own Joule-Thomson coefficient (dT/dp)_h,
305 RK4 in pressure: a different path through the equation of state than the (p, h) flash."""
306 import CoolProp.CoolProp as CP
307
308 def mu(p, T):
309 return CP.PropsSI("d(T)/d(P)|Hmass", "P", p, "T", T, fluid)
310
311 T, p = T1, p1
312 h = (p2 - p1) / steps
313 for _ in range(steps):
314 k1 = mu(p, T)
315 k2 = mu(p + h / 2, T + h / 2 * k1)
316 k3 = mu(p + h / 2, T + h / 2 * k2)
317 k4 = mu(p + h, T + h * k3)
318 T += h / 6 * (k1 + 2 * k2 + 2 * k3 + k4)
319 p += h
320 return T - T1
321
322
323@pytest.mark.parametrize("fluid, sign", [("helium", +1), ("nitrogen", -1)])
325 p1, p2 = 3000.0 * PSI, 600.0 * PSI
326 dT = P.jt_temperature_change(fluid, p1, 293.15, p2)
327 # helium sits above its inversion temperature at room temperature and warms; nitrogen cools
328 assert sign * dT > 1.0
329 name = "Helium" if fluid == "helium" else "Nitrogen"
330 assert dT == pytest.approx(_jt_by_integration(name, p1, 293.15, p2), abs=2e-3)
331 # a small throttle is mu_JT * dp
332 import CoolProp.CoolProp as CP
333 mu = CP.PropsSI("d(T)/d(P)|Hmass", "P", p1, "T", 293.15, name)
334 small = P.jt_temperature_change(fluid, p1, 293.15, p1 - 1e4)
335 assert small == pytest.approx(-mu * 1e4, rel=1e-3)
336
337
339 import CoolProp.CoolProp as CP
340 res = _synthetic()
341 V = 4.6871e-3
342 T_true = [293.15, 293.15, 288.0, 283.0]
343 p_b = res["network"]["nodes"]["KB1"]["p_psia"]
344 mass = [CP.PropsSI("D", "P", p * PSI, "T", T, "Helium") * V for p, T in zip(p_b, T_true)]
345 res["series"]["copv_mass_kg"] = mass
346 res["series"]["copv_psia"] = p_b
347 out_end = res["network"]["nodes"]["PR_D.out"]["p_psia"][3]
348 pr = P.build_pressurant(res, None, gas="helium", volume_m3=V, regulator={"id": "PR_D", "cv": 0.8})
349 assert "error" not in pr, pr
350 assert pr["bottle_T_K"] == pytest.approx(T_true, abs=1e-6)
351 assert pr["loaded_kg"] == pytest.approx(mass[0])
352 assert pr["residual_kg"] == pytest.approx(mass[3])
353 assert pr["used_kg"] == pytest.approx(mass[0] - mass[3])
354 # The floor state satisfies its four defining equations (checked here at the returned point):
355 assert pr["floor_converged"]
356 p_b_end, T_b_end = p_b[3] * PSI, T_true[3]
357 f_in, T_in = pr["floor_regulator_inlet_psia"] * PSI, pr["floor_regulator_inlet_T_K"]
358 f_b, T_b = pr["floor_psia"] * PSI, pr["floor_bottle_T_K"]
359 # (1) the regulator, wide open at its inlet state, passes exactly the burnout flow (IEC)
360 rho, gamma = R.gas_state("helium", f_in, T_in)
361 assert R.iec_gas_flow(f_in, out_end * PSI, rho, gamma, 0.8, 0.7)["mdot"] == pytest.approx(0.03, rel=1e-6)
362 # (2) the bottle sits on its burnout isentrope (no heat after burnout: ds = 0)
363 s_end = CP.PropsSI("SMASS", "P", p_b_end, "T", T_b_end, "Helium")
364 assert CP.PropsSI("SMASS", "P", f_b, "T", T_b, "Helium") == pytest.approx(s_end, rel=1e-6)
365 # ... which for helium, near-ideal, is close to T (p/p0)^((gamma-1)/gamma) with gamma = 5/3
366 assert T_b == pytest.approx(T_b_end * (f_b / p_b_end) ** 0.4, rel=0.02)
367 assert T_b < T_b_end - 30.0 # 3000 -> ~600 psia: much colder
368 # (3) the regulator inlet gas is the bottle gas throttled through the line (h conserved)
369 h_b = CP.PropsSI("HMASS", "P", f_b, "T", T_b, "Helium")
370 assert CP.PropsSI("HMASS", "P", f_in, "T", T_in, "Helium") == pytest.approx(h_b, rel=1e-6)
371 # (4) the line's 5 psi burnout drop, carried to the floor at the same flow as K mdot^2/(2 rho A^2)
372 assert pr["line_drop_end_psi"] == pytest.approx(5.0)
373 rho_in_end = CP.PropsSI("D", "P", res["network"]["nodes"]["PR_D.in"]["p_psia"][3] * PSI, "T", 292.9, "Helium")
374 drop = 5.0 * rho_in_end / CP.PropsSI("D", "P", f_in, "T", T_in, "Helium")
375 assert pr["floor_line_drop_psi"] == pytest.approx(drop, rel=1e-4)
376 assert pr["floor_psia"] == pytest.approx(pr["floor_regulator_inlet_psia"] + pr["floor_line_drop_psi"], abs=1e-3)
377 assert drop > 10.0 # a fifth of the pressure, colder: under half the density
378 unusable = CP.PropsSI("D", "P", f_b, "T", T_b, "Helium") * V
379 assert pr["unusable_kg"] == pytest.approx(unusable, rel=1e-9)
380 assert pr["required_kg"] == pytest.approx(mass[0] - mass[3] + unusable)
381 assert pr["margin_kg"] == pytest.approx(mass[3] - unusable)
382 # the other bound (bottle held at its burnout temperature, line at its burnout drop) is smaller
383 f_held = P.regulator_inlet_floor("helium", 0.03, out_end * PSI, 292.9, 0.8, 0.7) + 5.0 * PSI
384 held = CP.PropsSI("D", "P", f_held, "T", T_b_end, "Helium") * V
385 assert pr["unusable_held_T_kg"] == pytest.approx(held, rel=1e-9)
386 assert pr["unusable_held_T_kg"] < pr["unusable_kg"]
387 # the burn's own check: the isentrope from the loaded state to the burnout pressure
388 s0 = CP.PropsSI("SMASS", "P", p_b[0] * PSI, "T", T_true[0], "Helium")
389 assert pr["bottle_T_end_isentropic_K"] == pytest.approx(CP.PropsSI("T", "P", p_b_end, "SMASS", s0, "Helium"))
390 # JT on the regulator's inlet node state and outlet node pressure
391 p_in = res["network"]["nodes"]["PR_D.in"]["p_psia"][2] * PSI
392 p_out = res["network"]["nodes"]["PR_D.out"]["p_psia"][2] * PSI
393 assert pr["jt_dT_K"][2] == pytest.approx(P.jt_temperature_change("helium", p_in, 292.9, p_out))
394 assert pr["jt_dT_K"][2] > 0
395
396
397# ---- 4. saturation and cavitation -----------------------------------------------------------------
398
399from engine.layerx.diag import saturation as S # noqa: E402
400
401
402def _fake_prep(res):
403 """What the diagnostics read off a prepared model: branch bores, node fluids, species."""
404 bores = {"l_ox1": 0.01092, "MVO": 0.0127, "l_ox2": 0.01092, "l_fuel1": 0.01092, "MVF": 0.0127,
405 "l_fuel2": 0.01092, "ENG.oxidiser.injector": 0.008, "ENG.fuel.injector": 0.0072}
406 branches = {bid: SimpleNamespace(component=SimpleNamespace(p={"bore": bores[bid]} if bid in bores else {}))
407 for bid in res["network"]["branches"]}
408 fluid = {"ox": "oxygen", "fuel": "ethanol", "gas": "helium"}
409 nodes = {nid: SimpleNamespace(fluid=("oxygen" if nd["phase"] == "liquid" and nd["side"] == "ox" else
410 "ethanol" if nd["phase"] == "liquid" else fluid.get(nd["side"], "helium")))
411 for nid, nd in res["network"]["nodes"].items()}
412 built = SimpleNamespace(network=SimpleNamespace(branches=branches, nodes=nodes), dome_loaders={})
413 return SimpleNamespace(model=SimpleNamespace(built=built), setup=None,
414 derived={"species": {"oxidiser": "oxygen", "fuel": "ethanol"}, "pressurant_gas": "helium",
415 "inlet_nodes": {"oxidiser": "ENG.oxidiser", "fuel": "ENG.fuel"}})
416
417
419 geo = SimpleNamespace(
420 oxidizer=SimpleNamespace(n_elements=24, d_jet=0.0016318401623160582, impingement_angle=35.0,
421 spacing=0.008312130562622994),
422 fuel=SimpleNamespace(n_elements=24, d_jet=0.0014715307322406754, impingement_angle=48.0,
423 spacing=0.011038509387163336))
424 dis = {"oxidizer": SimpleNamespace(orifice_l_over_d=5.0, inlet_geometry="sharp", inlet_radius_ratio=None,
425 l_over_d_source="declared"),
426 "fuel": SimpleNamespace(orifice_l_over_d=5.545, inlet_geometry="sharp", inlet_radius_ratio=None,
427 l_over_d_source="declared")}
428 return SimpleNamespace(injector=SimpleNamespace(geometry=geo), discharge=dis,
429 fluids={"oxidizer": SimpleNamespace(density=1140.0), "fuel": SimpleNamespace(density=789.0)})
430
431
433 import CoolProp.CoolProp as CP
434 res = _synthetic()
435 sat = S.build_saturation(res, _fake_prep(res))
436 assert "error" not in sat, sat
437 rows = {r["id"]: r for r in sat["nodes"]}
438 assert "ENG.chamber" not in rows and "OXT" not in rows # gas and chamber nodes excluded
439 psat_lox = CP.PropsSI("P", "T", 90.0, "Q", 0, "Oxygen") / PSI # 14.41 psia
440 psat_eth = CP.PropsSI("P", "T", 293.15, "Q", 0, "Ethanol") / PSI # 0.85 psia
441 p = res["network"]["nodes"]["MVO.in"]["p_psia"]
442 assert rows["MVO.in"]["margin_psi"] == pytest.approx([x - psat_lox for x in p])
443 p_f = res["network"]["nodes"]["MVF.in"]["p_psia"]
444 assert rows["MVF.in"]["margin_psi"] == pytest.approx([x - psat_eth for x in p_f])
445 # static: less the larger velocity head of l_ox1 (10.92 mm) and MVO (12.7 mm) at the
446 # saturated-liquid density -> the 10.92 mm line's
447 rho = CP.PropsSI("D", "T", 90.0, "Q", 0, "Oxygen")
448 A = math.pi * 0.01092 ** 2 / 4
449 q3 = 1.9 ** 2 / (2 * rho * A * A) / PSI # ~26 psi at 17.8 m/s
450 assert rows["MVO.in"]["margin_static_psi"][3] == pytest.approx(p[3] - psat_lox - q3)
451 assert 20.0 < q3 < 30.0
452 # the line exit's static uses the line, not the orifices' area on the engine leg
453 pe = res["network"]["nodes"]["ENG.oxidiser"]["p_psia"]
454 assert rows["ENG.oxidiser"]["margin_static_psi"][3] == pytest.approx(pe[3] - psat_lox - q3)
455 # min over the firing steps only
456 k = 2 if p[2] - psat_lox < p[3] - psat_lox else 3
457 assert rows["MVO.in"]["min_psi"] == pytest.approx(p[k] - psat_lox) and rows["MVO.in"]["t_min"] == T[k]
458 # past the shut main, before Fire, the twin holds the line near atmosphere: not graded
459 pmo = res["network"]["nodes"]["MVO.out"]["p_psia"]
460 assert pmo[0] - psat_lox < 25.0
461 assert rows["MVO.out"]["min_psi"] == pytest.approx(min(pmo[2], pmo[3]) - psat_lox)
462 assert not any(r["flagged"] for r in sat["nodes"])
463 # a node 10 psi over saturation is flagged against the default 25 psi margin
464 res["network"]["nodes"]["MVF.in"]["p_psia"] = [psat_eth + 10.0 + q for q in (0, 0, 0, 0)]
465 sat = S.build_saturation(res, _fake_prep(res))
466 assert {r["id"]: r for r in sat["nodes"]}["MVF.in"]["flagged"]
467
468
470 """LE4 (3)'s capped LOX fill QD and its dump valve's outlet sit at 14.696 psia with no flow, LOX at
471 90.19 K on its own saturation line: -0.003 psi, amber on every LOX burn. A node no branch carries
472 flow through while firing is skipped and listed; the feed's own nodes are graded as before."""
473 import CoolProp.CoolProp as CP
474 res = _synthetic()
475 T = 90.0
476 psat = CP.PropsSI("P", "T", T, "Q", 0, "Oxygen") / PSI
477 res["network"]["nodes"]["DUMP.out"] = {"label": "Ox-Dump outlet", "kind": "port", "side": "ox", "phase": "liquid",
478 "p_psia": [psat - 0.003] * N, "T_K": [T] * N}
479 res["network"]["branches"]["DUMP"] = {"label": "Ox-Dump", "kind": "valve", "from": "OXT.out", "to": "DUMP.out",
480 "side": "ox", "mdot": [0.0] * N, "dp_psi": [0.0] * N, "cv": 2.0, "state": None}
481 prep = _fake_prep(res)
482 sat = S.build_saturation(res, prep)
483 rows = {r["id"]: r for r in sat["nodes"]}
484 assert "DUMP.out" not in rows
485 assert any(s.startswith("Ox-Dump outlet (no flow") for s in sat["skipped"])
486 assert sat["worst"]["id"] != "DUMP.out" and sat["worst"]["min_psi"] > 25.0
487 assert {"OXT.out", "MVO.in", "MVO.out", "ENG.oxidiser"} <= set(rows)
488 # The same node with propellant moving through it is graded (and flagged, at -0.003 psi).
489 res["network"]["branches"]["DUMP"]["mdot"] = [0.0, 0.0, 0.5, 0.5]
490 rows = {r["id"]: r for r in S.build_saturation(res, _fake_prep(res))["nodes"]}
491 assert rows["DUMP.out"]["min_psi"] == pytest.approx(-0.003, abs=1e-9) and rows["DUMP.out"]["flagged"]
492
493
495 import CoolProp.CoolProp as CP
496 res = _synthetic()
497 cfg = _fake_config()
498 cav = S.build_cavitation(res, _fake_prep(res), cfg)
499 assert "error" not in cav, cav
500 ox = cav["ox"]
501 pv = CP.PropsSI("P", "T", 90.0, "Q", 0, "Oxygen") / PSI
502 p_up = res["series"]["ox"]["manifold_psia"][3]
503 pc = res["series"]["chamber"]["pc_psia"][3]
504 assert ox["K"][3] == pytest.approx((p_up - pv) / (p_up - pc))
505 A = 24 * math.pi * 0.0016318401623160582 ** 2 / 4
506 cd = 1.9 / (A * math.sqrt(2 * 1140.0 * (p_up - pc) * PSI))
507 assert ox["Cd_eff"][3] == pytest.approx(cd)
508 assert ox["K_crit"][3] == pytest.approx((cd / 0.62) ** 2) # sharp entry, r/d 0: Cc = Cc0
509 assert ox["K"][0] is None # not firing
510 assert not ox["cavitates"] and not ox["flip_risk"]
511 # starve the orifice: manifold 500 psia against a 150 psia chamber -> K = 1.39 < K_crit 1.6
512 s = res["series"]
513 s["chamber"]["pc_psia"] = [14.7, 14.7, 400.0, 150.0]
514 s["ox"]["manifold_psia"][3] = 500.0
515 s["ox"]["dp_injector_psi"][3] = 350.0
516 s["ox"]["mdot"][3] = 0.78 * A * math.sqrt(2 * 1140.0 * 350.0 * PSI)
517 cav = S.build_cavitation(res, _fake_prep(res), cfg)
518 assert cav["ox"]["K"][3] == pytest.approx((500.0 - pv) / 350.0)
519 assert cav["ox"]["K_crit"][3] == pytest.approx((0.78 / 0.62) ** 2)
520 assert cav["ox"]["cavitates"] and cav["ox"]["flip_risk"] # L/d 5.0 <= 5.0
521 assert cav["ox"]["t_min"] == T[3]
522 cfg.discharge["oxidizer"].orifice_l_over_d = 8.0
523 cav = S.build_cavitation(res, _fake_prep(res), cfg)
524 assert cav["ox"]["cavitates"] and not cav["ox"]["flip_risk"]
525 assert cav["model"]["inputs"]["flip_l_over_d_max"]["provenance"].startswith("assumed")
526
527
528# ---- 5. injector ----------------------------------------------------------------------------------
529
530from engine.layerx.diag import injector as J # noqa: E402
531
532
534 res = _synthetic()
535 res["replay"] = {"t": [0.05, 0.15], "eta_cstar": [0.91, 0.93]}
536 cfg = _fake_config()
537 fwd = {"mdot_O": 1.85, "mdot_F": 1.22, "eta_cstar": 0.9105,
538 "diagnostics": {"momentum_ratio_R": 1.0304, "rupe_M": 1.1774, "Cd_O": 0.784, "Cd_F": 0.776}}
539 inj = J.build_injector(res, None, cfg, forward=fwd)
540 assert "error" not in inj, inj
541 d_o, d_f = 0.0016318401623160582, 0.0014715307322406754
542 A_o, A_f = 24 * math.pi * d_o ** 2 / 4, 24 * math.pi * d_f ** 2 / 4
543 m_o, m_f = 1.9, 1.25
544 v_o, v_f = m_o / (1140.0 * A_o), m_f / (789.0 * A_f)
545 assert inj["v_ox"][3] == pytest.approx(v_o) and inj["v_fuel"][3] == pytest.approx(v_f)
546 assert inj["v_ox"][0] is None # not firing
547 R_hand = math.sqrt(1140.0 * v_o ** 2 / (789.0 * v_f ** 2))
548 assert inj["momentum_ratio"][3] == pytest.approx(R_hand)
549 # the closed form the audit's ledger uses: R = O/F (A_F/A_O) sqrt(rho_F/rho_O)
550 assert inj["momentum_ratio"][3] == pytest.approx((m_o / m_f) * (A_f / A_o) * math.sqrt(789.0 / 1140.0))
551 assert inj["rupe_M"][3] == pytest.approx(R_hand ** 2 * d_o / d_f)
552 # tilt: LOX ring inboard (s_O < s_F), so LOX travels outward to the collision, fuel inward
553 p_o, p_f = m_o * v_o, m_f * v_f
554 pr = p_o * math.sin(math.radians(35.0)) - p_f * math.sin(math.radians(48.0))
555 pz = p_o * math.cos(math.radians(35.0)) + p_f * math.cos(math.radians(48.0))
556 assert inj["resultant_angle_deg"][3] == pytest.approx(math.degrees(math.atan2(pr, pz)))
557 assert inj["design_momentum_ratio"] == 1.0304
558 # eta_c*: inside the replay's span by linear interpolation, null outside it
559 assert inj["eta_cstar"][2] == pytest.approx(0.91)
560 assert inj["eta_cstar"][3] == pytest.approx(0.92)
561 assert inj["eta_cstar"][1] is None
562
563
564# ---- 6. thrust shape ------------------------------------------------------------------------------
565
566from engine.layerx.diag import thrustshape as TS # noqa: E402
567
568PC0 = 400.0 * PSI # a chamber held at 400 psia
569C_FLOW = {"ox": 1.9 / math.sqrt(170.0 * PSI), "fuel": 1.25 / math.sqrt(160.0 * PSI)}
570K_THRUST = 2300.0 # N per kg/s
571
572
573def _fake_engine(p_O, p_F):
574 """mdot = c sqrt(p_line - Pc0), F = k (mdot_O + mdot_F): closed-form, so the feed solve has one."""
575 mO = C_FLOW["ox"] * math.sqrt(max(p_O - PC0, 0.0))
576 mF = C_FLOW["fuel"] * math.sqrt(max(p_F - PC0, 0.0))
577 return {"mdot_O": mO, "mdot_F": mF, "F": K_THRUST * (mO + mF)}
578
579
580def _fed(p_out, K, side):
581 """Closed form of mdot = c sqrt(p_out - K mdot^2 - Pc0)."""
582 c = C_FLOW[side]
583 return math.sqrt(c * c * (p_out - PC0) / (1.0 + c * c * K))
584
585
586def _thrust_result(K_drift=0.0, accel=None):
587 t = [0.0, 0.05, 0.1, 0.2, 0.3, 0.4]
588 firing = [False, True, True, True, True, True]
589 K0 = {"ox": 17.0 * PSI / 1.9 ** 2, "fuel": 33.0 * PSI / 1.25 ** 2}
590 head = {"ox": 0.5, "fuel": 0.3}
591 series = {"t": t, "firing": firing, "dt": [0.05, 0.05, 0.05, 0.1, 0.1, 0.1]}
592 for s in ("ox", "fuel"):
593 tank = [578.0 + 100.0 * x for x in t]
594 a = [9.80665] * len(t) if accel is None else accel
595 outlet = [p + head[s] * ai / 9.80665 for p, ai in zip(tank, a)]
596 K = [K0[s] * (1.0 + K_drift * x) for x in t]
597 m = [_fed(o * PSI, k, s) for o, k in zip(outlet, K)]
598 inlet = [o - k * mm * mm / PSI for o, k, mm in zip(outlet, K, m)]
599 series[s] = {"tank_psia": tank, "outlet_psia": outlet, "inlet_psia": inlet, "mdot": m}
600 idx = [1, 2, 3, 4, 5]
601 F_ab = [_fake_engine(series["ox"]["inlet_psia"][i] * PSI, series["fuel"]["inlet_psia"][i] * PSI)["F"] for i in idx]
602 F_del = [f * (1.0 + 0.01 * t[i]) for f, i in zip(F_ab, idx)]
603 replay = {"t": [t[i] for i in idx], "index": idx, "thrust_N": F_del,
604 "inlet_O_psia": [series["ox"]["inlet_psia"][i] for i in idx],
605 "inlet_F_psia": [series["fuel"]["inlet_psia"][i] for i in idx]}
606 delivered = {"t": [t[i] for i in idx], "thrust_N": F_del,
607 "summary": {"mean_thrust_N": sum(F_del) / len(F_del)}}
608 res = {"series": series, "replay": replay, "delivered": delivered}
609 if accel is not None:
610 res["flight"] = {"ok": True, "schedule": {"t": t, "accel_m_s2": accel}}
611 return res, K0, head, F_ab, F_del
612
613
615 res, K0, head, F_ab, F_del = _thrust_result(K_drift=0.5)
616 ts = TS.build_thrust_shape(res, None, None, sampler=_fake_engine, target_N=7000.0)
617 assert "error" not in ts, ts
618 b = ts["breakdown"]
619 assert b["k0"] == 2 and b["t0"] == 0.2 # first replay point 0.2 s after Fire
620 t = [0.05, 0.1, 0.2, 0.3, 0.4]
621
622 def F_T(j): # the as-built engine through the k0 lines from the one-g tank outlet
623 i = res["replay"]["index"][j]
624 return K_THRUST * sum(_fed(res["series"][s]["outlet_psia"][i] * PSI, K0[s] * (1 + 0.5 * 0.2), s)
625 for s in ("ox", "fuel"))
626
627 for j in (3, 4):
628 assert b["erosion_N"][j] == pytest.approx((F_del[j] - F_ab[j]) - (F_del[2] - F_ab[2]), rel=1e-9)
629 assert b["tank_pressure_N"][j] == pytest.approx(F_T(j) - F_T(2), rel=1e-6)
630 assert b["accel_head_N"][j] == pytest.approx(0.0, abs=1e-9)
631 # the components add to the delivered change, and the residual is the drifting K's doing
632 assert b["dF_N"][j] == pytest.approx(F_del[j] - F_del[2])
633 total = b["tank_pressure_N"][j] + b["erosion_N"][j] + b["accel_head_N"][j] + b["residual_N"][j]
634 assert total == pytest.approx(b["dF_N"][j], rel=1e-12)
635 assert b["residual_N"][j] == pytest.approx((F_ab[j] - F_ab[2]) - (F_T(j) - F_T(2)), abs=1e-6)
636 assert b["residual_N"][j] < -1.0 # the line got lossier
637 assert b["dF_N"][0] is None # before k0
638 # with the lines' K fixed, the fixed-K feed explains the feed entirely
639 res, *_ = _thrust_result(K_drift=0.0)
640 b0 = TS.build_thrust_shape(res, None, None, sampler=_fake_engine)["breakdown"]
641 assert b0["residual_N"][4] == pytest.approx(0.0, abs=1e-5)
642 assert b0["feed_solve"]["converged"]
643 # shape: about the delivered mean, over t >= 0.2 s
644 mean = sum(F_del) / 5
645 dev = [f - mean for f, tt in zip(F_del, t) if tt >= 0.2]
646 assert ts["dev_max_pct"] == pytest.approx(max(abs(d) for d in dev) / mean * 100)
647 w = [0.05, 0.1, 0.05] # trapezoid weights on 0.2/0.3/0.4
648 assert ts["dev_rms_pct"] == pytest.approx(math.sqrt(sum(wi * d * d for wi, d in zip(w, dev)) / 0.2) / mean * 100)
649 assert ts["target_N"] == 7000.0 and ts["target_source"] == "restated for this run"
650
651
653 accel = [9.80665 * (1.0 + 20.0 * x) for x in [0.0, 0.05, 0.1, 0.2, 0.3, 0.4]]
654 res, K0, head, F_ab, F_del = _thrust_result(K_drift=0.0, accel=accel)
655 b = TS.build_thrust_shape(res, None, None, sampler=_fake_engine)["breakdown"]
656 assert b["accel_basis"].startswith("flight")
657
658 def F_T(j, flown):
659 i = res["replay"]["index"][j]
660 tot = 0.0
661 for s in ("ox", "fuel"):
662 p = res["series"][s]["tank_psia"][i] + head[s] * (accel[i] / 9.80665 if flown else 1.0)
663 tot += _fed(p * PSI, K0[s], s)
664 return K_THRUST * tot
665
666 want = (F_T(4, True) - F_T(4, False)) - (F_T(2, True) - F_T(2, False))
667 assert b["accel_head_N"][4] == pytest.approx(want, rel=1e-6)
668 assert b["accel_head_N"][4] > 0.0 # more g, more head, more thrust
669 assert b["tank_pressure_N"][4] == pytest.approx(F_T(4, False) - F_T(2, False), rel=1e-6)
670 assert b["residual_N"][4] == pytest.approx(0.0, abs=1e-5)
671
672
673# ---- 7. ledger ------------------------------------------------------------------------------------
674
675from engine.layerx.diag import ledger as LG # noqa: E402
676
677
679 import CoolProp.CoolProp as CP
680 res = _synthetic()
681 s = res["series"]
682 s["dt"] = [0.05, 0.05, 0.05, 0.10]
683 s["ox"]["stiffness"] = [0.0, 0.0, 0.36, 0.40]
684 s["regulators"] = {"PR_D": {"label": "PR-DOME", "outlet_psia": res["network"]["nodes"]["PR_D.out"]["p_psia"]}}
685 res["delivered"] = {"t": [0.05, 0.10], "pc_psia": [400.0, 401.0], "thrust_N": [6750.0, 6800.0],
686 "throat_area_ratio": [1.0, 1.04], "mr": [1.52, 1.51], "ambient_psia": [13.64, 13.64],
687 "summary": {"total_impulse_Ns": 24240.0}}
688 res["replay"] = {"t": [0.05, 0.10], "eta_cstar": [0.910, 0.912]}
689 res["summary"] = {"burn_time_s": 3.456}
690 cfg = _fake_config()
691 cfg.chamber_geometry = SimpleNamespace(A_throat=1.7947e-3, expansion_ratio=4.8276, Lstar=1.3586,
692 nozzle_efficiency=0.95)
693 cfg.thrust = SimpleNamespace(burn_time=3.994)
694 cfg.design_requirements = SimpleNamespace(lox_tank_capacity_kg=6.611, fuel_tank_capacity_kg=4.404)
695 fwd = {"Pc": 393.22 * PSI, "F": 6803.7, "MR": 1.5232, "Isp": 224.77, "mdot_O": 1.8633, "mdot_F": 1.2233,
696 "eta_cstar": 0.9105, "P_ambient": 94069.7, "Cd_O": 0.784, "Cd_F": 0.776,
697 "_basis": {"tank_psia": 578.0},
698 "diagnostics": {"delta_p_feed_O": 41.34 * PSI, "delta_p_feed_F": 47.31 * PSI,
699 "delta_p_injector_O": 143.44 * PSI, "delta_p_injector_F": 137.47 * PSI,
700 "P_injector_O": 536.66 * PSI, "P_injector_F": 530.69 * PSI}}
701 led = LG.build_ledger(res, None, cfg, forward=fwd)
702 assert isinstance(led, list), led
703 rows = {r["key"]: r for r in led}
704 # design: the Forward solve at the lockup, not config fields
705 assert rows["pc"]["design_value"] == pytest.approx(393.22)
706 assert rows["feed_loss_ox"]["design_value"] == pytest.approx(41.34)
707 assert rows["stiffness_ox"]["design_value"] == pytest.approx(143.44 / 393.22)
708 assert rows["tank_pressure_ox"]["design_value"] == 578.0
709 assert rows["ambient"]["design_value"] == pytest.approx(94069.7 / PSI)
710 assert rows["impulse"]["design_value"] == pytest.approx((6.611 + 4.404) * 224.77 * 9.80665)
711 # delivered: firing steps only, dt-weighted mean
712 tank = s["ox"]["tank_psia"]
713 d = rows["tank_pressure_ox"]["delivered"]
714 assert d["start"] == pytest.approx(tank[2]) and d["end"] == pytest.approx(tank[3])
715 assert d["mean"] == pytest.approx((tank[2] * 0.05 + tank[3] * 0.10) / 0.15)
716 loss = [a - b for a, b in zip(s["ox"]["tank_psia"], s["ox"]["manifold_psia"])]
717 assert rows["feed_loss_ox"]["delivered"]["max"] == pytest.approx(max(loss[2:]))
718 assert rows["stiffness_ox"]["delivered"]["max"] == pytest.approx(0.40)
719 assert rows["throat_area"]["delivered"]["end"] == pytest.approx(1.7947e-3 * 1.04 * 1e6)
720 assert rows["eta_cstar"]["delivered"]["mean"] == pytest.approx(0.911)
721 # Cd from the twin's flow and orifice drop
722 A = 24 * math.pi * 0.0016318401623160582 ** 2 / 4
723 dpi = s["ox"]["dp_injector_psi"][3] * PSI
724 assert rows["cd_ox"]["delivered"]["end"] == pytest.approx(1.9 / (A * math.sqrt(2 * 1140.0 * dpi)))
725 # density: config for the engine, the twin's saturated line delivered
726 assert rows["density_ox"]["design_value"] == 1140.0
727 assert rows["density_ox"]["delivered"]["mean"] == pytest.approx(CP.PropsSI("D", "T", 90.0, "Q", 0, "Oxygen"))
728 assert rows["density_ox"]["replaced"] == "no"
729 # pad: one g, and the tank head EngineDesign does not have
730 assert rows["specific_force"]["delivered"]["mean"] == 1.0
731 assert rows["tank_head_ox"]["design_value"] == 0.0
732 assert rows["tank_head_ox"]["delivered"]["mean"] == pytest.approx(0.5)
733 for r in led:
734 assert r["replaced"] in ("yes", "partly", "no")
735 # without a Forward solve the Forward rows say so instead of guessing
736 led = {r["key"]: r for r in LG.build_ledger(res, None, cfg)}
737 assert led["pc"]["design_value"] is None and "no Forward" in led["pc"]["design_source"]
738
739
741 """Nozzle erosion off (run 20261102-123645-807b98): no replay, no ``delivered``. The burn still
742 fired, on the twin's chamber; Pc, O/F, thrust, Isp and the flows read '—' in the Overview ledger.
743 They come from ``series.chamber`` and the sides' flows; what only the replay computes says why."""
744 res = _synthetic()
745 s = res["series"]
746 s["chamber"].update({"mr": [0.0, 0.0, 1.50, 1.52], "thrust_N": [0.0, 0.0, 6100.0, 6150.0],
747 "isp_s": [0.0, 0.0, 221.0, 222.0]})
748 res["summary"] = {"burn_time_s": 3.86, "total_impulse_Ns": 23730.0}
749 res["provenance"] = {"settings": {"replay": False}}
750 cfg = _fake_config()
751 cfg.chamber_geometry = SimpleNamespace(A_throat=1.7947e-3, expansion_ratio=4.8276, Lstar=1.3586,
752 nozzle_efficiency=0.95)
753 cfg.thrust = SimpleNamespace(burn_time=3.994)
754 cfg.design_requirements = SimpleNamespace(lox_tank_capacity_kg=6.611, fuel_tank_capacity_kg=4.404)
755 rows = {r["key"]: r for r in LG.build_ledger(res, None, cfg)}
756 # firing steps only (2 and 3), equal weights without dt
757 assert rows["pc"]["delivered"]["min"] == pytest.approx(400.0)
758 assert rows["pc"]["delivered"]["max"] == pytest.approx(401.0)
759 assert rows["pc"]["series_ref"] == "series.chamber.pc_psia"
760 assert rows["of"]["delivered"]["mean"] == pytest.approx(1.51)
761 assert rows["thrust"]["delivered"]["end"] == pytest.approx(6150.0)
762 assert rows["isp"]["delivered"]["start"] == pytest.approx(221.0)
763 assert rows["mdot_ox"]["delivered"]["end"] == pytest.approx(1.9)
764 assert rows["impulse"]["delivered"]["mean"] == pytest.approx(23730.0)
765 # the eroding throat and eta_c* are the replay's alone: none, and the row says why
766 for key in ("throat_area", "eta_cstar"):
767 assert rows[key]["delivered"] is None
768 assert rows[key]["missing"] == "erosion off"
769 assert "nozzle erosion off" in rows[key]["note"]
770 assert "missing" not in rows["pc"]
771
772
773# ---- the recorder's own shape: the tank head as an element on the path -----------------------------
774
776 """lib/feedtwin's NetworkTrace puts ``<tank>.head`` (kind tank_head) on each path and calls a
777 drawn SOL main valve a solenoid. Reshape the synthetic stand the same way."""
778 net = res["network"]
779 for side, tank, mv in (("ox", "OXT", "MVO"), ("fuel", "FUT", "MVF")):
780 up, dn = net["nodes"][tank]["p_psia"], net["nodes"][f"{tank}.out"]["p_psia"]
781 net["branches"][f"{tank}.head"] = {"label": f"TK-{side} liquid head", "kind": "tank_head",
782 "from": tank, "to": f"{tank}.out", "side": side,
783 "mdot": net["branches"][f"l_{side}1"]["mdot"],
784 "dp_psi": [a - b for a, b in zip(up, dn)]}
785 net["branches"][mv]["kind"] = "solenoid"
786 path = net["paths"][side]
787 path.insert(path.index(f"l_{side}1"), f"{tank}.head")
788 for nid in ("ENG.oxidiser", "ENG.fuel"):
789 net["nodes"][nid]["kind"] = "injector_inlet"
790 net["nodes"]["MVO.out"]["T_K"] = [0.0, 0.0, 90.0, 90.0] # the recorder's 0.0: not reached
791 net["nodes"]["MVF.in"]["T_K"] = [0.0] * N # never reached by the walk
792 return res
793
794
797 lad = L.build_ladder(res)
798 for side, head in (("ox", 0.5), ("fuel", 0.3)):
799 s = lad[side]
800 assert s["closes"]
801 assert [e["kind"] for e in s["elements"]][7] == "tank_head"
802 assert s["elements"][7]["dp_psi"] == pytest.approx([-head] * N)
803 sol = R.build_solenoids(res, None, gas="helium")
804 # the press solenoids only: the mains past the tank are not on the regulator-to-tank leg
805 assert sorted(r["id"] for r in sol) == ["SV_FUEL_PRESS", "SV_LOX_PRESS"]
806 ox = next(r for r in sol if r["side"] == "ox")
807 assert ox["share_of_reg_to_tank"][2] == pytest.approx(1.5 / 3.1)
808 sat = S.build_saturation(res, _fake_prep(res))
809 mvo = {r["id"]: r for r in sat["nodes"]}["MVO.out"]
810 assert mvo["margin_psi"][0] is None and mvo["margin_psi"][2] is not None
811 # a node the walk never reached falls back to the tank's bulk liquid temperature
812 mvf = {r["id"]: r for r in sat["nodes"]}["MVF.in"]
813 assert mvf["T_source"].startswith("series.fuel.liquid_K")
814 assert mvf["min_psi"] is not None
815
816
817# ---- the LE4 helium burn (slow; LAYERX_GOLDEN=1) ----------------------------------------------------
818
819@pytest.mark.skipif(os.environ.get("LAYERX_GOLDEN") != "1", reason="slow LE4 burn: set LAYERX_GOLDEN=1 to run it")
821 """The baseline design on copv_study_he, pad, at AUDIT 9.6's 578 psia lockup and otherwise
822 default settings, with lib/feedtwin's network recorder on, against the hand checks of AUDIT
823 9.6-9.7 and the baseline (section 8).
824
825 Re-baselined 2026-10-07. The AUDIT's figures are the 2026-10-02 twin: adiabatic lines, the
826 578 psia lockup the default was then. Three things have moved them since, each deliberate:
827
828 * 2026-10-03 (night), line walls on (Layer X CHANGELOG): the press path's metal warms the gas
829 the bottle's expansion cooled, so each ullage litre takes less helium. He used 0.0906 ->
830 0.0818 kg; the bottle ends ~220 psi fuller, so the regulator's supply effect (17 psi/1000
831 psi of inlet fall) is ~3.8 psi smaller and its outlet rise 47.1 -> 43.86 psi; the press flow
832 at 0.5 s is 0.01105 kg/s of 304 K gas, not 0.01121 of 298 K, so the solenoid drops are
833 1.704 / 1.393 psi (hand, Cv law at the twin's state: 1.7045 / 1.3935) and the regulator's
834 peak use 14.3 %, not the AUDIT's 17.8 %.
835 * 2026-10-06, the ullage storage closure (a35563ce, ``Session._ullage_storage``): the
836 momentum ratio's range 1.0283-1.0312 -> 1.0285-1.0315 (the press flow no longer held at the
837 start-of-step tank pressure); with the closure stubbed out it returns, as does SV-LOX-PRESS's
838 0.1 % (1.7060 -> 1.7045). The supply datum (35b98bf2) moves nothing here.
839 * 2026-10-07, the dome dial defaults to the drawn dome (b67edc93): 564.7 psia on this drawing.
840 At that lockup every figure here moves (SV-LOX-PRESS 1.609 psi: 4.0 % less helium, 2.4 %
841 thinner, dp ~ mdot^2/rho), and thrust never comes within 2 % of 7.2 kN. The lockup is stated
842 below so the hand checks stay at the point they were made."""
843 import dataclasses
844 import importlib.util
845 from pathlib import Path
846
847 fb = pytest.importorskip("feedtwin.session.burn")
848 if "network" not in {f.name for f in dataclasses.fields(fb.Probes)}:
849 pytest.skip("lib/feedtwin has no network recorder")
850 root = Path(__file__).resolve().parents[1]
851 spec = importlib.util.spec_from_file_location("layerx_baseline", root / "scripts" / "layerx_baseline.py")
852 lb = importlib.util.module_from_spec(spec)
853 spec.loader.exec_module(lb)
854 from engine.core.runner import PintleEngineRunner
855 from engine.layerx import LayerXSettings, prepare, run_prepared
856
857 traces = []
858 find = fb.find_probes
859 monkeypatch.setattr(fb, "find_probes", lambda s: dataclasses.replace(find(s), network=True))
860 rec = fb.BurnTrace.recorder
861 monkeypatch.setattr(fb.BurnTrace, "recorder", lambda self, s: (traces.append(self), rec(self, s))[1])
862
863 config, _ = lb.load_engine_config(root / "docs" / "layerx" / "baseline-2026-10-02.json")
864 drawing = lb.find_drawing("copv_study_he")
865 cfg = copy.deepcopy(config)
866 runner = PintleEngineRunner(cfg)
867 # AUDIT 9.6 A, Method: lockup 578 psia (not the drawn dome's 564.7, the default since 2026-10-07)
868 prep = prepare(cfg, runner, drawing, LayerXSettings(drawing_id=drawing.id, tank_pressure_psia=578.0), [])
869 result = run_prepared(prep, runner=runner, replay=True, config=cfg)
870 if not isinstance(result.get("network"), dict) or "nodes" not in result["network"]:
871 result["network"] = fb.network_record(traces[-1])
872 from engine.layerx.diag.ledger import build_feed_diagnostics
873
874 d = build_feed_diagnostics(result, prep, cfg, runner=runner, target_N=7200.0)
875 for key, block in d.items():
876 assert not (isinstance(block, dict) and block.get("available") is False), (key, block)
877 t = result["series"]["t"]
878 k05 = min(range(len(t)), key=lambda i: abs(t[i] - 0.5))
879 # ladder: closes on node values; AUDIT 9.6 B2 at 0.5 s (1.72 / 1.42 there; 2026-10-07: see docstring)
880 for side in ("ox", "fuel"):
881 assert d["ladder"][side]["closes"], d["ladder"][side]["max_abs_residual_psi"]
882 sol = {r["id"]: r for r in d["solenoids"]}
883 assert sol["SV_LOX_PRESS"]["dp_psi"][k05] == pytest.approx(1.704, abs=0.03)
884 assert sol["SV_FUEL_PRESS"]["dp_psi"][k05] == pytest.approx(1.393, abs=0.03)
885 assert sol["SV_LOX_PRESS"]["dp_iec_psi"][k05] == pytest.approx(sol["SV_LOX_PRESS"]["dp_psi"][k05], rel=0.01)
886 # regulator: on its drawn law; outlet rise is the supply effect less the droop (AUDIT C2: +47.1,
887 # adiabatic; 2026-10-07: +43.86 = SPE 44.66 - droop 0.80, line walls on)
888 reg = d["regulator"]
889 assert max(abs(x) for x, f in zip(reg["residual_psi"], result["series"]["firing"]) if f) < 0.05
890 assert reg["rise"]["outlet_rise_psi"] == pytest.approx(43.86, abs=0.5)
891 assert not reg["any_wide_open"] and reg["use_frac_max"] == pytest.approx(0.143, abs=0.005)
892 # pressurant: AUDIT 8 (0.0906 kg He used, adiabatic; 0.0818 with line walls, CHANGELOG
893 # 2026-10-03 night); helium warms across the regulator, as the twin walks it
894 pr = d["pressurant"]
895 assert pr["species"] == "helium" and pr["used_kg"] == pytest.approx(0.0818, abs=0.001)
896 live = [(a, b) for a, b, f in zip(pr["jt_dT_K"], pr["twin_dT_K"], result["series"]["firing"]) if f]
897 assert all(a > 0 for a, _ in live) and max(abs(a - b) for a, b in live) < 0.05
898 assert pr["margin_kg"] > 0
899 # saturation and cavitation: AUDIT 9.5 4 (LOX >= ~540 psi of margin); K well above K_crit
900 assert min(r["min_psi"] for r in d["saturation"]["nodes"] if r["side"] == "ox") > 500.0
901 assert not d["cavitation"]["ox"]["cavitates"] and 3.0 < d["cavitation"]["ox"]["min_K"] < 4.0
902 # injector: AUDIT ledger #18, R 1.0311 -> 1.0279 (1.0315 -> 1.0285 since the 2026-10-06 ullage storage closure)
903 lo, hi = d["injector"]["range"]["momentum_ratio"]
904 assert lo == pytest.approx(1.0285, abs=5e-4) and hi == pytest.approx(1.0315, abs=5e-4)
905 # thrust shape: AUDIT 1 #5 / 9.7 #11 -- 29 steps within 2 % of 7.2 kN, first at 2.90 s
906 ts = d["thrust_shape"]
907 assert ts["t_first_at_target"] == pytest.approx(2.90, abs=0.051)
908 end = ts["breakdown"]["at_end"]
909 assert end["tank_pressure_N"] + end["erosion_N"] + end["accel_head_N"] + end["residual_N"] == \
910 pytest.approx(end["dF_N"], abs=1e-6)
911 assert abs(end["residual_N"]) < 0.05 * end["dF_N"]
912 # ledger: AUDIT 4 #1 and #4
913 led = {r["key"]: r for r in d["ledger"]}
914 assert led["tank_pressure_ox"]["delivered"]["end"] == pytest.approx(618.35, abs=3.0)
915 assert led["feed_loss_ox"]["design_value"] == pytest.approx(41.34, abs=0.1)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
static route_t * find(uint16_t pin)
Definition board_exti.c:20
_jt_by_integration(fluid, p1, T1, p2, steps=400)
test_iec_capacity_by_hand_and_against_fluids(p2_psia, choked)
_thrust_result(K_drift=0.0, accel=None)
test_ledger_with_the_erosion_replay_off_reads_the_twins_chamber()
test_ladder_elements_sum_to_end_to_end_and_head_is_a_gain()
test_joule_thomson_sign_and_magnitude(fluid, sign)
test_feed_diagnostics_on_the_le4_helium_burn(monkeypatch)
test_ledger_rows_design_from_forward_delivered_from_the_burn()