STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_stability.py
Go to the documentation of this file.
1"""Layer X chug margin on the drawing's basis (engine/layerx/diag/stability.py; audit D7, 9.4).
2
3What these check, and against what:
4
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
9 ``LAYERX_GOLDEN=1``.
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.
14
15Slow cases (one LE4 helium burn, or two for the time-step check) run only with ``LAYERX_GOLDEN=1``.
16"""
17
18from __future__ import annotations
19
20import copy
21import importlib.util
22import json
23import math
24import os
25from pathlib import Path
26from typing import Any, Dict
27
28import pytest
29
30
31#: The study drawings' lockup these tests were written at [psia] (scripts/layerx_baseline.py).
32STUDY_LOCKUP_PSIA = 564.7
33
34feedtwin = pytest.importorskip("feedtwin", reason="lib/feedtwin is not installed")
35
36from engine.layerx.prepare import PSI # noqa: E402
37
38ROOT = Path(__file__).resolve().parents[1]
39BASELINE = ROOT / "docs" / "layerx" / "baseline-2026-10-07.json"
40HE = "copv_study_he"
41SLOW = os.environ.get("LAYERX_GOLDEN") == "1"
42
43#: 1/2 in. x 0.035 in. Swagelok tube, the drawing's bore on every propellant line [m].
44BORE = 10.92e-3
45AREA = math.pi * BORE ** 2 / 4.0
46
47
48def _script():
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)
52 return mod
53
54
55@pytest.fixture(scope="module")
56def le4():
57 script = _script()
58 config, _ = script.load_engine_config(BASELINE)
59 return script, config
60
61
62@pytest.fixture(scope="module")
63def prep(le4):
64 """LE4 on the helium hot-fire drawing, prepared (no burn)."""
65 from engine.core.runner import PintleEngineRunner
66 from engine.layerx import LayerXSettings, prepare
67
68 script, config = le4
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"]
73 return p
74
75
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
81
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)}
85
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}}
90
91
92@pytest.fixture(scope="module")
93def short(prep):
94 """A real EngineDesign erosion replay of the short firing: ``{"series", "replay"}``."""
95 from engine.layerx import replay as rpl
96
97 series = _series(prep)
98 rp = rpl.replay(prep, series, n=4)
99 assert rp.get("available"), rp
100 return {"series": series, "replay": rp}
101
102
103# ------------------------------------------------------------------ 1. the drawing's basis
104
105
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)."""
110 from engine.layerx.diag.stability import drawing_feed
111
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
118 # The ball valves have a bore and no length: said, not guessed.
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"])
121
122
124 """The config basis is the replay's own chug gate, re-evaluated: equal to the last bit."""
125 from engine.layerx.diag.stability import stability_block
126
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}
133
134
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."""
138 from engine.layerx.diag import stability as S
139
140 series, rp = short["series"], short["replay"]
141 runner = prep.link.sampler.runner
142 i = rp["index"][0]
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)
146 assert not notes
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)
157 # nothing else moves
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"]
161
162
164 """No line on the path and no outlet pressures: the drawing basis is the config basis."""
165 from engine.layerx.diag import stability as S
166
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
173 try:
174 blk = S.stability_block(prep, {"series": series, "replay": short["replay"]}, basis="drawing")
175 finally:
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"]
180
181
183 """``build_stability_inputs(feed=None)`` is the old call; a bad value is refused, not clipped."""
184 from engine.layerx.diag import stability as S
185
186 runner = prep.link.sampler.runner
187 rp = short["replay"]
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}})
195
196
197# ------------------------------------------------------------------ 2. the eroded geometry
198
199
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."""
204 from engine.layerx import replay as rpl
205 from engine.layerx.diag.stability import stability_block
206
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"]
213
214
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."""
218 from engine.layerx.diag.stability import stability_block
219 from engine.pipeline.config_schemas import ensure_chamber_geometry
220
221 cg = ensure_chamber_geometry(copy.deepcopy(prep.link.sampler.runner.config))
222 rp = copy.deepcopy(short["replay"])
223 n = len(rp["t"])
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"]
230
231
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."""
236 from engine.layerx.diag import stability as S
237 from engine.pipeline.config_schemas import ensure_chamber_geometry
238
239 runner = prep.link.sampler.runner
240 base = runner.config
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}
244 rp = short["replay"]
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"]
252
253
254# ------------------------------------------------------------------ 3. the start window
255
256
257def _ramp_series(dt: float, horizon: float = 0.5) -> Dict[str, Any]:
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]
264
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)
268
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)}
272
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]}}
277
278
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)."""
282 from engine.layerx.diag.stability import drawing_feed, start_window_of
283
284 fp = drawing_feed(prep)
285 w = start_window_of(prep, short, fp)
286 s = short["series"]
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]
296
297
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
303 before Fire."""
304 from engine.layerx.diag.stability import drawing_feed, start_window_of
305
306 fp = drawing_feed(prep)
307 s = short["series"]
308 travel = start_window_of(prep, short, fp)
309
310 def with_states(ox, fu):
311 return {"series": s, "network": {"branches": {"MVO": {"state": ox}, "MVF": {"state": fu}}}}
312
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)
317 # a main that never quite reaches open until step 4; None is read as shut
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]
322 # open from the first recorded step (no signal): no evidence of the opening, the travel rule holds
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"]
326
327
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."""
333 from engine.layerx.diag import stability as S
334
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)
338 g = S._gate(inp)
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"]
341 # and the ordinary case still reads the band's own argmin
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"]
344
345
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."""
349 from engine.layerx.diag.stability import stability_block
350
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"])
358
359
360# ------------------------------------------------------------------ 4. Nyquist and the lag sweep
361
362
363def _inputs_at(prep, short, t, basis="config"):
364 """The stability inputs the block used at replay time ``t``, rebuilt."""
365 from engine.layerx.diag import stability as S
366 from engine.pipeline.config_schemas import ensure_chamber_geometry
367
368 runner = prep.link.sampler.runner
369 rp = short["replay"]
370 k = rp["t"].index(t)
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)
378
379
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."""
384 import numpy as np
385 from engine.layerx.diag.stability import stability_block
386
387 blk = stability_block(prep, short, basis="drawing")
388 ny = blk["nyquist"]
389 inp = _inputs_at(prep, short, ny["t"], "drawing")
390 ch = inp["chamber"]
391 dtau = (ny["lag_fraction"] - inp["mixing_lag_fraction"]) * inp["tau_mix_basis"]
392 s = 1j * np.asarray(ny["omega"])
393 acc = 0.0
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]
403 mags = []
404 for j in k:
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])))
407 mag, w = max(mags)
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 # the gate's ~22 Hz, not the nominal lag's ~35 Hz
411
412
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
416 the gate."""
417 from engine.layerx.diag import stability as S
418
419 blk = S.stability_block(prep, short, basis="config")
420 ts = blk["tau_sweep"]
421 inp = _inputs_at(prep, short, ts["t"])
422 g = S._gate(inp)
423 band = g["band"]
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" # ethanol's vaporisation paces LE4 (audit 9.4: 12 vs 3.6 ms)
431
432
433# ------------------------------------------------------------------ 5. feed-line acoustics
434
435
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
440 from engine.layerx.diag.stability import _coolprop_liquid
441
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)
446
447
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)."""
451 from engine.layerx.diag.stability import korteweg
452
453 a0, rho, D, e, E = 923.4, 1150.2, 10.92e-3, 0.889e-3, 193e9
454 K = rho * a0 ** 2
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 # no wall stated: rigid, an upper bound
459 # a stiffer tube is faster, a thicker wall is faster
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)
462
463
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."""
468 from engine.layerx.diag import stability as S
469
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])
483 # the path and its long run (l_fu1, 0.9144 m: 313 Hz) are near; the 5 cm stub and LOX are not
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"])
486
487
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."""
491 from engine.layerx.diag import stability as S
492
493 ref = S.stability_block(prep, short, basis="drawing")
494
495 def no_state(fluid, T, p):
496 raise ValueError(f"no fluid {fluid!r}")
497
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
503
504
505# ------------------------------------------------------------------ 6. the chug loop with line inertance
506
507
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
514
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)
518
519
520def _one_stream(I: float, tau: float):
521 from engine.pipeline.stability import chug
522
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)
526 return st, ch
527
528
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."""
535 from engine.pipeline.stability import chug
536 from engine.pipeline.stability.analysis import _chug_fast
537
538 st, ch = _one_stream(I, 17.0e-3)
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)
544 if I == 10297.0:
545 gm0, _ = _closed_form(ch.K_c(), ch.theta_c(), st.tau_conv, Z, 0.0)
546 assert gm / gm0 > 1.2
547
548
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."""
553 from engine.pipeline.stability import chug
554 from engine.pipeline.stability.analysis import _chug_fast
555
556 I, w0 = 10297.0, 2 * math.pi * f0
557 st, ch = _one_stream(I, 0.0)
558 Z = st.resistance() + 1.0 / st.G_inj()
559 th = ch.theta_c()
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 # c* and T scaled together: K_c -> K, theta unchanged
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)
568
569
570# ------------------------------------------------------------------ the contract
571
572
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."""
576 from engine.layerx.diag.stability import stability_block
577
578 blk = stability_block(prep, short, basis="drawing", eroded=True)
579 json.dumps(blk, allow_nan=False)
580 assert blk["basis"] == "drawing"
581 n = len(blk["t"])
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"
590 m = blk["model"]
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"]
597
598
599# ------------------------------------------------------------------ slow: the LE4 golden burn
600
601
602@pytest.fixture(scope="module")
603def golden(le4):
604 if not SLOW:
605 pytest.skip("slow LE4 burn: set LAYERX_GOLDEN=1")
606 from engine.core.runner import PintleEngineRunner
607 from engine.layerx import LayerXSettings, prepare, run_prepared
608
609 script, config = le4
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"]
616
617
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."""
621 from engine.layerx.diag.stability import stability_block
622
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
628
629
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."""
634 from engine.core.runner import PintleEngineRunner
635 from engine.layerx import LayerXSettings, prepare, run_prepared
636 from engine.layerx.diag.stability import stability_block
637
638 p50, r50, _ = golden
639 script, config = le4
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
649 # ...while the ungraded minimum still carries the ramp sample
650 assert stability_block(p5, r5, basis="config")["worst"]["margin"] < 1.25
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
int32_t p
Definition flight_e2e.c:78
_one_stream(float I, float tau)
test_eroded_reproduces_the_solvers_own_eroded_chug(prep, short)
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)
Dict[str, Any] _series(prep, int n_fire=5, float dt=0.05)
test_drawing_basis_on_a_drawing_that_declares_nothing_changes_nothing(prep, short)
test_eroded_on_an_uneroded_chamber_changes_nothing(prep, short)
test_tau_sweep_contains_the_gate_and_the_nominal(prep, short)
test_nyquist_is_the_models_open_loop_by_hand(prep, short)
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)