1"""Layer X chug margin on the drawing's basis (engine/layerx/diag/stability.py; audit D7, 9.4).
3What these check, and against what:
5* **The config basis is today's replay, exactly.** The diagnostic re-solves EngineDesign's
6 chamber at the replay's own points and geometry; its gate must equal the replay's
7 ``chug_margin`` to the last bit, or it is not the same model and "other basis" comparisons mean
8 nothing. Checked on a short real replay here, and on the LE4 golden burn (1.398) when
10* **The drawing basis takes the drawing's numbers and nothing else.** Its inertance is checked
11 against sum(L)/A by hand from the shipped helium drawing (0.14 m and 0.9644 m of 10.92 mm tube);
12 its resistance against 2 (line drop + dump) / mdot by hand at the same point. A drawing and a
13 run that declare nothing for it change nothing.
15Slow cases (one LE4 helium burn, or two for the time-step check) run only with ``LAYERX_GOLDEN=1``.
18from __future__
import annotations
25from pathlib
import Path
26from typing
import Any, Dict
32STUDY_LOCKUP_PSIA = 564.7
34feedtwin = pytest.importorskip(
"feedtwin", reason=
"lib/feedtwin is not installed")
38ROOT = Path(__file__).resolve().parents[1]
39BASELINE = ROOT /
"docs" /
"layerx" /
"baseline-2026-10-07.json"
41SLOW = os.environ.get(
"LAYERX_GOLDEN") ==
"1"
45AREA = math.pi * BORE ** 2 / 4.0
49 spec = importlib.util.spec_from_file_location(
"layerx_baseline", ROOT /
"scripts" /
"layerx_baseline.py")
50 mod = importlib.util.module_from_spec(spec)
51 spec.loader.exec_module(mod)
55@pytest.fixture(scope="module")
58 config, _ = script.load_engine_config(BASELINE)
62@pytest.fixture(scope="module")
64 """LE4 on the helium hot-fire drawing, prepared (no burn)."""
69 drawing = script.find_drawing(HE)
70 cfg = copy.deepcopy(config)
71 p = prepare(cfg, PintleEngineRunner(cfg), drawing, LayerXSettings(drawing_id=drawing.id, tank_pressure_psia=STUDY_LOCKUP_PSIA), [])
72 assert p.ok, [c
for c
in p.checks
if c.status ==
"fail"]
76def _series(prep, n_fire: int = 5, dt: float = 0.05) -> Dict[str, Any]:
77 """A short steady firing at the LE4 helium burn's first-step state (t = 0.05 s of the golden
78 run): line exits 557.0 / 541.5 psia, tank outlets 574.9 / 574.7 psia."""
79 t = [round(k * dt, 10)
for k
in range(n_fire + 1)]
80 fire = [
False] + [
True] * n_fire
82 def side(inlet: float, outlet: float, mdot: float, T: float) -> Dict[str, Any]:
83 return {
"inlet_psia": [inlet] * len(t),
"outlet_psia": [outlet] * len(t),
"tank_psia": [outlet - 0.4] * len(t),
84 "mdot": [0.0] + [mdot] * n_fire,
"liquid_K": [T] * len(t)}
86 return {
"t": t,
"firing": fire,
"dt": [dt] * len(t),
87 "ox": side(556.9864, 574.8921, 1.8517, 90.0),
88 "fuel": side(541.5243, 574.7475, 1.2149, 293.15),
89 "chamber": {
"pc_psia": [14.7] + [390.47] * n_fire}}
92@pytest.fixture(scope="module")
94 """A real EngineDesign erosion replay of the short firing: ``{"series", "replay"}``."""
98 rp = rpl.replay(prep, series, n=4)
99 assert rp.get(
"available"), rp
100 return {
"series": series,
"replay": rp}
107 """Sum of L/A over the drawn lines tank -> injector, each on its own bore. Hand: LOX
108 l_ox1 + l_ox2 = 0.07 + 0.07 m, fuel l_fu1 + l_fu2 = 0.9144 + 0.05 m, all 10.92 mm tube
109 (audit 9.4 section 2: 1495 and 10297 1/m)."""
112 fp = drawing_feed(prep)
113 assert [ln[
"id"]
for ln
in fp[
"oxidiser"][
"lines"]] == [
"l_ox1",
"l_ox2"]
114 assert [ln[
"id"]
for ln
in fp[
"fuel"][
"lines"]] == [
"l_fu1",
"l_fu2"]
115 assert fp[
"oxidiser"][
"inertance"] == pytest.approx(0.14 / AREA, rel=1e-9)
116 assert fp[
"fuel"][
"inertance"] == pytest.approx(0.9644 / AREA, rel=1e-9)
117 assert round(fp[
"oxidiser"][
"inertance"]) == 1495
and round(fp[
"fuel"][
"inertance"]) == 10297
119 assert any(
"MV-OX" in f
and "inertance is not counted" in f
for f
in fp[
"oxidiser"][
"flags"])
120 assert all(
"estimated" in ln[
"length_provenance"]
for ln
in fp[
"fuel"][
"lines"])
124 """The config basis is the replay's own chug gate, re-evaluated: equal to the last bit."""
127 blk = stability_block(prep, short, basis=
"config")
128 assert blk[
"available"], blk
129 ref = short[
"replay"][
"chug_margin"]
130 replay_pts = [m
for m, rp
in zip(blk[
"margin"], blk[
"replay_point"])
if rp]
131 assert replay_pts == ref
132 assert blk[
"check"] == {
"against":
"replay.chug_margin (config basis)",
"points": len(ref),
"max_abs_diff": 0.0}
136 """At one point, the drawing basis's streams carry the drawing's inertance and
137 R = 2 (tank outlet - line exit + exit dump) / mdot, every other input unchanged."""
140 series, rp = short[
"series"], short[
"replay"]
141 runner = prep.link.sampler.runner
143 Pc, diag = S._solve_point(runner, S._with_geometry(runner.config,
None),
144 rp[
"inlet_O_psia"][0] * PSI, rp[
"inlet_F_psia"][0] * PSI)
145 fd, notes = S._drawing_feed_at(S.drawing_feed(prep), series, i, diag)
147 inp_c = S._inputs(runner.config, Pc, diag)
148 inp_d = S._inputs(runner.config, Pc, diag, feed=fd)
149 assert inp_c[
"feed_basis"] ==
"config" and inp_d[
"feed_basis"] ==
"caller"
150 for key, skey, L
in ((
"O",
"ox", 0.14), (
"F",
"fuel", 0.9644)):
151 sc = next(s
for s
in inp_c[
"streams"]
if s.name == key)
152 sd = next(s
for s
in inp_d[
"streams"]
if s.name == key)
153 line = (series[skey][
"outlet_psia"][i] - series[skey][
"inlet_psia"][i]) * PSI
154 dump = diag[f
"delta_p_feed_{key}"]
155 assert sd.inertance() == pytest.approx(L / AREA, rel=1e-9)
156 assert sd.resistance() == pytest.approx(2.0 * (line + dump) / sd.mdot, rel=1e-12)
158 for attr
in (
"mdot",
"eta_inj",
"Pc",
"tau_conv"):
159 assert getattr(sd, attr) == getattr(sc, attr)
160 assert inp_d[
"chamber"] == inp_c[
"chamber"]
164 """No line on the path and no outlet pressures: the drawing basis is the config basis."""
167 series = copy.deepcopy(short[
"series"])
168 for s
in (
"ox",
"fuel"):
169 series[s].pop(
"outlet_psia")
170 empty = {
"oxidiser": {
"inertance":
None},
"fuel": {
"inertance":
None}}
171 real = S.drawing_feed
172 S.drawing_feed =
lambda prep: empty
174 blk = S.stability_block(prep, {
"series": series,
"replay": short[
"replay"]}, basis=
"drawing")
176 S.drawing_feed = real
177 assert blk[
"available"], blk
178 assert [m
for m, r
in zip(blk[
"margin"], blk[
"replay_point"])
if r] == short[
"replay"][
"chug_margin"]
179 assert blk[
"margin_other"] == blk[
"margin"]
183 """``build_stability_inputs(feed=None)`` is the old call; a bad value is refused, not clipped."""
186 runner = prep.link.sampler.runner
188 Pc, diag = S._solve_point(runner, S._with_geometry(runner.config,
None),
189 rp[
"inlet_O_psia"][0] * PSI, rp[
"inlet_F_psia"][0] * PSI)
190 a = S._inputs(runner.config, Pc, diag)
191 b = S._inputs(runner.config, Pc, diag, feed={})
192 assert [(s.feed_length, s.dP_feed)
for s
in a[
"streams"]] == [(s.feed_length, s.dP_feed)
for s
in b[
"streams"]]
193 with pytest.raises(ValueError):
194 S._inputs(runner.config, Pc, diag, feed={
"fuel": {
"inertance": -1.0}})
201 """``eroded=True`` is the coupled solver's ``chug_eroded_geometry=True``, re-evaluated: the
202 replay run with that flag and this diagnostic agree to the last bit, so the geometry the
203 diagnostic rebuilds from the replay's columns is the geometry the solver had."""
207 rp = rpl.replay(prep, short[
"series"], n=4, chug_eroded_geometry=
True)
208 assert rp.get(
"available")
and rp[
"chug_eroded_geometry"]
is True
209 blk = stability_block(prep, {
"series": short[
"series"],
"replay": rp}, basis=
"config", eroded=
True)
210 assert blk[
"eroded"]
is True
211 assert blk[
"check"][
"max_abs_diff"] == 0.0
212 assert [m
for m, r
in zip(blk[
"margin"], blk[
"replay_point"])
if r] == rp[
"chug_margin"]
216 """Turned on against a chamber that has not eroded (the replay's geometry set to the design's),
217 ``eroded=True`` gives exactly the design-point answer."""
221 cg = ensure_chamber_geometry(copy.deepcopy(prep.link.sampler.runner.config))
222 rp = copy.deepcopy(short[
"replay"])
224 rp.update(A_throat_m2=[cg.A_throat] * n, V_chamber_m3=[cg.volume] * n, A_exit_m2=[cg.A_exit] * n,
225 D_chamber_mm=[cg.chamber_diameter * 1e3] * n)
226 res = {
"series": short[
"series"],
"replay": rp}
227 on = stability_block(prep, res, basis=
"drawing", eroded=
True)
228 off = stability_block(prep, res, basis=
"drawing", eroded=
False)
229 assert on[
"margin"] == off[
"margin"]
and on[
"margin_other"] == off[
"margin_other"]
233 """A larger throat lowers the chamber gain K_c = c*/A_t and shortens theta_c = L* c*/(R T), and
234 the loop gain falls: +4 % of throat area must raise the margin. At the same point the eroded
235 chamber gain is the design's times A_t0/A_t, by hand."""
239 runner = prep.link.sampler.runner
241 cg = ensure_chamber_geometry(copy.deepcopy(base))
242 grown = {
"A_throat": cg.A_throat * 1.04,
"volume": cg.volume,
"A_exit": cg.A_exit,
243 "chamber_diameter": cg.chamber_diameter}
245 cfg_now = S._with_geometry(base, grown)
246 Pc, diag = S._solve_point(runner, cfg_now, rp[
"inlet_O_psia"][0] * PSI, rp[
"inlet_F_psia"][0] * PSI)
247 frozen = S._inputs(base, Pc, diag)
248 eroded = S._inputs(cfg_now, Pc, diag)
249 assert eroded[
"chamber"].K_c() == pytest.approx(frozen[
"chamber"].K_c() / 1.04, rel=1e-12)
250 assert eroded[
"chamber"].Lstar == pytest.approx(frozen[
"chamber"].Lstar / 1.04, rel=1e-12)
251 assert S._gate(eroded)[
"gate"] > S._gate(frozen)[
"gate"]
258 """One continuous start sampled at ``dt``: both mains open linearly over 50 ms (the drawing's
259 travel), the line exits climbing from 45 % to full with the valve, then a slow rise of
260 10 psi/s, as the helium burn's do. The same function of time at every ``dt``."""
261 n = int(round(horizon / dt))
262 t = [round(-dt + k * dt, 10)
for k
in range(n + 2)]
263 fire = [tt > 1e-12
for tt
in t]
265 def p(full: float, tt: float) -> float:
266 x =
min(
max(tt / 0.05, 0.0), 1.0)
267 return full * (0.45 + 0.55 * x) + 10.0 *
max(tt - 0.05, 0.0)
269 def side(inlet: float, outlet: float, mdot: float, T: float) -> Dict[str, Any]:
270 return {
"inlet_psia": [
p(inlet, tt)
for tt
in t],
"outlet_psia": [outlet + 10.0 *
max(tt - 0.05, 0.0)
for tt
in t],
271 "mdot": [mdot *
min(
max(tt / 0.05, 0.0), 1.0)
for tt
in t],
"liquid_K": [T] * len(t)}
273 return {
"t": t,
"firing": fire,
"dt": [dt] * len(t),
274 "ox": side(556.9864, 574.8921, 1.8517, 90.0),
275 "fuel": side(541.5243, 574.7475, 1.2149, 293.15),
276 "chamber": {
"pc_psia": [390.47 *
min(
max(tt / 0.05, 0.0), 1.0)
for tt
in t]}}
280 """Window = the mains' travel (the drawing's 0.05 s) + 3 line time constants, the longest of
281 tau = (sum L/A) mdot / (2 (tank outlet - Pc)) by hand: the fuel's, ~4.9 ms (audit 9.5)."""
284 fp = drawing_feed(prep)
285 w = start_window_of(prep, short, fp)
287 tau_f = (0.9644 / AREA) * 1.2149 / (2.0 * (574.7475 - 390.47) * PSI)
288 tau_o = (0.14 / AREA) * 1.8517 / (2.0 * (574.8921 - 390.47) * PSI)
289 assert w[
"t_open"] == pytest.approx(0.05)
and w[
"valve_travel"][
"MVO"][
"value"] == 0.05
290 assert w[
"tau_line_s"][
"fuel"] == pytest.approx(tau_f, rel=1e-9)
291 assert w[
"tau_line_s"][
"oxidiser"] == pytest.approx(tau_o, rel=1e-9)
292 assert 4.5e-3 < tau_f < 5.5e-3
293 assert w[
"start_window_s"] == pytest.approx(0.05 + 3.0 * tau_f, rel=1e-12)
294 assert start_window_of(prep, short, fp, 0.2)[
"start_window_s"] == 0.2
295 assert s[
"firing"][1]
299 """With ``result.network`` (DATA-CONTRACT 2) the mains' recorded opening sets t_open: the first
300 step both are open after one was seen short of open. feedtwin's trace reads a valve with no
301 signal as fully open (``network_trace._opening``), so a recording that is open from its first
302 step proves nothing and the drawing's travel rule is kept -- never a window that starts
306 fp = drawing_feed(prep)
308 travel = start_window_of(prep, short, fp)
310 def with_states(ox, fu):
311 return {
"series": s,
"network": {
"branches": {
"MVO": {
"state": ox},
"MVF": {
"state": fu}}}}
313 w = start_window_of(prep, with_states([0.0, 0.5, 1.0, 1.0, 1.0, 1.0], [0.0, 1.0, 1.0, 1.0, 1.0, 1.0]), fp)
314 assert w[
"t_open_basis"] ==
"recorded valve state" and w[
"t_open"] == s[
"t"][2]
315 tau =
max(v
for v
in w[
"tau_line_s"].values()
if v)
316 assert w[
"start_window_s"] == pytest.approx(s[
"t"][2] + 3.0 * tau, rel=1e-12)
318 w = start_window_of(prep, with_states([
None, 0.5, 0.9, 0.99, 0.9995, 1.0], [0.0] + [1.0] * 5), fp)
319 assert w[
"t_open"] == s[
"t"][4]
320 w = start_window_of(prep, with_states([
None] + [1.0] * 5, [
None] + [1.0] * 5), fp)
321 assert w[
"t_open_basis"] ==
"recorded valve state" and w[
"t_open"] == s[
"t"][1]
323 w = start_window_of(prep, with_states([1.0] * 6, [1.0] * 6), fp)
324 assert w[
"t_open_basis"] == travel[
"t_open_basis"]
and w[
"t_open"] == travel[
"t_open"]
325 assert w[
"start_window_s"] == travel[
"start_window_s"]
329 """chug_band's minimum includes the nominal point, but its ``at_min_fraction`` is the band
330 grid's argmin. When the nominal lag is lower than every band sample (here: a band that ends
331 short of the nominal fraction, on a margin that falls with the lag), the gate *is* the nominal
332 and its frequency is the nominal's -- not the frequency at the band's lowest sample."""
335 inp = dict(
_inputs_at(prep, short, short[
"replay"][
"t"][0]))
336 f0 = inp[
"mixing_lag_fraction"]
337 inp[
"chug_band"] = (0.0, f0 - 0.1)
339 assert g[
"gate"] == g[
"gm_nominal"] <
min(g[
"band"][
"gain_margins"])
340 assert g[
"gate_fraction"] == f0
and g[
"f_gate_hz"] == g[
"f_nominal_hz"]
342 g1 = S._gate(
_inputs_at(prep, short, short[
"replay"][
"t"][0]))
343 assert g1[
"gate_fraction"] == g1[
"band"][
"at_min_fraction"] == 1.0
and g1[
"f_gate_hz"] != g1[
"f_nominal_hz"]
347 """The same start sampled at 5 and 50 ms: the minimum over every step (the start included)
348 moves with where the samples fall in the valve ramp; the graded minimum does not."""
351 fine = stability_block(prep, {
"series":
_ramp_series(0.005)}, basis=
"config")
352 coarse = stability_block(prep, {
"series":
_ramp_series(0.05)}, basis=
"config")
353 assert fine[
"available"]
and coarse[
"available"], (fine, coarse)
354 assert fine[
"worst"][
"t"] < 0.05
and fine[
"worst"][
"margin"] < coarse[
"worst"][
"margin"] - 0.05
355 assert abs(fine[
"settled_min"][
"margin"] - coarse[
"settled_min"][
"margin"]) < 0.01
356 assert fine[
"settled_min"][
"t"] >= fine[
"start_window_s"] - 1e-9
357 assert any(fine[
"in_start"])
and not any(i
for i, t
in zip(fine[
"in_start"], fine[
"t"])
if t >= fine[
"start_window_s"])
364 """The stability inputs the block used at replay time ``t``, rebuilt."""
368 runner = prep.link.sampler.runner
371 geo = S._replay_geometry(rp, ensure_chamber_geometry(copy.deepcopy(runner.config)))
372 Pc, diag = S._solve_point(runner, S._with_geometry(runner.config, S._geometry_at(geo, t, k)),
373 rp[
"inlet_O_psia"][k] * PSI, rp[
"inlet_F_psia"][k] * PSI)
374 if basis ==
"config":
375 return S._inputs(runner.config, Pc, diag)
376 fd, _ = S._drawing_feed_at(S.drawing_feed(prep), short[
"series"], rp[
"index"][k], diag)
377 return S._inputs(runner.config, Pc, diag, feed=fd)
381 """L(iw) = K_c/(theta iw + 1) * sum exp(-iw tau_k)/(I_k iw + R_k + 2 eta_k Pc/mdot_k), written
382 out from each stream's numbers (regulator not modelled), at the lag that sets the gate. Where
383 it crosses the negative real axis, |L| = 1/gate and the frequency is the gate's."""
387 blk = stability_block(prep, short, basis=
"drawing")
389 inp =
_inputs_at(prep, short, ny[
"t"],
"drawing")
391 dtau = (ny[
"lag_fraction"] - inp[
"mixing_lag_fraction"]) * inp[
"tau_mix_basis"]
392 s = 1j * np.asarray(ny[
"omega"])
394 for st
in inp[
"streams"]:
395 Z = (st.feed_length / st.feed_area) * s + 2.0 * st.dP_feed / st.mdot + 2.0 * st.eta_inj * st.Pc / st.mdot
396 acc = acc + np.exp(-s *
max(st.tau_conv + dtau, 0.0)) / Z
397 theta = ch.Lstar * ch.cstar / (ch.R_gas * ch.T_c)
398 L = (ch.cstar / ch.A_t) / (theta * s + 1.0) * acc
399 assert np.allclose(ny[
"re"], L.real, rtol=1e-9, atol=1e-12)
400 assert np.allclose(ny[
"im"], L.imag, rtol=1e-9, atol=1e-12)
401 im, re = np.asarray(ny[
"im"]), np.asarray(ny[
"re"])
402 k = [j
for j
in range(len(im) - 1)
if im[j] * im[j + 1] < 0
and re[j] < 0]
405 x = im[j] / (im[j] - im[j + 1])
406 mags.append((-(re[j] + x * (re[j + 1] - re[j])), ny[
"omega"][j] + x * (ny[
"omega"][j + 1] - ny[
"omega"][j])))
408 assert 1.0 / mag == pytest.approx(ny[
"gain_margin"], rel=2e-3)
409 assert w / (2 * math.pi) == pytest.approx(ny[
"f_cross_hz"], rel=1e-2)
410 assert 15.0 < ny[
"f_cross_hz"] < 30.0
414 """The sweep is the band, densely: at the band's own five fractions it equals chug_band's
415 gain margins exactly, at the nominal fraction the nominal margin, and its minimum is at most
419 blk = S.stability_block(prep, short, basis=
"config")
420 ts = blk[
"tau_sweep"]
424 for f, gm
in zip(band[
"fractions"], band[
"gain_margins"]):
425 assert ts[
"margin"][ts[
"fraction"].index(f)] == gm
426 f0 = inp[
"mixing_lag_fraction"]
427 assert ts[
"margin"][ts[
"fraction"].index(f0)] == g[
"gm_nominal"]
428 assert ts[
"min"][
"margin"] <= g[
"gate"] + 1e-15
429 assert ts[
"tau_ms"][ts[
"fraction"].index(f0)] == pytest.approx(ts[
"nominal_ms"], rel=1e-12)
430 assert ts[
"stream"] ==
"F"
437 """LOX at 90 K / 555 psia and ethanol at 293.15 K / 540 psia, straight from CoolProp (audit
438 9.4 section 4: 1150.2 kg/m3, 923 m/s; 792.6 kg/m3, 1182 m/s)."""
439 import CoolProp.CoolProp
as CP
442 for fluid, T, p, rho, a
in ((
"oxygen", 90.0, 555.0, 1150.2, 923.4), (
"ethanol", 293.15, 540.0, 792.6, 1182.3)):
443 r, c, name = _coolprop_liquid(fluid, T, p * PSI)
444 assert r == CP.PropsSI(
"D",
"T", T,
"P", p * PSI, name)
and c == CP.PropsSI(
"A",
"T", T,
"P", p * PSI, name)
445 assert r == pytest.approx(rho, abs=0.1)
and c == pytest.approx(a, abs=0.5)
449 """a = a0 / sqrt(1 + rho a0^2 D / (E e)): LOX in 1/2 x 0.035 in. 316 tube, 0.981 GPa liquid,
450 10.92 mm bore, 0.889 mm wall, 193 GPa -> factor 1.031, 896 m/s (audit 9.4 section 4)."""
453 a0, rho, D, e, E = 923.4, 1150.2, 10.92e-3, 0.889e-3, 193e9
455 assert korteweg(a0, rho, D, e, E) == pytest.approx(a0 / math.sqrt(1.0 + K * D / (E * e)), rel=1e-15)
456 assert a0 / korteweg(a0, rho, D, e, E) == pytest.approx(1.031, abs=5e-4)
457 assert korteweg(a0, rho, D, e, E) == pytest.approx(896.0, abs=0.5)
458 assert korteweg(a0, rho, D, 0.0, E) == a0
460 assert korteweg(a0, rho, D, e, 2 * E) > korteweg(a0, rho, D, e, E)
461 assert korteweg(a0, rho, D, 2 * e, E) > korteweg(a0, rho, D, e, E)
465 """The fuel path (0.9644 m) quarter-wave sits at ~296 Hz and LOX's (0.14 m) at ~1600 Hz,
466 8-70x above chug: not near it. Put the chug band at the fuel's quarter-wave and it is flagged.
467 The drawing's copied wall thickness is flagged."""
470 fp = S.drawing_feed(prep)
471 series = short[
"series"]
472 ac = S.acoustics(prep, series, fp, 1, [21.8, 23.2])
473 path = {r[
"side"]: r
for r
in ac[
"lines"]
if "whole path" in r[
"line"]}
474 rho, a0, _ = S._coolprop_liquid(
"ethanol", 293.15, 0.5 * (574.7475 + 541.5243) * PSI)
475 a = S.korteweg(a0, rho, BORE, 0.889e-3, S.TUBE_MODULUS_PA)
476 assert path[
"fuel"][
"f_quarter_hz"] == pytest.approx(a / (4 * 0.9644), rel=1e-12)
477 assert path[
"fuel"][
"f_half_hz"] == pytest.approx(a / (2 * 0.9644), rel=1e-12)
478 assert path[
"fuel"][
"f_quarter_hz"] == pytest.approx(296.0, abs=3.0)
479 assert path[
"oxidiser"][
"f_quarter_hz"] == pytest.approx(1600.0, abs=15.0)
480 assert not any(r[
"near_chug"]
for r
in ac[
"lines"])
481 assert 0.0 < path[
"fuel"][
"lumped_error"] < 0.02
482 near = S.acoustics(prep, series, fp, 1, [path[
"fuel"][
"f_quarter_hz"] * 1.1])
484 assert {r[
"line"]
for r
in near[
"lines"]
if r[
"near_chug"]} == {path[
"fuel"][
"line"],
"l_fu1"}
485 assert any(
"copied line to line" in f
for f
in ac[
"flags"])
489 """A propellant CoolProp cannot state loses that side's line rows and says so; the chug margin
490 (the block's reason to exist) is unchanged."""
493 ref = S.stability_block(prep, short, basis=
"drawing")
495 def no_state(fluid, T, p):
496 raise ValueError(f
"no fluid {fluid!r}")
498 monkeypatch.setattr(S,
"_coolprop_liquid", no_state)
499 blk = S.stability_block(prep, short, basis=
"drawing")
500 assert blk[
"available"], blk
501 assert blk[
"margin"] == ref[
"margin"]
and blk[
"settled_min"] == ref[
"settled_min"]
502 assert blk[
"acoustic"] == []
and sum(
"acoustics skipped" in f
for f
in blk[
"acoustic_flags"]) == 2
508def _closed_form(K_c: float, theta: float, tau: float, Z: float, I: float):
509 """One stream, regulator off: L(iw) = K_c e^(-iw tau) / ((1 + iw theta)(Z + iw I)). Phase and
510 magnitude both fall monotonically with w, so the worst negative-axis crossing is the first:
511 w tau + atan(w theta) + atan(w I/Z) = pi, and GM = sqrt(1 + (w theta)^2) sqrt(Z^2 + (w I)^2) / K_c.
512 (The constant-lag chug loop with the line's inertia: Summerfield, J. ARS 21(5), 1951.)"""
513 from scipy.optimize
import brentq
515 w = brentq(
lambda w: w * tau + math.atan(w * theta) + math.atan(w * I / Z) - math.pi, 1e-6, 2 * math.pi * 1e5,
516 xtol=1e-14, rtol=1e-15)
517 return math.sqrt(1 + (w * theta) ** 2) * math.sqrt(Z * Z + (w * I) ** 2) / K_c, w / (2 * math.pi)
523 st = chug.ChugStream(
"F", mdot=1.21, eta_inj=0.35, Pc=2.69e6, dP_feed=1.0e5, feed_length=I * AREA,
524 feed_area=AREA, tau_conv=tau, regulator=chug.Regulator(enabled=
False))
525 ch = chug.ChugChamber(cstar=1567.0, A_t=1.795e-3, Lstar=1.359, gamma=1.13, R_gas=372.0, T_c=3205.0)
529@pytest.mark.parametrize("I", [0.0, 1495.0, 10297.0, 30000.0])
531 """The gate's own solver (EngineDesign's ``_chug_fast``: the compiled kernel when it is on, and
532 the Python ``chug_margin_fast``) on LE4's fuel side with the line's inertance, against the
533 closed form: GM within 0.5 % (the 200-point grid's interpolation), frequency within 1 %. At
534 LE4's 10297 1/m the inertance moves GM by +25 %, so a dropped I*s term cannot pass."""
539 Z = st.resistance() + 1.0 / st.G_inj()
540 gm, f =
_closed_form(ch.K_c(), ch.theta_c(), st.tau_conv, Z, I)
541 for res
in (_chug_fast([st], ch), chug.chug_margin_fast([st], ch)):
542 assert res[
"gain_margin"] == pytest.approx(gm, rel=5e-3)
543 assert res[
"f_chug_hz"] == pytest.approx(f, rel=1e-2)
545 gm0, _ =
_closed_form(ch.K_c(), ch.theta_c(), st.tau_conv, Z, 0.0)
546 assert gm / gm0 > 1.2
549@pytest.mark.parametrize("f0", [15.0, 25.0, 40.0])
551 """Put the loop exactly on its stability boundary at f0 (tau* and K_c* from the closed form,
552 LE4's fuel inertance): the code must read GM = 1 at f0."""
556 I, w0 = 10297.0, 2 * math.pi * f0
558 Z = st.resistance() + 1.0 / st.G_inj()
560 st = chug.ChugStream(**{**st.__dict__,
"tau_conv": (math.pi - math.atan(w0 * th) - math.atan(w0 * I / Z)) / w0})
561 K = math.sqrt(1 + (w0 * th) ** 2) * math.sqrt(Z * Z + (w0 * I) ** 2)
562 k = K * ch.A_t / ch.cstar
563 chb = chug.ChugChamber(cstar=ch.cstar * k, A_t=ch.A_t, Lstar=ch.Lstar, gamma=ch.gamma, R_gas=ch.R_gas, T_c=ch.T_c * k)
564 assert chb.theta_c() == pytest.approx(th, rel=1e-12)
and chb.K_c() == pytest.approx(K, rel=1e-12)
565 res = _chug_fast([st], chb)
566 assert res[
"gain_margin"] == pytest.approx(1.0, abs=5e-3)
567 assert res[
"f_chug_hz"] == pytest.approx(f0, rel=1e-2)
574 """DATA-CONTRACT section 3 ``stability``: the keys, plain JSON (no NaN), a ``model`` block whose
575 every input says where it came from, and a failure that is reported, never raised."""
578 blk = stability_block(prep, short, basis=
"drawing", eroded=
True)
579 json.dumps(blk, allow_nan=
False)
580 assert blk[
"basis"] ==
"drawing"
582 assert all(len(blk[k]) == n
for k
in (
"margin",
"frequency_hz",
"index",
"in_start",
"margin_other"))
583 assert set(blk[
"worst"]) >= {
"t",
"margin",
"frequency_hz"}
and set(blk[
"settled_min"]) >= {
"t",
"margin"}
584 assert isinstance(blk[
"start_window_s"], float)
585 assert set(blk[
"nyquist"]) >= {
"t",
"omega",
"re",
"im"}
and len(blk[
"nyquist"][
"re"]) == len(blk[
"nyquist"][
"omega"])
586 assert set(blk[
"tau_sweep"]) >= {
"tau_ms",
"margin",
"nominal_ms"}
587 assert blk[
"acoustic"]
and all(set(r) >= {
"line",
"side",
"length_m",
"f_quarter_hz",
"f_half_hz",
"near_chug"}
588 for r
in blk[
"acoustic"])
589 assert set(blk[
"other_basis"]) >= {
"basis",
"margin_min",
"t"}
and blk[
"other_basis"][
"basis"] ==
"config"
591 assert set(m) == {
"name",
"source",
"assumptions",
"inputs"}
and m[
"assumptions"]
592 assert all(set(v) >= {
"value",
"unit",
"provenance"}
and v[
"provenance"]
for v
in m[
"inputs"].values())
593 assert "drawing" in m[
"inputs"][
"inertance_F"][
"provenance"]
594 assert {u[
"name"]
for u
in blk[
"unmeasured"]} >= {
"stability.mixing_lag_fraction",
"l_fu1.length"}
595 bad = stability_block(prep, short, basis=
"nonsense")
596 assert bad[
"available"]
is False and "basis" in bad[
"error"]
602@pytest.fixture(scope="module")
605 pytest.skip(
"slow LE4 burn: set LAYERX_GOLDEN=1")
607 from engine.layerx import LayerXSettings, prepare, run_prepared
610 drawing = script.find_drawing(HE)
611 cfg = copy.deepcopy(config)
612 runner = PintleEngineRunner(cfg)
613 p = prepare(cfg, runner, drawing, LayerXSettings(drawing_id=drawing.id, tank_pressure_psia=STUDY_LOCKUP_PSIA), [])
614 result = run_prepared(p, runner=runner, replay=
True, config=cfg, progress=
lambda *a:
None)
615 return p, result, json.loads(BASELINE.read_text())[
"cases"][
"he_pad"][
"metrics"]
619 """Audit 9.4 section 3: unfreezing A_t and L* adds ~+0.004 at the minimum and +0.08 at burnout
620 on [doc] (5.7 % throat growth). LE4's YAML grows the throat 4.0 %, so burnout rises ~+0.06."""
623 p, result, _ = golden
624 off = stability_block(p, result, basis=
"config", eroded=
False)
625 on = stability_block(p, result, basis=
"config", eroded=
True)
626 assert 0.04 < on[
"margin"][-1] - off[
"margin"][-1] < 0.10
627 assert 0.0 <= on[
"worst"][
"margin"] - off[
"worst"][
"margin"] < 0.01
631 """The audit's measurement (5.1): the replay's minimum reads 1.367 / 1.161 at 50 / 5 ms on
632 [doc] (1.398 / 1.191 on the YAML). Graded from the first fully-open step, a 5 ms burn and the
633 50 ms burn agree within 0.01."""
635 from engine.layerx import LayerXSettings, prepare, run_prepared
640 drawing = script.find_drawing(HE)
641 cfg = copy.deepcopy(config)
642 runner = PintleEngineRunner(cfg)
643 p5 = prepare(cfg, runner, drawing, LayerXSettings(drawing_id=drawing.id, tank_pressure_psia=STUDY_LOCKUP_PSIA, dt=0.005), [])
644 r5 = run_prepared(p5, runner=runner, replay=
True, config=cfg, progress=
lambda *a:
None)
645 for basis
in (
"config",
"drawing"):
646 a = stability_block(p50, r50, basis=basis)
647 b = stability_block(p5, r5, basis=basis)
648 assert abs(a[
"settled_min"][
"margin"] - b[
"settled_min"][
"margin"]) < 0.01, basis
650 assert stability_block(p5, r5, basis=
"config")[
"worst"][
"margin"] < 1.25
_one_stream(float I, float tau)
test_eroded_reproduces_the_solvers_own_eroded_chug(prep, short)
test_chug_with_line_inertance_against_the_closed_form(I)
test_feed_override_is_opt_in_and_validated(prep, short)
test_eroded_throat_raises_the_margin_by_the_chamber_gain(prep, short)
_inputs_at(prep, short, t, basis="config")
test_golden_start_window_removes_the_dt_dependence(golden, le4)
_closed_form(float K_c, float theta, float tau, float Z, float I)
test_gate_frequency_follows_the_nominal_when_it_sets_the_gate(prep, short)
test_line_acoustics_on_the_helium_drawing(prep, short)
test_block_follows_the_data_contract(prep, short)
test_drawing_basis_streams_by_hand(prep, short)
test_start_window_removes_the_dt_dependence(prep)
Dict[str, Any] _series(prep, int n_fire=5, float dt=0.05)
test_start_window_by_hand(prep, short)
test_drawing_basis_on_a_drawing_that_declares_nothing_changes_nothing(prep, short)
test_chug_on_the_closed_form_boundary_reads_neutral(f0)
test_eroded_on_an_uneroded_chamber_changes_nothing(prep, short)
test_tau_sweep_contains_the_gate_and_the_nominal(prep, short)
test_drawing_inertance_is_sum_L_over_A_by_hand(prep)
test_nyquist_is_the_models_open_loop_by_hand(prep, short)
test_golden_eroded_raises_burnout(golden)
Dict[str, Any] _ramp_series(float dt, float horizon=0.5)
test_config_basis_reproduces_the_replay_exactly(prep, short)
test_acoustics_failure_does_not_take_the_margin(prep, short, monkeypatch)
test_start_window_from_the_recorded_valve_state(prep, short)
test_coolprop_liquid_sound_speeds()