1"""Stability analysis for combustion and feed system dynamics.
31. Combustion stability: chug (feed-coupled, low frequency) and acoustic (chamber modes).
42. Feed-line acoustics and the water-hammer bound, both lines.
53. Overall classification at an operating point.
7Every gate-facing margin is a physical ratio that is 1 at neutral stability:
8 * chug: the Nyquist gain margin of the feed-chamber loop (the factor the loop gain can rise
9 before a pole crosses into the right half-plane), taken at the low end of the unmeasured
11 * acoustic: damping/driving for the worst mode (= n_crit/n), gated only when
12 ``stability.acoustic_gate`` asks for it -- by default it is reported, not gated (acoustic.py).
13They are compared directly with ``design_requirements.min_stability_margin``. The tanh remaps that
14used to sit between the physics and the gate (and saturated at 1.3) are gone.
17from __future__
import annotations
20from functools
import lru_cache
21from typing
import Dict, Tuple, Optional, List, Any
32 chamber_volume: float,
37 R: Optional[float] =
None,
38 Tc: Optional[float] =
None,
41 Order-of-magnitude chug (bulk-mode) frequency estimates from chamber geometry alone.
43 Two estimates: the residence-time frequency ``1 / (2 pi tau_res)`` with ``tau_res = L*/c*``, and
44 a Helmholtz bulk mode using the throat as the neck. These are *placeholders* -- the physical chug
45 frequency comes from the feed-coupled loop in ``chug.py`` and overwrites ``frequency`` in
46 ``comprehensive_stability_analysis``. The old heuristic "stability_index" / "stability_margin"
47 that used to ride along here (floored at 0.4 and mapped to a margin so that "reasonable designs
48 achieve required margins") was not a physical quantity and has been removed; margins come from
49 the gain-margin model only.
54 - frequency: Helmholtz estimate when gas properties are known, else the residence estimate [Hz]
55 - frequency_residence, frequency_helmholtz: the two estimates [Hz] (nan if unavailable)
56 - period: 1 / frequency [s]
57 - tau_residence: L* / c* [s]
58 - Lstar: characteristic length [m]
60 if throat_area <= 0.0
or chamber_volume <= 0.0
or cstar <= 0.0:
61 return {
"frequency": float(
"nan"),
"frequency_residence": float(
"nan"),
62 "frequency_helmholtz": float(
"nan"),
"period": float(
"nan"),
63 "tau_residence": float(
"nan"),
"Lstar": float(
"nan")}
64 Lstar = chamber_volume / throat_area
65 tau_residence = Lstar / cstar
66 freq_res = 1.0 / (2.0 * np.pi * tau_residence)
70 freq_helm = float(
"nan")
71 if R
is not None and Tc
is not None and gamma * R * Tc > 0:
72 a = float(np.sqrt(gamma * R * Tc))
73 d_throat = np.sqrt(4.0 * throat_area / np.pi)
74 L_eff =
max(0.5 * d_throat, 1.0e-3)
75 freq_helm = float((a / (2.0 * np.pi)) * np.sqrt(throat_area / (chamber_volume * L_eff)))
77 freq = freq_helm
if np.isfinite(freq_helm)
else float(freq_res)
81 theta_c = (float(Lstar * cstar / (R * Tc))
if (R
is not None and Tc
is not None and R * Tc > 0)
85 "frequency": float(freq),
86 "frequency_residence": float(freq_res),
87 "frequency_helmholtz": float(freq_helm),
88 "period": float(1.0 / freq)
if freq > 0
else float(
"inf"),
89 "tau_residence": float(tau_residence),
90 "Lstar": float(Lstar),
95 chamber_length: float,
96 chamber_diameter: float,
97 gas_temperature: float,
102 Calculate acoustic resonance frequencies for longitudinal and transverse modes.
106 chamber_length : float
108 chamber_diameter : float
110 gas_temperature : float
113 Specific heat ratio [-]
115 Gas constant [J/(kg K)]
120 - longitudinal_modes: list of longitudinal mode frequencies [Hz]
121 - transverse_modes: list of first few transverse mode frequencies [Hz]
122 - sound_speed: sound speed [m/s]
125 sound_speed = float(np.sqrt(gamma * R * gas_temperature))
128 L =
max(chamber_length, 1.0e-3)
129 D =
max(chamber_diameter, 1.0e-3)
132 longitudinal_modes: List[float] = [float(n * sound_speed / (2.0 * L))
for n
in range(1, 6)]
137 alpha_values = [1.84118, 3.05424, 3.83171, 4.20119, 5.33144]
138 transverse_modes: List[float] = []
139 for alpha
in alpha_values:
140 freq = alpha * sound_speed / (np.pi * D)
141 transverse_modes.append(float(freq))
144 "longitudinal_modes": longitudinal_modes,
145 "transverse_modes": transverse_modes,
146 "sound_speed": sound_speed,
155 feed_line_length: float,
156 feed_line_diameter: float,
157 propellant_density: float,
159 flow_velocity: float,
160 pressure_drop: Optional[float] =
None,
162 wall_modulus_pa: Optional[float] =
None,
163 wall_thickness_m: Optional[float] =
None,
164 valve_closure_time_s: Optional[float] =
None,
167 Feed-line acoustics and the water-hammer spike for one propellant line.
171 feed_line_length, feed_line_diameter : float
172 Line length and bore [m]
173 propellant_density, bulk_modulus : float
174 Liquid density [kg/m^3] and isentropic bulk modulus [Pa]
175 flow_velocity : float
176 Mean line velocity [m/s]
177 pressure_drop : float, optional
178 Unused; kept so old callers still bind.
179 wall_modulus_pa, wall_thickness_m : float, optional
180 Tube Young's modulus and wall. With both, the wave speed carries the Korteweg wall
181 compliance ``a = a_liquid / sqrt(1 + K D / (E e))`` (Wylie & Streeter, Fluid Transients);
182 without, the rigid-wall ``sqrt(K/rho)`` is an upper bound.
183 valve_closure_time_s : float, optional
184 Valve closure time. None = instantaneous: the Joukowsky bound ``rho a v``. For a closure
185 slower than the round trip ``2L/a`` the spike is ``rho a v (2L/a) / t_c`` (Michaud).
190 - pogo_frequency: quarter-wave line mode (tank end open, injector end closed) [Hz]
191 - surge_frequency: half-wave line mode (closed-closed) [Hz]
192 - sound_speed: wave speed in the line, wall compliance included when given [m/s]
193 - sound_speed_liquid: sqrt(K/rho) [m/s]
194 - korteweg_factor: a_liquid / a [-] (1 = rigid wall)
195 - water_hammer_pressure: valve-closure spike [Pa]
196 - water_hammer_basis: which formula produced it
198 The feed-coupled *stability* margin is the chug gain margin from ``chug.py``; the caller writes
199 it into this dict as ``stability_margin``. ``water_hammer_margin`` (steady drop over the
200 instantaneous-closure spike) compared two unrelated pressures and has been removed.
202 L =
max(feed_line_length, 1.0e-3)
203 rho = propellant_density
206 a_liq = float(np.sqrt(K / rho))
208 if (wall_modulus_pa
is not None and wall_thickness_m
is not None
209 and wall_modulus_pa > 0
and wall_thickness_m > 0
and feed_line_diameter > 0):
210 korteweg = float(np.sqrt(1.0 + K * feed_line_diameter / (wall_modulus_pa * wall_thickness_m)))
211 sound_speed = a_liq / korteweg
212 pogo_frequency = float(sound_speed / (4.0 * L))
213 surge_frequency = float(sound_speed / (2.0 * L))
215 delta_v =
max(flow_velocity, 0.0)
216 joukowsky = float(rho * sound_speed * delta_v)
217 t_round = 2.0 * L / sound_speed
218 if valve_closure_time_s
is None or valve_closure_time_s <= t_round:
219 water_hammer_pressure = joukowsky
220 basis = (
"Joukowsky rho*a*v, instantaneous closure (upper bound)" if valve_closure_time_s
is None
221 else "Joukowsky rho*a*v, closure faster than the 2L/a round trip")
223 water_hammer_pressure = float(joukowsky * t_round / valve_closure_time_s)
224 basis =
"Michaud rho*a*v*(2L/a)/t_c, closure slower than the 2L/a round trip"
227 "pogo_frequency": pogo_frequency,
228 "surge_frequency": surge_frequency,
229 "sound_speed": float(sound_speed),
230 "sound_speed_liquid": a_liq,
231 "korteweg_factor": korteweg,
232 "water_hammer_pressure": water_hammer_pressure,
233 "water_hammer_basis": basis,
234 "valve_closure_time_s": valve_closure_time_s,
246ACOUSTIC_GATE_MODES = (
"report_only",
"nominal_phase",
"worst_phase")
250 """20 log10(GM) [dB]; NaN for a non-positive or non-finite margin."""
251 if gain_margin
is None or not np.isfinite(gain_margin)
or gain_margin <= 0:
253 return float(20.0 * np.log10(gain_margin))
257 """Display score in [0, 1] of the limiting gate margin: ``clip((m - 0.85) / 0.45, 0, 1)``.
259 A fixed linear map with no physics of its own: score s <=> margin 0.85 + 0.45 s, so a
260 ``min_stability_score`` of 0.58 is a margin floor of 1.111. Non-finite margin -> 0 (fail closed).
262 if min_margin
is None or not np.isfinite(min_margin):
263 return 1.0
if (min_margin
is not None and min_margin == float(
"inf"))
else 0.0
264 return float(np.clip((min_margin - 0.85) / 0.45, 0.0, 1.0))
268 """The state test Layer 1 means by ``require_stable_state``: True -> only 'stable' passes;
269 False -> 'stable' or 'marginal'. 'unstable' and 'unknown' never pass."""
270 return state ==
"stable" if require_stable_state
else state
in (
"stable",
"marginal")
274 """Gate threshold for 'stable': design_requirements.min_stability_margin relaxed by
275 stability_margin_handicap exactly as Layer 1 relaxes it; the schema default when absent."""
277 dr = getattr(config,
"design_requirements",
None)
278 m = getattr(dr,
"min_stability_margin",
None)
279 if m
is None or not np.isfinite(float(m)):
280 m = DesignRequirementsConfig.model_fields[
"min_stability_margin"].default
281 h = float(getattr(dr,
"stability_margin_handicap", 0.0)
or 0.0)
282 return float(m) *
max(0.0, 1.0 - h)
286 acoustic_alpha_nominal: float, acoustic_gated: bool,
287 requirement: float) -> str:
288 """'unstable' / 'marginal' / 'stable' / 'unknown' from the physical margins.
290 unstable: the model predicts growth at its nominal inputs (chug GM < 1, or a gated acoustic
291 mode with alpha > 0 / margin < 1);
292 stable: every gated margin clears max(1, requirement);
293 marginal: predicted stable, but some gated margin falls short of the requirement (for chug,
294 possibly only at the pessimistic end of the mixing-lag band);
295 unknown: a gated number is missing -- never passes.
297 gated = [chug_gm_nominal, chug_gate] + ([acoustic_gate]
if acoustic_gated
else [])
298 if any(v
is None or np.isnan(v)
for v
in gated):
300 if chug_gm_nominal <= 1.0
or (acoustic_gated
and (acoustic_gate <= 1.0
or acoustic_alpha_nominal > 0.0)):
302 floor =
max(1.0, float(requirement))
303 if chug_gate >= floor
and (
not acoustic_gated
or acoustic_gate >= floor):
309 """Config-first fluid property lookup. ``default=None`` is allowed and returned as-is when the
310 property is missing โ callers use that to route the fallback through the assumptions registry
311 (UNIFICATION P2c) instead of silently substituting."""
313 f = fluids[key]
if isinstance(fluids, dict)
else getattr(fluids, key)
314 v = getattr(f, attr,
None)
315 if v
is not None and np.isfinite(float(v)):
317 return None if default
is None else float(default)
319 return None if default
is None else float(default)
324 fs = config.feed_system
325 f = fs[key]
if isinstance(fs, dict)
else getattr(fs, key)
326 v = getattr(f, attr,
None)
327 return float(v)
if (v
is not None and np.isfinite(float(v)))
else float(default)
329 return float(default)
336_FLUID_THERMO_FALLBACKS = {
337 "lox": (1140.0, 213000.0, 90.2, 154.58),
338 "methane": ( 422.6, 510000.0, 111.65, 190.56),
339 "ethanol": ( 789.0, 838000.0, 351.4, 514.0),
340 "rp1": ( 810.0, 246000.0, 489.0, 678.0),
341 "ipa": ( 786.0, 665000.0, 355.6, 508.3),
342 "nitrousoxide": (1220.0, 376000.0, 184.7, 309.52),
343 "hydrogen": ( 70.8, 446000.0, 20.3, 33.15),
344 "nitrogen": ( 806.0, 199000.0, 77.36, 126.19),
346_THERMO_INDEX = {
"density": (0,
"kg/m^3"),
"latent_heat": (1,
"J/kg"),
347 "boiling_point": (2,
"K"),
"critical_temperature": (3,
"K")}
348_GENERIC_THERMO = {
"fuel": (800.0, 300000.0, 450.0, 600.0),
349 "oxidizer": (1140.0, 213000.0, 90.2, 154.58)}
356 "lox":
"Oxygen",
"methane":
"Methane",
"ethanol":
"Ethanol",
"ipa":
"n-Propanol",
357 "nitrousoxide":
"NitrousOxide",
"hydrogen":
"Hydrogen",
"nitrogen":
"Nitrogen",
361@lru_cache(maxsize=32)
363 """Critical temperature [K] from CoolProp for a canonical fluid name, or None.
365 Cached: the fast stability tier runs on every optimizer candidate, and a PropsSI call per
366 candidate would be a real cost for a value that cannot change within a process.
368 name = _COOLPROP_NAMES.get(canon)
372 from CoolProp.CoolProp
import PropsSI
373 v = float(PropsSI(
"Tcrit", name))
374 return v
if np.isfinite(v)
and v > 0
else None
381 f = config.fluids[key]
if isinstance(config.fluids, dict)
else getattr(config.fluids, key)
382 return str(getattr(f,
"name",
"")
or "")
388 """``fluids[key].attr`` from the config; else CoolProp for the named fluid; else the handbook
389 table; else a generic value. Every fallback is recorded, and the generic one says outright that
390 the fluid was unrecognised so it reads as "fix the config", not as a property."""
391 v =
_fluid_attr(getattr(config,
"fluids",
None), key, attr,
None)
396 idx, unit = _THERMO_INDEX[attr]
398 canon = _canon_fluid(name)
399 if attr ==
"critical_temperature":
402 return assume(f
"stability.fluids.{key}.{attr}", cp, unit=unit,
403 reason=f
"fluids.{key}.critical_temperature missing; CoolProp value for {name}")
404 if canon
in _FLUID_THERMO_FALLBACKS:
405 return assume(f
"stability.fluids.{key}.{attr}", _FLUID_THERMO_FALLBACKS[canon][idx], unit=unit,
406 reason=f
"fluids.{key}.{attr} missing from config; handbook value for {name}")
407 return assume(f
"stability.fluids.{key}.{attr}", _GENERIC_THERMO[
"oxidizer" if key ==
"oxidizer" else "fuel"][idx],
408 unit=unit, reason=f
"fluids.{key}.{attr} missing and fluid {name!r} is not in the handbook table -- set it in the config")
412 """``"liquid"`` or ``"gas"`` at the injector face.
414 Explicit config wins. Otherwise: supercritical at the tank temperature is gas-like, and a fluid
415 whose vapour pressure at its own bulk temperature exceeds the chamber pressure arrives as vapour.
416 Both inferences are recorded โ getting this wrong changes which time lags exist at all, so it
417 must never be a silent guess.
422 f = config.fluids[key]
if isinstance(config.fluids, dict)
else getattr(config.fluids, key)
423 explicit = getattr(f,
"injection_phase",
None)
426 if explicit
in (
"liquid",
"gas"):
429 T =
_fluid_attr(getattr(config,
"fluids",
None), key,
"temperature",
None)
431 if T
is not None and np.isfinite(T_crit)
and T >= T_crit:
432 return assume(f
"stability.fluids.{key}.injection_phase",
"gas", unit=
"-",
433 reason=f
"{name or key} is stored at {T:.0f} K, at or above its critical "
434 f
"temperature {T_crit:.0f} K -- inferred to arrive as a gas")
439 """(length [m], flow area [m^2]) of one feed line for the chug inertance L/A.
441 Length comes from ``feed_system.<side>.length`` -- a field that did not exist until now, which is
442 why the model used to carry a hardcoded 0.305 m for every engine. Area is the schema-derived
443 ``A_hydraulic`` (pi d^2/4 unless the user gave a non-circular passage).
446 L =
_feed_attr(config, side,
"length", float(
"nan"))
447 if not np.isfinite(L)
or L <= 0.0:
448 L = assume(f
"stability.feed.{side}.length", 0.305, unit=
"m",
449 reason=f
"feed_system.{side}.length not set (tank-outlet to manifold run)")
450 A =
_feed_attr(config, side,
"A_hydraulic", float(
"nan"))
451 if not np.isfinite(A)
or A <= 0.0:
452 d =
_feed_attr(config, side,
"d_inlet", float(
"nan"))
453 if np.isfinite(d)
and d > 0.0:
454 A = float(np.pi * (d / 2.0) ** 2)
456 A = float(np.pi * (assume(f
"stability.feed.{side}.d_inlet", 0.0127, unit=
"m",
457 reason=f
"feed_system.{side} has no bore") / 2.0) ** 2)
458 return float(L), float(A)
461@lru_cache(maxsize=64)
463 """Isentropic bulk modulus ``rho a^2`` [Pa] from CoolProp at (T, P), or None.
465 Cached on the rounded state: the value cannot change within a process, and PropsSI per
466 candidate would be a real cost."""
467 name = _COOLPROP_NAMES.get(canon)
471 from CoolProp.CoolProp
import PropsSI
472 rho = float(PropsSI(
"D",
"T", T,
"P", P, name))
473 a = float(PropsSI(
"A",
"T", T,
"P", P, name))
475 return v
if np.isfinite(v)
and v > 0
else None
481 """``fluids[key].bulk_modulus_pa``; else CoolProp rho*a^2 at the fluid temperature and line
482 pressure; else a recorded generic value. LOX at 90 K / 4 MPa is 0.98 GPa, not the 1.5 GPa
483 order-of-magnitude figure that used to stand in for it."""
486 v =
_fluid_attr(getattr(config,
"fluids",
None), key,
"bulk_modulus_pa",
None)
487 if v
is not None and v > 0:
490 T =
_fluid_attr(getattr(config,
"fluids",
None), key,
"temperature",
None)
491 if T
is not None and np.isfinite(P_line)
and P_line > 0:
494 return assume(record_name, k, unit=
"Pa",
495 reason=f
"fluids.{key}.bulk_modulus_pa missing; CoolProp rho*a^2 for {name} "
496 f
"at {T:.0f} K, {P_line / 1e6:.2f} MPa")
497 return assume(record_name, 1.5e9, unit=
"Pa",
498 reason=f
"fluids.{key}.bulk_modulus_pa missing and no CoolProp state for {name!r}; "
499 f
"generic liquid value -- set it in the config (measure via water-hammer test T5)")
503_TUBE_E_STAINLESS = 193.0e9
506def _feed_wall(config, side: str) -> Tuple[Optional[float], Optional[float], str]:
507 """(Young's modulus [Pa], wall [m], note) for the Korteweg wave-speed correction.
509 The wall comes from a ``*_TUBE_035``-style ``line_size`` (0.035 in); a pipe/fitting size or a
510 bare bore carries no wall, and the line is then treated as rigid (an upper bound on a) and
511 said so. The tube material is not in the config, so E is a recorded assumption."""
515 fs = config.feed_system
516 f = fs[side]
if isinstance(fs, dict)
else getattr(fs, side)
517 size = str(getattr(f,
"line_size",
"")
or "")
520 m = re.search(
r"_TUBE_(\d{3})$", size.upper())
522 return None,
None,
"rigid wall assumed: feed_system line_size names no tube wall"
523 e = int(m.group(1)) * 1e-3 * 0.0254
524 E = assume(f
"stability.feed.{side}.wall_modulus", _TUBE_E_STAINLESS, unit=
"Pa",
525 reason=
"feed tube material not in the config; 300-series stainless steel assumed")
526 return float(E), float(e), f
"{size}: {e * 1e3:.3f} mm wall, E {E / 1e9:.0f} GPa"
530 """(L_chamber, D_chamber) [m]. The solved unified geometry first: its ``length`` is the total
531 chamber length and its ``chamber_diameter`` is the cylindrical bore, which is what the transverse
532 acoustic modes live in. Legacy configs fall back to ``chamber.length`` and the volume-mean
533 diameter; anything still missing is a recorded assumption."""
535 L = float(getattr(cg,
"length",
None)
or 0.0)
537 L = float(getattr(getattr(config,
"chamber",
None),
"length",
None)
or 0.0)
539 L = assume(
"stability.chamber.length", 0.18, unit=
"m", reason=
"no solved or configured chamber length")
540 D = float(getattr(cg,
"chamber_diameter",
None)
or 0.0)
542 V = float(getattr(cg,
"volume",
None)
or 0.0)
543 if V > 0.0
and L > 0.0:
544 D = float(np.sqrt(4.0 * V / (np.pi * L)))
546 D = assume(
"stability.chamber.diameter", 0.1, unit=
"m", reason=
"no chamber diameter or volume")
550def _jet_geometry(config, diagnostics: Dict[str, Any], side: str) -> Tuple[float, float]:
551 """(jet/post inner diameter [m], injection velocity [m/s]) for one stream.
553 These are Leonardi eq. 6-7's ``D_l`` and ``u_l``. Both come from whichever injector the config
554 actually names -- the solved closure diagnostics first (every injector model publishes ``u_O``/
555 ``u_F``), then the injector's own geometry block. Returns NaN rather than a stand-in when the
556 injector type carries no equivalent dimension; the lag model then drops the atomization term and
557 records it, instead of inventing a jet.
559 Impinging -> the jet diameter. Coaxial -> the core port (oxidizer) and the annulus hydraulic
560 diameter (fuel), which is what L17 calls the liquid post. Pintle -> the tip orifice (oxidizer)
561 and the annular gap's hydraulic diameter, 2*h_gap (fuel).
563 key =
"O" if side ==
"oxidizer" else "F"
564 u = diagnostics.get(f
"u_{key}")
565 u = float(u)
if (u
is not None and np.isfinite(float(u))
and float(u) > 0.0)
else float(
"nan")
567 d = diagnostics.get(f
"d_jet_{key}")
568 if d
is not None and np.isfinite(float(d))
and float(d) > 0.0:
571 inj = getattr(config,
"injector",
None)
572 geom = getattr(inj,
"geometry",
None)
573 itype = str(getattr(inj,
"type",
"")
or "")
575 if itype ==
"impinging":
576 elem = geom.oxidizer
if side ==
"oxidizer" else geom.fuel
577 return float(elem.d_jet), u
578 if itype ==
"coaxial":
579 if side ==
"oxidizer":
580 return float(geom.core.d_port), u
582 return float(2.0 * geom.annulus.gap_thickness), u
583 if itype ==
"pintle":
584 if side ==
"oxidizer":
585 return float(geom.lox.d_orifice), u
586 return float(2.0 * geom.fuel.h_gap), u
589 return float(
"nan"), u
593 sc = getattr(config,
"stability",
None)
597def _feed_override(feed: Optional[Dict[str, Dict[str, float]]], side: str) -> Dict[str, float]:
598 """The caller's feed impedance for one side (``feed[side]`` or ``feed["O"/"F"]``), validated.
600 Only ``inertance`` [1/m] and ``dP_feed`` [Pa] are read. A value that is not a finite,
601 non-negative number is refused rather than clipped: it would otherwise move the chug margin
602 with nothing in the payload saying why."""
605 key =
"O" if side ==
"oxidizer" else "F"
606 f = feed.get(side)
or feed.get(key)
or {}
607 out: Dict[str, float] = {}
608 for name
in (
"inertance",
"dP_feed"):
613 if not np.isfinite(v)
or v < 0.0:
614 raise ValueError(f
"feed[{side!r}][{name!r}] must be finite and >= 0, got {v!r}")
620 gamma: float, R: float, Tc: float, diagnostics: Dict[str, Any],
621 cg: Any, *, overrides: Optional[Dict[str, float]] =
None,
622 feed: Optional[Dict[str, Dict[str, float]]] =
None) -> Dict[str, Any]:
623 """Extract chug/acoustic model inputs from config + diagnostics. Shared by the fast path
624 (compute_physical_stability) and the rich report (report.py) so they use IDENTICAL extraction.
625 [Phys ยง3.2, ยง4, ยง5]
627 Sources, in order: solved geometry (``cg``), the closure diagnostics of this evaluation, the
628 config (``fluids`` for the propellants, ``feed_system`` for the plumbing, ``stability`` for the
629 model calibration), and finally recorded assumptions -- never a silent constant.
631 ``feed`` (opt-in, default None = the config's plumbing, exactly as before): the feed impedance
632 of each side stated by the caller, ``{"oxidizer"|"O": {"inertance": I [1/m], "dP_feed": dp [Pa]},
633 "fuel"|"F": {...}}``. ``inertance`` replaces ``feed_system.<side>.length / A_hydraulic`` (the
634 caller has summed L/A over the lines it knows, e.g. a P&ID drawing's); ``dP_feed`` replaces the
635 closure's feed drop as the steady drop the linearised resistance ``R = 2 dp / mdot`` is taken
636 from, *whole*: the ``supply_K`` share is not subtracted from it, because the caller's drop runs
637 from the tank outlet and the supply is upstream of the tank. Either key may be given alone.
643 A_t = float(cg.A_throat)
644 V_c = float(cg.volume)
645 Lstar = V_c / A_t
if A_t > 0
else float(getattr(cg,
"Lstar", 0.8))
647 A_c = float(np.pi * (D_ch / 2.0) ** 2)
648 contraction_ratio = A_c / A_t
if A_t > 0
else float(
"nan")
652 rc = getattr(config,
"regen_cooling",
None)
653 k_g = float(getattr(rc,
"hot_gas_thermal_conductivity", 0.0)
or 0.0)
if rc
is not None else 0.0
655 k_g = assume(
"stability.hot_gas_thermal_conductivity", DEFAULT_HOT_GAS_THERMAL_COND_W_M_K,
656 unit=
"W/(m*K)", reason=
"regen_cooling.hot_gas_thermal_conductivity not set")
657 mu_g = float(getattr(rc,
"hot_gas_viscosity", 0.0)
or 0.0)
if rc
is not None else 0.0
659 mu_g = assume(
"stability.hot_gas_viscosity", DEFAULT_HOT_GAS_VISC_PA_S,
660 unit=
"Pa*s", reason=
"regen_cooling.hot_gas_viscosity not set")
663 cp_g = gamma * R / (gamma - 1.0)
664 rho_g = Pc / (R * Tc)
if (R > 0
and Tc > 0)
else 2.0
665 nu_g = mu_g / rho_g
if rho_g > 0
else 2.0e-5
666 Pr_g = mu_g * cp_g / k_g
if k_g > 0
else float(
"nan")
667 a_snd = core.sound_speed(gamma, R, Tc)
669 mdot_O = float(diagnostics.get(
"mdot_O")
or mdot_total * MR / (1.0 + MR))
670 mdot_F = float(diagnostics.get(
"mdot_F")
or mdot_total / (1.0 + MR))
672 def _drop(key: str, frac: float, what: str, effect: str) -> float:
675 v = diagnostics.get(key)
676 if v
is not None and np.isfinite(float(v)):
678 return assume(f
"stability.{key}", frac * Pc, unit=
"Pa",
679 reason=f
"closure diagnostics carry no {key}; {what} taken as {frac:.2f}*Pc, "
682 _inj =
"the chug margin does not depend on this injector at all"
683 _feed =
"the linearised feed resistance is a placeholder"
684 dpiO = _drop(
"delta_p_injector_O", 0.30,
"oxidizer injector drop", _inj)
685 dpiF = _drop(
"delta_p_injector_F", 0.30,
"fuel injector drop", _inj)
686 dpfO = _drop(
"delta_p_feed_O", 0.10,
"oxidizer feed drop", _feed)
687 dpfF = _drop(
"delta_p_feed_F", 0.10,
"fuel feed drop", _feed)
692 D32_O = diagnostics.get(
"D32_O")
693 if D32_O
is None or not np.isfinite(float(D32_O))
or float(D32_O) <= 0.0:
694 D32_O = assume(
"stability.D32_oxidizer", 80e-6, unit=
"m",
695 reason=
"closure produced no oxidizer SMD; order-of-magnitude liquid-oxidizer "
696 "spray. The chug lag scales as SMD^2, so this is a large lever")
698 D32_F = diagnostics.get(
"D32_F")
699 if D32_F
is None or not np.isfinite(float(D32_F))
or float(D32_F) <= 0.0:
700 D32_F = assume(
"stability.D32_fuel", 60e-6, unit=
"m",
701 reason=
"closure produced no fuel SMD; order-of-magnitude liquid-fuel spray")
708 if ov.get(
"smd_um")
is not None:
709 D32_O = float(ov[
"smd_um"]) * 1e-6
710 if ov.get(
"smd_F_um")
is not None:
711 D32_F = float(ov[
"smd_F_um"]) * 1e-6
712 if ov.get(
"eta_inj_O")
is not None:
713 eta_O = float(ov[
"eta_inj_O"])
716 eta_O = dpiO / Pc
if Pc > 0
else 0.3
717 if ov.get(
"eta_inj_F")
is not None:
718 eta_F = float(ov[
"eta_inj_F"])
721 eta_F = dpiF / Pc
if Pc > 0
else 0.3
729 _P_line_O = diagnostics.get(
"P_tank_O")
730 _P_line_O = float(_P_line_O)
if _P_line_O
is not None else Pc + dpiO + dpfO
731 K_bulk_O =
_liquid_bulk_modulus(config,
"oxidizer", _P_line_O,
"stability.fluids.oxidizer.bulk_modulus_pa")
732 tcrO =
_fluid_thermo(config,
"oxidizer",
"critical_temperature")
734 muO =
_fluid_attr(config.fluids,
"oxidizer",
"viscosity", float(
"nan"))
735 muF =
_fluid_attr(config.fluids,
"fuel",
"viscosity", float(
"nan"))
736 sigO =
_fluid_attr(config.fluids,
"oxidizer",
"surface_tension", float(
"nan"))
737 sigF =
_fluid_attr(config.fluids,
"fuel",
"surface_tension", float(
"nan"))
740 d_jet_O, u_inj_O =
_jet_geometry(config, diagnostics,
"oxidizer")
741 d_jet_F, u_inj_F =
_jet_geometry(config, diagnostics,
"fuel")
744 u_gas = float(mdot_total / (rho_g * A_c))
if (rho_g > 0
and A_c > 0)
else float(
"nan")
748 name=
_fluid_name(config,
"oxidizer")
or "oxidizer", phase=phaseO,
749 rho_l=rho_O, mu_l=muO, sigma_l=sigO, T_boil=tbO, T_crit=tcrO, h_fg=hfg_O,
750 D0=D32_O, u_inj=u_inj_O, d_orifice=d_jet_O),
752 name=
_fluid_name(config,
"fuel")
or "fuel", phase=phaseF,
753 rho_l=rho_F, mu_l=muF, sigma_l=sigF, T_boil=tbF, T_crit=tcrF, h_fg=hfg_F,
754 D0=D32_F, u_inj=u_inj_F, d_orifice=d_jet_F),
761 lag_model = str(ov.get(
"time_lag_model")
or sc.time_lag_model)
762 convection = str(ov.get(
"convection_model")
or sc.convection_model)
765 mix_fraction = float(sc.mixing_lag_fraction)
if lag_model ==
"leonardi_dtl" else 0.0
766 if ov.get(
"mixing_lag_fraction")
is not None:
767 mix_fraction = float(ov[
"mixing_lag_fraction"])
768 lags = timelag.compute_lags(
769 lag_streams, lag_chamber, model=lag_model, mix_fraction=mix_fraction,
770 convection=convection,
771 on_fallback=
lambda name, value, unit, reason: assume(name, value, unit=unit, reason=reason),
773 tau_conv_O = float(lags[
"O"].tau_total)
774 tau_conv_F = float(lags[
"F"].tau_total)
777 _vap = [lags[k].tau_vap
for k
in (
"O",
"F")
778 if not lag_streams[k].is_gas
and np.isfinite(lags[k].tau_vap)
and lags[k].tau_vap > 0]
779 tau_mix_basis = float(
max(_vap))
if _vap
else 0.0
780 K_v_O, K_v_F = float(lags[
"O"].K_v), float(lags[
"F"].K_v)
781 if not np.isfinite(tau_conv_O)
or tau_conv_O <= 0.0:
782 tau_conv_O = assume(
"stability.tau_conv_O", 2.0e-3, unit=
"s",
783 reason=f
"{lag_model} oxidizer lag non-finite (check T_boil < Tc, h_fg, SMD)")
784 if not np.isfinite(tau_conv_F)
or tau_conv_F <= 0.0:
785 tau_conv_F = assume(
"stability.tau_conv_F", 1.5e-3, unit=
"s",
786 reason=f
"{lag_model} fuel lag non-finite (check T_boil < Tc, h_fg, SMD)")
790 reg_kw = dict(enabled=bool(sc.regulator_enabled), corner_hz=float(sc.regulator_corner_hz),
791 Z_hf=float(sc.regulator_Z_hf), max_excursion_pa=float(sc.regulator_max_excursion_psi) * 6894.757)
801 feed_basis =
"config"
804 feed_basis =
"caller"
805 if "inertance" in ovO:
806 L_feed_O = ovO[
"inertance"] * A_feed_O
807 if "inertance" in ovF:
808 L_feed_F = ovF[
"inertance"] * A_feed_F
810 dpfO = ovO[
"dP_feed"]
812 dpfF = ovF[
"dP_feed"]
815 feed_length=L_feed_O, feed_area=A_feed_O, tau_conv=tau_conv_O,
818 feed_length=L_feed_F, feed_area=A_feed_F, tau_conv=tau_conv_F,
824 R_gas=float(R)
if R > 0
else None, T_c=float(Tc)
if Tc > 0
else None)
826 _band_lo = getattr(sc,
"chug_band_mixing_lag_fraction_min", 0.0)
827 _band_hi = getattr(sc,
"chug_band_mixing_lag_fraction_max", 1.0)
829 if (lag_model ==
"leonardi_dtl" and tau_mix_basis > 0
and _band_lo
is not None and _band_hi
is not None
830 and ov.get(
"mixing_lag_fraction")
is None):
831 chug_band = (float(
min(_band_lo, _band_hi)), float(
max(_band_lo, _band_hi)))
832 chi_ac = float(ov.get(
"chi_acoustic", sc.chi_acoustic))
833 n_int = float(ov.get(
"n_interaction", sc.n_interaction))
840 _liquid_taus = [tau
for k, tau
in ((
"O", tau_conv_O), (
"F", tau_conv_F))
841 if not lag_streams[k].is_gas
and np.isfinite(tau)
and tau > 0]
842 tau_rate_limiting =
max(_liquid_taus)
if _liquid_taus
else max(tau_conv_O, tau_conv_F)
843 tau_sens = chi_ac * tau_rate_limiting
848 M_ne = sc.mach_nozzle_entrance
850 M_ne = core.mach_from_area_ratio_subsonic(contraction_ratio, gamma)
if np.isfinite(contraction_ratio)
else float(
"nan")
851 if not np.isfinite(M_ne)
or M_ne <= 0.0:
852 M_ne = assume(
"stability.mach_nozzle_entrance", 0.2, unit=
"-",
853 reason=
"contraction ratio unavailable for the isentropic solve")
854 gas =
acoustic.GasState(gamma=gamma, a_sound=a_snd, nu_g=nu_g, mach_nozzle_entrance=float(M_ne),
855 prandtl=float(Pr_g)
if np.isfinite(Pr_g)
and Pr_g > 0
else None)
856 acoustic_gate = str(getattr(sc,
"acoustic_gate",
"report_only")
or "report_only")
857 if acoustic_gate
not in ACOUSTIC_GATE_MODES:
858 raise ValueError(f
"stability.acoustic_gate {acoustic_gate!r}; known: {ACOUSTIC_GATE_MODES}")
860 twophase_frac=float(sc.damping_twophase_frac),
861 droplet_loading=float(sc.droplet_loading))
864 "streams": streams,
"chamber": chamber,
"gas": gas,
"damping_coeffs": coeffs,
865 "acoustic_gate": acoustic_gate,
866 "chug_band": chug_band,
"tau_mix_basis": tau_mix_basis,
867 "dP_feed_O": dpfO,
"dP_feed_F": dpfF,
"dP_inj_O": dpiO,
"dP_inj_F": dpiF,
868 "mdot_O": mdot_O,
"mdot_F": mdot_F,
"prandtl_g": float(Pr_g),
869 "D_ch": D_ch,
"L_ch": L_ch,
"Lstar": Lstar,
"contraction_ratio": contraction_ratio,
870 "mach_nozzle_entrance": float(M_ne),
871 "tau_conv_O": tau_conv_O,
"tau_conv_F": tau_conv_F,
"tau_sens": tau_sens,
872 "tau_rate_limiting": float(tau_rate_limiting),
873 "lag_model": lag_model,
"convection_model": convection,
"mixing_lag_fraction": mix_fraction,
874 "lag_breakdown": {k: v.as_dict()
for k, v
in lags.items()},
875 "phase_O": phaseO,
"phase_F": phaseF,
876 "fluid_name_O":
_fluid_name(config,
"oxidizer")
or "oxidizer",
877 "fluid_name_F":
_fluid_name(config,
"fuel")
or "fuel",
878 "injector_type": str(getattr(getattr(config,
"injector",
None),
"type",
"")
or "unknown"),
879 "d_jet_O": d_jet_O,
"d_jet_F": d_jet_F,
"u_inj_O": u_inj_O,
"u_inj_F": u_inj_F,
880 "chi_acoustic": chi_ac,
"n_interaction": n_int,
881 "eta_inj_O": eta_O,
"eta_inj_F": eta_F,
882 "D32_O": D32_O,
"D32_F": D32_F,
"K_v_O": K_v_O,
"K_v_F": K_v_F,
883 "rho_O": rho_O,
"rho_F": rho_F,
"K_bulk_O": K_bulk_O,
884 "feed_length_O": L_feed_O,
"feed_length_F": L_feed_F,
886 "feed_basis": feed_basis,
887 "u_O": diagnostics.get(
"u_O"),
"Cd_O": diagnostics.get(
"Cd_O"),
888 "u_F": diagnostics.get(
"u_F"),
"Cd_F": diagnostics.get(
"Cd_F"),
891 "rate_limiting_stream": (
"O" if (np.isfinite(tau_conv_O)
and tau_conv_O >= tau_conv_F)
893 "Pc": Pc,
"wh_pressure_pa":
None,
898 """Fast chug margin through the compiled kernel when the accelerator is on, else Python."""
900 from engine
import accel
905 return accel.chug_margin_fast(streams, chamber)
908 return chug.chug_margin_fast(streams, chamber)
911def chug_band(inp: Dict[str, Any], nominal_gm: float, n_pts: int = 5) -> Optional[Dict[str, Any]]:
912 """Chug gain margin over the unmeasured mixing-lag fraction band ``inp["chug_band"]``.
914 tau_mix = f * tau_mix_basis is shared by both streams, so moving f shifts every stream's
915 lag by (f - f_nominal) * basis. GM is not monotone in the lag in general, so the band is
916 sampled rather than read off its ends. None when there is no band (d^2-law, or a fixed
919 band = inp.get(
"chug_band")
922 f0 = float(inp[
"mixing_lag_fraction"])
923 basis = float(inp[
"tau_mix_basis"])
924 fracs = [float(f)
for f
in np.linspace(band[0], band[1], n_pts)]
927 if abs(f - f0) < 1e-12:
928 gms.append(float(nominal_gm))
931 for s0
in inp[
"streams"]:
933 s1.tau_conv =
max(float(s0.tau_conv) + (f - f0) * basis, 0.0)
935 gms.append(float(
_chug_fast(st, inp[
"chamber"]).get(
"gain_margin", float(
"nan"))))
936 all_gm = gms + [float(nominal_gm)]
937 finite = [g
for g
in all_gm
if np.isfinite(g)]
938 k_min = int(np.nanargmin(gms))
if any(np.isfinite(gms))
else 0
940 "parameter":
"mixing_lag_fraction",
941 "range": [float(band[0]), float(band[1])],
942 "fractions": fracs,
"gain_margins": gms,
943 "min": float(
min(finite))
if len(finite) == len(all_gm)
else float(
"nan"),
944 "max": float(
max(finite))
if finite
else float(
"nan"),
945 "at_min_fraction": fracs[k_min],
946 "nominal_fraction": f0,
950def _chug_feed_drop(config: Any, side: str, dp_feed: float, mdot: float, rho: float) -> float:
951 """The feed drop the chug loop sees as resistance: the line's, without the supply's share."""
952 fs = (getattr(config,
"feed_system",
None)
or {}).get(side)
953 k_supply = float(getattr(fs,
"supply_K", 0.0)
or 0.0)
if fs
is not None else 0.0
954 area = float(getattr(fs,
"A_hydraulic", 0.0)
or 0.0)
if fs
is not None else 0.0
955 if k_supply <= 0.0
or area <= 0.0
or rho <= 0.0
or not np.isfinite(dp_feed):
957 supply = k_supply * mdot * mdot / (2.0 * rho * area * area)
958 return max(dp_feed - supply, 0.0)
962 gamma: float, R: float, Tc: float, diagnostics: Dict[str, Any],
963 cg: Any) -> Optional[Dict[str, Any]]:
964 """Build inputs and run the FAST tiers (per-eval path). [Phys ยง3.2 fast, ยง4.2 fast; plan A4]
966 ``chug_gate_margin`` is the Nyquist GM at the low end of the mixing-lag band (the nominal GM
967 when there is no band); ``acoustic_gate_margin`` is damping/driving per ``stability.acoustic_gate``,
968 +inf when the acoustic model is report-only. Both are 1 at neutral stability.
973 chug_fast =
_chug_fast(inp[
"streams"], inp[
"chamber"])
974 gm = float(chug_fast.get(
"gain_margin", float(
"nan")))
976 chug_gate = band[
"min"]
if band
is not None else gm
982 ac_fast = acoustic.fast_acoustic(inp[
"D_ch"], inp[
"L_ch"], inp[
"gas"],
983 n=inp[
"n_interaction"], tau_sens=inp[
"tau_sens"],
984 coeffs=inp[
"damping_coeffs"])
985 mode = inp[
"acoustic_gate"]
986 if mode ==
"nominal_phase":
987 ac_gate = float(ac_fast[
"margin"])
988 elif mode ==
"worst_phase":
989 ac_gate = float(ac_fast[
"margin_worst_phase"])
991 ac_gate = float(
"inf")
997 "acoustic_gate": mode,
998 "chug_gate_margin": float(chug_gate),
999 "acoustic_gate_margin": ac_gate,
1000 "f_chug_hz": chug_fast.get(
"f_chug_hz"),
1001 "tau_conv_O": inp[
"tau_conv_O"],
"tau_conv_F": inp[
"tau_conv_F"],
"tau_sens": inp[
"tau_sens"],
1002 "eta_inj_O": inp[
"eta_inj_O"],
"eta_inj_F": inp[
"eta_inj_F"],
"D_ch": inp[
"D_ch"],
"L_ch": inp[
"L_ch"],
1003 "mach_nozzle_entrance": inp[
"mach_nozzle_entrance"],
1013 config: PintleEngineConfig,
1021 diagnostics: Dict[str, Any],
1024 Comprehensive stability analysis combining combustion, acoustic, and feed system.
1029 - stability_state: "stable", "marginal", "unstable", or "unknown" (model not evaluable;
1030 never passes a gate)
1031 - stability_score: display map of the limiting gate margin, see ``stability_score``
1032 - is_stable: backward compatibility boolean
1033 - chugging: frequencies, ``stability_margin`` (gate: GM at the low end of the mixing-lag
1034 band), ``chug_gain_margin`` (nominal GM), ``chug_gain_margin_db``, ``chug_gm_band``
1035 - acoustic: mode frequencies, ``stability_margin`` (gate; +inf when report-only),
1036 ``gate_status``, nominal and worst-phase margins
1037 - feed_system: oxidizer-line acoustics (legacy top level) and ``feed_lines`` for both
1038 - mode_coupling: list of potentially coupled mode pairs
1039 - issues, recommendations, Lstar
1042 cg = ensure_chamber_geometry(config)
1043 V_chamber = float(cg.volume)
1044 A_throat = float(cg.A_throat)
1045 Lstar = V_chamber / A_throat
if A_throat > 0.0
else cg.Lstar
1049 chamber_volume=V_chamber, throat_area=A_throat, cstar=cstar, gamma=gamma, Pc=Pc, R=R, Tc=Tc,
1052 chamber_length=L_chamber, chamber_diameter=D_chamber, gas_temperature=Tc, gamma=gamma, R=R,
1055 issues: List[str] = []
1064 except Exception
as exc:
1066 phys_error = f
"{type(exc).__name__}: {exc}"
1069 acoustic_gate_mode =
"report_only"
1070 if phys
is not None:
1071 chug_gm = float(phys[
"chug"].get(
"gain_margin", nan))
1072 chug_margin = float(phys[
"chug_gate_margin"])
1073 acoustic_margin = float(phys[
"acoustic_gate_margin"])
1074 acoustic_gate_mode = phys[
"acoustic_gate"]
1075 _fch = phys.get(
"f_chug_hz")
1076 if _fch
is not None and np.isfinite(_fch)
and _fch > 0:
1077 chugging[
"frequency"] = float(_fch)
1078 band = phys.get(
"chug_band")
1079 chugging[
"chug_gm_band"] = band
1081 issues.append(f
"Chug (feed-coupled LF) loop predicted unstable: gain margin {chug_gm:.2f} < 1 at "
1082 f
"the nominal mixing lag. Stiffen the injector or improve atomization")
1083 elif band
is not None and np.isfinite(band[
"min"])
and band[
"min"] <= 1.0:
1084 issues.append(f
"Chug gain margin {chug_gm:.2f} at the nominal mixing lag, but {band['min']:.2f} "
1085 f
"at mixing_lag_fraction {band['at_min_fraction']:.2f}: the verdict hangs on an "
1087 if acoustic_gate_mode !=
"report_only" and not phys[
"acoustic"].get(
"stable",
True):
1088 issues.append(f
"Acoustic mode {phys['acoustic'].get('limiting_mode')} driven (alpha>0)")
1090 chug_gm = chug_margin = acoustic_margin = nan
1091 issues.append(
"Stability model could not be evaluated for this point"
1092 + (f
" ({phys_error})" if phys_error
else "") +
"; state 'unknown' fails every gate")
1093 chugging[
"stability_margin"] = chug_margin
1094 chugging[
"chug_gain_margin"] = chug_gm
1096 if acoustic_gate_mode ==
"report_only":
1097 issues.append(
"High-frequency (acoustic) stability is not assessed a priori: the damping budget is "
1098 "uncalibrated, so the modes are reported, not gated. Rate it by test "
1099 "(>= 25 kHz Pc, pulse/bomb; Harrje & Reardon SP-194)")
1104 inp = phys.get(
"inputs")
if phys
is not None else None
1105 feed_lines: Dict[str, Dict[str, Any]] = {}
1106 for side, key
in ((
"oxidizer",
"O"), (
"fuel",
"F")):
1108 d_line = float(np.sqrt(4.0 * A_line / np.pi))
1110 mdot_k = diagnostics.get(f
"mdot_{key}")
1112 mdot_k = mdot_total * (MR
if key ==
"O" else 1.0) / (1.0 + MR)
1113 P_line = diagnostics.get(f
"P_tank_{key}")
1115 P_line = Pc + (inp[f
"dP_inj_{key}"] + inp[f
"dP_feed_{key}"]
if inp
is not None else 0.0)
1117 E_wall, e_wall, wall_note =
_feed_wall(config, side)
1118 v_line = float(mdot_k) / (rho * A_line)
if A_line > 0.0
and rho > 0
else 0.0
1120 feed_line_length=L_line, feed_line_diameter=d_line, propellant_density=rho,
1121 bulk_modulus=K, flow_velocity=v_line,
1122 wall_modulus_pa=E_wall, wall_thickness_m=e_wall, valve_closure_time_s=
None,
1124 line.update({
"length_m": float(L_line),
"bore_m": d_line,
"bulk_modulus_pa": float(K),
1125 "line_velocity_m_s": v_line,
"wall": wall_note})
1126 feed_lines[side] = line
1128 feed_stability: Dict[str, Any] = dict(feed_lines[
"oxidizer"])
1129 feed_stability[
"feed_lines"] = feed_lines
1131 feed_stability[
"stability_margin"] = chug_margin
1134 acoustic_modes_dict: Dict[str, float] = {}
1135 for i, freq
in enumerate(acoustic_raw[
"longitudinal_modes"]):
1136 acoustic_modes_dict[f
"L{i+1}"] = freq
1137 for i, freq
in enumerate(acoustic_raw[
"transverse_modes"]):
1138 acoustic_modes_dict[f
"T{i+1}"] = freq
1143 modes: List[Dict[str, Any]] = []
1144 if phys
is not None and np.isfinite(chugging[
"frequency"]):
1145 modes.append({
"name":
"chugging",
"type":
"combustion",
"frequency": chugging[
"frequency"]})
1146 for side, key
in ((
"oxidizer",
"O"), (
"fuel",
"F")):
1147 modes.append({
"name": f
"pogo_{key}",
"type":
"feed",
"frequency": feed_lines[side][
"pogo_frequency"]})
1148 modes.append({
"name": f
"surge_{key}",
"type":
"feed",
"frequency": feed_lines[side][
"surge_frequency"]})
1149 for i, f
in enumerate(acoustic_raw[
"longitudinal_modes"][:3]):
1150 modes.append({
"name": f
"L{i+1}",
"type":
"acoustic_long",
"frequency": f})
1151 for i, f
in enumerate(acoustic_raw[
"transverse_modes"][:2]):
1152 modes.append({
"name": f
"T{i+1}",
"type":
"acoustic_trans",
"frequency": f})
1154 mode_coupling: List[Dict[str, Any]] = []
1155 coupling_tol_rel = 0.10
1156 for i
in range(len(modes)):
1157 for j
in range(i + 1, len(modes)):
1158 f1 = modes[i][
"frequency"]
1159 f2 = modes[j][
"frequency"]
1163 rel_diff = abs(f1 - f2) / fmax
1164 if rel_diff < coupling_tol_rel:
1165 mode_coupling.append({
1166 "mode_a": modes[i][
"name"],
"mode_b": modes[j][
"name"],
1167 "freq_a": float(f1),
"freq_b": float(f2),
"relative_difference": float(rel_diff),
1170 issues.append(
"Potential mode coupling between combustion, acoustic, and feed system modes")
1172 if Lstar < 0.5
or Lstar > 3.0:
1173 issues.append(f
"L* outside typical range (0.5 m to 3.0 m). Current L* = {Lstar:.2f} m")
1178 acoustic_gated = acoustic_gate_mode !=
"report_only"
1181 stability_state =
"unknown"
1184 chug_gm, chug_margin, acoustic_margin,
1185 float(phys[
"acoustic"].get(
"alpha_max", nan)), acoustic_gated, requirement)
1186 gated_margins = [chug_margin] + ([acoustic_margin]
if acoustic_gated
else [])
1187 min_margin = float(
min(gated_margins))
if all(np.isfinite(m)
for m
in gated_margins)
else nan
1188 score =
stability_score(min_margin)
if stability_state !=
"unknown" else 0.0
1191 stability_state=stability_state, chugging=chugging, acoustic=acoustic_raw,
1192 feed_system=feed_stability, mode_coupling=mode_coupling, Lstar=Lstar,
1197 "modes": acoustic_modes_dict,
1198 "stability_margin": float(acoustic_margin),
1199 "gate_status": acoustic_gate_mode,
1201 if phys
is not None:
1202 ac = phys[
"acoustic"]
1203 acoustic[
"alpha_max"] = ac.get(
"alpha_max")
1204 acoustic[
"limiting_mode"] = ac.get(
"limiting_mode")
1205 acoustic[
"margin_nominal_phase"] = ac.get(
"margin")
1206 acoustic[
"margin_nominal_phase_mode"] = ac.get(
"margin_mode")
1207 acoustic[
"margin_worst_phase"] = ac.get(
"margin_worst_phase")
1208 acoustic[
"margin_worst_phase_mode"] = ac.get(
"worst_phase_mode")
1211 "stability_state": stability_state,
1212 "stability_score": score,
1213 "stability_requirement": requirement,
1214 "is_stable": stability_state ==
"stable",
1215 "chugging": chugging,
1216 "acoustic": acoustic,
1217 "feed_system": feed_stability,
1218 "mode_coupling": mode_coupling,
1221 "recommendations": recommendations,
1226 stability_state: str,
1227 chugging: Dict[str, float],
1228 acoustic: Dict[str, Any],
1229 feed_system: Dict[str, float],
1230 mode_coupling: List[Dict[str, Any]],
1233 """Generate stability improvement recommendations based on analysis."""
1234 recs: List[str] = []
1236 if stability_state ==
"unknown":
1237 recs.append(
"The stability model could not be evaluated at this point; treat it as unassessed.")
1238 elif stability_state ==
"unstable":
1239 recs.append(
"High risk of instability at this operating point. Consider design changes before test.")
1240 elif stability_state ==
"marginal":
1241 recs.append(
"Stability margin is limited. Plan to instrument heavily and ramp up cautiously in testing.")
1243 recs.append(
"System appears reasonably stable for this point. Still monitor during hot fire.")
1247 gm = chugging.get(
"stability_margin")
1248 if gm
is not None and np.isfinite(gm)
and gm < 2.0:
1249 recs.append(
"Chug gain margin is thin: stiffen the injector (raise dP_inj/Pc), shorten the "
1250 "combustion lag (finer SMD), or add feed-line inertance or resistance.")
1254 if not np.isfinite(chugging[
"frequency"]):
1256 elif chugging[
"frequency"] < 10.0:
1257 recs.append(
"Very low chugging frequency. Check for strong coupling to vehicle or feed system modes.")
1258 elif chugging[
"frequency"] > 400.0:
1259 recs.append(
"High chugging frequency. Check sensor bandwidth and structure response in that band.")
1263 recs.append(
"L* is quite short. Consider increasing chamber length or volume to improve stability and performance.")
1265 recs.append(
"L* is quite long. This can add mass and potentially introduce higher order acoustic issues.")
1268 for pair
in mode_coupling:
1270 f
"Potential mode coupling: {pair['mode_a']} at {pair['freq_a']:.1f} Hz "
1271 f
"and {pair['mode_b']} at {pair['freq_b']:.1f} Hz differ by "
1272 f
"{pair['relative_difference'] * 100:.1f} percent."
1274 recs.append(
"Consider shifting one of these frequencies by adjusting geometry or feed system properties.")
1277 recs.append(
"No obvious stability issues detected. Still validate with test data.")
float _chug_feed_drop(Any config, str side, float dp_feed, float mdot, float rho)
Optional[Dict[str, Any]] chug_band(Dict[str, Any] inp, float nominal_gm, int n_pts=5)
Dict[str, Any] _chug_fast(streams, chamber)
float stability_score(float min_margin)
_fluid_attr(fluids, key, attr, default)
Tuple[Optional[float], Optional[float], str] _feed_wall(config, str side)
Dict[str, Any] comprehensive_stability_analysis(PintleEngineConfig config, float Pc, float MR, float mdot_total, float cstar, float gamma, float R, float Tc, Dict[str, Any] diagnostics)
Dict[str, Any] analyze_feed_system_stability(float feed_line_length, float feed_line_diameter, float propellant_density, float bulk_modulus, float flow_velocity, Optional[float] pressure_drop=None, *, Optional[float] wall_modulus_pa=None, Optional[float] wall_thickness_m=None, Optional[float] valve_closure_time_s=None)
Tuple[float, float] _feed_geometry(config, str side)
Dict[str, Any] build_stability_inputs(config, float Pc, float MR, float mdot_total, float cstar, float gamma, float R, float Tc, Dict[str, Any] diagnostics, Any cg, *, Optional[Dict[str, float]] overrides=None, Optional[Dict[str, Dict[str, float]]] feed=None)
bool stability_state_ok(str state, bool require_stable_state)
float gain_margin_db(float gain_margin)
str _fluid_name(config, str key)
Dict[str, float] calculate_chugging_frequency(float chamber_volume, float throat_area, float cstar, float gamma, float Pc, Optional[float] R=None, Optional[float] Tc=None)
str _injection_phase(config, str key)
Optional[Dict[str, Any]] compute_physical_stability(config, float Pc, float MR, float mdot_total, float cstar, float gamma, float R, float Tc, Dict[str, Any] diagnostics, Any cg)
_feed_attr(config, key, attr, default)
float _stability_requirement(config)
StabilityConfig _stability_config(config)
List[str] _generate_stability_recommendations(str stability_state, Dict[str, float] chugging, Dict[str, Any] acoustic, Dict[str, float] feed_system, List[Dict[str, Any]] mode_coupling, float Lstar)
str classify_stability(float chug_gm_nominal, float chug_gate, float acoustic_gate, float acoustic_alpha_nominal, bool acoustic_gated, float requirement)
Dict[str, Any] calculate_acoustic_modes(float chamber_length, float chamber_diameter, float gas_temperature, float gamma, float R)
Tuple[float, float] _chamber_dims(config, cg)
Tuple[float, float] _jet_geometry(config, Dict[str, Any] diagnostics, str side)
Dict[str, float] _feed_override(Optional[Dict[str, Dict[str, float]]] feed, str side)
float _liquid_bulk_modulus(config, str key, float P_line, str record_name)
float _fluid_thermo(config, str key, str attr)
Optional[float] _coolprop_critical_temperature(str canon)
Optional[float] _coolprop_bulk_modulus(str canon, float T, float P)