STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
chug_timelag_benchmark.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Score the two chug time-lag models against EXTERNAL data, not against this codebase.
3
4The anchor is the validation engine of
5
6 M. Leonardi, F. Nasuti, F. Di Matteo, J. Steelant, "A methodology to study the possible
7 occurrence of chugging in liquid rocket engines during transient start-up",
8 Acta Astronautica 139 (2017) 344-356. [L17]
9
10itself a re-analysis of the NASA gaseous-hydrogen / liquid-oxygen chug rig of ref. [25]. That rig is
11useful here for three reasons that no STAR config can supply: it has a MEASURED chug frequency, a
12MEASURED stability boundary, and one propellant injected as a GAS — which is the case STAR's old
13code could not express at all, because it ran the d^2-law droplet model on both streams.
14
15Four benchmarks, in order of how much they can prove:
16
17 A Solver vs experiment. Feed chug.py the paper's own lags and check it reproduces the measured
18 66 Hz and the measured Delta_p_ox/pc ~ 0.35 inherent stability limit. Validates the loop
19 independently of any lag model.
20 B Lag model vs experiment. Same chamber, same drop size; compare each model's tau_vap against
21 the experiment-derived 4.4 ms (and tau_tot against 6.6 ms).
22 C Convection correction vs textbook. L17 eq. 8's (1 + 1.5 alpha) against Ranz-Marshall at the
23 same droplet Reynolds number.
24 D Blast radius. What each model does to the lags of STAR's own shipped engines.
25 E THE DECIDER. Each model's OWN lags driven end-to-end through chug.py, scored on the two
26 quantities ref. [25] actually measured. This is the only benchmark that compares models on an
27 output rather than on an intermediate, so it is the one that picks the shipping default.
28
29Run: python3 scripts/chug_timelag_benchmark.py
30Exit code is 0 when every benchmark that has a pass/fail criterion passes.
31"""
32
33from __future__ import annotations
34
35import math
36import os
37import sys
38
39import numpy as np
40
41sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
42
43from engine.pipeline.stability import chug, timelag # noqa: E402
44
45PA_PER_BAR = 1.0e5
46
47# ---------------------------------------------------------------------------
48# L17 Table 1 + §3 — the validation engine. Every number below is FROM THE PAPER.
49# ---------------------------------------------------------------------------
50D_C = 0.0508 # chamber diameter [m] L17 table 1
51D_TH = 0.0124 # throat diameter [m] L17 table 1
52LSTAR = 2.31 # characteristic length [m] L17 table 1
53L_C = 0.104 # chamber length [m] L17 table 1
54MR = 5.0 # mixture ratio [-] L17 table 1
55PC = 44.8 * PA_PER_BAR # chamber pressure [Pa] L17 table 1
56MDOT_OX = 0.249 # oxidizer mass flow [kg/s] L17 table 1
57V_CAV = 1.0e-5 # injector cavity volume [m^3] L17 table 1
58TC = 2038.0 # chamber temperature [K] L17 §3 (at eta_c* = 0.75)
59ETA_CSTAR = 0.75 # combustion efficiency [-] L17 §3
60
61TAU_OX_PAPER = 6.6e-3 # total oxidizer lag [s] L17 §3.1 (from the measured 66 Hz)
62TAU_FU_PAPER = 2.2e-3 # gaseous-fuel (mixing) lag [s] L17 §3.1
63TAU_VAP_PAPER = 4.4e-3 # oxidizer vaporization lag [s] L17 §3.1 (L50 / v_inj, ref. [39])
64D0_PAPER = 83.0e-6 # reference initial drop size [m] L17 §3.2
65F_CHUG_EXPERIMENT = 66.0 # measured chug frequency [Hz] L17 §3.1, ref. [25]
66DP_OX_LIMIT = 0.35 # inherent stability limit, Delta_p_ox/pc at Delta_p_fu/pc = 0.5 L17 §3.1
67DP_FU_FIXED = 0.5
68
69A_T = math.pi * (D_TH / 2.0) ** 2
70A_C = math.pi * (D_C / 2.0) ** 2
71MDOT_FU = MDOT_OX / MR
72MDOT_TOT = MDOT_OX + MDOT_FU
73
74# GH2/LOX product-gas properties at MR = 5, Tc = 2038 K. NOT from the paper: the paper does not
75# tabulate them. Handbook/CEA-typical values for a fuel-rich H2-O2 mixture, carried with an explicit
76# band so benchmark B reports a RANGE and no conclusion rests on a single guessed property.
77GAMMA = 1.26
78R_G = 640.0 # J/(kg.K), steam + excess H2 at MR 5
79K_G_BAND = (0.35, 0.70) # W/(m.K) — H2-rich products at ~2000 K
80CP_G = GAMMA * R_G / (GAMMA - 1.0)
81T_CRIT_O2 = 154.58 # K, NIST
82T_BOIL_O2 = 90.19 # K, NIST at 1 atm
83H_FG_O2 = 213000.0 # J/kg, NIST at 1 atm
84RHO_L_O2 = 1140.0 # kg/m^3
85
86
87def _cstar_from_pc() -> float:
88 """c* implied by the paper's own operating point: c* = Pc*A_t/mdot."""
89 return float(PC * A_T / MDOT_TOT)
90
91
92def _streams(eta_ox: float, eta_fu: float, tau_ox: float, tau_fu: float):
93 """L17 rig as two ChugStreams. The rig decouples the chamber from the feed lines (L17 fig. 3):
94 the injector cavities hold constant pressure, so there is no line inertance or resistance to
95 model — which is exactly why it is a clean test of the chamber+injector loop."""
96 reg = chug.Regulator(enabled=False)
97 o = chug.ChugStream("O", mdot=MDOT_OX, eta_inj=eta_ox, Pc=PC, dP_feed=0.0,
98 feed_length=0.0, feed_area=A_C, tau_conv=tau_ox, regulator=reg)
99 f = chug.ChugStream("F", mdot=MDOT_FU, eta_inj=eta_fu, Pc=PC, dP_feed=0.0,
100 feed_length=0.0, feed_area=A_C, tau_conv=tau_fu, regulator=reg)
101 return [o, f]
102
103
104def _chamber() -> chug.ChugChamber:
105 return chug.ChugChamber(cstar=_cstar_from_pc(), A_t=A_T, Lstar=LSTAR, gamma=GAMMA)
106
107
108# ---------------------------------------------------------------------------
109# A. Solver vs experiment
110# ---------------------------------------------------------------------------
111
112def bench_a() -> bool:
113 print("=" * 78)
114 print("A. chug.py vs the L17 / ref.[25] experiment (lags taken FROM the paper)")
115 print("=" * 78)
116 ch = _chamber()
117 print(f" c* implied by Pc*A_t/mdot : {ch.cstar:8.1f} m/s (eta_c* = {ETA_CSTAR} rig)")
118 print(f" theta_c = L*/(Gamma^2 c*) : {ch.theta_c()*1e3:8.3f} ms")
119
120 st = _streams(DP_OX_LIMIT, DP_FU_FIXED, TAU_OX_PAPER, TAU_FU_PAPER)
121 fast = chug.chug_margin_fast(st, ch)
122 f_pred = fast["f_chug_hz"]
123 err = abs(f_pred - F_CHUG_EXPERIMENT) / F_CHUG_EXPERIMENT * 100.0
124 print(f"\n chug frequency at the stability limit")
125 print(f" measured (ref.[25]) : {F_CHUG_EXPERIMENT:8.1f} Hz")
126 print(f" L17 constant-DTL model : {60.0:8.1f} Hz (+-5 Hz broadband resolution)")
127 print(f" L17 variable-DTL model : {65.0:8.1f} Hz")
128 print(f" STAR chug.py : {f_pred:8.1f} Hz ({err:5.1f} % from measured)")
129 ok_f = np.isfinite(f_pred) and err < 35.0
130
131 # Boundary: sweep Delta_p_ox/pc at fixed Delta_p_fu/pc and find the gain-margin crossing.
132 print(f"\n inherent stability limit, Delta_p_ox/pc at Delta_p_fu/pc = {DP_FU_FIXED}")
133 etas = np.linspace(0.15, 0.90, 151)
134 gms = [chug.chug_margin_fast(_streams(e, DP_FU_FIXED, TAU_OX_PAPER, TAU_FU_PAPER), ch)["gain_margin"]
135 for e in etas]
136 cross = None
137 for i in range(len(etas) - 1):
138 if (gms[i] - 1.0) * (gms[i + 1] - 1.0) < 0:
139 t = (1.0 - gms[i]) / (gms[i + 1] - gms[i])
140 cross = etas[i] + t * (etas[i + 1] - etas[i])
141 break
142 print(f" measured / L17 : {DP_OX_LIMIT:8.2f}")
143 if cross is None:
144 print(f" STAR chug.py : no gain-margin crossing in 0.15..0.90")
145 ok_b = False
146 else:
147 print(f" STAR chug.py : {cross:8.2f} "
148 f"({abs(cross-DP_OX_LIMIT)/DP_OX_LIMIT*100:.0f} % from measured)")
149 ok_b = abs(cross - DP_OX_LIMIT) / DP_OX_LIMIT < 0.60
150 print(f"\n -> {'PASS' if (ok_f and ok_b) else 'FAIL'}"
151 f" (frequency {'ok' if ok_f else 'off'}, boundary {'ok' if ok_b else 'off'})")
152 return ok_f and ok_b
153
154
155# ---------------------------------------------------------------------------
156# B. Lag model vs experiment
157# ---------------------------------------------------------------------------
158
161 name="LOX", phase="liquid", rho_l=RHO_L_O2, mu_l=1.9e-4, sigma_l=0.013,
162 T_boil=T_BOIL_O2, T_crit=T_CRIT_O2, h_fg=H_FG_O2, D0=D0,
163 u_inj=float("nan"), d_orifice=float("nan"), # L17 does not publish the post geometry
164 )
165
166
167def _gh2_stream() -> timelag.StreamThermo:
168 return timelag.StreamThermo(name="GH2", phase="gas")
169
170
171def bench_b() -> bool:
172 print()
173 print("=" * 78)
174 print("B. tau_vap: each model vs the experiment-derived 4.4 ms (D0 = 83 um, L17 §3.2)")
175 print("=" * 78)
176 rho_g = PC / (R_G * TC)
177 u_g = MDOT_TOT / (rho_g * A_C)
178 print(f" chamber: Pc {PC/PA_PER_BAR:.1f} bar, Tc {TC:.0f} K, MR {MR}, "
179 f"rho_g {rho_g:.2f} kg/m3, u_g {u_g:.1f} m/s")
180
181 rows = []
182 for k_g in K_G_BAND:
183 ch = timelag.ChamberThermo(Pc=PC, Tc=TC, MR=MR, rho_g=rho_g, u_g=u_g, k_g=k_g, cp_g=CP_G)
184 streams = {"O": _lox_stream(D0_PAPER), "F": _gh2_stream()}
185 for model, conv in (("d2_law", "none"),
186 ("leonardi_dtl", "none"),
187 ("leonardi_dtl", "leonardi_eq8")):
188 lags = timelag.compute_lags(streams, ch, model=model, mix_fraction=0.5,
189 convection=conv)
190 rows.append((f"{model}/{conv}" if model == "leonardi_dtl" else model,
191 k_g, lags["O"], lags["F"]))
192
193 print(f"\n {'model':<28}{'k_g':>6}{'tau_vap':>11}{'err vs 4.4ms':>14}"
194 f"{'tau_tot(O)':>12}{'err vs 6.6ms':>14}{'tau(F)':>9}")
195 best = {}
196 for model, k_g, lo, lf in rows:
197 e_v = (lo.tau_vap - TAU_VAP_PAPER) / TAU_VAP_PAPER * 100.0
198 e_t = (lo.tau_total - TAU_OX_PAPER) / TAU_OX_PAPER * 100.0
199 tag = "-" if model.startswith("leonardi") else f"{k_g:.2f}"
200 print(f" {model:<28}{tag:>6}{lo.tau_vap*1e3:>10.3f}m{e_v:>13.0f}%"
201 f"{lo.tau_total*1e3:>11.3f}m{e_t:>13.0f}%{lf.tau_total*1e3:>8.3f}m")
202 best.setdefault(model, []).append(abs(e_v))
203
204 print(f"\n L17's own stated tau_vap for this point: {TAU_VAP_PAPER*1e3:.1f} ms")
205 print(f" gaseous fuel lag, measured (L17 §3.1) : {TAU_FU_PAPER*1e3:.1f} ms")
206 for name in sorted(best):
207 print(f" best-case |error| in tau_vap, {name:<28}: {min(best[name]):5.0f} %")
208 print(" -> L17 eq. 9's constant in its QUIESCENT form is the only variant inside 20 % of")
209 print(" the experiment-derived anchor, and it is the only one that does not depend on")
210 print(" the hot-gas conductivity k_g (which alone moves d2_law by a factor of 2).")
211
212 # The gas stream is the structural check: it must carry the mixing lag and NOTHING else.
213 _, _, _, lf = rows[0]
214 ok_gas = (lf.tau_atom == 0.0 and lf.tau_vap == 0.0 and lf.tau_total > 0.0)
215 print(f" gas-phase stream carries tau_mix only: {'PASS' if ok_gas else 'FAIL'} "
216 f"(atom {lf.tau_atom*1e3:.3f} ms, vap {lf.tau_vap*1e3:.3f} ms, "
217 f"mix {lf.tau_mix*1e3:.3f} ms)")
218 return ok_gas
219
220
221# ---------------------------------------------------------------------------
222# C. Convection correction vs textbook
223# ---------------------------------------------------------------------------
224
225def bench_c() -> bool:
226 print()
227 print("=" * 78)
228 print("C. L17 eq. 8 convective speed-up vs Ranz-Marshall")
229 print("=" * 78)
230 rho_g = PC / (R_G * TC)
231 mu_g = 7.0e-5 # Pa.s, H2-rich products ~2000 K (handbook)
232 u_g = MDOT_TOT / (rho_g * A_C)
233 print(f" {'Pc [bar]':>9}{'L17 1+1.5a':>13}{'Re_d':>10}{'Ranz-Marshall':>16}{'ratio':>9}")
234 ok = True
235 for pc_bar in (10.0, 44.8, 100.0, 200.0):
236 leo = timelag.convection_correction_leonardi(pc_bar)
237 rho = pc_bar * PA_PER_BAR / (R_G * TC)
238 u_slip = MDOT_TOT / (rho * A_C) # gas velocity; drop is ~stationary by comparison
239 Re_d = rho * u_slip * D0_PAPER / mu_g
240 rm = timelag.ranz_marshall_correction(Re_d)
241 print(f" {pc_bar:>9.1f}{leo:>13.3f}{Re_d:>10.1f}{rm:>16.3f}{leo/rm:>9.2f}")
242 if not (0.2 < leo / rm < 5.0):
243 ok = False
244 print("\n L17 eq. 8 depends on pressure ONLY (no slip velocity, no drop size), so it cannot")
245 print(" track Ranz-Marshall across conditions; it falls as Pc rises while the physical")
246 print(" correction rises. Both agree to within a factor of ~2 near the paper's own 44.8 bar,")
247 print(" which is where it was calibrated.")
248 print(f" -> {'PASS' if ok else 'FAIL'} (same order of magnitude over 10-200 bar)")
249 return ok
250
251
252# ---------------------------------------------------------------------------
253# D. Blast radius on STAR's own engines
254# ---------------------------------------------------------------------------
255
256def bench_d() -> bool:
257 print()
258 print("=" * 78)
259 print("D. Blast radius: what each model does to a STAR-class engine")
260 print("=" * 78)
261 # Representative 7.2 kN ethalox and 8 kN methalox points (design values, not a solve).
262 cases = [
263 ("ethalox LOX/ethanol", dict(
264 Pc=2.4e6, Tc=3094.0, MR=1.71, R_g=389.0, gamma=1.14,
265 ox=dict(name="LOX", rho=1140.0, mu=1.8e-4, sigma=0.013, Tb=90.19, Tc_=154.58,
266 hfg=213000.0, D0=80e-6, u=30.0, d=0.9e-3),
267 fu=dict(name="Ethanol", rho=789.0, mu=1.2e-3, sigma=0.0223, Tb=351.4, Tc_=514.0,
268 hfg=838000.0, D0=60e-6, u=25.0, d=0.7e-3))),
269 ("methalox LOX/CH4", dict(
270 Pc=2.4e6, Tc=3500.0, MR=2.8, R_g=360.0, gamma=1.18,
271 ox=dict(name="LOX", rho=1140.0, mu=1.8e-4, sigma=0.013, Tb=90.19, Tc_=154.58,
272 hfg=213000.0, D0=80e-6, u=30.0, d=0.9e-3),
273 fu=dict(name="Methane", rho=422.6, mu=1.1e-4, sigma=0.013, Tb=111.65, Tc_=190.56,
274 hfg=510000.0, D0=60e-6, u=35.0, d=0.7e-3))),
275 ]
276 A_c_star = math.pi * (0.10 / 2) ** 2
277 print(f" {'case':<22}{'stream':<9}{'d2_law':>10}{'leonardi':>11}{'ratio':>8}")
278 for label, c in cases:
279 rho_g = c["Pc"] / (c["R_g"] * c["Tc"])
280 cp_g = c["gamma"] * c["R_g"] / (c["gamma"] - 1.0)
281 u_g = 20.0 / (rho_g * A_c_star)
282 ch = timelag.ChamberThermo(Pc=c["Pc"], Tc=c["Tc"], MR=c["MR"], rho_g=rho_g,
283 u_g=u_g, k_g=0.20, cp_g=cp_g)
284 streams = {}
285 for key, d in (("O", c["ox"]), ("F", c["fu"])):
286 streams[key] = timelag.StreamThermo(
287 name=d["name"], phase="liquid", rho_l=d["rho"], mu_l=d["mu"], sigma_l=d["sigma"],
288 T_boil=d["Tb"], T_crit=d["Tc_"], h_fg=d["hfg"], D0=d["D0"],
289 u_inj=d["u"], d_orifice=d["d"])
290 a = timelag.compute_lags(streams, ch, model="d2_law", mix_fraction=0.0)
291 b = timelag.compute_lags(streams, ch, model="leonardi_dtl", mix_fraction=0.5)
292 for key in ("O", "F"):
293 r = b[key].tau_total / a[key].tau_total if a[key].tau_total > 0 else float("nan")
294 print(f" {label if key=='O' else '':<22}{streams[key].name:<9}"
295 f"{a[key].tau_total*1e3:>9.3f}m{b[key].tau_total*1e3:>10.3f}m{r:>8.2f}")
296 print(f" {'':<22}{' (leonardi split O: atom ' + f'{b[chr(79)].tau_atom*1e3:.3f}':<9}"
297 f" vap {b['O'].tau_vap*1e3:.3f} mix {b['O'].tau_mix*1e3:.3f} ms)")
298 return True
299
300
301# ---------------------------------------------------------------------------
302# E. The decider: each model end-to-end against the measured frequency and boundary
303# ---------------------------------------------------------------------------
304
305def _boundary_and_frequency(tau_ox: float, tau_fu: float):
306 """(Delta_p_ox/pc at the gain-margin crossing, chug frequency there)."""
307 ch = _chamber()
308 etas = np.linspace(0.10, 0.95, 341)
309 gms = [chug.chug_margin_fast(_streams(e, DP_FU_FIXED, tau_ox, tau_fu), ch)["gain_margin"]
310 for e in etas]
311 cross = None
312 for i in range(len(etas) - 1):
313 if (gms[i] - 1.0) * (gms[i + 1] - 1.0) < 0:
314 t = (1.0 - gms[i]) / (gms[i + 1] - gms[i])
315 cross = etas[i] + t * (etas[i + 1] - etas[i])
316 break
317 e = cross if cross is not None else DP_OX_LIMIT
318 f = chug.chug_margin_fast(_streams(e, DP_FU_FIXED, tau_ox, tau_fu), ch)["f_chug_hz"]
319 return cross, f
320
321
322def bench_e() -> bool:
323 print()
324 print("=" * 78)
325 print("E. THE DECIDER — each model's own lags, end-to-end, vs what ref.[25] measured")
326 print("=" * 78)
327 rho_g = PC / (R_G * TC)
328 u_g = MDOT_TOT / (rho_g * A_C)
329 MIX = 0.5
330 streams = {"O": _lox_stream(D0_PAPER), "F": _gh2_stream()}
331
332 trials = [("L17's own stated lags (reference)", TAU_OX_PAPER, TAU_FU_PAPER, None)]
333 for conv in ("none", "leonardi_eq8", "ranz_marshall"):
334 ch = timelag.ChamberThermo(Pc=PC, Tc=TC, MR=MR, rho_g=rho_g, u_g=u_g,
335 k_g=0.50, cp_g=CP_G)
336 lag = timelag.compute_lags(streams, ch, model="leonardi_dtl",
337 mix_fraction=MIX, convection=conv)
338 trials.append((f"leonardi_dtl convection={conv}",
339 lag["O"].tau_total, lag["F"].tau_total, "leonardi_dtl"))
340 for k_g in K_G_BAND:
341 ch = timelag.ChamberThermo(Pc=PC, Tc=TC, MR=MR, rho_g=rho_g, u_g=u_g, k_g=k_g, cp_g=CP_G)
342 lag = timelag.compute_lags(streams, ch, model="d2_law", mix_fraction=MIX)
343 trials.append((f"d2_law + mix k_g={k_g:.2f} W/mK",
344 lag["O"].tau_total, lag["F"].tau_total, "d2_law"))
345 ch = timelag.ChamberThermo(Pc=PC, Tc=TC, MR=MR, rho_g=rho_g, u_g=u_g, k_g=0.50, cp_g=CP_G)
346 lag = timelag.compute_lags(streams, ch, model="d2_law", mix_fraction=0.0)
347 trials.append(("d2_law, no mix (STAR before this change)",
348 lag["O"].tau_total, lag["F"].tau_total, "d2_law_old"))
349
350 print(f"\n {'lags from':<40}{'tau_O':>8}{'tau_F':>8}{'f':>8}{'f err':>8}"
351 f"{'bnd':>7}{'bnd err':>9}{'score':>7}")
352 scores: Dict[str, float] = {}
353 for label, t_o, t_f, key in trials:
354 c, f = _boundary_and_frequency(t_o, t_f)
355 fe = abs(f - F_CHUG_EXPERIMENT) / F_CHUG_EXPERIMENT * 100.0
356 be = abs(c - DP_OX_LIMIT) / DP_OX_LIMIT * 100.0 if c else float("nan")
357 print(f" {label:<40}{t_o*1e3:>7.2f}m{t_f*1e3:>7.2f}m{f:>8.1f}"
358 f"{(f-F_CHUG_EXPERIMENT)/F_CHUG_EXPERIMENT*100:>7.0f}%{c if c else float('nan'):>7.2f}"
359 f"{(c-DP_OX_LIMIT)/DP_OX_LIMIT*100 if c else float('nan'):>8.0f}%{fe+be:>7.0f}")
360 if key:
361 scores[label] = fe + be
362
363 winner = min(scores, key=scores.get)
364 print(f"\n score = |frequency error| + |boundary error|, in percent. Lower is better.")
365 print(f" WINNER: {winner} (score {scores[winner]:.0f})")
366 d2 = [v for k, v in scores.items() if k.startswith("d2_law + mix")]
367 print(f" d2_law spread across the k_g band alone: {min(d2):.0f} to {max(d2):.0f}")
368 print(f" leonardi_dtl convection=none has NO k_g dependence — the hot-gas conductivity")
369 print(f" drops out of the lag entirely, and it is a property nobody on this program measures.")
370 ok = winner.startswith("leonardi_dtl convection=none")
371 print(f"\n -> {'PASS' if ok else 'FAIL'}: shipping default should be "
372 f"time_lag_model=leonardi_dtl, convection=none")
373 return ok
374
375
376# ---------------------------------------------------------------------------
377# F. Feed-line inertance vs the closed form
378# ---------------------------------------------------------------------------
379#
380# Benchmarks A and E run with feed_length = 0 (the L17 rig decouples its lines), so the line
381# inertance term I*s in Z_feed -- the term that lifts LE4's gate from ~1.01 to ~1.33 (Layer X
382# audit 9.4 section 2) -- had no check at all (audit 5.3). This one is the constant-time-lag
383# chug loop with the feed line's inertia, the system Summerfield set up (M. Summerfield, "A Theory
384# of Unstable Combustion in Liquid Propellant Rocket Systems", J. American Rocket Society 21(5),
385# 108-114, 1951), reduced to one stream so its stability boundary is closed-form:
386#
387# L(iw) = K_c exp(-iw tau) / ((1 + iw theta) (Z + iw I)), Z = R + 1/G real
388# arg L = -(w tau + atan(w theta) + atan(w I / Z)) strictly decreasing in w
389# |L| = K_c / (sqrt(1 + w^2 theta^2) sqrt(Z^2 + w^2 I^2)) strictly decreasing in w
390#
391# so the worst negative-real-axis crossing is the first (phase -pi), at the unique root w_c of
392# w tau + atan(w theta) + atan(w I/Z) = pi, and GM = sqrt(1 + w_c^2 theta^2) sqrt(Z^2 + w_c^2 I^2)/K_c.
393# On the boundary itself, for any w: tau*(w) = (pi - atan(w theta) - atan(w I/Z))/w and
394# K_c*(w) = sqrt(1 + w^2 theta^2) sqrt(Z^2 + w^2 I^2). Hand-derived from the model's own equations:
395# it proves the code solves the loop it states, with inertance, not that the loop is the engine.
396
397def inertance_closed_form(K_c: float, theta: float, tau: float, Z: float, I: float):
398 """(GM, f [Hz]) of the one-stream loop above, by bisection on the phase equation."""
399 lo, hi = 1e-6, 2.0 * math.pi * 1e5
400 phase = lambda w: w * tau + math.atan(w * theta) + math.atan(w * I / Z) - math.pi # noqa: E731
401 for _ in range(200):
402 mid = 0.5 * (lo + hi)
403 if phase(mid) > 0:
404 hi = mid
405 else:
406 lo = mid
407 w = 0.5 * (lo + hi)
408 return math.sqrt(1.0 + (w * theta) ** 2) * math.sqrt(Z * Z + (w * I) ** 2) / K_c, w / (2.0 * math.pi)
409
410
411def bench_f() -> bool:
412 print("=" * 78)
413 print("F. feed-line inertance: chug.py vs the closed-form one-stream boundary")
414 print("=" * 78)
415 # LE4's fuel side at t = 0.05 s of the helium burn, rounded: 1.21 kg/s, eta 0.35 at 2.69 MPa,
416 # c* 1567 m/s, A_t 1.795e-3 m2, L* 1.359 m, R 372 J/(kg K), Tc 3205 K, lag 17 ms.
417 mdot, eta, pc, tau = 1.21, 0.35, 2.69e6, 17.0e-3
418 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)
419 area = math.pi * 0.01092 ** 2 / 4.0
420 ok = True
421 print(f" K_c {ch.K_c():.4g} Pa.s/kg theta {ch.theta_c() * 1e3:.3f} ms tau {tau * 1e3:.1f} ms")
422 print(f" {'I [1/m]':>10} {'GM code':>9} {'GM exact':>9} {'err':>8} {'f code':>8} {'f exact':>8}")
423 for I in (0.0, 1495.0, 10297.0, 30000.0):
424 st = chug.ChugStream("F", mdot=mdot, eta_inj=eta, Pc=pc, dP_feed=1.0e5, feed_length=I * area,
425 feed_area=area, tau_conv=tau, regulator=chug.Regulator(enabled=False))
426 Z = st.resistance() + 1.0 / st.G_inj()
427 gm_x, f_x = inertance_closed_form(ch.K_c(), ch.theta_c(), tau, Z, I)
428 fast = chug.chug_margin_fast([st], ch)
429 err = fast["gain_margin"] / gm_x - 1.0
430 ok &= abs(err) < 5e-3 and abs(fast["f_chug_hz"] / f_x - 1.0) < 1e-2
431 print(f" {I:10.0f} {fast['gain_margin']:9.4f} {gm_x:9.4f} {err * 100:7.3f}% {fast['f_chug_hz']:8.2f} {f_x:8.2f}")
432 # On the boundary: tau and K_c set from the closed form at w0 must read GM = 1 at f0.
433 for f0 in (15.0, 25.0, 40.0):
434 w0, I = 2.0 * math.pi * f0, 10297.0
435 st = chug.ChugStream("F", mdot=mdot, eta_inj=eta, Pc=pc, dP_feed=1.0e5, feed_length=I * area,
436 feed_area=area, tau_conv=0.0, regulator=chug.Regulator(enabled=False))
437 Z = st.resistance() + 1.0 / st.G_inj()
438 th = ch.theta_c()
439 tau_b = (math.pi - math.atan(w0 * th) - math.atan(w0 * I / Z)) / w0
440 K_b = math.sqrt(1.0 + (w0 * th) ** 2) * math.sqrt(Z * Z + (w0 * I) ** 2)
441 st.tau_conv = tau_b
442 chb = chug.ChugChamber(cstar=K_b * ch.A_t, A_t=ch.A_t, Lstar=ch.Lstar, gamma=ch.gamma,
443 R_gas=ch.R_gas, T_c=ch.T_c * (K_b * ch.A_t / ch.cstar))
444 fast = chug.chug_margin_fast([st], chb)
445 okb = abs(fast["gain_margin"] - 1.0) < 5e-3 and abs(fast["f_chug_hz"] / f0 - 1.0) < 1e-2
446 ok &= okb
447 print(f" boundary at {f0:4.0f} Hz (tau* {tau_b * 1e3:6.2f} ms): GM {fast['gain_margin']:.4f} at "
448 f"{fast['f_chug_hz']:.2f} Hz {'ok' if okb else 'OFF'}")
449 print(f"\n -> {'PASS' if ok else 'FAIL'}")
450 return ok
451
452
453def main() -> int:
454 print("chug time-lag model benchmark — external anchors only\n")
455 results = {"A solver vs experiment": bench_a(),
456 "B lag model vs experiment": bench_b(),
457 "C convection vs textbook": bench_c(),
458 "D blast radius": bench_d(),
459 "E decider (end-to-end)": bench_e(),
460 "F feed inertance vs closed form": bench_f()}
461 print()
462 print("=" * 78)
463 for k, v in results.items():
464 print(f" {'PASS' if v else 'FAIL'} {k}")
465 print("=" * 78)
466 return 0 if all(results.values()) else 1
467
468
469if __name__ == "__main__":
470 raise SystemExit(main())
#define min(a, b)
Definition Arduino.h:42
timelag.StreamThermo _gh2_stream()
inertance_closed_form(float K_c, float theta, float tau, float Z, float I)
_streams(float eta_ox, float eta_fu, float tau_ox, float tau_fu)
_boundary_and_frequency(float tau_ox, float tau_fu)
timelag.StreamThermo _lox_stream(float D0)