STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
report.py
Go to the documentation of this file.
1"""Rich stability report (<=5 s) for the final report and forward mode.
2
3Runs the RICH tiers (chug root-find, full acoustic mode set with damping budgets) and assembles the
4single payload that both the post-optimizer report and forward mode render from (plan §A5 schema).
5Every block maps to a §V visualization. Pure-ish: takes the same (config, Pc, ..., diagnostics, cg)
6as comprehensive_stability_analysis and reuses analysis.build_stability_inputs for identical extraction.
7
8[Phys §3-§6; plan A5, §V]
9"""
10
11from __future__ import annotations
12
13from typing import Any, Dict, List, Optional, Tuple
14import numpy as np
15
16from engine.pipeline.stability import chug, acoustic, analysis
17
18_PA_PER_PSI = 6894.757
19
20
21# ---------------------------------------------------------------------------
22# Visualization data builders
23# ---------------------------------------------------------------------------
24
25def _eta_window(streams, *, lo_frac: float = 0.35, hi_frac: float = 2.2,
26 floor: float = 0.02, ceil: float = 0.90) -> Tuple[float, float]:
27 """(eta_lo, eta_hi) sweep window bracketing THIS design's injector stiffness.
28
29 The window used to be a fixed 0.08..0.45 for every engine, which puts a design at eta = 0.55
30 off the right edge of its own chart and a design at eta = 0.05 off the left. Anchoring it to
31 the design point keeps the operating dot on the plot whatever the injector does."""
32 etas = [float(s.eta_inj) for s in streams if np.isfinite(s.eta_inj) and s.eta_inj > 0]
33 eta0 = float(np.mean(etas)) if etas else 0.25
34 return (float(max(floor, min(eta0 * lo_frac, 0.15))),
35 float(min(ceil, max(eta0 * hi_frac, 0.45))))
36
37
38def _chug_boundary_curve(streams, chamber, n_pts: int = 24,
39 eta_window: Optional[Tuple[float, float]] = None) -> List[List[float]]:
40 """Viz #1: the chug stability boundary in the design's own frame: x = the streams' mass-
41 weighted injector stiffness, reached by scaling every stream's drop by one factor; y = their
42 mass-weighted lag over theta_c, reached by scaling every lag by one factor k. At each x, k is
43 bisected to gain margin 1. The design itself is the point (1, 1) of both scalings, so it sits
44 in the same plane as the curve (``_chug_design_point``) -- the old curve set every stream to
45 one common eta and scaled their mean lag, and then plotted each stream against it, which is
46 two different systems on one chart."""
47 import copy
48 theta_c = chamber.theta_c()
49 if not np.isfinite(theta_c) or theta_c <= 0:
50 return []
51 eta0 = chug.mean_eta(streams)
52 tau0 = _mean_tau(streams)
53 lo_eta, hi_eta = eta_window if eta_window else _eta_window(streams)
54 curve: List[List[float]] = []
55 for eta in np.linspace(lo_eta, hi_eta, n_pts):
56 def gm_at(kfac: float) -> float:
57 sc = []
58 for s in streams:
59 s2 = copy.copy(s)
60 s2.eta_inj = float(s.eta_inj * eta / eta0)
61 s2.tau_conv = float(s.tau_conv * kfac)
62 sc.append(s2)
63 return chug.chug_margin_fast(sc, chamber)["gain_margin"] - 1.0
64 # GM is not monotone in the lag: scan for the FIRST scale that goes unstable, then bisect.
65 ks = np.geomspace(0.05, 20.0, 41)
66 fs = [gm_at(float(k)) for k in ks]
67 j = next((i for i in range(1, len(ks)) if np.isfinite(fs[i - 1]) and np.isfinite(fs[i])
68 and fs[i - 1] > 0 >= fs[i]), None)
69 if j is None or fs[0] <= 0:
70 continue
71 lo, hi = float(ks[j - 1]), float(ks[j])
72 f_lo = fs[j - 1]
73 for _ in range(24):
74 mid = float(np.sqrt(lo * hi))
75 if gm_at(mid) * f_lo > 0:
76 lo = mid
77 else:
78 hi = mid
79 curve.append([float(eta), float(tau0 * np.sqrt(lo * hi) / theta_c)])
80 return curve
81
82
83def _mean_tau(streams) -> float:
84 m = sum(max(float(s.mdot), 0.0) for s in streams)
85 return float(sum(float(s.mdot) * float(s.tau_conv) for s in streams) / m) if m > 0 else float("nan")
86
87
88def _chug_design_point(streams, chamber, band: Optional[Dict[str, Any]], inp: Dict[str, Any]) -> Dict[str, Any]:
89 """Where the design sits on the boundary chart: nominal mixing lag, and the mixing lag the gate
90 is taken at (the low end of the band). Both at the design's own mean stiffness."""
91 theta_c = chamber.theta_c()
92 eta0, tau0 = chug.mean_eta(streams), _mean_tau(streams)
93 out = {"eta": float(eta0), "tau_theta_c": float(tau0 / theta_c) if theta_c > 0 else float("nan")}
94 if band is not None and inp.get("tau_mix_basis") is not None:
95 shift = (float(band["at_min_fraction"]) - float(inp["mixing_lag_fraction"])) * float(inp["tau_mix_basis"])
96 out["gate_tau_theta_c"] = float((tau0 + shift) / theta_c) if theta_c > 0 else float("nan")
97 out["gate_mixing_fraction"] = float(band["at_min_fraction"])
98 return out
99
100
101def _stream_from_march(inp: Dict[str, Any], key: str, march: Dict[str, Any]) -> Dict[str, Any]:
102 """One stream's vaporization from the droplet march that set eta_vap
103 (combustion_physics.vaporization_profile): x from the injector face, ``L_vap_m`` where 95 % of
104 the stream's liquid is gone (None when it is not gone by the chamber end), ``L_ch_m`` the
105 chamber the march ran in (L* A_t / A_c, the volume-equivalent cylinder).
106
107 This card used to take L_vap = u_inj x tau_conv: the injection velocity held all the way down
108 the chamber, times the CHUG lag -- which on the Leonardi model includes a mixing lag that is
109 not evaporation at all. On the 6.5 kN ethalox doublet that read 675 mm against a 199 mm
110 chamber while the march that sets eta_vap had the fuel 95 % gone at 157 mm."""
111 st = march["streams"].get(key)
112 fluid = str(inp.get(f"fluid_name_{key}", key))
113 L = float(march["L_chamber"])
114 D32 = float(inp[f"D32_{key}"])
115 if st is None or st.get("instant"):
116 return {"stream": key, "fluid": fluid, "phase": str(inp.get(f"phase_{key}", "gas")), "smd_um": None,
117 "tau_conv_s": float(inp[f"tau_conv_{key}"]), "L_vap_m": None, "L_ch_m": L,
118 "frac_vaporized_end": 1.0, "vaporized_in_chamber": True, "d2_profile": [],
119 "remaining_profile": [],
120 "note": f"{fluid} enters as vapour — nothing to vaporize."}
121 x95 = st["x95"]
122 return {
123 "stream": key, "fluid": fluid, "phase": str(inp.get(f"phase_{key}", "liquid")),
124 "smd_um": float(D32 * 1e6), "smd_band_um": [float(D32 * 0.8e6), float(D32 * 1.2e6)],
125 "tau_conv_s": float(inp[f"tau_conv_{key}"]),
126 "L_vap_m": None if x95 is None else float(x95), "L_ch_m": L,
127 "frac_vaporized_end": float(st["frac_end"]),
128 "vaporized_in_chamber": x95 is not None and float(x95) <= L,
129 # Liquid mass left along the chamber: what the curve draws.
130 "remaining_profile": [[float(x), float(1.0 - f)] for x, f in st["profile"]],
131 "d2_profile": [],
132 "basis": "droplet march (the model eta_vap uses): Rosin-Rammler classes, heat-up, "
133 "d²-law with blowing, drag in the burning gas",
134 }
135
136
137def _stream_vaporization(inp: Dict[str, Any], Pc: float, key: str, n_pts: int) -> Dict[str, Any]:
138 """d^2-law droplet decay for ONE stream. ``key`` is "O" or "F". Used only when the solve
139 carried no droplet march (see ``_stream_from_march``): the drop's speed held at injection and
140 its life taken as the chug convective lag, which over-counts both ways."""
141 from engine.pipeline.assumptions import assume
142
143 D32 = float(inp[f"D32_{key}"])
144 L_ch = float(inp["L_ch"])
145 phase = str(inp.get(f"phase_{key}", "liquid"))
146 fluid = str(inp.get(f"fluid_name_{key}", key))
147 tau_vap = float(inp[f"tau_conv_{key}"])
148 side = "oxidizer" if key == "O" else "fuel"
149
150 if phase.lower().startswith("g"):
151 # A gas has no droplets to track. Say so rather than drawing a decay curve for it.
152 return {"stream": key, "fluid": fluid, "phase": phase, "smd_um": None,
153 "tau_conv_s": tau_vap, "L_vap_m": None, "L_ch_m": L_ch,
154 "vaporized_in_chamber": True, "d2_profile": [],
155 "note": f"{fluid} is injected as a gas — no atomization or vaporization to plot."}
156
157 rho = inp.get(f"rho_{key}")
158 if rho is None or not np.isfinite(float(rho)) or float(rho) <= 0.0:
159 rho = assume(f"stability.viz.rho_{side}", 1140.0 if key == "O" else 800.0, unit="kg/m^3",
160 reason=f"{side} density missing when drawing the vaporization profile")
161 rho = float(rho)
162 eta = float(inp[f"eta_inj_{key}"])
163
164 # Representative droplet axial speed: the solved injection velocity when the closure provides
165 # it, else Bernoulli with the solved Cd.
166 u = inp.get(f"u_{key}")
167 if u is not None and np.isfinite(float(u)) and float(u) > 0.0:
168 v_drop = float(u)
169 else:
170 Cd = inp.get(f"Cd_{key}")
171 if Cd is None or not np.isfinite(float(Cd)) or float(Cd) <= 0.0:
172 Cd = assume(f"stability.viz.Cd_{side}", 0.6, unit="-",
173 reason=f"solved {side} discharge coefficient unavailable for the droplet "
174 f"velocity; sharp-edged-orifice value")
175 v_drop = float(Cd) * float(np.sqrt(max(2.0 * eta * Pc / rho, 1.0)))
176
177 L_vap = v_drop * tau_vap if np.isfinite(tau_vap) else float("nan")
178 x_max = float(max(L_ch, L_vap if np.isfinite(L_vap) else L_ch) * 1.1)
179 xs = np.linspace(0.0, x_max, n_pts)
180 d2 = (np.clip(1.0 - xs / L_vap, 0.0, 1.0)
181 if (np.isfinite(L_vap) and L_vap > 0) else np.ones_like(xs))
182 return {
183 "stream": key, "fluid": fluid, "phase": phase,
184 "smd_um": float(D32 * 1e6), "smd_band_um": [float(D32 * 0.8e6), float(D32 * 1.2e6)],
185 "tau_conv_s": float(tau_vap),
186 "L_vap_m": float(L_vap), "L_ch_m": L_ch,
187 "vaporized_in_chamber": bool(np.isfinite(L_vap) and L_vap <= L_ch),
188 "d2_profile": [[float(x), float(y)] for x, y in zip(xs, d2)],
189 }
190
191
192def _vaporization_profile(inp: Dict[str, Any], Pc: float, n_pts: int = 40,
193 march: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
194 """Viz #5: droplet decay along the chamber, for BOTH streams.
195
196 The top-level keys (``L_vap_m``, ``smd_um``, ``tau_conv_s``, ``vaporized_in_chamber``) describe
197 the **rate-limiting** stream — the one that paces the burn — not the oxidizer. They used to be
198 hardwired to the oxidizer, which is right only when the oxidizer happens to be the slower
199 vaporizer. On LOX/methane it is (3.7 ms vs 2.9 ms) so the card read correctly by luck; on
200 LOX/ethanol it is not (13 ms vs 25 ms), and the card reported a 211 mm vaporization length for
201 LOX while ethanol -- the stream actually setting the lag -- was far worse. The health radar
202 scores off these keys, so it was scoring the wrong stream too.
203 """
204 if march is not None:
205 per_stream = [_stream_from_march(inp, k, march) for k in ("O", "F")]
206 # The stream the march finishes last paces the burn.
207 def _late(s):
208 return (s["L_vap_m"] is None, s["L_vap_m"] or 0.0, -s.get("frac_vaporized_end", 1.0))
209 liquid = [s for s in per_stream if s.get("smd_um") is not None]
210 rl = max(liquid, key=_late)["stream"] if liquid else str(inp.get("rate_limiting_stream", "O"))
211 else:
212 per_stream = [_stream_vaporization(inp, Pc, k, n_pts) for k in ("O", "F")]
213 rl = str(inp.get("rate_limiting_stream", "O"))
214 lead = next((s for s in per_stream if s["stream"] == rl), per_stream[0])
215 # A gas stream can never be the one to plot; fall back to the liquid if it somehow is. (A
216 # liquid with no L_vap is one not 95 % vaporized by the chamber end: the worst, not a gas.)
217 if lead.get("smd_um") is None:
218 lead = next((s for s in per_stream if s.get("smd_um") is not None), lead)
219
220 out = dict(lead)
221 out.pop("note", None)
222 out["basis"] = lead.get("basis") or ("u_inj x chug lag (no droplet march in this result)")
223 out["streams"] = per_stream
224 out["rate_limiting_stream"] = lead["stream"]
225 out["tau_sens_s"] = float(inp["tau_sens"])
226 if lead.get("smd_um") is None:
227 out["smd_um"] = float(inp["D32_O"] * 1e6)
228 out["smd_band_um"] = [float(inp["D32_O"] * 0.8e6), float(inp["D32_O"] * 1.2e6)]
229 return out
230
231
232def _sensitivity(inp: Dict[str, Any], nominal_gm: float) -> Dict[str, Any]:
233 """Sensitivity of both verdicts to the inputs nobody has measured (cheap sweeps).
234
235 Acoustic: alpha of the limiting mode over n and chi (chi scales the RATE-LIMITING stream's lag,
236 which is what tau_sens is built from). Chug: GM over the feed lengths (x0.5, x2) and over a
237 uniform scale on every conversion lag (x0.5, x2). The liquid bulk modulus does not enter the
238 lumped chug loop at all (no line compliance), so the chug verdict cannot move with it."""
239 import copy
240 D_ch, L_ch, gas, coeffs = inp["D_ch"], inp["L_ch"], inp["gas"], inp["damping_coeffs"]
241 tv = inp["tau_rate_limiting"]
242 a_n = [acoustic.fast_acoustic(D_ch, L_ch, gas, n=nn, tau_sens=inp["tau_sens"], coeffs=coeffs)["alpha_max"]
243 for nn in (0.3, 0.6)]
244 # alpha is not monotone in chi (omega*tau_sens runs through many periods), so sample the band.
245 a_chi = [acoustic.fast_acoustic(D_ch, L_ch, gas, n=inp["n_interaction"], tau_sens=cc * tv, coeffs=coeffs)["alpha_max"]
246 for cc in np.linspace(0.05, 0.30, 51)]
247
248 def gm_scaled(attr: str, k: float) -> float:
249 st = []
250 for s0 in inp["streams"]:
251 s1 = copy.copy(s0)
252 setattr(s1, attr, float(getattr(s0, attr)) * k)
253 st.append(s1)
254 return float(chug.chug_margin_fast(st, inp["chamber"])["gain_margin"])
255
256 return {"acoustic_alpha_vs_n": [float(min(a_n)), float(max(a_n))],
257 "acoustic_alpha_vs_chi": [float(min(a_chi)), float(max(a_chi))],
258 "chug_gm_nominal": float(nominal_gm),
259 "chug_gm_vs_feed_length": {"x0.5": gm_scaled("feed_length", 0.5), "x2": gm_scaled("feed_length", 2.0)},
260 "chug_gm_vs_lag_scale": {"x0.5": gm_scaled("tau_conv", 0.5), "x2": gm_scaled("tau_conv", 2.0)},
261 "chug_bulk_modulus": "not an input: the lumped chug loop has no line compliance"}
262
263
264def _chug_pole(chug_rich: Dict[str, Any]) -> Dict[str, float]:
265 """Viz #1b: dominant chug pole location in the s-plane (σ + jω)."""
266 alpha = chug_rich.get("alpha")
267 f_hz = chug_rich.get("f_chug_hz")
268 if alpha is None or f_hz is None or not np.isfinite(alpha) or not np.isfinite(f_hz):
269 return {"real": float("nan"), "imag": float("nan")}
270 return {"real": float(alpha), "imag": float(2 * np.pi * f_hz)}
271
272
273def _locus_crossing(locus: List[Dict[str, float]]) -> Dict[str, float]:
274 """Where the locus branch crosses the imaginary axis: the neutral-stability gain and frequency.
275
276 Linear interpolation in ``eta`` on the sign change of ``Re(s)``. This is the number a designer
277 reads off a root locus — "stiffen past here and the pole is in the left half-plane" — so it is
278 computed once on the backend rather than eyeballed off the chart."""
279 out = {"eta": float("nan"), "f_hz": float("nan")}
280 for a, b in zip(locus, locus[1:]):
281 if a["real"] == 0.0 or a["real"] * b["real"] < 0.0:
282 da = b["real"] - a["real"]
283 t = (0.0 - a["real"]) / da if da != 0 else 0.0
284 out["eta"] = float(a["eta"] + t * (b["eta"] - a["eta"]))
285 out["f_hz"] = float(a["f_hz"] + t * (b["f_hz"] - a["f_hz"]))
286 break
287 return out
288
289
290def _radar(chug_margin: float, ac: Dict[str, Any], vap: Dict[str, Any],
291 gate_threshold: float) -> Dict[str, Any]:
292 """Viz #7: one-glance health radar. Every axis is a ratio that is 1 at neutral stability:
293 chug GM, acoustic damping/driving per mode (nominal phase, report-only unless gated), and
294 L_ch/L_vap for vaporization. Plot caps: 3 for the margins, 1.3 for vaporization (an infinite
295 damping/driving would otherwise serialize as null)."""
296 def mode_margin(name):
297 for m in ac["modes"]:
298 if m["mode"] == name:
299 return m["margin"]
300 return float("nan")
301 lv = vap.get("L_vap_m")
302 if lv is not None and np.isfinite(lv) and lv > 0:
303 vap_complete = float(np.clip(vap["L_ch_m"] / lv, 0.0, 1.3))
304 elif vap.get("frac_vaporized_end") is not None:
305 # Not 95 % gone by the chamber end: how far along it got, on the same 1 = neutral scale.
306 vap_complete = float(np.clip(vap["frac_vaporized_end"] / 0.95, 0.0, 1.0))
307 else:
308 vap_complete = 1.3
309 def worst_phase(name):
310 for m in ac["modes"]:
311 if m["mode"] == name:
312 return m["margin_worst_phase"]
313 return float("nan")
314 axes = ["chug", "1L", "1T", "vaporization"]
315 cap = lambda v: float(np.clip(v, 0.0, 3.0)) if not np.isnan(v) else float("nan")
316 # Acoustic axes are the worst-phase margin (damping over the most driving any lag could give):
317 # the nominal-phase one flips with a few percent of an uncalibrated tau. Neither gates.
318 values = [cap(chug_margin), cap(worst_phase("1L")), cap(worst_phase("1T")), vap_complete]
319 return {"axes": axes, "values": values, "threshold": [gate_threshold] * len(axes),
320 "gated": [True, False, False, False],
321 "basis": ["chug Nyquist gain margin, worst mixing lag (the gate)",
322 "damping / worst-phase driving (uncalibrated damping; report only)",
323 "damping / worst-phase driving (uncalibrated damping; report only)",
324 "L_ch / L_vap(95 %) from the droplet march (performance, not stability)"]}
325
326
327# ---------------------------------------------------------------------------
328# Diagnostics — plain-language verdict + actionable levers (forward-mode panel §C)
329# ---------------------------------------------------------------------------
330
331_DRIVER_LABEL = {
332 "feed_inertance": "feed-line inertance",
333 "feed_resistance": "feed-line resistance",
334 "injector_stiffness": "injector stiffness (ΔP_inj)",
335 "regulator": "dome-regulator dynamics",
336 "unknown": "feed/injector coupling",
337}
338
339
340def _diagnostics(state: str, chug_margin: float, acoustic_margin: float, gate_threshold: float,
341 limiting: Optional[str], chug_rich: Dict[str, Any], ac: Dict[str, Any],
342 vap: Dict[str, Any], fallbacks: List[Dict[str, Any]],
343 acoustic_gate: str = "report_only") -> Dict[str, Any]:
344 """Turn the rich quantities into a verdict, findings, and design actions tied to the
345 sensitivity sliders (η_inj, SMD, n, χ). Derived from the SAME numbers the cards render,
346 so the headline can never disagree with the charts."""
347 findings: List[Dict[str, str]] = []
348 actions: List[Dict[str, Optional[str]]] = []
349 ac_gated = acoustic_gate != "report_only"
350
351 # --- chug (low-frequency, feed-coupled) ---
352 chug_stable = chug_rich.get("stable", True)
353 chug_f = chug_rich.get("f_chug_hz")
354 driver = chug_rich.get("driver", "unknown")
355 driver_lbl = _DRIVER_LABEL.get(driver, driver)
356 if not chug_stable or not (chug_margin >= gate_threshold):
357 txt = "Chug " + ("grows" if not chug_stable else "is short of margin")
358 if chug_f is not None and np.isfinite(chug_f) and chug_f > 0:
359 txt += f" near {chug_f:.0f} Hz"
360 txt += f": gain margin {chug_margin:.2f} against {gate_threshold:.2f}, driven by {driver_lbl}."
361 findings.append({"severity": "critical" if not chug_stable else "warn", "text": txt})
362 actions.append({
363 "text": "Raise injector ΔP/Pc.",
364 "rationale": "A stiffer injector decouples chamber-pressure oscillations from the feed, "
365 "shrinking the chug loop gain.",
366 "lever": "η_inj",
367 })
368 actions.append({
369 "text": "Atomize finer (smaller D32).",
370 "rationale": "Finer droplets shorten the vaporization lag τ, pushing the chug pole left.",
371 "lever": "SMD",
372 })
373
374 # --- acoustic (high-frequency chamber modes) ---
375 ac_modes = ac.get("modes", [])
376 lim_name = ac.get("limiting_mode")
377 lim = next((m for m in ac_modes if m.get("mode") == lim_name), ac_modes[0] if ac_modes else None)
378 if not ac_gated:
379 txt = ("Acoustic stability is not assessed: the damping is uncalibrated. Rate it by test "
380 "(≥ 25 kHz Pc, pulse or bomb; Harrje & Reardon SP-194).")
381 findings.append({"severity": "warn", "text": txt})
382 elif ac.get("any_unstable") or not (acoustic_margin >= gate_threshold):
383 if lim is not None:
384 driven = lim.get("alpha", 0.0) > 0
385 txt = (f"Acoustic mode {lim.get('mode')} ({lim.get('f_hz', 0):.0f} Hz) is "
386 f"{'driven' if driven else 'only lightly damped'} "
387 f"(α={lim.get('alpha', 0):.0f} 1/s, damping/driving {acoustic_margin:.2f}).")
388 else:
389 txt = f"Acoustic margin is low ({acoustic_margin:.2f})."
390 findings.append({"severity": "critical" if ac.get("any_unstable") else "warn", "text": txt})
391 actions.append({
392 "text": f"Add chamber acoustic damping (baffles / acoustic liner) tuned to "
393 f"{lim_name or 'the limiting mode'}.",
394 "rationale": "Passive damping raises the nozzle/viscous/two-phase budget against the "
395 "combustion driving term.",
396 "lever": None,
397 })
398 actions.append({
399 "text": "Soften the combustion response (lower n, check χ).",
400 "rationale": "A smaller n weakens the heat-release feedback that drives the mode (Rayleigh criterion).",
401 "lever": "n",
402 })
403
404 # --- vaporization completeness ---
405 if not vap.get("vaporized_in_chamber", True):
406 lvap, lch = vap.get("L_vap_m"), vap.get("L_ch_m")
407 ratio = (lvap / lch) if (lch and lvap is not None and np.isfinite(lvap) and lch > 0) else float("nan")
408 txt = "Spray not 95 % vaporized in the chamber"
409 if np.isfinite(ratio):
410 txt += f" (L_vap ≈ {ratio:.1f}× L_ch)"
411 elif vap.get("frac_vaporized_end") is not None:
412 txt += f" ({vap['frac_vaporized_end'] * 100:.1f} % of the {vap.get('fluid', '')} by the chamber end)"
413 txt += "."
414 findings.append({"severity": "warn", "text": txt})
415 actions.append({
416 "text": "Atomize finer or lengthen the chamber.",
417 "rationale": "A shorter vaporization length completes burning upstream of the nozzle.",
418 "lever": "SMD",
419 })
420
421 if not findings:
422 findings.append({"severity": "ok",
423 "text": "Nothing driven."})
424
425 gm_txt = f"chug gain margin {chug_margin:.2f} against {gate_threshold:.2f}"
426 if state == "stable":
427 headline = f"Stable: {gm_txt}."
428 elif state == "marginal":
429 headline = f"Marginal: {gm_txt}."
430 elif state == "unknown":
431 headline = "Not assessed: the stability model could not be evaluated here."
432 else:
433 headline = f"Unstable: {limiting or 'a mode'} is driven ({gm_txt})."
434
435 fb = fallbacks
436 if fb:
437 names = ", ".join(str(f.get("name", "?")) for f in fb[:3])
438 more = "…" if len(fb) > 3 else ""
439 # Say where the missing values live. "Load a propellant preset" was printed for every
440 # fallback including feed-line lengths and chamber geometry, which no propellant preset
441 # supplies -- advice that cannot work reads as noise and gets ignored.
442 kinds = {("propellant" if ".fluids." in str(f.get("name", "")) else
443 "plumbing" if ".feed." in str(f.get("name", "")) else
444 "model") for f in fb}
445 hints = []
446 if "propellant" in kinds:
447 hints.append("load a propellant preset for the fluid properties")
448 if "plumbing" in kinds:
449 hints.append("set feed_system lengths/bores for the plumbing")
450 if "model" in kinds:
451 hints.append("the rest are model calibration defaults")
452 assumptions_note = (f"{len(fb)} physics input(s) fell back to recorded defaults "
453 f"({names}{more}). " + "; ".join(hints).capitalize() + ".")
454 else:
455 assumptions_note = "Config fully specified the stability physics — no fallbacks used."
456
457 return {
458 "headline": headline,
459 "state": state,
460 "limiting_mode": limiting,
461 "driver": driver_lbl if limiting == "chug" else None,
462 "findings": findings,
463 "actions": actions,
464 "assumptions_note": assumptions_note,
465 }
466
467
468# ---------------------------------------------------------------------------
469# Top-level assembly
470# ---------------------------------------------------------------------------
471
472def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: float,
473 gamma: float, R: float, Tc: float, diagnostics: Dict[str, Any],
474 cg: Any, *, gate_threshold: Optional[float] = None,
475 overrides: Optional[Dict[str, float]] = None) -> Dict[str, Any]:
476 """Assemble the full rich stability payload (plan §A5 schema). <=5 s.
477
478 ``gate_threshold`` defaults to design_requirements.min_stability_margin (as Layer 1 relaxes
479 it), so the report and the optimizer gate on the same number."""
480 from engine.pipeline import assumptions as _assumptions
481 if gate_threshold is None:
482 gate_threshold = analysis._stability_requirement(config)
483 with _assumptions.scope() as _used_here:
484 return _build_rich_report(config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg,
485 gate_threshold=gate_threshold, overrides=overrides,
486 used_here=_used_here)
487
488
489def _build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: float,
490 gamma: float, R: float, Tc: float, diagnostics: Dict[str, Any],
491 cg: Any, *, gate_threshold: float, overrides: Optional[Dict[str, float]],
492 used_here: Dict[str, Any]) -> Dict[str, Any]:
493 inp = analysis.build_stability_inputs(
494 config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg, overrides=overrides,
495 )
496 streams, chamber, gas = inp["streams"], inp["chamber"], inp["gas"]
497
498 # --- chug (rich root-find + boundary + band over the unmeasured mixing lag) ---
499 chug_rich = chug.chug_growth_rate(streams, chamber)
500 chug_fast = chug.chug_margin_fast(streams, chamber)
501 gm_nominal = float(chug_fast.get("gain_margin", float("nan")))
502 band = analysis.chug_band(inp, gm_nominal)
503 chug_margin = band["min"] if band is not None else gm_nominal
504 eta_window = _eta_window(streams)
505 boundary = _chug_boundary_curve(streams, chamber, eta_window=eta_window)
506 design_point = _chug_design_point(streams, chamber, band, inp)
507 theta_c = chamber.theta_c()
508 design_streams = []
509 lag_break = inp.get("lag_breakdown") or {}
510 for label, eta, tau in (
511 ("O", inp["eta_inj_O"], inp["tau_conv_O"]),
512 ("F", inp["eta_inj_F"], inp["tau_conv_F"]),
513 ):
514 tt = float(tau / theta_c) if (np.isfinite(theta_c) and theta_c > 0) else float("nan")
515 lb = lag_break.get(label, {})
516 design_streams.append({
517 "stream": label,
518 "fluid": inp.get(f"fluid_name_{label}", label),
519 "phase": inp.get(f"phase_{label}", "liquid"),
520 "eta_inj": float(eta), "tau_theta_c": tt, "tau_s": float(tau),
521 "tau_atom_s": lb.get("tau_atom_s"), "tau_vap_s": lb.get("tau_vap_s"),
522 "tau_mix_s": lb.get("tau_mix_s"),
523 })
524
525 # Root locus: the dominant eigenvalue tracked through the s-plane as injector stiffness sweeps.
526 locus = chug.chug_root_locus(
527 streams, chamber, eta_values=np.linspace(eta_window[0], eta_window[1], 26), scale_design=True)
528 eta_critical = _locus_crossing(locus)
529
530 # --- acoustic (full mode set with damping budgets) ---
531 ac = acoustic.analyze_acoustic_modes(inp["D_ch"], inp["L_ch"], gas,
532 n=inp["n_interaction"], tau_sens=inp["tau_sens"],
533 coeffs=inp["damping_coeffs"])
534 ac_gate_mode = inp["acoustic_gate"]
535 ac_gated = ac_gate_mode != "report_only"
536 m_key = "margin_worst_phase" if ac_gate_mode == "worst_phase" else "margin"
537 ac_margins = [m[m_key] for m in ac["modes"]]
538 acoustic_margin_model = float(min(ac_margins)) if ac_margins else float("nan")
539 acoustic_margin = acoustic_margin_model if ac_gated else float("inf")
540 acoustic_modes = [{
541 "name": m["mode"], "freq_hz": m["f_hz"], "alpha": m["alpha"],
542 "driving": m["driving"], "driving_max": m["driving_max"],
543 "margin": m["margin"], "margin_worst_phase": m["margin_worst_phase"], "n_min": m["n_min"],
544 "damping": {"noz": m["damping"]["nozzle"], "visc": m["damping"]["viscous"],
545 "inj": m["damping"]["injector"], "twophase": m["damping"]["twophase"]},
546 } for m in ac["modes"]]
547
548 # --- phase clock (omega*tau_sens per mode) ---
549 # Driving is n(1 - cos wt) (acoustic.py): none at wt = 0 mod 2pi, most at pi. drive_share is
550 # the mode's driving over the most any lag could give, (1 - cos wt)/2.
551 phase = []
552 for m in ac["modes"]:
553 wt = float(2 * np.pi * m["f_hz"] * inp["tau_sens"])
554 phase.append({"mode": m["mode"], "omega_tau": wt, "omega_tau_mod": float(wt % (2 * np.pi)),
555 "drive_share": float(m["driving"] / m["driving_max"]) if m["driving_max"] > 0 else 0.0})
556
557 from engine.pipeline.combustion_physics import vaporization_profile
558 try:
559 march = vaporization_profile((diagnostics or {}).get("cstar_efficiency") or {})
560 except Exception:
561 march = None
562 vap = _vaporization_profile(inp, Pc, march=march)
563 sens = _sensitivity(inp, gm_nominal)
564 fallbacks = _fallbacks_used(used_here)
565 radar = _radar(chug_margin, ac, vap, gate_threshold)
566
567 ac_alpha_nominal = ac["modes"][0]["alpha"] if ac["modes"] else float("nan")
568 state = analysis.classify_stability(gm_nominal, chug_margin, acoustic_margin,
569 ac_alpha_nominal, ac_gated, gate_threshold)
570 if chug_rich.get("stable") is False and state in ("stable", "marginal"):
571 state = "unstable" # the root-find found a growing pole the scan did not
572 min_margin = float(min(chug_margin, acoustic_margin))
573 limiting = "chug" if (not ac_gated or chug_margin <= acoustic_margin) else ac.get("limiting_mode")
574 diag = _diagnostics(state, chug_margin, acoustic_margin, gate_threshold, limiting,
575 chug_rich, ac, vap, fallbacks, acoustic_gate=ac_gate_mode)
576
577 return {
578 "summary": {"state": state, "min_margin": min_margin,
579 "gate_margin_threshold": gate_threshold, "limiting_mode": limiting,
580 "margin_basis": "chug Nyquist gain margin at the low end of the mixing-lag band"
581 + ("" if not ac_gated else f"; acoustic damping/driving ({ac_gate_mode})")},
582 "diagnostics": diag,
583 "chug": {
584 "alpha": chug_rich.get("alpha"), "freq_hz": chug_rich.get("f_chug_hz"),
585 "zeta": chug_rich.get("zeta"), "margin": chug_margin,
586 "gain_margin_nominal": gm_nominal,
587 "gain_margin_db": analysis.gain_margin_db(chug_margin),
588 "gain_margin_nominal_db": analysis.gain_margin_db(gm_nominal),
589 "gm_band": band,
590 "phase_margin_deg": chug_fast.get("phase_margin_deg"),
591 "theta_c_s": float(theta_c),
592 "regulator_status": chug_rich.get("regulator_status"),
593 "alpha_no_reg": chug_rich.get("alpha_no_reg"), "driver": chug_rich.get("driver"),
594 "boundary_curve": boundary,
595 "boundary_basis": "every stream's drop and lag scaled together from the design; x = "
596 "mass-weighted ΔP_inj/Pc, y = mass-weighted τ/θ_c; nominal mixing lag",
597 "design_point": design_point,
598 "eta_mean": float(chug.mean_eta(streams)),
599 "pole": _chug_pole(chug_rich),
600 "design_streams": design_streams,
601 "root_locus": locus,
602 "locus_param": "eta_inj_mean (every stream's drop scaled by one factor)",
603 "eta_window": [float(eta_window[0]), float(eta_window[1])],
604 "eta_critical": eta_critical,
605 "lag_model": inp.get("lag_model"),
606 "convection_model": inp.get("convection_model"),
607 "lag_breakdown": lag_break,
608 },
609 "acoustic": {"margin": acoustic_margin, "margin_model": acoustic_margin_model,
610 "gate_status": ac_gate_mode, "modes": acoustic_modes,
611 "any_unstable": ac["any_unstable"], "limiting_mode": ac["limiting_mode"],
612 "chamber_length_m": float(inp["L_ch"]), "chamber_diameter_m": float(inp["D_ch"]),
613 "sound_speed_m_s": float(gas.a_sound)},
614 "phase": phase,
615 "vaporization": vap,
616 "radar": radar,
617 "assumptions": {
618 "n": inp["n_interaction"], "chi_acoustic": inp["chi_acoustic"],
619 "dP_reg_max_psi": float(streams[0].regulator.max_excursion_pa / _PA_PER_PSI),
620 "eta_inj_O": inp["eta_inj_O"], "eta_inj_F": inp["eta_inj_F"],
621 "smd_O_um": float(inp["D32_O"] * 1e6),
622 "smd_F_um": float(inp["D32_F"] * 1e6),
623 "rate_limiting_stream": inp.get("rate_limiting_stream"),
624 "mach_nozzle_entrance": float(inp["mach_nozzle_entrance"]),
625 "contraction_ratio": float(inp["contraction_ratio"]),
626 "feed_length_O_m": float(inp["feed_length_O"]), "feed_length_F_m": float(inp["feed_length_F"]),
627 "acoustic_gate": ac_gate_mode,
628 "acoustic_overlap": {m["mode"]: m["overlap"] for m in ac["modes"]},
629 "damping_injector_frac": float(inp["damping_coeffs"].injector_frac),
630 "damping_twophase_frac": float(inp["damping_coeffs"].twophase_frac),
631 # Which named models produced this answer, and what the propellants/injector actually
632 # are -- so a report can never be read as if it described a different engine.
633 "time_lag_model": inp.get("lag_model"),
634 "convection_model": inp.get("convection_model"),
635 "mixing_lag_fraction": inp.get("mixing_lag_fraction"),
636 "mixing_lag_fraction_band": list(inp["chug_band"]) if inp.get("chug_band") else None,
637 "injector_type": inp.get("injector_type"),
638 "fluid_O": inp.get("fluid_name_O"), "fluid_F": inp.get("fluid_name_F"),
639 "phase_O": inp.get("phase_O"), "phase_F": inp.get("phase_F"),
640 "tau_conv_O_s": float(inp["tau_conv_O"]), "tau_conv_F_s": float(inp["tau_conv_F"]),
641 "lag_breakdown": lag_break,
642 # Every recorded silent-default substitution this process has made (P2c registry).
643 # Empty list = config fully specified the physics. The hardcoded-Cd bug class, surfaced.
644 "fallbacks_used": fallbacks,
645 },
646 "sensitivity": sens,
647 }
648
649
650def _fallbacks_used(used_here: Optional[Dict[str, Any]] = None):
651 """Substitutions made by THIS evaluation.
652
653 ``used_here`` is the collection from the ``assumptions.scope()`` wrapped around the report. The
654 old form read the process-global registry, so a report inherited every fallback the process had
655 ever recorded -- after a methalox run, an ethalox run with a complete preset still announced the
656 previous propellant's missing fields. Falls back to the global registry only when called without
657 a scope (kept so an external caller does not break).
658 """
659 try:
660 from engine.pipeline import assumptions
661 except ImportError:
662 return []
663 if used_here is not None:
664 return assumptions.as_list(used_here)
665 return assumptions.fallbacks_used()
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
_fallbacks_used(Optional[Dict[str, Any]] used_here=None)
Definition report.py:650
List[List[float]] _chug_boundary_curve(streams, chamber, int n_pts=24, Optional[Tuple[float, float]] eta_window=None)
Definition report.py:39
Dict[str, Any] _sensitivity(Dict[str, Any] inp, float nominal_gm)
Definition report.py:232
Dict[str, Any] _stream_from_march(Dict[str, Any] inp, str key, Dict[str, Any] march)
Definition report.py:101
Dict[str, Any] _radar(float chug_margin, Dict[str, Any] ac, Dict[str, Any] vap, float gate_threshold)
Definition report.py:291
Dict[str, Any] _vaporization_profile(Dict[str, Any] inp, float Pc, int n_pts=40, Optional[Dict[str, Any]] march=None)
Definition report.py:193
Dict[str, Any] _chug_design_point(streams, chamber, Optional[Dict[str, Any]] band, Dict[str, Any] inp)
Definition report.py:88
Dict[str, Any] build_rich_report(config, float Pc, float MR, float mdot_total, float cstar, float gamma, float R, float Tc, Dict[str, Any] diagnostics, Any cg, *, Optional[float] gate_threshold=None, Optional[Dict[str, float]] overrides=None)
Definition report.py:475
Dict[str, Any] _diagnostics(str state, float chug_margin, float acoustic_margin, float gate_threshold, Optional[str] limiting, Dict[str, Any] chug_rich, Dict[str, Any] ac, Dict[str, Any] vap, List[Dict[str, Any]] fallbacks, str acoustic_gate="report_only")
Definition report.py:343
Dict[str, float] _chug_pole(Dict[str, Any] chug_rich)
Definition report.py:264
Dict[str, Any] _build_rich_report(config, float Pc, float MR, float mdot_total, float cstar, float gamma, float R, float Tc, Dict[str, Any] diagnostics, Any cg, *, float gate_threshold, Optional[Dict[str, float]] overrides, Dict[str, Any] used_here)
Definition report.py:492
Tuple[float, float] _eta_window(streams, *, float lo_frac=0.35, float hi_frac=2.2, float floor=0.02, float ceil=0.90)
Definition report.py:26
Dict[str, float] _locus_crossing(List[Dict[str, float]] locus)
Definition report.py:273
Dict[str, Any] _stream_vaporization(Dict[str, Any] inp, float Pc, str key, int n_pts)
Definition report.py:137