1"""Regression tests for the 2026-09-26 stability/feed audit (STAB-1..10, FEED-1, FEED-4).
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.
11from scipy.optimize
import fsolve
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
25COMMON = dict(Pc=2.4e6, MR=2.8, mdot_total=2.71, cstar=1800.0, gamma=1.18, R=350.0, Tc=3500.0)
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}
38 """STAB-2: the driving is the part of q' in phase with p', n(1 - cos wt), not n sin wt."""
41 f, n, gamma, ov = 1000.0, 0.5, 1.2, 1.0
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):
47 q = n * (np.cos(w * t) - np.cos(w * (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))
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)
60 """STAB-1: at n = 0.5 the LOX/hydrocarbon 1L mode can now be driven for some lag."""
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"]
69 self.assertGreater(worst, 0.0)
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)
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"])
84 """STAB-4 (and the nozzle term of STAB-1): closed duct with a compact choked nozzle."""
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)
95 g, M, a, L = 1.1375, 0.0724, 1159.85, 0.131
96 Y = 0.5 * (g - 1.0) * M
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)
109 f, D, nu, g, Pr = 4450.0, 0.127, 1.6e-5, 1.1375, 0.75
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)
116 """STAB-5: a -3pi crossing the old -pi-only test missed (GM read 4.27, loop unstable)."""
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
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"])
129 """Open loop written here from eq. 3.3, not imported."""
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
139 w = 2 * np.pi * np.logspace(np.log10(2), np.log10(2000), 200001)
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])
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"])
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)
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)
165 """STAB-9: theta_c = m_gas/mdot = L* c*/(R T) with the delivered c*."""
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)
172 cfg = load_config(
"configs/default.yaml")
174 cg=ensure_chamber_geometry(cfg), **COMMON)
176 self.assertAlmostEqual(ch.theta_c(), ch.Lstar * COMMON[
"cstar"] / (COMMON[
"R"] * COMMON[
"Tc"]),
181 """STAB-10: Z_hf = 0 is 'not modelled', not a with/without pair of identical numbers."""
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)]
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"])
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"]))
202 """STAB-3/6/7/8 through analysis on a real config."""
206 cls.
cfg = load_config(
"configs/default.yaml")
207 cls.
cg = ensure_chamber_geometry(cls.
cfg)
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"])
217 for frac, gm_band
in ((band[
"fractions"][0], band[
"gain_margins"][0]),
218 (band[
"fractions"][-1], band[
"gain_margins"][-1])):
220 overrides={
"mixing_lag_fraction": frac}, **COMMON)
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"])
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")
234 orig = analysis.compute_physical_stability
237 raise RuntimeError(
"synthetic")
238 analysis.compute_physical_stability = boom
240 res = analysis.comprehensive_stability_analysis(config=self.
cfg, diagnostics=
synth_diagnostics(), **COMMON)
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))
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)
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
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")
273 """FEED-4: CoolProp bulk modulus, Korteweg wall compliance, closure-time water hammer."""
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")
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)
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)
296 rho, K, v, L = 790.0, 1.1e9, 15.0, 0.9144
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)
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"])
313 """FEED-1: no invented ripple or inlet; SPE is the regulator's, acting on the COPV's drop."""
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)
324 generate_dome_regulated_pressure_curve)
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)
330 self.assertTrue(np.all(np.diff(Pt) >= 0))
334 dome_regulated_tank_pair)
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))
341if __name__ ==
"__main__":
test_worst_phase_margin_is_n_min_over_n(self)
test_some_lag_drives_1L(self)
test_inputs_carry_the_state(self)
test_theta_from_state(self)
test_inlet_below_setpoint_raises(self)
test_supply_pressure_effect_on_copv_drop(self)
test_no_inlet_history_is_the_flat_setpoint(self)
test_both_lines_reported(self)
test_slow_closure_is_michaud(self)
test_bulk_modulus_from_coolprop_when_unset(self)
test_lox_quarter_wave_with_wall(self)
test_kirchhoff_wall_layer(self)
test_modes_are_half_wave(self)
test_against_the_admittance_eigenproblem(self)
test_requirement_above_1p3_is_expressible(self)
test_missing_injector_drop_is_recorded_and_zero_is_zero(self)
test_acoustic_report_only_by_default(self)
test_chug_gate_is_the_gain_margin_band_minimum(self)
test_model_error_fails_closed(self)
test_gain_margin_catches_minus_3pi(self)
test_rich_tier_finds_the_growing_root(self)
test_peak_at_omega_tau_pi(self)
test_quadrature_of_p_q(self)
test_unset_impedance_is_not_modelled(self)
test_measured_impedance_is_compared(self)
synth_diagnostics(Pc=2.4e6, **kw)