STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_combustion_stability_audit.py
Go to the documentation of this file.
1"""Regression tests for the 2026-09-26 stability/feed audit (STAB-1..10, FEED-1, FEED-4).
2
3Every expected value comes from outside the code under test: a closed form worked here, a
4quadrature, a root-find of an independently written characteristic equation, or CoolProp.
5"""
6
7import math
8import unittest
9
10import numpy as np
11from scipy.optimize import fsolve
12
13from engine.pipeline.stability import acoustic, analysis, chug, core
14from engine.pipeline.stability.acoustic import GasState
15from engine.pipeline.stability.chug import ChugChamber, ChugStream, Regulator
16from engine.pipeline.io import load_config
17from engine.pipeline.config_schemas import ensure_chamber_geometry
18
19PSI = 6894.757
20
21# 6500 N LOX/ethanol chamber state (runner evaluation at 584.27 psia tanks, 2026-09-26).
22GAS_6500 = dict(gamma=1.1375, a_sound=1166.0, nu_g=1.6e-5, mach_nozzle_entrance=0.072)
23D_6500, L_6500 = 0.127, 0.131
24
25COMMON = dict(Pc=2.4e6, MR=2.8, mdot_total=2.71, cstar=1800.0, gamma=1.18, R=350.0, Tc=3500.0)
26
27
28def synth_diagnostics(Pc=2.4e6, **kw):
29 d = {"mdot_O": 2.0, "mdot_F": 0.71,
30 "delta_p_injector_O": 0.25 * Pc, "delta_p_injector_F": 0.25 * Pc,
31 "delta_p_feed_O": 0.10 * Pc, "delta_p_feed_F": 0.12 * Pc,
32 "D32_O": 60e-6, "D32_F": 50e-6}
33 d.update(kw)
34 return d
35
36
37class TestRayleighDriving(unittest.TestCase):
38 """STAB-2: the driving is the part of q' in phase with p', n(1 - cos wt), not n sin wt."""
39
41 f, n, gamma, ov = 1000.0, 0.5, 1.2, 1.0
42 w = 2 * np.pi * f
43 t = np.linspace(0.0, 1.0 / f, 40001)[:-1]
44 for wt in (0.3, np.pi / 2, np.pi, 1.5 * np.pi, 2 * np.pi - 0.1):
45 tau = wt / w
46 p = np.cos(w * t)
47 q = n * (np.cos(w * t) - np.cos(w * (t - tau))) # Crocco: n[p(t) - p(t - tau)]
48 in_phase = np.mean(p * q) / np.mean(p * p)
49 expected = 0.5 * w * (gamma - 1.0) * ov * in_phase
50 got = acoustic.mode_driving_rate(f, gamma, ov, n, tau)
51 self.assertAlmostEqual(got, expected, delta=1e-6 * abs(w))
52
54 f = 1000.0
55 self.assertAlmostEqual(acoustic.mode_driving_rate(f, 1.2, 1.0, 0.5, 0.5 / f),
56 np.pi * f * 0.2 * 0.5 * 2.0, places=6) # 628.3 1/s
57
58
59class TestAcousticGateCanFail(unittest.TestCase):
60 """STAB-1: at n = 0.5 the LOX/hydrocarbon 1L mode can now be driven for some lag."""
61
63 gas = GasState(**GAS_6500)
64 taus = np.linspace(1e-5, 5e-3, 5001)
65 worst = max(acoustic.fast_acoustic(D_6500, L_6500, gas, n=0.5, tau_sens=tt)["alpha_max"]
66 for tt in taus)
67 # Hand: 1L = a/2L = 4450 Hz; driving at w*tau = pi is w(g-1)(0.7)(0.5) = 1345 1/s against
68 # 0.05 pi f = 699 plus a nozzle term a(g-1)M/(2L) = 44 1/s.
69 self.assertGreater(worst, 0.0)
70
72 gas = GasState(**GAS_6500)
73 f1L = GAS_6500["a_sound"] / (2 * L_6500)
74 m = acoustic.mode_growth_rate(f1L, "1L", D_6500, L_6500, gas, n=0.5, tau_sens=1e-4)
75 drive_max = 2 * np.pi * f1L * (GAS_6500["gamma"] - 1.0) * 0.7 * 0.5
76 self.assertAlmostEqual(m["margin_worst_phase"], m["damping_total"] / drive_max, places=9)
77 # at n = n_min the mode is neutral at w*tau = pi
78 tau_pi = 0.5 / f1L
79 m_crit = acoustic.mode_growth_rate(f1L, "1L", D_6500, L_6500, gas, n=m["n_min"], tau_sens=tau_pi)
80 self.assertAlmostEqual(m_crit["alpha"], 0.0, delta=1e-6 * m_crit["damping_total"])
81
82
83class TestHalfWaveAndNozzleAdmittance(unittest.TestCase):
84 """STAB-4 (and the nozzle term of STAB-1): closed duct with a compact choked nozzle."""
85
87 self.assertEqual(core.longitudinal_mode_frequencies(1000.0, 0.5, 2), [1000.0, 2000.0])
88 res = analysis.calculate_acoustic_modes(0.131, 0.127, 3200.0, 1.1375, 373.6)
89 a = res["sound_speed"]
90 self.assertAlmostEqual(res["longitudinal_modes"][0], a / (2 * 0.131), places=6)
91
93 # p = cos(kx), rigid at x = 0, rho a u'/p' = Y at x = L => tan(kL) = i Y. Solve for complex
94 # kL near pi: its real part is the frequency, its imaginary part the nozzle decay rate.
95 g, M, a, L = 1.1375, 0.0724, 1159.85, 0.131
96 Y = 0.5 * (g - 1.0) * M
97
98 def res(x):
99 z = complex(x[0], x[1])
100 r = np.tan(z) - 1j * Y
101 return [r.real, r.imag]
102 zr, zi = fsolve(res, [np.pi, 0.0], xtol=1e-14)
103 f1 = a * zr / (2 * np.pi * L)
104 decay = a * abs(zi) / L
105 self.assertAlmostEqual(core.longitudinal_mode_frequencies(a, L, 1)[0], f1, delta=1e-6 * f1)
106 self.assertAlmostEqual(core.nozzle_damping_rate(a, L, M, g), decay, delta=1e-4 * decay)
107
109 f, D, nu, g, Pr = 4450.0, 0.127, 1.6e-5, 1.1375, 0.75
110 w = 2 * np.pi * f
111 expected = np.sqrt(w * nu / 2.0) / (D / 2.0) * (1.0 + (g - 1.0) / np.sqrt(Pr))
112 self.assertAlmostEqual(core.viscous_damping_rate(f, D, nu, g, Pr), expected, places=9)
113
114
115class TestNyquistAllCrossings(unittest.TestCase):
116 """STAB-5: a -3pi crossing the old -pi-only test missed (GM read 4.27, loop unstable)."""
117
118 Pc = 3.61e6
119 O = dict(mdot=0.9907, eta=0.3745, dPf=648654.0, L=0.6093, A=1.534e-4, tau=1.638e-3)
120 F = dict(mdot=0.7122, eta=0.1775, dPf=625208.0, L=0.9913, A=3.534e-4, tau=13.85e-3)
121 cstar, At, Lstar, gamma = 1872.7, 4.353e-4, 1.093, 1.178
122
123 def case(self):
124 mk = lambda n, d: ChugStream(n, mdot=d["mdot"], eta_inj=d["eta"], Pc=self.Pc, dP_feed=d["dPf"],
125 feed_length=d["L"], feed_area=d["A"], tau_conv=d["tau"])
126 return [mk("O", self.O), mk("F", self.F)], ChugChamber(self.cstar, self.At, self.Lstar, self.gamma)
127
128 def L_indep(self, s):
129 """Open loop written here from eq. 3.3, not imported."""
130 G = np.sqrt(self.gamma) * (2 / (self.gamma + 1)) ** ((self.gamma + 1) / (2 * (self.gamma - 1)))
131 th = self.Lstar / (G * G * self.cstar)
132 acc = 0.0
133 for d in (self.O, self.F):
134 Z = d["L"] / d["A"] * s + 2 * d["dPf"] / d["mdot"] + 2 * d["eta"] * self.Pc / d["mdot"]
135 acc = acc + np.exp(-s * d["tau"]) / Z
136 return self.cstar / self.At / (th * s + 1) * acc
137
139 w = 2 * np.pi * np.logspace(np.log10(2), np.log10(2000), 200001)
140 Lw = self.L_indep(1j * w)
141 im = Lw.imag
142 i = np.flatnonzero(im[:-1] * im[1:] < 0)
143 fr = im[i] / (im[i] - im[i + 1])
144 re = Lw.real[i] + fr * (Lw.real[i + 1] - Lw.real[i])
145 gm_ref = 1.0 / np.max(-re[re < 0]) # ~0.90: unstable
146 streams, ch = self.case()
147 r = chug.chug_margin_fast(streams, ch)
148 self.assertLess(gm_ref, 1.0)
149 self.assertAlmostEqual(r["gain_margin"], gm_ref, delta=0.02 * gm_ref)
150 self.assertFalse(r["stable"])
151
153 def res(x):
154 F = 1 + self.L_indep(complex(x[0], x[1]))
155 return [F.real, F.imag]
156 a_ref, w_ref = fsolve(res, [0.0, 2 * np.pi * 100.0], xtol=1e-12)
157 self.assertGreater(a_ref, 0.0) # +7.4 1/s at 102.6 Hz
158 streams, ch = self.case()
159 r = chug.chug_growth_rate(streams, ch)
160 self.assertAlmostEqual(r["alpha"], a_ref, delta=0.05 * abs(a_ref))
161 self.assertAlmostEqual(r["f_chug_hz"], w_ref / (2 * np.pi), delta=0.5)
162
163
164class TestChamberTimeConstant(unittest.TestCase):
165 """STAB-9: theta_c = m_gas/mdot = L* c*/(R T) with the delivered c*."""
166
168 ch = ChugChamber(cstar=1639.13, A_t=1.5337e-3, Lstar=1.0, gamma=1.1375, R_gas=373.56, T_c=3198.8)
169 self.assertAlmostEqual(ch.theta_c(), 1639.13 / (373.56 * 3198.8), places=12) # 1.3717 ms
170
172 cfg = load_config("configs/default.yaml")
173 inp = analysis.build_stability_inputs(cfg, diagnostics=synth_diagnostics(),
174 cg=ensure_chamber_geometry(cfg), **COMMON)
175 ch = inp["chamber"]
176 self.assertAlmostEqual(ch.theta_c(), ch.Lstar * COMMON["cstar"] / (COMMON["R"] * COMMON["Tc"]),
177 places=12)
178
179
180class TestRegulatorStatus(unittest.TestCase):
181 """STAB-10: Z_hf = 0 is 'not modelled', not a with/without pair of identical numbers."""
182
183 def _case(self, Z_hf):
184 Pc = 2.4e6
185 reg = Regulator(corner_hz=3.0, Z_hf=Z_hf, enabled=True)
186 s = [ChugStream("O", 2.0, 0.2, Pc, 0.1 * Pc, 0.305, 1.43e-4, 4e-3, regulator=reg),
187 ChugStream("F", 0.71, 0.2, Pc, 0.1 * Pc, 0.305, 7.1e-5, 4e-3, regulator=reg)]
188 return s, ChugChamber(1800.0, 9.44e-4, 0.8, 1.18)
189
191 r = chug.chug_growth_rate(*self._case(0.0))
192 self.assertEqual(r["regulator_status"], "not_modelled")
193 self.assertIsNone(r["alpha_no_reg"])
194
196 r = chug.chug_growth_rate(*self._case(5.0e5))
197 self.assertEqual(r["regulator_status"], "modelled")
198 self.assertTrue(np.isfinite(r["alpha_no_reg"]))
199
200
201class TestIntegration(unittest.TestCase):
202 """STAB-3/6/7/8 through analysis on a real config."""
203
204 @classmethod
205 def setUpClass(cls):
206 cls.cfg = load_config("configs/default.yaml")
207 cls.cg = ensure_chamber_geometry(cls.cfg)
208
210 # STAB-3 + STAB-8: the gate is the Nyquist GM itself, at the low end of the mixing band.
211 res = analysis.comprehensive_stability_analysis(config=self.cfg, diagnostics=synth_diagnostics(), **COMMON)
212 band = res["chugging"]["chug_gm_band"]
213 self.assertIsNotNone(band)
214 self.assertEqual(res["chugging"]["stability_margin"], band["min"])
215 # Independent path: recompute the lags with the band's ends as overrides (compute_lags
216 # again, not the shift the band uses) and read GM straight off the Nyquist scan.
217 for frac, gm_band in ((band["fractions"][0], band["gain_margins"][0]),
218 (band["fractions"][-1], band["gain_margins"][-1])):
219 inp = analysis.build_stability_inputs(self.cfg, diagnostics=synth_diagnostics(), cg=self.cg,
220 overrides={"mixing_lag_fraction": frac}, **COMMON)
221 # same scan path as the band (accelerated when on), so only the lag shift is under test
222 gm = analysis._chug_fast(inp["streams"], inp["chamber"])["gain_margin"]
223 self.assertAlmostEqual(gm_band, gm, delta=1e-6 * gm)
224 self.assertLessEqual(band["min"], res["chugging"]["chug_gain_margin"])
225 self.assertGreaterEqual(band["max"], res["chugging"]["chug_gain_margin"])
226
228 # The tanh gate saturated at 1.3: no GM could satisfy min_stability_margin 1.3.
229 self.assertEqual(analysis.classify_stability(2.5, 2.0, float("inf"), -1.0, False, 1.5), "stable")
230 self.assertEqual(analysis.classify_stability(2.5, 1.4, float("inf"), -1.0, False, 1.5), "marginal")
231
233 # STAB-6: an exception in the physical model is 'unknown', never a neutral 'stable' 1.10.
234 orig = analysis.compute_physical_stability
235
236 def boom(*a, **k):
237 raise RuntimeError("synthetic")
238 analysis.compute_physical_stability = boom
239 try:
240 res = analysis.comprehensive_stability_analysis(config=self.cfg, diagnostics=synth_diagnostics(), **COMMON)
241 finally:
242 analysis.compute_physical_stability = orig
243 self.assertEqual(res["stability_state"], "unknown")
244 self.assertEqual(res["stability_score"], 0.0)
245 self.assertTrue(math.isnan(res["chugging"]["stability_margin"]))
246 self.assertFalse(analysis.stability_state_ok(res["stability_state"], False))
247
249 # STAB-7: a published 0.0 is zero stiffness; an absent key is a recorded assumption.
250 from engine.pipeline import assumptions
251 diag = synth_diagnostics(delta_p_injector_O=0.0)
252 diag.pop("delta_p_injector_F")
253 with assumptions.scope() as used:
254 inp = analysis.build_stability_inputs(self.cfg, diagnostics=diag, cg=self.cg, **COMMON)
255 self.assertEqual(inp["eta_inj_O"], 0.0)
256 self.assertIn("stability.delta_p_injector_F", used)
257 self.assertNotIn("stability.delta_p_injector_O", used)
258
260 # STAB-1: the uncalibrated HF model gates nothing unless asked; gated, it can fail.
261 res = analysis.comprehensive_stability_analysis(config=self.cfg, diagnostics=synth_diagnostics(), **COMMON)
262 self.assertEqual(res["acoustic"]["gate_status"], "report_only")
263 self.assertEqual(res["acoustic"]["stability_margin"], float("inf"))
264 cfg = self.cfg.model_copy(deep=True)
265 cfg.stability.acoustic_gate = "worst_phase"
266 cfg.stability.n_interaction = 0.9 # past n_min for 1L: every lag can drive it
267 gated = analysis.comprehensive_stability_analysis(config=cfg, diagnostics=synth_diagnostics(), **COMMON)
268 self.assertLess(gated["acoustic"]["stability_margin"], 1.0)
269 self.assertEqual(gated["stability_state"], "unstable")
270
271
272class TestFeedLineAcoustics(unittest.TestCase):
273 """FEED-4: CoolProp bulk modulus, Korteweg wall compliance, closure-time water hammer."""
274
276 from CoolProp.CoolProp import PropsSI
277 rho = PropsSI("D", "T", 90.0, "P", 4.03e6, "Oxygen")
278 a = PropsSI("A", "T", 90.0, "P", 4.03e6, "Oxygen") # 924.3 m/s
279 K = rho * a * a
280 D, e, E, L = 0.010922, 0.035 * 0.0254, 193e9, 0.1016
281 a_eff = a / np.sqrt(1.0 + K * D / (E * e))
282 r = analysis.analyze_feed_system_stability(L, D, rho, K, 15.8,
283 wall_modulus_pa=E, wall_thickness_m=e)
284 self.assertAlmostEqual(r["pogo_frequency"], a_eff / (4 * L), delta=1e-6 * a_eff) # ~2200 Hz
285
287 from CoolProp.CoolProp import PropsSI
288 cfg = load_config("configs/default.yaml").model_copy(deep=True)
289 cfg.fluids["oxidizer"].bulk_modulus_pa = None
290 cfg.fluids["oxidizer"].temperature = 90.0
291 K = analysis._liquid_bulk_modulus(cfg, "oxidizer", 4.03e6, "test.K")
292 ref = PropsSI("D", "T", 90.0, "P", 4.03e6, "Oxygen") * PropsSI("A", "T", 90.0, "P", 4.03e6, "Oxygen") ** 2
293 self.assertAlmostEqual(K, ref, delta=0.01 * ref) # 0.98 GPa, not 1.5
294
296 rho, K, v, L = 790.0, 1.1e9, 15.0, 0.9144
297 a = np.sqrt(K / rho)
298 tc = 50e-3 # >> 2L/a = 1.5 ms
299 r = analysis.analyze_feed_system_stability(L, 0.0109, rho, K, v, valve_closure_time_s=tc)
300 self.assertAlmostEqual(r["water_hammer_pressure"], 2 * rho * L * v / tc, places=6)
301 r0 = analysis.analyze_feed_system_stability(L, 0.0109, rho, K, v)
302 self.assertAlmostEqual(r0["water_hammer_pressure"], rho * a * v, places=6)
303
305 cfg = load_config("configs/default.yaml")
306 res = analysis.comprehensive_stability_analysis(config=cfg, diagnostics=synth_diagnostics(), **COMMON)
307 lines = res["feed_system"]["feed_lines"]
308 self.assertEqual(set(lines), {"oxidizer", "fuel"})
309 self.assertNotIn("water_hammer_margin", res["feed_system"])
310
311
312class TestDomeRegulatedCurve(unittest.TestCase):
313 """FEED-1: no invented ripple or inlet; SPE is the regulator's, acting on the COPV's drop."""
314
316 from engine.optimizer.feed_pressure_model import dome_regulated_tank_pair
317 P = 584.27 * PSI
318 _, P_O, P_F = dome_regulated_tank_pair(P, P, 4.0, 200)
319 self.assertLess(np.max(np.abs(P_O - P)), 1.0)
320 self.assertLess(np.max(np.abs(P_F - P)), 1.0)
321
323 from engine.optimizer.feed_pressure_model import (RegulatorModel,
324 generate_dome_regulated_pressure_curve)
325 P = 584.27 * PSI
326 _, Pt = generate_dome_regulated_pressure_curve(
327 P, burn_time_s=4.0, P_inlet_0_pa=4015 * PSI, P_inlet_end_pa=1800 * PSI,
328 regulator=RegulatorModel(supply_pressure_effect=0.017, source="TB 1031"))
329 self.assertAlmostEqual((Pt[-1] - P) / PSI, 0.017 * (4015 - 1800), places=6) # 37.66 psi
330 self.assertTrue(np.all(np.diff(Pt) >= 0))
331
333 from engine.optimizer.feed_pressure_model import (RegulatorDropout, RegulatorModel,
334 dome_regulated_tank_pair)
335 P = 584.27 * PSI
336 with self.assertRaises(RegulatorDropout):
337 dome_regulated_tank_pair(P, P, 4.0, 200, copv_initial_pa=1.2 * P, copv_end_pa=0.84 * P,
338 regulator=RegulatorModel(supply_pressure_effect=0.010))
339
340
341if __name__ == "__main__":
342 unittest.main()
#define max(a, b)
Definition Arduino.h:43
json_pointer res
Definition json.hpp:13957