STAR
Cal STAR โ€” firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
analysis.py
Go to the documentation of this file.
1"""Stability analysis for combustion and feed system dynamics.
2
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.
6
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
10 mixing-lag band;
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.
15"""
16
17from __future__ import annotations
18
19import os
20from functools import lru_cache
21from typing import Dict, Tuple, Optional, List, Any
22import numpy as np
23from engine.pipeline.config_schemas import PintleEngineConfig, StabilityConfig
24from engine.pipeline.constants import DEFAULT_HOT_GAS_THERMAL_COND_W_M_K, DEFAULT_HOT_GAS_VISC_PA_S
25
26
27# ---------------------------------------------------------------------------
28# Combustion and acoustic stability
29# ---------------------------------------------------------------------------
30
32 chamber_volume: float,
33 throat_area: float,
34 cstar: float,
35 gamma: float,
36 Pc: float,
37 R: Optional[float] = None,
38 Tc: Optional[float] = None,
39) -> Dict[str, float]:
40 """
41 Order-of-magnitude chug (bulk-mode) frequency estimates from chamber geometry alone.
42
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.
50
51 Returns
52 -------
53 dict
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]
59 """
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)
67
68 # Helmholtz-like bulk mode: f_H = (a / 2pi) * sqrt(A_neck / (V * L_eff)), neck = throat,
69 # L_eff ~ half a throat diameter.
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)))
76
77 freq = freq_helm if np.isfinite(freq_helm) else float(freq_res)
78 # The gas residence time, m_gas / mdot = rho_c V / mdot = L* c* / (R T) -- what the chug
79 # model's theta_c and the results' "Residence Time" are. L*/c* is not a residence time: it is
80 # short of it by Gamma^2 (~0.4), and was shown as "tau residence" beside the real one.
81 theta_c = (float(Lstar * cstar / (R * Tc)) if (R is not None and Tc is not None and R * Tc > 0)
82 else float("nan"))
83 return {
84 "theta_c": theta_c,
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),
91 }
92
93
95 chamber_length: float,
96 chamber_diameter: float,
97 gas_temperature: float,
98 gamma: float,
99 R: float,
100) -> Dict[str, Any]:
101 """
102 Calculate acoustic resonance frequencies for longitudinal and transverse modes.
103
104 Parameters
105 ----------
106 chamber_length : float
107 Chamber length [m]
108 chamber_diameter : float
109 Chamber diameter [m]
110 gas_temperature : float
111 Gas temperature [K]
112 gamma : float
113 Specific heat ratio [-]
114 R : float
115 Gas constant [J/(kg K)]
116
117 Returns
118 -------
119 dict
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]
123 """
124 # Sound speed
125 sound_speed = float(np.sqrt(gamma * R * gas_temperature))
126
127 # Guard against degenerate length or diameter
128 L = max(chamber_length, 1.0e-3)
129 D = max(chamber_diameter, 1.0e-3)
130
131 # Longitudinal modes: closed-closed half-wave (rigid face, choked nozzle). core.longitudinal_mode_frequencies
132 longitudinal_modes: List[float] = [float(n * sound_speed / (2.0 * L)) for n in range(1, 6)]
133
134 # Transverse cylindrical modes: hard-wall eigenvalues are zeros of J'_m (velocity roots), NOT the
135 # pressure roots of J_m. Previously used [2.405, ...] (J_m zeros) โ€” that was wrong; the rigid-wall
136 # transverse set is [1.841 (1T), 3.054 (2T), 3.832 (1R), 4.201 (3T), 5.331 (1T1R)]. [Phys ยง4.1]
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))
142
143 return {
144 "longitudinal_modes": longitudinal_modes,
145 "transverse_modes": transverse_modes,
146 "sound_speed": sound_speed,
147 }
148
149
150# ---------------------------------------------------------------------------
151# Feed system stability
152# ---------------------------------------------------------------------------
153
155 feed_line_length: float,
156 feed_line_diameter: float,
157 propellant_density: float,
158 bulk_modulus: float,
159 flow_velocity: float,
160 pressure_drop: Optional[float] = None,
161 *,
162 wall_modulus_pa: Optional[float] = None,
163 wall_thickness_m: Optional[float] = None,
164 valve_closure_time_s: Optional[float] = None,
165) -> Dict[str, Any]:
166 """
167 Feed-line acoustics and the water-hammer spike for one propellant line.
168
169 Parameters
170 ----------
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).
186
187 Returns
188 -------
189 dict
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
197
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.
201 """
202 L = max(feed_line_length, 1.0e-3)
203 rho = propellant_density
204 K = bulk_modulus
205
206 a_liq = float(np.sqrt(K / rho))
207 korteweg = 1.0
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)) # closed-open
213 surge_frequency = float(sound_speed / (2.0 * L)) # closed-closed
214
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")
222 else:
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"
225
226 return {
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,
235 }
236
237
238# ---------------------------------------------------------------------------
239# Physical stability margins -- fast tiers for the per-eval path
240# ---------------------------------------------------------------------------
241# Both gate margins are 1 at neutral stability and compare directly with
242# design_requirements.min_stability_margin: chug = Nyquist gain margin, acoustic = damping/driving.
243
244#: stability.acoustic_gate -> which acoustic margin reaches the gate. "report_only" keeps the
245#: acoustic model out of the gate (margin +inf there, i.e. no constraint); see acoustic.py.
246ACOUSTIC_GATE_MODES = ("report_only", "nominal_phase", "worst_phase")
247
248
249def gain_margin_db(gain_margin: float) -> float:
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:
252 return float("nan")
253 return float(20.0 * np.log10(gain_margin))
254
255
256def stability_score(min_margin: float) -> float:
257 """Display score in [0, 1] of the limiting gate margin: ``clip((m - 0.85) / 0.45, 0, 1)``.
258
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).
261 """
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))
265
266
267def stability_state_ok(state: str, require_stable_state: bool) -> bool:
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")
271
272
273def _stability_requirement(config) -> float:
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."""
276 from engine.pipeline.config_schemas import DesignRequirementsConfig
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)
283
284
285def classify_stability(chug_gm_nominal: float, chug_gate: float, acoustic_gate: float,
286 acoustic_alpha_nominal: float, acoustic_gated: bool,
287 requirement: float) -> str:
288 """'unstable' / 'marginal' / 'stable' / 'unknown' from the physical margins.
289
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.
296 """
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):
299 return "unknown"
300 if chug_gm_nominal <= 1.0 or (acoustic_gated and (acoustic_gate <= 1.0 or acoustic_alpha_nominal > 0.0)):
301 return "unstable"
302 floor = max(1.0, float(requirement))
303 if chug_gate >= floor and (not acoustic_gated or acoustic_gate >= floor):
304 return "stable"
305 return "marginal"
306
307
308def _fluid_attr(fluids, key, attr, default):
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."""
312 try:
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)):
316 return float(v)
317 return None if default is None else float(default)
318 except Exception:
319 return None if default is None else float(default)
320
321
322def _feed_attr(config, key, attr, default):
323 try:
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)
328 except Exception:
329 return float(default)
330
331
332# Handbook thermodynamic fallbacks BY FLUID, used only when the config omits a property. Every use
333# is recorded in the assumptions registry. Previously the fuel fallbacks were methane's (h_fg 510 kJ/kg,
334# T_boil 111.6 K) regardless of which fuel the config named, and the oxidizer's were LOX's.
335# density kg/m^3 latent heat J/kg boiling point K critical T K
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), # n-dodecane surrogate
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),
345}
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)}
350
351#: Fluid name -> CoolProp fluid, for properties the config did not supply. Only names whose
352#: thermodynamics CoolProp actually covers; RP-1 is a cut, not a compound, so it is left to the
353#: n-dodecane surrogate in the handbook table above rather than asked of CoolProp under a name
354#: CoolProp would silently resolve to something else.
355_COOLPROP_NAMES = {
356 "lox": "Oxygen", "methane": "Methane", "ethanol": "Ethanol", "ipa": "n-Propanol",
357 "nitrousoxide": "NitrousOxide", "hydrogen": "Hydrogen", "nitrogen": "Nitrogen",
358}
359
360
361@lru_cache(maxsize=32)
362def _coolprop_critical_temperature(canon: str) -> Optional[float]:
363 """Critical temperature [K] from CoolProp for a canonical fluid name, or None.
364
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.
367 """
368 name = _COOLPROP_NAMES.get(canon)
369 if not name:
370 return None
371 try:
372 from CoolProp.CoolProp import PropsSI
373 v = float(PropsSI("Tcrit", name))
374 return v if np.isfinite(v) and v > 0 else None
375 except Exception:
376 return None
377
378
379def _fluid_name(config, key: str) -> str:
380 try:
381 f = config.fluids[key] if isinstance(config.fluids, dict) else getattr(config.fluids, key)
382 return str(getattr(f, "name", "") or "")
383 except Exception:
384 return ""
385
386
387def _fluid_thermo(config, key: str, attr: str) -> float:
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)
392 if v is not None:
393 return v
394 from engine.pipeline.assumptions import assume
395 from engine.pipeline.io import _canon_fluid
396 idx, unit = _THERMO_INDEX[attr]
397 name = _fluid_name(config, key)
398 canon = _canon_fluid(name)
399 if attr == "critical_temperature":
401 if cp is not None:
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")
409
410
411def _injection_phase(config, key: str) -> str:
412 """``"liquid"`` or ``"gas"`` at the injector face.
413
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.
418 """
419 from engine.pipeline.assumptions import assume
420 explicit = None
421 try:
422 f = config.fluids[key] if isinstance(config.fluids, dict) else getattr(config.fluids, key)
423 explicit = getattr(f, "injection_phase", None)
424 except Exception:
425 f = None
426 if explicit in ("liquid", "gas"):
427 return str(explicit)
428 name = _fluid_name(config, key)
429 T = _fluid_attr(getattr(config, "fluids", None), key, "temperature", None)
430 T_crit = _fluid_thermo(config, key, "critical_temperature")
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")
435 return "liquid"
436
437
438def _feed_geometry(config, side: str) -> Tuple[float, float]:
439 """(length [m], flow area [m^2]) of one feed line for the chug inertance L/A.
440
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).
444 """
445 from engine.pipeline.assumptions import assume
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)
455 else:
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)
459
460
461@lru_cache(maxsize=64)
462def _coolprop_bulk_modulus(canon: str, T: float, P: float) -> Optional[float]:
463 """Isentropic bulk modulus ``rho a^2`` [Pa] from CoolProp at (T, P), or None.
464
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)
468 if not name:
469 return None
470 try:
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))
474 v = rho * a * a
475 return v if np.isfinite(v) and v > 0 else None
476 except Exception:
477 return None
478
479
480def _liquid_bulk_modulus(config, key: str, P_line: float, record_name: str) -> float:
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."""
484 from engine.pipeline.assumptions import assume
485 from engine.pipeline.io import _canon_fluid
486 v = _fluid_attr(getattr(config, "fluids", None), key, "bulk_modulus_pa", None)
487 if v is not None and v > 0:
488 return float(v)
489 name = _fluid_name(config, key)
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:
492 k = _coolprop_bulk_modulus(_canon_fluid(name), round(float(T), 1), round(float(P_line), -4))
493 if k is not None:
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)")
500
501
502#: Young's modulus assumed for a feed tube whose material the config does not name [Pa].
503_TUBE_E_STAINLESS = 193.0e9 # 300-series stainless, room temperature (ASM handbook)
504
505
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.
508
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."""
512 import re
513 from engine.pipeline.assumptions import assume
514 try:
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 "")
518 except Exception:
519 size = ""
520 m = re.search(r"_TUBE_(\d{3})$", size.upper())
521 if not m:
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"
527
528
529def _chamber_dims(config, cg) -> Tuple[float, float]:
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."""
534 from engine.pipeline.assumptions import assume
535 L = float(getattr(cg, "length", None) or 0.0)
536 if L <= 0.0:
537 L = float(getattr(getattr(config, "chamber", None), "length", None) or 0.0)
538 if L <= 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)
541 if D <= 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)))
545 else:
546 D = assume("stability.chamber.diameter", 0.1, unit="m", reason="no chamber diameter or volume")
547 return L, D
548
549
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.
552
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.
558
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).
562 """
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")
566
567 d = diagnostics.get(f"d_jet_{key}")
568 if d is not None and np.isfinite(float(d)) and float(d) > 0.0:
569 return float(d), u
570
571 inj = getattr(config, "injector", None)
572 geom = getattr(inj, "geometry", None)
573 itype = str(getattr(inj, "type", "") or "")
574 try:
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
581 # Annulus hydraulic diameter = 2 * gap (outer minus inner diameter).
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
587 except Exception:
588 pass
589 return float("nan"), u
590
591
592def _stability_config(config) -> StabilityConfig:
593 sc = getattr(config, "stability", None)
594 return sc if isinstance(sc, StabilityConfig) else StabilityConfig()
595
596
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.
599
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."""
603 if not feed:
604 return {}
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"):
609 v = f.get(name)
610 if v is None:
611 continue
612 v = float(v)
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}")
615 out[name] = v
616 return out
617
618
619def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, cstar: float,
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]
626
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.
630
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.
638 """
639 from engine.pipeline.stability import core, chug, acoustic, timelag
640 from engine.pipeline.assumptions import assume
641
642 sc = _stability_config(config)
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))
646 L_ch, D_ch = _chamber_dims(config, cg)
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")
649
650 # Hot-gas transport properties: the same ones the thermal model uses (regen_cooling block is the
651 # engine's hot-gas property record, regardless of whether regen is enabled).
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
654 if k_g <= 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
658 if mu_g <= 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")
661 # Product-gas cp from the CEA state of THIS evaluation (gamma, R). This used to read a fixed
662 # regen_cooling.hot_gas_cp = 2200 J/(kg*K) for every propellant.
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") # Kirchhoff thermal layer
667 a_snd = core.sound_speed(gamma, R, Tc)
668
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))
671
672 def _drop(key: str, frac: float, what: str, effect: str) -> float:
673 # A published 0.0 is a real zero-stiffness injector, not a missing value; only an absent or
674 # non-finite key falls back, and the fallback is recorded (coaxial publishes none today).
675 v = diagnostics.get(key)
676 if v is not None and np.isfinite(float(v)):
677 return 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, "
680 f"so {effect}")
681
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)
688 # SMD comes from whichever spray model the config's injector selected (Ingebo for impinging,
689 # Lefebvre for coaxial, the sheet model for pintle) -- this layer must never pick one. When the
690 # closure did not produce one, the substitution is recorded rather than silently applied; it was
691 # a bare 80/60 um, i.e. a LOX/methane impinging spray asserted for every engine.
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")
697 D32_O = float(D32_O)
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")
702 D32_F = float(D32_F)
703 ov = overrides or {}
704 # The SMD sliders. `smd_um` has always meant the OXIDIZER spray and keeps that meaning for
705 # back-compatibility; `smd_F_um` was missing entirely, so on an engine whose FUEL is the
706 # rate-limiting vaporizer (LOX/ethanol 6500 N: tau_F 5.7 ms vs tau_O 3.1 ms) the atomization
707 # slider could not move the quantity that sets the lag.
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"])
714 dpiO = eta_O * Pc
715 else:
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"])
719 dpiF = eta_F * Pc
720 else:
721 eta_F = dpiF / Pc if Pc > 0 else 0.3
722
723 rho_O = _fluid_thermo(config, "oxidizer", "density")
724 rho_F = _fluid_thermo(config, "fuel", "density")
725 hfg_O = _fluid_thermo(config, "oxidizer", "latent_heat")
726 hfg_F = _fluid_thermo(config, "fuel", "latent_heat")
727 tbO = _fluid_thermo(config, "oxidizer", "boiling_point")
728 tbF = _fluid_thermo(config, "fuel", "boiling_point")
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")
733 tcrF = _fluid_thermo(config, "fuel", "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"))
738 phaseO = _injection_phase(config, "oxidizer")
739 phaseF = _injection_phase(config, "fuel")
740 d_jet_O, u_inj_O = _jet_geometry(config, diagnostics, "oxidizer")
741 d_jet_F, u_inj_F = _jet_geometry(config, diagnostics, "fuel")
742
743 # Mean axial gas velocity in the chamber -- the ``u_g`` of the atomization Weber number.
744 u_gas = float(mdot_total / (rho_g * A_c)) if (rho_g > 0 and A_c > 0) else float("nan")
745
746 lag_streams = {
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),
755 }
756 lag_chamber = timelag.ChamberThermo(Pc=Pc, Tc=Tc, MR=MR, rho_g=rho_g, u_g=u_gas,
757 k_g=k_g, cp_g=cp_g)
758 # `or` rather than a dict default: an override dict that carries the key with a None value
759 # (a caller passing model_dump() unfiltered) must fall back to the config, not stringify None
760 # into a model name the registry will reject.
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)
763 # The d^2-law is the historical model; it never carried a mixing lag, so selecting it must not
764 # introduce one. Gate on the model having something to do, not merely on the field being set.
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"]) # 0.0 is meaningful here, so test for None
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),
772 )
773 tau_conv_O = float(lags["O"].tau_total)
774 tau_conv_F = float(lags["F"].tau_total)
775 # The mixing lag is mix_fraction x this (compute_lags: slowest liquid tau_vap). Kept so the
776 # chug margin can be taken over the unmeasured mixing-fraction band without re-running the lags.
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)")
787
788 L_feed_O, A_feed_O = _feed_geometry(config, "oxidizer")
789 L_feed_F, A_feed_F = _feed_geometry(config, "fuel")
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)
792 # The supply's share of the lumped feed loss (feed_system.<side>.supply_K): it sets the inlet
793 # pressure above, but it is the regulator and ullage at chug frequencies, which the Regulator
794 # model below carries. Counting it again as line resistance raised the gain margin (1.51 ->
795 # 1.57 on the 6.8 kN stand fit), in the unsafe direction. Zero by default: K0 all line.
796 dpfO = _chug_feed_drop(config, "oxidizer", dpfO, mdot_O, rho_O)
797 dpfF = _chug_feed_drop(config, "fuel", dpfF, mdot_F, rho_F)
798 # The caller's feed impedance (opt-in). The equivalent length is I * A on the config's own area,
799 # so ChugStream.inertance() = length/area returns the caller's I and nothing else in the stream
800 # changes. Without ``feed`` none of this runs and the streams are exactly the config's.
801 feed_basis = "config"
802 ovO, ovF = _feed_override(feed, "oxidizer"), _feed_override(feed, "fuel")
803 if ovO or ovF:
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
809 if "dP_feed" in ovO:
810 dpfO = ovO["dP_feed"]
811 if "dP_feed" in ovF:
812 dpfF = ovF["dP_feed"]
813 streams = [
814 chug.ChugStream("O", mdot=mdot_O, eta_inj=max(eta_O, 1e-3), Pc=Pc, dP_feed=dpfO,
815 feed_length=L_feed_O, feed_area=A_feed_O, tau_conv=tau_conv_O,
816 regulator=chug.Regulator(**reg_kw)),
817 chug.ChugStream("F", mdot=mdot_F, eta_inj=max(eta_F, 1e-3), Pc=Pc, dP_feed=dpfF,
818 feed_length=L_feed_F, feed_area=A_feed_F, tau_conv=tau_conv_F,
819 regulator=chug.Regulator(**reg_kw)),
820 ]
821 # theta_c from the chamber state (R, Tc) the rest of this model uses -- rho_g above -- with the
822 # delivered c*. The Gamma-form assumed RT = (Gamma c*_act)^2 and ran ~10 % long.
823 chamber = chug.ChugChamber(cstar=cstar, A_t=A_t, Lstar=Lstar, gamma=gamma,
824 R_gas=float(R) if R > 0 else None, T_c=float(Tc) if Tc > 0 else None)
825 # Mixing-lag band the chug gate must hold over. Only the double-time-lag model has a mixing lag.
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)
828 chug_band = None
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))
834 # Sensitive lag for the acoustic n-tau driving. The rate-limiting stream is whichever LIQUID
835 # stream converts slowest -- not "the oxidizer" (this read tau_conv_O unconditionally, which is
836 # only right when the oxidizer happens to be both liquid and slower; on a gas/liquid pair such as
837 # GOX/ethanol it priced the acoustic driving off a stream that has no droplets at all).
838 # Uses the POST-fallback lags: a liquid stream whose lag was non-finite and got substituted is
839 # still a liquid stream and still competes to be the rate-limiting one.
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 # [Phys ยง5]
844
845 # Nozzle-entrance Mach sets the convective (nozzle) damping. Config value if given, else the
846 # subsonic isentropic solution for the actual contraction ratio (a fixed 0.2 corresponds to a
847 # contraction ratio of ~2.9 and overstated nozzle damping for every wider chamber).
848 M_ne = sc.mach_nozzle_entrance
849 if M_ne is None:
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}")
859 coeffs = acoustic.DampingCoeffs(injector_frac=float(sc.damping_injector_frac),
860 twophase_frac=float(sc.damping_twophase_frac),
861 droplet_loading=float(sc.droplet_loading))
862
863 return {
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,
885 # "config" (the config's plumbing) or "caller" (``feed`` restated it, opt-in).
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"),
889 # Which stream actually paces the burn. Everything that reports "the" vaporization length,
890 # "the" lag or "the" SMD has to follow this, not the oxidizer by position.
891 "rate_limiting_stream": ("O" if (np.isfinite(tau_conv_O) and tau_conv_O >= tau_conv_F)
892 else "F"),
893 "Pc": Pc, "wh_pressure_pa": None,
894 }
895
896
897def _chug_fast(streams, chamber) -> Dict[str, Any]:
898 """Fast chug margin through the compiled kernel when the accelerator is on, else Python."""
899 from engine.pipeline.stability import chug
900 from engine import accel
901 if accel.enabled():
902 try:
903 # attribute access, not a direct import -- see the note in
904 # tests/test_accel_is_actually_used.py
905 return accel.chug_margin_fast(streams, chamber)
906 except Exception:
907 pass
908 return chug.chug_margin_fast(streams, chamber)
909
910
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"]``.
913
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
917 override)."""
918 import copy
919 band = inp.get("chug_band")
920 if not band:
921 return None
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)]
925 gms = []
926 for f in fracs:
927 if abs(f - f0) < 1e-12:
928 gms.append(float(nominal_gm))
929 continue
930 st = []
931 for s0 in inp["streams"]:
932 s1 = copy.copy(s0)
933 s1.tau_conv = max(float(s0.tau_conv) + (f - f0) * basis, 0.0)
934 st.append(s1)
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
939 return {
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,
947 }
948
949
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):
956 return dp_feed
957 supply = k_supply * mdot * mdot / (2.0 * rho * area * area)
958 return max(dp_feed - supply, 0.0)
959
960
961def compute_physical_stability(config, Pc: float, MR: float, mdot_total: float, cstar: float,
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]
965
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.
969 """
970 from engine.pipeline.stability import acoustic
971 inp = build_stability_inputs(config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg)
972 # Accelerated fast path: the 200-pt complex chug sweep is the dominant per-eval stability cost.
973 chug_fast = _chug_fast(inp["streams"], inp["chamber"])
974 gm = float(chug_fast.get("gain_margin", float("nan")))
975 band = chug_band(inp, gm)
976 chug_gate = band["min"] if band is not None else gm
977
978 # fast_acoustic stays pure Python on purpose: 10.5 us here vs 4.2 us in C, and
979 # acoustic.fast_acoustic is two mode_growth_rate calls with no loop. A ~6 us
980 # difference does not earn a kernel. (The chug sweep did: 200 complex points,
981 # measured at ~8.8% of Layer-1 wall time when left unaccelerated.)
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"])
990 else:
991 ac_gate = float("inf")
992
993 return {
994 "chug": chug_fast,
995 "chug_band": band,
996 "acoustic": ac_fast,
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"],
1004 "inputs": inp,
1005 }
1006
1007
1008# ---------------------------------------------------------------------------
1009# Comprehensive stability analysis
1010# ---------------------------------------------------------------------------
1011
1013 config: PintleEngineConfig,
1014 Pc: float,
1015 MR: float,
1016 mdot_total: float,
1017 cstar: float,
1018 gamma: float,
1019 R: float,
1020 Tc: float,
1021 diagnostics: Dict[str, Any],
1022) -> Dict[str, Any]:
1023 """
1024 Comprehensive stability analysis combining combustion, acoustic, and feed system.
1025
1026 Returns
1027 -------
1028 dict with keys
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
1040 """
1041 from engine.pipeline.config_schemas import ensure_chamber_geometry
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
1046 L_chamber, D_chamber = _chamber_dims(config, cg)
1047
1049 chamber_volume=V_chamber, throat_area=A_throat, cstar=cstar, gamma=gamma, Pc=Pc, R=R, Tc=Tc,
1050 )
1051 acoustic_raw = calculate_acoustic_modes(
1052 chamber_length=L_chamber, chamber_diameter=D_chamber, gas_temperature=Tc, gamma=gamma, R=R,
1053 )
1054
1055 issues: List[str] = []
1056
1057 # -------------------------------------------------------------------
1058 # Physical margins
1059 # -------------------------------------------------------------------
1060 phys = None
1061 phys_error = None
1062 try:
1063 phys = compute_physical_stability(config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg)
1064 except Exception as exc: # never fail the eval on a stability-model error -- but never pass it either
1065 phys = None
1066 phys_error = f"{type(exc).__name__}: {exc}"
1067
1068 nan = float("nan")
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) # physical chug freq, not L*/c* placeholder
1078 band = phys.get("chug_band")
1079 chugging["chug_gm_band"] = band
1080 if chug_gm <= 1.0:
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 "
1086 f"unmeasured lag")
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)")
1089 else:
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
1095 chugging["chug_gain_margin_db"] = gain_margin_db(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)")
1100
1101 # -------------------------------------------------------------------
1102 # Feed-line acoustics, both lines
1103 # -------------------------------------------------------------------
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")):
1107 L_line, A_line = _feed_geometry(config, side)
1108 d_line = float(np.sqrt(4.0 * A_line / np.pi))
1109 rho = _fluid_thermo(config, side, "density")
1110 mdot_k = diagnostics.get(f"mdot_{key}")
1111 if mdot_k is None:
1112 mdot_k = mdot_total * (MR if key == "O" else 1.0) / (1.0 + MR)
1113 P_line = diagnostics.get(f"P_tank_{key}")
1114 if P_line is None:
1115 P_line = Pc + (inp[f"dP_inj_{key}"] + inp[f"dP_feed_{key}"] if inp is not None else 0.0)
1116 K = _liquid_bulk_modulus(config, side, float(P_line), f"stability.feed.bulk_modulus_{key}")
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,
1123 )
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
1127 # Top level stays the oxidizer line, which is what older readers expect.
1128 feed_stability: Dict[str, Any] = dict(feed_lines["oxidizer"])
1129 feed_stability["feed_lines"] = feed_lines
1130 # Feed-coupled instability IS chug; Layer 1 still reads this key (and so counts chug twice).
1131 feed_stability["stability_margin"] = chug_margin
1132
1133 # Build acoustic mode dictionary
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
1139
1140 # -------------------------------------------------------------------
1141 # Mode coupling (reporting only: a 10 % frequency proximity note, it gates nothing)
1142 # -------------------------------------------------------------------
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})
1153
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"]
1160 fmax = max(f1, f2)
1161 if fmax <= 0.0:
1162 continue
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),
1168 })
1169 if mode_coupling:
1170 issues.append("Potential mode coupling between combustion, acoustic, and feed system modes")
1171
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")
1174
1175 # -------------------------------------------------------------------
1176 # Classification
1177 # -------------------------------------------------------------------
1178 acoustic_gated = acoustic_gate_mode != "report_only"
1179 requirement = _stability_requirement(config)
1180 if phys is None:
1181 stability_state = "unknown"
1182 else:
1183 stability_state = classify_stability(
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
1189
1190 recommendations = _generate_stability_recommendations(
1191 stability_state=stability_state, chugging=chugging, acoustic=acoustic_raw,
1192 feed_system=feed_stability, mode_coupling=mode_coupling, Lstar=Lstar,
1193 )
1194
1195 acoustic = {
1196 **acoustic_raw,
1197 "modes": acoustic_modes_dict,
1198 "stability_margin": float(acoustic_margin),
1199 "gate_status": acoustic_gate_mode,
1200 }
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")
1209
1210 return {
1211 "stability_state": stability_state,
1212 "stability_score": score,
1213 "stability_requirement": requirement,
1214 "is_stable": stability_state == "stable", # Backward compatibility
1215 "chugging": chugging,
1216 "acoustic": acoustic,
1217 "feed_system": feed_stability,
1218 "mode_coupling": mode_coupling,
1219 "Lstar": Lstar,
1220 "issues": issues,
1221 "recommendations": recommendations,
1222 }
1223
1224
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]],
1231 Lstar: float,
1232) -> List[str]:
1233 """Generate stability improvement recommendations based on analysis."""
1234 recs: List[str] = []
1235
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.")
1242 else:
1243 recs.append("System appears reasonably stable for this point. Still monitor during hot fire.")
1244
1245 # Chug: gate margin of the feed-coupled loop (>1 stable). A thin margin is one under 6 dB
1246 # (GM 2), the usual floor for a feedback loop.
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.")
1251 # Baffles and acoustic liners damp chamber acoustic modes; they do nothing for chug, a
1252 # bulk mode whose loop runs through the feed. (This used to follow every chug warning.)
1253
1254 if not np.isfinite(chugging["frequency"]):
1255 pass
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.")
1260
1261 # L* tuning
1262 if Lstar < 0.5:
1263 recs.append("L* is quite short. Consider increasing chamber length or volume to improve stability and performance.")
1264 elif Lstar > 3.0:
1265 recs.append("L* is quite long. This can add mass and potentially introduce higher order acoustic issues.")
1266
1267 # Mode coupling
1268 for pair in mode_coupling:
1269 recs.append(
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."
1273 )
1274 recs.append("Consider shifting one of these frequencies by adjusting geometry or feed system properties.")
1275
1276 if not recs:
1277 recs.append("No obvious stability issues detected. Still validate with test data.")
1278
1279 return recs
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
float _chug_feed_drop(Any config, str side, float dp_feed, float mdot, float rho)
Definition analysis.py:950
Optional[Dict[str, Any]] chug_band(Dict[str, Any] inp, float nominal_gm, int n_pts=5)
Definition analysis.py:911
Dict[str, Any] _chug_fast(streams, chamber)
Definition analysis.py:897
float stability_score(float min_margin)
Definition analysis.py:256
_fluid_attr(fluids, key, attr, default)
Definition analysis.py:308
Tuple[Optional[float], Optional[float], str] _feed_wall(config, str side)
Definition analysis.py:506
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)
Definition analysis.py:1022
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)
Definition analysis.py:165
Tuple[float, float] _feed_geometry(config, str side)
Definition analysis.py:438
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)
Definition analysis.py:622
bool stability_state_ok(str state, bool require_stable_state)
Definition analysis.py:267
float gain_margin_db(float gain_margin)
Definition analysis.py:249
str _fluid_name(config, str key)
Definition analysis.py:379
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)
Definition analysis.py:39
str _injection_phase(config, str key)
Definition analysis.py:411
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)
Definition analysis.py:963
_feed_attr(config, key, attr, default)
Definition analysis.py:322
StabilityConfig _stability_config(config)
Definition analysis.py:592
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)
Definition analysis.py:1232
str classify_stability(float chug_gm_nominal, float chug_gate, float acoustic_gate, float acoustic_alpha_nominal, bool acoustic_gated, float requirement)
Definition analysis.py:287
Dict[str, Any] calculate_acoustic_modes(float chamber_length, float chamber_diameter, float gas_temperature, float gamma, float R)
Definition analysis.py:100
Tuple[float, float] _chamber_dims(config, cg)
Definition analysis.py:529
Tuple[float, float] _jet_geometry(config, Dict[str, Any] diagnostics, str side)
Definition analysis.py:550
Dict[str, float] _feed_override(Optional[Dict[str, Dict[str, float]]] feed, str side)
Definition analysis.py:597
float _liquid_bulk_modulus(config, str key, float P_line, str record_name)
Definition analysis.py:480
float _fluid_thermo(config, str key, str attr)
Definition analysis.py:387
Optional[float] _coolprop_critical_temperature(str canon)
Definition analysis.py:362
Optional[float] _coolprop_bulk_modulus(str canon, float T, float P)
Definition analysis.py:462