STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_hardware.py
Go to the documentation of this file.
1"""Layer X hardware read-outs: what the erosion replay keeps, and the hardware geometry built on it.
2
3What these check, and against what:
4
5* The replay keeps the per-step arrays the coupled solver computes (audit 5.2) without touching the
6 keys it had. The new arrays are checked against identities that do not go through the code under
7 test: the throat diameter against its area, the c* efficiency against its own breakdown, the
8 throat station's recession against the old throat-recession key, the net load against its parts.
9* An eroding engine is replayed on the coupled solver only (audit 5.3): a graphite-only engine,
10 which used to replay through the legacy loop at zero erosion, now erodes; the legacy path refuses.
11
12The replay here runs on LE4 (``configs/ethalox_6800N.yaml``) at synthetic line-exit pressures
13through a minimal stand-in for ``Prepared``: the replay reads only the runner, the as-built
14throat, the step and the ambient from it, and a twin burn would cost a minute per test.
15"""
16
17from __future__ import annotations
18
19import copy
20import math
21from pathlib import Path
22from types import SimpleNamespace
23
24import numpy as np
25import pytest
26
27from engine.core.runner import PintleEngineRunner
28from engine.layerx import replay as rpl
29from engine.layerx.card import line_exit_config
30from engine.pipeline.io import load_config
31
32ROOT = Path(__file__).resolve().parents[1]
33LE4 = ROOT / "configs" / "ethalox_6800N.yaml"
34PSI = 6894.757293168361
35AMBIENT = 94070.0 # Pa; a site value, not the default, so a dropped ambient shows
36
37pytestmark = pytest.mark.skipif(not LE4.is_file(), reason="LE4 config (configs/ethalox_6800N.yaml) not in this checkout")
38
39#: The replay's keys before 2026-10-02 (engine/layerx/replay.py): kept, unchanged.
40OLD_KEYS = (
41 "available", "t", "index", "inlet_O_psia", "inlet_F_psia", "pc_psia", "thrust_N", "mdot_O", "mdot_F",
42 "mr", "isp_s", "cstar", "eta_cstar", "gamma", "A_throat_m2", "throat_area_ratio", "recession_throat_mm",
43 "recession_chamber_mm", "eps", "p_exit_psia", "t_exit_K", "gamma_exit", "tc_K", "Lstar_m",
44 "heat_flux_throat_MW_m2", "heat_flux_chamber_MW_m2", "T_graphite_surface_K", "T_liner_surface_K",
45 "char_depth_peak_mm", "chug_margin", "throat_ablation", "liner_ablation",
46)
47
48
49def fake_prep(config, dt: float = 0.05, ambient: float = AMBIENT):
50 """What ``replay`` reads of a ``Prepared``: the line-exit runner, the as-built throat, dt, ambient."""
51 runner = PintleEngineRunner(line_exit_config(config))
52 link = SimpleNamespace(sampler=SimpleNamespace(runner=runner, config=runner.config),
53 design=SimpleNamespace(throat_area=float(runner.config.chamber_geometry.A_throat)))
54 return SimpleNamespace(link=link, plan=SimpleNamespace(dt=dt), ambient_pa=ambient)
55
56
57def series(n: int = 69, dt: float = 0.05, po=(560.0, 600.0), pf=(565.0, 605.0)):
58 """A burn's line-exit pressures as the twin reports them: rising with the regulator's SPE."""
59 t = [dt * (k + 1) for k in range(n)]
60 return {"t": t, "firing": [True] * n, "dt": [dt] * n,
61 "ox": {"inlet_psia": list(np.linspace(*po, n))}, "fuel": {"inlet_psia": list(np.linspace(*pf, n))}}
62
63
64@pytest.fixture(scope="module")
65def config():
66 return load_config(str(LE4))
67
68
69@pytest.fixture(scope="module")
70def prep(config):
71 return fake_prep(config)
72
73
74@pytest.fixture(scope="module")
75def burn():
76 return series()
77
78
79@pytest.fixture(scope="module")
80def replayed(prep, burn):
81 side = {}
82 rp = rpl.replay(prep, burn, sidecars=side, soak=True)
83 assert rp["available"], rp.get("error")
84 return rp, side
85
86
87# ------------------------------------------------------------------ 1. the replay keeps its arrays
88
89
91 rp, _ = replayed
92 missing = [k for k in OLD_KEYS if k not in rp]
93 assert not missing
94 n = len(rp["t"])
95 assert n == rpl.REPLAY_POINTS
96 for k in OLD_KEYS:
97 if isinstance(rp[k], list):
98 assert len(rp[k]) == n, k
99
100
102 rp, _ = replayed
103 n = len(rp["t"])
104 for key in list(rpl.RESULT_COLUMNS) + list(rpl.DIAGNOSTIC_COLUMNS):
105 col = rp[key]
106 assert isinstance(col, list) and len(col) == n, key
107 # LE4's doublet computes every one of them: a None here is a dropped path, not a gap.
108 assert all(v is not None and math.isfinite(v) for v in col), key
109
110
112 rp, _ = replayed
113 for d_mm, a in zip(rp["D_throat_mm"], rp["A_throat_m2"]):
114 assert d_mm == pytest.approx(math.sqrt(4.0 * a / math.pi) * 1e3, rel=1e-12)
115
116
118 """eta_c* = eta_vap * eta_mix * eta_HL (engine/pipeline/combustion_eff.py): the breakdown the
119 replay keeps must multiply back to the efficiency the chamber solve used."""
120 rp, _ = replayed
121 for eta, v, m, h in zip(rp["eta_cstar"], rp["eta_vap"], rp["eta_mix"], rp["eta_heat_loss"]):
122 assert v * m * h == pytest.approx(eta, rel=1e-9)
123 # and c* = eta c*_ideal
124 for c, eta, ci in zip(rp["cstar"], rp["eta_cstar"], rp["cstar_ideal"]):
125 assert c == pytest.approx(eta * ci, rel=1e-12)
126
127
129 """dp_injector is below line exit - Pc by exactly the Borda dump EngineDesign keeps at the line
130 exit: positive, and a few percent of the drop (the 6.8 kN LOX dump is ~26 psi at ~140 psi)."""
131 rp, _ = replayed
132 for p_in, pc, dp in zip(rp["inlet_O_psia"], rp["pc_psia"], rp["dp_injector_O_psi"]):
133 dump = p_in - pc - dp
134 assert 0.0 < dump < 0.5 * (p_in - pc)
135
136
138 rp, _ = replayed
139 st = rp["stations"]
140 assert set(st) == {"liner0", "liner1", "liner2", "liner3", "throat"}
141 thr = st["throat"]
142 assert thr["kind"] == "graphite" and thr["x_mm"] == 0.0
143 # The throat station is what the old throat-recession key reported.
144 assert thr["recession_mm"] == pytest.approx(rp["recession_throat_mm"], rel=1e-12)
145 # Remaining is the first layer less what receded (6 mm graphite, 12.7 mm liner on LE4).
146 t_gr = config.graphite_insert.initial_thickness * 1e3
147 for rec, rem in zip(thr["recession_mm"], thr["remaining_mm"]):
148 assert rem == pytest.approx(t_gr - rec, abs=1e-9)
149 t_ab = config.ablative_cooling.initial_thickness * 1e3
150 for rec, rem in zip(st["liner0"]["recession_mm"], st["liner0"]["remaining_mm"]):
151 assert rem == pytest.approx(t_ab - rec, abs=1e-9)
152 # The barrel station is the old chamber-recession key.
153 assert st["liner0"]["recession_mm"] == pytest.approx(rp["recession_chamber_mm"], rel=1e-12)
154 # Net load = convection + radiation - the carbon reactions' heat (graphite only).
155 for name, s in st.items():
156 for qn, qc, qr, qh in zip(s["q_net_MW_m2"], s["q_conv_MW_m2"], s["q_rad_MW_m2"], s["q_chem_MW_m2"]):
157 assert qn == pytest.approx(qc + qr - qh, rel=1e-9, abs=1e-12), name
158 # LE4 declares its 304 case (2026-10-03): the station's back face is the case's outer face, the
159 # old T_graphite_back key, and the insert's own back face is its first interface.
160 assert thr["T_back_K"] == pytest.approx(rp["T_graphite_back_K"], rel=1e-12)
161 assert config.stainless_steel_case is not None and config.stainless_steel_case.thickness == pytest.approx(0.00635)
162 assert [lay["name"] for lay in rp["wall_layers"]["throat"]["layers"]] == ["graphite", "backing", "case"]
163
164
166 """The station read-out (``_station_report``) against the barrel and throat loads the solver
167 already reported through its own path (``_wall_report``: q_conv/q_rad/q_chem_throat,
168 q_conv/q_rad_chamber). The throat is the same quantity both ways. At the barrel the station's
169 convection is the *blown* one the liner takes, the old key the unblown Bartz value: their ratio
170 is the pyrolysis-blowing reduction, a little under 1 (0.9931 at t = 0.05 s in audit 9.2)."""
171 rp, _ = replayed
172 thr, bar = rp["stations"]["throat"], rp["stations"]["liner0"]
173 assert thr["q_conv_MW_m2"] == pytest.approx(rp["q_conv_throat_MW_m2"], rel=1e-9)
174 assert thr["q_rad_MW_m2"] == pytest.approx(rp["q_rad_throat_MW_m2"], rel=1e-9)
175 assert thr["q_chem_MW_m2"] == pytest.approx(rp["q_chem_throat_MW_m2"], rel=1e-9)
176 assert bar["q_rad_MW_m2"] == pytest.approx(rp["q_rad_chamber_MW_m2"], rel=1e-9)
177 for blown, bare in zip(bar["q_conv_MW_m2"], rp["q_conv_chamber_MW_m2"]):
178 assert 0.9 < blown / bare <= 1.0
179
180
182 rp, side = replayed
183 ax = side["axial"]
184 assert ax["t"] == rp["t"]
185 nx = len(ax["x_mm"])
186 assert ax["x_mm"] == sorted(ax["x_mm"])
187 for key in ("q_MW_m2", "T_wall_K", "recession_mm", "T_back_K"):
188 assert len(ax[key]) == len(rp["t"]) and all(len(row) == nx for row in ax[key]), key
189 # Columns are the stations in x order: the throat (x = 0) is the last.
190 j = ax["station"].index("throat")
191 assert [row[j] for row in ax["recession_mm"]] == rp["stations"]["throat"]["recession_mm"]
192 prof = ax["profile"]
193 assert len(prof["q_MW_m2"]) == len(rp["t"]) and all(len(r) == len(prof["x_mm"]) for r in prof["q_MW_m2"])
194 # The whole-contour profile spans face to exit.
195 assert prof["x_mm"][0] < ax["x_mm"][0] and prof["x_mm"][-1] > 0.0
196
197
198# ------------------------------------------------------------------ 2. no silent zero-erosion
199
200
202 assert rpl.erodes(config)
203 c = config.model_copy(deep=True)
204 c.ablative_cooling.enabled = False
205 assert rpl.erodes(c) # graphite only
206 c.graphite_insert.enabled = False
207 assert not rpl.erodes(c)
208
209
211 """Liner off, graphite on: the replay used to pass ``track_ablative_geometry=None``, which the
212 runner read from the liner alone, and replayed this engine at 0.000 % throat growth."""
213 c = config.model_copy(deep=True)
214 c.ablative_cooling.enabled = False
215 rp = rpl.replay(fake_prep(c), burn)
216 assert rp["available"], rp.get("error")
217 assert rp["coupled_solver"]
218 assert rp["throat_area_ratio"][-1] > 1.01
219 assert all(v is not None for v in rp["T_graphite_surface_K"])
220
221
223 c = config.model_copy(deep=True)
224 c.ablative_cooling.track_geometry_evolution = False
225 rp = rpl.replay(fake_prep(c), burn)
226 assert rp["available"] and rp["coupled_solver"]
227 assert rp["recession_chamber_mm"][-1] > 0.5
228
229
231 # Its own runner: a call resets the runner's last coupled solver, which the soak-back reads.
232 runner = fake_prep(config).link.sampler.runner
233 with pytest.raises(ValueError, match="strict_erosion"):
234 runner.evaluate_arrays_with_time(np.array([0.0, 1.0]), np.full(2, 560 * PSI), np.full(2, 565 * PSI),
235 track_ablative_geometry=True, use_coupled_solver=False,
236 strict_erosion=True)
237
238
239# ------------------------------------------------------------------ 3. soak-back after the burn
240
241
242def _slab_solver(L=0.01, k=0.5, rho=1500.0, cp=1200.0, T0=400.0, dT=100.0):
243 """A coupled solver reduced to one wall station: a single constant-property slab whose
244 temperature is the first Fourier mode of an insulated slab, T0 + dT cos(pi y / L)."""
245 from engine.pipeline.thermal.wall_conduction import Layer, WallModel
246 from engine.pipeline.time_varying_solver import TimeVaryingCoupledSolver
247
248 m = WallModel([Layer(L, k, rho, cp, "slab")], T0)
249 m.T = T0 + dT * np.cos(np.pi * m.y / L)
250 solver = object.__new__(TimeVaryingCoupledSolver)
251 solver._walls = {"slab": {"x": 0.0, "kind": "liner", "model": m}}
252 return solver, m
253
254
256 """3 L^2/alpha, alpha = k/(rho cp), L the liner left at the station that receded least
257 (LE4's 12.7 mm phenolic liner is ~900 s; the 6 mm graphite ~0.4 s)."""
258 rp, _ = replayed
259 abl = config.ablative_cooling
260 left = min(rp["stations"][f"liner{i}"]["recession_mm"][-1] for i in range(4))
261 L = abl.initial_thickness - left * 1e-3
262 per = abl.material_density * abl.specific_heat / abl.thermal_conductivity
263 tau = L * L * per
264 # With the 304 case declared, the insert is backed by the phenolic out to the case: that slab,
265 # (D_c/2 + t_liner) - (D_t/2 + t_graphite) thick, is now the slowest layer.
266 cg, gr = config.chamber_geometry, config.graphite_insert
267 gap = 0.5 * cg.chamber_diameter + abl.initial_thickness - (math.sqrt(4.0 * cg.A_throat / math.pi) / 2.0 + gr.initial_thickness)
268 tau = max(tau, gap * gap * per)
269 soak = rp["soak"]
270 assert soak["available"]
271 assert soak["duration_s"] == pytest.approx(3.0 * tau, rel=1e-9)
272 assert soak["model"]["inputs"]["slowest_L2_over_alpha"]["value"] == pytest.approx(tau, rel=1e-9)
273
274
276 """Insulated slab, first mode only: T(L, t) = T0 - dT exp(-pi^2 alpha t / L^2) (Carslaw &
277 Jaeger 1959, ch. III). The back face rises from T0 - dT to T0, reaching 95 % of the rise at
278 t = ln(20) L^2 / (pi^2 alpha); energy is conserved, so the peak is the capacity-weighted mean."""
279 L, k, rho, cp, T0, dT = 0.01, 0.5, 1500.0, 1200.0, 400.0, 100.0
280 solver, m = _slab_solver(L, k, rho, cp, T0, dT)
281 T_eq = float(np.sum(m._C * m.T) / np.sum(m._C)) # the discrete stored heat, exactly conserved
282 alpha = k / (rho * cp)
283 res = solver.soak_back_history(factor=3.0)
284 assert res["duration_s"] == pytest.approx(3.0 * L * L / alpha, rel=1e-12)
285 s = res["stations"]["slab"]
286 assert s["T_back_start"] == pytest.approx(T0 - dT, abs=1e-9)
287 assert s["T_back_peak"] == pytest.approx(T_eq, abs=0.01)
288 assert T_eq == pytest.approx(T0, abs=0.5) # the mode carries no net heat, to the grid's resolution
289 t95 = math.log(20.0) * L * L / (math.pi ** 2 * alpha)
290 # Backward Euler at the default step count runs the slowest mode ~1 % slow (lambda h = 0.025).
291 assert s["t_back_95_s"] == pytest.approx(t95, rel=0.02)
292
293
295 solver, _ = _slab_solver(L=0.002, k=90.0, rho=1800.0, cp=700.0) # a thin, fast insert
296 res = solver.soak_back_history(duration=600.0)
297 s = res["stations"]["slab"]
298 assert s["t_back_peak_s"] < 0.05 * res["duration_s"]
299
300
302 rp, _ = replayed
303 soak = rp["soak"]
304 # The station's back face is the 304 case's outer face, adiabatic to the room: still an upper bound.
305 assert soak["back_basis"] == "case outer face, adiabatic (upper bound)"
306 assert {r["station"] for r in soak["stations"]} == set(rp["stations"])
307 insert = next(r for r in soak["stations"] if r["station"] == "throat")
308 # The throat station's back face is the case's outer face, behind ~46 mm of phenolic: the
309 # insert's heat reaches it only slowly (L^2/alpha of the backing ~15,000 s), so it rises long
310 # after burnout, and the insert's own back face (the first interface) is hot from the start.
311 assert insert["T_back_peak_K"] >= insert["T_back_start_K"]
312 assert insert["t_back_95_s"] > 60.0
313 assert insert["T_interface_peak_K"] > insert["T_back_peak_K"]
314 for r in soak["stations"]:
315 assert r["T_back_peak_K"] >= r["T_back_start_K"] - 1e-9
316 # The headline the page shows (D-60): the bond line's peak, which is a peak, seconds after
317 # shutdown; and the outer face said to be asymptotic, its clock the time to 95 % of the rise.
318 # The page used to print the outer face's "peak" 7.5 h after burnout.
319 bond = max(soak["stations"], key=lambda r: r["T_interface_peak_K"])
320 assert soak["interface_peak_K"] == bond["T_interface_peak_K"] and soak["interface_station"] == bond["station"]
321 assert soak["t_interface_peak_s"] < 60.0 < soak["t_95_s"]
322 assert soak["asymptotic"] is (soak["t_peak_s"] > 0.5 * soak["duration_s"])
323 m = soak["model"]
324 assert m["name"] and m["source"] and m["assumptions"] and m["inputs"]
325 assert all({"value", "unit", "provenance"} <= set(v) for v in m["inputs"].values())
326
327
329 """The runner keeps only its last coupled solve. The soak-back runs on it only when it is the
330 replay asked about: same end time and same end throat; a replay that ended elsewhere, or at the
331 same time on another throat (another pass of the same burn), is refused, not soaked."""
332 p = fake_prep(config)
333 rp = rpl.replay(p, series(n=8, dt=0.4))
334 assert rp["available"], rp.get("error")
335 assert rpl.soak_back(p, rp)["available"] # its own replay soaks
336 later = dict(rp, t=list(rp["t"][:-1]) + [rp["t"][-1] + 1.0])
337 out = rpl.soak_back(p, later)
338 assert out["available"] is False and "not this replay" in out["error"]
339 other_throat = dict(rp, A_throat_m2=list(rp["A_throat_m2"][:-1]) + [rp["A_throat_m2"][-1] * 1.001])
340 out = rpl.soak_back(p, other_throat)
341 assert out["available"] is False and "not this replay" in out["error"]
342
343
344# ------------------------------------------------------------------ 5. eroded geometry in the chug loop
345
346
347def _solve_capturing(config, monkeypatch, **kw):
348 """Run the coupled solver on a short burn and record the geometry each stability call saw."""
350 from engine.pipeline.time_varying_solver import TimeVaryingCoupledSolver
351
352 real = stab.comprehensive_stability_analysis
353 seen = []
354
355 def spy(config, *a, **k):
356 cg = config.chamber_geometry
357 seen.append((float(cg.A_throat), float(cg.volume)))
358 return real(config, *a, **k)
359
360 monkeypatch.setattr(stab, "comprehensive_stability_analysis", spy)
361 runner = PintleEngineRunner(line_exit_config(config))
362 solver = TimeVaryingCoupledSolver(runner.config, runner.cea_cache, P_ambient=AMBIENT, **kw)
363 t = np.linspace(0.0, 3.4, 7)
364 solver.solve_time_series(t, np.linspace(560.0, 600.0, 7) * PSI, np.linspace(565.0, 605.0, 7) * PSI)
365 return solver, seen
366
367
369 """Default off: every step's chug analysis runs on the as-built throat and volume, as it did
370 before the flag existed (time_varying_solver.py passed self.config)."""
371 solver, seen = _solve_capturing(config, monkeypatch)
372 assert solver.chug_eroded_geometry is False
373 a0, v0 = solver.A_throat_initial, solver.V_chamber_initial
374 assert len(seen) == len(solver.state_history)
375 assert all(a == a0 and v == v0 for a, v in seen)
376 # while the chamber it describes has eroded
377 assert solver.state_history[-1].A_throat > 1.02 * a0
378
379
381 """On: each step's chug loop sees that step's A_t and V, so its L* = V/A_t is the eroded one."""
382 on, seen_on = _solve_capturing(config, monkeypatch, chug_eroded_geometry=True)
383 for (a, v), s in zip(seen_on, on.state_history):
384 assert a == pytest.approx(s.A_throat, rel=1e-12)
385 assert v == pytest.approx(s.V_chamber, rel=1e-12)
386 assert v / a == pytest.approx(s.Lstar, rel=1e-12)
387 off, _ = _solve_capturing(config, monkeypatch)
388 m_on = [s.chugging_stability_margin for s in on.state_history]
389 m_off = [s.chugging_stability_margin for s in off.state_history]
390 # Same chamber at the first step (nothing has eroded yet; the copy's A_t is rebuilt from its
391 # diameter, one ulp off), a different chug margin by burnout.
392 assert m_on[0] == pytest.approx(m_off[0], rel=1e-12)
393 assert abs(m_on[-1] / m_off[-1] - 1.0) > 1e-3
394 # and the burn itself is untouched: the flag changes only what the chug analysis reads.
395 for a, b in zip(on.state_history, off.state_history):
396 assert a.Pc == b.Pc and a.A_throat == b.A_throat
397
398
399# ------------------------------------------------------------------ 4. diag/hardware.py
400
401
402@pytest.fixture(scope="module")
403def hardware(prep, replayed, burn):
404 from engine.layerx.diag.hardware import hardware_block
405
406 rp, _ = replayed
407 dv = rpl.delivered(rp, burn, 0.05)
408 dv["ambient_psia"] = [AMBIENT / PSI] * len(dv["t"])
409 hb = hardware_block(prep, rp, dv, burn)
410 assert hb["available"], hb.get("error")
411 return hb
412
413
415 rp, _ = replayed
416 assert hardware["t"] == rp["t"] and hardware["index"] == rp["index"]
417 for key in ("throat_d_mm", "At_ratio", "eps", "Lstar_m", "contraction", "liner_min_mm", "insert_back_K"):
418 assert len(hardware[key]) == len(rp["t"]), key
419
420
422 rp, _ = replayed
423 cg = config.chamber_geometry
424 for k in range(len(rp["t"])):
425 a_t = rp["A_throat_m2"][k]
426 # eps = A_e/A_t(t), A_e fixed at the design's (LE4's nozzle does not ablate)
427 assert rp["A_exit_m2"][k] == pytest.approx(cg.A_exit, rel=1e-12)
428 assert hardware["eps"][k] == pytest.approx(cg.A_exit / a_t, rel=1e-6)
429 # L* = V(t)/A_t(t)
430 assert hardware["Lstar_m"][k] == pytest.approx(rp["V_chamber_m3"][k] / a_t, rel=1e-6)
431 # contraction = (D_c/D_t)^2, the solver's pi (D_c/2)^2 / A_t
432 assert hardware["contraction"][k] == pytest.approx(rp["contraction_ratio"][k], rel=1e-6)
433 # As built at the first point but for the first step's erosion (a few hundredths of a percent).
434 assert hardware["eps"][0] == pytest.approx(cg.A_exit / cg.A_throat, rel=1e-3)
435 assert hardware["Lstar_m"][0] == pytest.approx(cg.volume / cg.A_throat, rel=1e-3)
436 # The throat opens, so eps falls and L* is the volume's race against the throat.
437 assert hardware["eps"][-1] < hardware["eps"][0]
438
439
440def test_liner_left_and_insert_back_face(hardware, replayed, config):
441 rp, _ = replayed
442 st = rp["stations"]
443 t_l = config.ablative_cooling.initial_thickness * 1e3
444 for k in range(len(rp["t"])):
445 want = min(t_l - st[f"liner{i}"]["recession_mm"][k] for i in range(4))
446 assert hardware["liner_min_mm"][k] == pytest.approx(want, abs=1e-4)
447 # With the case declared the insert's back face is its first interface (graphite/phenolic).
448 assert hardware["insert_back_K"] == pytest.approx(st["throat"]["T_interface_K"], abs=1e-3)
449 assert hardware["insert_back_basis"].startswith("insert back face")
450 assert any("upper bound" in a for a in hardware["model"]["assumptions"])
451
452
454 """The first replay point has already eroded (the walls start at Fire): its A_t over as built is
455 the replay's own throat_area_ratio[0], a little above 1 (audit 9.2: 0.027 % on the He burn)."""
456 rp, _ = replayed
457 r0 = hardware["summary"]["first_point_area_ratio"]
458 assert r0 == pytest.approx(rp["throat_area_ratio"][0], rel=1e-12)
459 assert 1.0 < r0 < 1.002
460
461
463 """With a case behind the insert the station's back face is the case's outer face; the insert's
464 own back face is the graphite/case interface. LE4 declares none, where the two are the same node."""
465 from engine.layerx.diag.hardware import hardware_block
466 from engine.pipeline.config_schemas import StainlessSteelCaseConfig
467
468 c = config.model_copy(deep=True)
469 c.stainless_steel_case = StainlessSteelCaseConfig(thickness=0.00635)
470 p = fake_prep(c)
471 burn = series(n=8, dt=0.4)
472 rp = rpl.replay(p, burn)
473 assert rp["available"], rp.get("error")
474 thr = rp["stations"]["throat"]
475 # LE4's liner is on, so the phenolic backs the insert out to the case.
476 assert [lay["name"] for lay in rp["wall_layers"]["throat"]["layers"]] == ["graphite", "backing", "case"]
477 hb = hardware_block(p, rp, None, burn)
478 assert hb["available"], hb.get("error")
479 assert hb["insert_back_K"] == pytest.approx(thr["T_interface_K"], abs=1e-3)
480 # the case's outer face lags the bondline while the insert heats
481 assert thr["T_back_K"][-1] < thr["T_interface_K"][-1] - 1.0
482 assert hb["insert_back_basis"].startswith("insert back face")
483
484
485def test_contour_as_built_and_eroded(hardware, replayed, config):
486 rp, _ = replayed
487 c = hardware["contour"]
488 x, r0 = np.array(c["x_mm"]), np.array(c["r0_mm"])
489 cg = config.chamber_geometry
490 R_t = math.sqrt(cg.A_throat / math.pi) * 1e3
491 R_c = cg.chamber_diameter / 2.0 * 1e3
492 j0 = int(np.argmin(np.abs(x)))
493 assert x[j0] == pytest.approx(0.0, abs=1e-6) and r0[j0] == pytest.approx(R_t, rel=1e-4)
494 assert r0[0] == pytest.approx(R_c, rel=1e-9)
495 # LE4: face-to-throat 224.4 mm (the config's own comment; audit 9.2 section 4)
496 assert c["x_face_mm"] == pytest.approx(-224.4, abs=0.1)
497 st = rp["stations"]
498 for k, r in enumerate(c["frames"]["r_mm"]):
499 r = np.array(r)
500 # throat: the replay's eroded throat radius, D_t(t)/2
501 assert r[j0] == pytest.approx(rp["D_throat_mm"][k] / 2.0, abs=1e-3)
502 # barrel: the first liner station's recession
503 assert r[0] - r0[0] == pytest.approx(st["liner0"]["recession_mm"][k], abs=1e-3)
504 # the insert recedes over its whole length (its narrowest point is the solver's throat);
505 # the nozzle downstream of the insert does not recede in the model
506 x_end = c["x_insert_mm"][1] if c.get("x_insert_mm") else 0.0
507 assert r.min() == pytest.approx(rp["D_throat_mm"][k] / 2.0, abs=1e-3)
508 assert np.allclose(r[x > x_end + 1e-6], r0[x > x_end + 1e-6], atol=1e-9)
509 # the liner end: the last liner station's recession (insert upstream edge)
510 je = int(np.argmin(np.abs(x - c["x_liner_end_mm"])))
511 if abs(x[je] - c["x_liner_end_mm"]) < 1e-6:
512 assert r[je] - r0[je] == pytest.approx(st["liner3"]["recession_mm"][k], abs=1e-3)
513 assert c["liner_r_mm"][0] == pytest.approx(r0[0] + config.ablative_cooling.initial_thickness * 1e3, abs=1e-3)
514 assert c["insert_r_mm"][j0] == pytest.approx(r0[j0] + config.graphite_insert.initial_thickness * 1e3, abs=1e-3)
515
516
518 from engine.layerx.diag.hardware import separation
519
520 pa = 101325.0
521 # M_e 2.7: (1.88 * 2.7 - 1)^-0.64 = 4.076^-0.64 = 0.40686
522 crit = (1.88 * 2.7 - 1.0) ** -0.64
523 assert crit == pytest.approx(0.40686, abs=1e-5)
524 pe = [0.45 * pa, 0.405 * pa, 0.35 * pa, 1.1 * pa]
525 out = separation([0.0, 1.0, 2.0, 3.0], pe, [pa] * 4, [2.7] * 4)
526 assert out["summerfield"] == [False, False, True, False]
527 assert out["schmucker"] == [False, True, True, False]
528 assert out["flag"] is True
529 assert out["min_ratio"] == pytest.approx(0.35) and out["t_min"] == 2.0
530 # the ambient above which Schmucker separates this exit: p_e/crit; the exit pressure below which
531 # it separates at this ambient: p_a crit
532 assert out["schmucker_pa_crit_psia"][3] == pytest.approx(1.1 * pa / crit / PSI, rel=1e-5)
533 assert out["schmucker_pe_sep_psia"][0] == pytest.approx(pa * crit / PSI, rel=1e-5)
534 full = separation([0.0], [1.05 * pa], [pa], [2.7])
535 assert full["flag"] is False and full["margin"] == pytest.approx(1.05 / 0.4, rel=1e-6)
536
537
539 """Pad, He: p_e/p_a ~1.0-1.1 against Summerfield's 0.4 and Schmucker's ~0.41 (audit 9.2 s5)."""
540 sep = hardware["separation"]
541 assert sep["flag"] is False
542 assert 0.95 < sep["min_ratio"] < 1.2
543 assert all(0.39 < c < 0.43 for c in sep["schmucker_ratio"])
544 assert sep["pe_pa"][0] == pytest.approx(sep["pe_psia"][0] * PSI, rel=1e-6)
545 m = sep["model"]
546 assert "Summerfield" in m["source"] and "Schmucker" in m["source"]
547
548
550 rp, _ = replayed
551 isp = hardware["isp"]
552 cea = prep.link.sampler.runner.cea_cache
553 for k in range(len(rp["t"])):
554 ideal, cl, nl, dl = isp["ideal_s"][k], isp["cstar_loss_s"][k], isp["nozzle_loss_s"][k], isp["delivered_s"][k]
555 assert ideal - cl - nl == pytest.approx(dl, abs=1e-5)
556 assert dl == pytest.approx(rp["isp_s"][k], abs=1e-6)
557 eta = rp["eta_cstar"][k]
558 assert cl == pytest.approx((1.0 - eta) * ideal, abs=1e-5)
559 # the stagnation part by its closed form, with kappa from the chamber solve
560 Pc, MR, e, ka = rp["pc_psia"][k] * PSI, rp["mr"][k], rp["eps"][k], rp["kappa"][k]
561 cf_pc, cf_p0 = cea.eval_cf_vac(MR, Pc, e), cea.eval_cf_vac(MR, Pc / ka, e)
562 stag = eta * rp["cstar_ideal"][k] * (cf_pc - cf_p0 + (ka - 1.0) * AMBIENT * e / Pc) / 9.80665
563 assert isp["stagnation_loss_s"][k] == pytest.approx(stag, abs=2e-4)
564 z = isp["zeta_n"]
565 assert isp["zeta_n_loss_s"][k] == pytest.approx(eta * rp["cstar_ideal"][k] * (1 - z) * cf_p0 / 9.80665, abs=2e-4)
566 assert "D16" in isp["model"]["inputs"]["nozzle_efficiency"]["provenance"]
567
568
569def test_isp_ideal_is_cea_by_hand(hardware, replayed, prep):
570 """The ideal is CEA's shifting-equilibrium Isp: checked against rocketcea itself at two points
571 (vacuum Isp and c* from CEA, less the ambient term p_a eps c*/(Pc g0)), not through the table."""
572 cea_obj = pytest.importorskip("rocketcea.cea_obj")
573 rp, _ = replayed
574 cache = prep.link.sampler.runner.cea_cache
575 C = cea_obj.CEA_Obj(oxName=cache.config.ox_name, fuelName=cache.config.fuel_name)
576 for k in (0, len(rp["t"]) - 1):
577 pc_psia, MR, e = rp["pc_psia"][k], rp["mr"][k], rp["eps"][k]
578 isp_vac = C.get_Isp(Pc=pc_psia, MR=MR, eps=e)
579 cstar = C.get_Cstar(Pc=pc_psia, MR=MR) * 0.3048
580 hand = isp_vac - AMBIENT * e * cstar / (pc_psia * PSI * 9.80665)
581 assert hardware["isp"]["ideal_s"][k] == pytest.approx(hand, rel=3e-3)
582
583
585 from engine.layerx.diag.hardware import hardware_block
586
587 assert hardware_block(prep, {"available": False, "error": "x"})["available"] is False
588 assert hardware_block(prep, None)["available"] is False
589 bad = hardware_block(prep, {"available": True, "t": [0.1], "stations": {}, "A_throat_m2": ["?"]})
590 assert bad["available"] is False and bad["error"]
591
592
593# ------------------------------------------------------------------ 6. the engine card
594
595
597 """supply_K is the supply's share of K0. The line exit zeroes K0, so the drop left is the exit
598 dump alone and holds no supply share; left in, the chug loop subtracted a supply drop from the
599 dump (audit D7: gate 1.330 -> 1.248 after a feed-fit write, nothing physical changed)."""
600 from engine.pipeline.stability.analysis import _chug_feed_drop
601
602 c = config.model_copy(deep=True)
603 for side in c.feed_system.values():
604 side.supply_K = 0.6
605 out = line_exit_config(c)
606 assert all(side.supply_K == 0.0 for side in out.feed_system.values())
607 assert all(side.supply_K == 0.6 for side in c.feed_system.values()) # a copy; the design keeps it
608 # The chug loop's resistance on the line-exit copy: the dump drop, untouched.
609 dump = 1.79e5 # Pa, ~26 psi: the 6.8 kN LOX exit dump
610 assert _chug_feed_drop(out, "oxidizer", dump, 1.87, 1140.0) == dump
611 # and without the fix the same call would have subtracted the supply's share
612 assert _chug_feed_drop(c, "oxidizer", dump, 1.87, 1140.0) < dump
613
614
616 """LE4 carries supply_K 0, so the D7 fix leaves its line-exit copy (and the baseline) as it was."""
617 assert all(side.supply_K == 0.0 for side in config.feed_system.values())
618 a = line_exit_config(config)
619 b = copy.deepcopy(config)
620 for side in b.feed_system.values():
621 side.K0, side.K1, side.phi_type, side.fittings, side.roughness_m = 0.0, 0.0, "none", [], None
622 assert a.model_dump() == b.model_dump()
623
624
626 """End to end: the replay's chug margin on a design that carries supply_K is the one on the same
627 design without it (both through the line-exit copy)."""
628 c = config.model_copy(deep=True)
629 for side in c.feed_system.values():
630 side.supply_K = 0.6
631 short = series(n=8, dt=0.4)
632 a = rpl.replay(fake_prep(c), short)
633 b = rpl.replay(fake_prep(config), short)
634 assert a["available"] and b["available"]
635 assert a["chug_margin"] == b["chug_margin"]
636
637
638@pytest.fixture(scope="module")
639def card(config):
640 from engine.layerx.card import build_card
641
642 return build_card(config, center_pa=575.0 * PSI, ambient_pa=AMBIENT)
643
644
646 from engine.layerx.card import expansion_chamber
647
648 plain = card.chamber_model(ambient_pressure=AMBIENT)
649 ex = expansion_chamber(card, prep.link.sampler.runner.cea_cache, ambient_pressure=AMBIENT)
650 for mo, mf in ((1.80, 1.20), (1.87, 1.29), (1.95, 1.25), (0.0, 0.0)):
651 assert ex.evaluate(mo, mf) == plain.evaluate(mo, mf)
652 assert ex.expansion_scale(1.5, 2.7e6) == 1.0
653 m = ex.model_block()
654 assert m["name"] and m["source"] and m["assumptions"]
655 assert m["inputs"]["eps0"]["value"] == pytest.approx(card.exit_area / card.throat_area, rel=1e-12)
656 # LE4's table has an eps axis, so the option can act (a 2-D table would hold the scale at 1)
657 assert m["inputs"]["cea_eps_axis"]["value"] is True
658
659
661 """Throat 4 % larger in area, exit fixed: v_vac x Cf_vac(eps0/1.04)/Cf_vac(eps0), from the table;
662 chamber pressure and flows untouched."""
663 from engine.layerx.card import expansion_chamber
664
665 cea = prep.link.sampler.runner.cea_cache
666 plain = card.chamber_model(ambient_pressure=AMBIENT)
667 ex = expansion_chamber(card, cea, ambient_pressure=AMBIENT)
668 plain.throat_area = ex.throat_area = card.throat_area * 1.04
669 mo, mf = 1.90, 1.27
670 a, b = plain.evaluate(mo, mf), ex.evaluate(mo, mf)
671 assert b.pressure == a.pressure and b.mdot_total == a.mdot_total
672 eps0 = card.exit_area / card.throat_area
673 ratio = cea.eval_cf_vac(a.mixture_ratio, a.pressure, eps0 / 1.04) / cea.eval_cf_vac(a.mixture_ratio, a.pressure, eps0)
674 assert ratio < 1.0 # less expansion, less vacuum thrust coefficient
675 assert (b.thrust + AMBIENT * card.exit_area) / (a.thrust + AMBIENT * card.exit_area) == pytest.approx(ratio, rel=1e-12)
676 assert b.specific_impulse == pytest.approx(b.thrust / (b.mdot_total * 9.80665), rel=1e-12)
677
678
680 """Off the card's O/F range the excess propellant passes the throat unburned (CardChamber's
681 ``burning``). With the Cf_vac ratio forced to 1 the expansion chamber must be the card's, at any
682 throat and any O/F: it priced thrust on the total flow instead (O/F 20 at a 0.1 % larger throat:
683 2489 N against the card's 0 on LE4 (3)'s card)."""
684 from engine.layerx.card import expansion_chamber
685
686 plain = card.chamber_model(ambient_pressure=AMBIENT)
687 ex = expansion_chamber(card, prep.link.sampler.runner.cea_cache, ambient_pressure=AMBIENT)
688 ex.cf_vac = lambda mr, p, eps: 1.0
689 plain.throat_area = ex.throat_area = card.throat_area * 1.001
690 lo, hi = card.chamber.cstar.x0, card.chamber.cstar.x_max
691 cases = [(1.90, 1.27), (2.0, 0.1), (0.5, 1.0), (hi * 1.6, 1.0), (lo * 0.5, 1.0)]
692 assert any(not lo <= mo / mf <= hi for mo, mf in cases)
693 for mo, mf in cases:
694 a, b = plain.evaluate(mo, mf), ex.evaluate(mo, mf)
695 assert b.thrust == pytest.approx(a.thrust, rel=1e-12, abs=1e-9), (mo, mf)
696 assert b.pressure == a.pressure and b.specific_impulse == pytest.approx(a.specific_impulse, rel=1e-12, abs=1e-12)
697
698
700 """At the replay's eroded throat and EngineDesign's own flows, the plain card's thrust runs high
701 (it keeps the as-built eps); the expansion-scaled card closes most of it (audit D4-B)."""
702 from engine.layerx.card import expansion_chamber
703
704 rp, _ = replayed
705 plain = card.chamber_model(ambient_pressure=AMBIENT)
706 ex = expansion_chamber(card, prep.link.sampler.runner.cea_cache, ambient_pressure=AMBIENT)
707 k = len(rp["t"]) - 1
708 plain.throat_area = ex.throat_area = rp["A_throat_m2"][k]
709 F = rp["thrust_N"][k]
710 e_plain = plain.evaluate(rp["mdot_O"][k], rp["mdot_F"][k]).thrust / F - 1.0
711 e_ex = ex.evaluate(rp["mdot_O"][k], rp["mdot_F"][k]).thrust / F - 1.0
712 assert e_plain > 2e-3 # +0.4 % on this burn
713 assert abs(e_ex) < 0.4 * abs(e_plain)
714 assert ex.eps_clamped == 0
715
716
718 """The narrowest drawn wall must be D_t0 + 2 s_throat, the solver's throat: with the insert's
719 downstream half left as built, the drawing kept the as-built throat while the run reported the
720 throat area growing (2026-10-03)."""
721 import numpy as np
722
723 from engine.layerx.diag.hardware import eroded_radius
724
725 x = np.linspace(-60.0, 40.0, 401)
726 r_t0 = 23.9
727 r0 = r_t0 + 0.004 * x ** 2 # a throat at x = 0, opening both ways
728 stations = {"throat": {"kind": "graphite", "recession_mm": [0.0, 0.47]}}
729 for k, s in enumerate((0.0, 0.47)):
730 wall = eroded_radius(x, r0, stations, k, x_liner_end=-12.0, x_insert_end=12.0)
731 assert 2 * wall.min() == pytest.approx(2 * (r_t0 + s), abs=1e-9)
732 # Downstream of the insert nothing moves.
733 wall = eroded_radius(x, r0, stations, 1, x_liner_end=-12.0, x_insert_end=12.0)
734 assert np.allclose(wall[x > 12.0 + 1e-9], r0[x > 12.0 + 1e-9])
735
736
738 """LE4's insert sits in the liner's phenolic out to the 304 case (~46 mm). Over a burn:
739
740 * the case stays at ambient: the phenolic's penetration depth sqrt(alpha t) is ~0.7 mm;
741 * the phenolic still draws ~k dT / sqrt(pi alpha t) ~0.4 MW/m2 from a ~2000 K insert, which a 6 mm
742 graphite slab (rho c t ~7.7 kJ/m2K) feels as ~100-200 K over 3 s: the insert runs cooler and its
743 throat erodes less than the adiabatic upper bound (no case declared, nothing behind the insert).
744 """
745 c_case = config.model_copy(deep=True)
746 c_none = config.model_copy(deep=True)
747 c_none.stainless_steel_case = None
748 burn = series(n=8, dt=0.4)
749 with_case = rpl.replay(fake_prep(c_case), burn)
750 without = rpl.replay(fake_prep(c_none), burn)
751 assert with_case["available"] and without["available"]
752 thr_c, thr_n = with_case["stations"]["throat"], without["stations"]["throat"]
753 assert max(abs(v - 300.0) for v in thr_c["T_back_K"]) < 1.0 # the steel never warms
754 assert thr_c["T_interface_K"][-1] < thr_n["T_back_K"][-1] - 50.0 # the insert loses heat to it
755 g_case = with_case["throat_area_ratio"][-1] - 1.0
756 g_none = without["throat_area_ratio"][-1] - 1.0
757 assert 0.0 < g_case < g_none # below the adiabatic bound
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
static emmc_card_info_t card
Definition emmc_stm32.c:55
test_soak_refuses_another_replays_walls(config)
test_contour_as_built_and_eroded(hardware, replayed, config)
test_hardware_follows_the_replay_points(hardware, replayed)
test_axial_sidecar_is_rows_of_t_by_columns_of_x(replayed)
test_the_legacy_path_refuses_an_eroding_engine(config)
test_expansion_chamber_scales_vacuum_velocity_by_cf_vac(card, prep)
test_a_line_exit_replay_ignores_supply_k(config, burn)
test_expansion_chamber_follows_enginedesign_at_the_eroded_throat(card, prep, replayed)
test_an_untracked_liner_still_erodes(config, burn)
test_chug_sees_the_design_point_by_default(config, monkeypatch)
test_line_exit_drops_the_supply_share_with_k0(config)
hardware(prep, replayed, burn)
test_stations_carry_recession_temperatures_and_load(replayed, config)
test_chug_sees_the_eroded_chamber_when_asked(config, monkeypatch)
test_a_graphite_only_engine_now_erodes(config, burn)
test_the_phenolic_backing_cools_the_insert_and_shields_the_case(config)
test_the_replay_keeps_its_old_keys(replayed)
test_every_kept_array_is_one_value_per_replay_point(replayed)
series(int n=69, float dt=0.05, po=(560.0, 600.0), pf=(565.0, 605.0))
test_isp_waterfall_closes_and_its_terms_are_the_thrust_identities(hardware, replayed, prep)
test_soak_on_le4_is_an_adiabatic_upper_bound(replayed)
test_erodes_names_the_engines_with_a_receding_wall(config)
test_expansion_chamber_is_the_card_at_the_as_built_throat(card, prep)
_slab_solver(L=0.01, k=0.5, rho=1500.0, cp=1200.0, T0=400.0, dT=100.0)
test_insert_back_face_with_a_declared_case_is_the_bondline(config)
test_soak_window_is_three_conduction_times_of_the_slowest_layer(replayed, config)
test_injector_drop_is_line_exit_less_the_dump_and_chamber(replayed)
test_expansion_chamber_prices_thrust_on_the_flow_that_burns(card, prep)
test_eps_lstar_and_contraction_from_the_areas_by_hand(hardware, replayed, config)
test_summary_first_point_is_against_as_built(hardware, replayed)
test_station_loads_are_the_solvers_own_barrel_and_throat_loads(replayed)
test_isp_ideal_is_cea_by_hand(hardware, replayed, prep)
test_cstar_efficiency_is_its_breakdown(replayed)
_solve_capturing(config, monkeypatch, **kw)
test_liner_left_and_insert_back_face(hardware, replayed, config)
fake_prep(config, float dt=0.05, float ambient=AMBIENT)
test_insulated_slab_soaks_to_its_stored_heat_on_the_fourier_clock()