1"""Low-frequency (chug) stability: lumped feed <-> chamber <-> combustion model.
3Implements the impedance-form characteristic equation [Phys §3.2, eq 3.3]:
5 F(s) = 1 + Y_ch(s) * sum_k exp(-s*tau_conv_k) / Z_feed_k(s) = 0
7 Y_ch(s) = K_c / (theta_c*s + 1) chamber transfer function (K_c = c*/A_t)
8 Z_feed_k = Z_reg_k(s) + I_k*s + R_k + 1/G_inj_k series feed+injector+regulator impedance
10All quantities use the **mass-flow** through-variable convention (impedances in Pa·s/kg). Pure numeric
11module — params come in as dataclasses; config extraction lives in the integration layer (analysis.py).
14 * ``chug_margin_fast`` — gain/phase-margin proxy from a 1-D frequency scan (no transcendental
15 root-find). For the Layer-1 inner loop. Calibrated to the **sign** of alpha.
16 * ``chug_growth_rate`` — complex root-find of (3.3) for the dominant (alpha, omega). For the report.
18The regulator enters in **two separate roles** [Phys §3.2 / §6.1]:
19 * impedance ``Z_reg(s)`` inside ``Z_feed`` — shifts the poles / alpha (homogeneous stability);
20 * forcing bound ``max_excursion_pa`` — disturbance amplitude (reported separately, NOT a pole-shifter).
23from __future__
import annotations
25from dataclasses
import dataclass, field
26from functools
import lru_cache
27from typing
import Dict, List, Optional, Tuple
39 """Dome-regulator model. Z_reg(s) is a high-pass: ~0 below the corner (regulated), -> Z_hf above
40 (regulator can't keep up). Z_hf = 0 means the regulator is NOT MODELLED: the feed sees an ideal
41 pressure source, which is what a dome regulator plus ullage is at the ~100 Hz chug band. The
42 forcing bound ``max_excursion_pa`` is reported, NOT used in the characteristic equation. [Phys §6.1]
44 corner_hz: float = 3.0
46 max_excursion_pa: float = 0.0
51 """True only when the regulator can change the loop: enabled AND a measured Z_hf."""
55 """Series impedance Z_reg(s) [Pa·s/kg]. High-pass toward Z_hf above the corner."""
59 return complex(self.
Z_hf) * (s / wc) / (1.0 + s / wc)
64 """One propellant stream (O or F) feeding the shared chamber."""
73 regulator: Regulator = field(default_factory=Regulator)
83 """Linearized feed resistance R = dDP_feed/dmdot ~ 2*DP_feed/mdot (quadratic loss) [Pa·s/kg]."""
88 def Z_feed(self, s: complex, *, with_regulator: bool =
True) -> complex:
89 """Series feed+injector(+regulator) impedance Z_feed_k(s) [Pa·s/kg]."""
91 Zr = self.
regulator.impedance(s)
if with_regulator
else 0.0 + 0.0j
101 theta_factor: float = 1.0
102 R_gas: Optional[float] =
None
103 T_c: Optional[float] =
None
106 """Gas residence time m_gas/mdot. From the chamber state, L* c*/(R T), when R and T are
107 given; otherwise the ideal-c* identity L*/(Gamma^2 c*)."""
108 if self.
R_gas is not None and self.
T_c is not None and self.
R_gas > 0
and self.
T_c > 0:
109 th = core.chamber_residence_time_from_state(self.Lstar, self.
cstar, self.
R_gas, self.
T_c)
111 th = core.chamber_residence_time(self.Lstar, self.
cstar, self.gamma)
117 def Y_ch(self, s: complex) -> complex:
119 return self.
K_c() / (th * s + 1.0)
127 *, with_regulator: bool =
True) -> complex:
128 """Open-loop transfer L(s) = Y_ch(s) * sum_k exp(-s*tau_k)/Z_feed_k(s). Char. eq is 1 + L(s) = 0."""
131 Zf = st.Z_feed(s, with_regulator=with_regulator)
134 acc += np.exp(-s * st.tau_conv) / Zf
135 return chamber.Y_ch(s) * acc
138def _open_loop_grid(omega: np.ndarray, streams: List[ChugStream], chamber: ChugChamber,
139 *, with_regulator: bool =
True) -> np.ndarray:
140 """Vectorised L(iw) over a whole frequency grid.
142 Same computation as calling chug_open_loop once per point: every operation in
143 Z_feed and Y_ch is element-wise in s, and the per-stream primitives (G_inj,
144 inertance, resistance, regulator corner) do not depend on s at all. The
145 per-point version recomputed all of them at each of the 200 grid points, which
146 made this the dominant per-eval stability cost.
148 Term order is kept identical to Z_feed/chug_open_loop deliberately, since
149 floating-point addition is not associative. Agreement is ~1 ULP rather than
150 bit-for-bit: numpy's complex exp takes a different code path on arrays than on
151 scalars, which is far below any tolerance here but is not exactly zero.
153 s_arr = 1j * np.asarray(omega, dtype=np.float64)
154 acc = np.zeros_like(s_arr)
158 if with_regulator
and st.regulator.modelled:
159 wc = 2.0 * np.pi *
max(st.regulator.corner_hz, 1e-6)
160 Zr = complex(st.regulator.Z_hf) * (s_arr / wc) / (1.0 + s_arr / wc)
161 Zf = Zr + st.inertance() * s_arr + st.resistance() + (1.0 / G
if G > 0
else np.inf)
162 with np.errstate(divide=
"ignore", invalid=
"ignore"):
163 term = np.exp(-s_arr * st.tau_conv) / Zf
166 acc = acc + np.where(Zf == 0, 0.0 + 0.0j, term)
167 return chamber.K_c() / (chamber.theta_c() * s_arr + 1.0) * acc
171 *, with_regulator: bool =
True) -> complex:
172 """F(s) = 1 + L(s). Roots s = alpha + i*omega give chug frequency/growth rate."""
173 return 1.0 +
chug_open_loop(s, streams, chamber, with_regulator=with_regulator)
182 grid = 2.0 * np.pi * np.logspace(np.log10(f_lo), np.log10(f_hi), n)
183 grid.flags.writeable =
False
187def _freq_grid(f_lo: float = 2.0, f_hi: float = 2000.0, n: int = 200) -> np.ndarray:
196 """(omega_c, |L|_c) where L(iw) crosses the negative real axis: Im L changes sign with Re L < 0.
198 Every such crossing is a phase of -pi - 2*pi*k. Testing the unwrapped phase against -pi alone
199 (the old rule) misses k >= 1, where a slow second stream can put the loop's largest gain.
201 im, re = L.imag, L.real
202 i0, i1 = im[:-1], im[1:]
203 hits = np.flatnonzero((i0 == 0.0) | (i0 * i1 < 0.0))
205 return np.empty(0), np.empty(0)
206 di = im[hits] - im[hits + 1]
208 frac = np.where(safe, im[hits] / np.where(safe, di, 1.0), 0.0)
209 re_c = re[hits] + frac * (re[hits + 1] - re[hits])
210 w_c = omega[hits] + frac * (omega[hits + 1] - omega[hits])
212 return w_c[neg], -re_c[neg]
216 *, with_regulator: bool =
True,
217 f_lo: float = 2.0, f_hi: float = 2000.0) -> Dict[str, float]:
218 """Fast chug margin via the Nyquist gain margin of L(iw). No root-find. [Phys §3.2 fast form]
220 Char. eq 1+L=0 => instability when L(iw) encircles -1. L is open-loop stable (chamber and feed
221 poles are real and negative; delays add none), so the loop is stable when L stays inside the
222 unit circle wherever it crosses the negative real axis: GM = 1 / max |L| over ALL those
223 crossings (phase -pi, -3pi, ...), and GM < 1 means encirclement.
225 Returns dict: ``gain_margin`` (>1 stable), ``stable`` (bool), ``f_chug_hz`` (frequency of the
226 worst crossing, the chug-frequency estimate), ``crossings_hz`` (every negative-axis crossing,
227 for seeding the root-find), ``phase_margin_deg``, ``margin`` (= gain_margin).
230 L =
_open_loop_grid(omega, streams, chamber, with_regulator=with_regulator)
231 phase = np.unwrap(np.angle(L))
238 k = int(np.argmax(mag_c))
239 gm_best = float(1.0 / mag_c[k])
if mag_c[k] > 0
else np.inf
240 f_pc = float(w_c[k] / (2.0 * np.pi))
244 pm_deg = float(
"nan")
246 h0, h1 = h[:-1], h[1:]
247 gain_hits = np.flatnonzero((h0 == 0.0) | (h0 * h1 < 0.0))
249 dh = h[gain_hits] - h[gain_hits + 1]
250 frac = np.where(dh != 0.0, h[gain_hits] / np.where(dh != 0.0, dh, 1.0), 0.0)
251 ph_c = phase[gain_hits] + frac * (phase[gain_hits + 1] - phase[gain_hits])
252 dist = np.mod(ph_c + np.pi, 2.0 * np.pi)
253 dist = np.where(dist > np.pi, dist - 2.0 * np.pi, dist)
254 pm_deg = float(np.degrees(dist[np.argmin(np.abs(dist))]))
256 if not np.isfinite(gm_best):
259 gm_best = float(1.0 /
max(mag.max(), 1e-12))
260 gm_best =
max(gm_best, 1.0)
if mag.max() < 1.0
else gm_best
262 stable = gm_best > 1.0
264 "gain_margin": float(gm_best),
265 "stable": bool(stable),
266 "f_chug_hz": float(f_pc),
267 "crossings_hz": [float(w / (2.0 * np.pi))
for w
in w_c],
268 "phase_margin_deg": pm_deg,
269 "margin": float(gm_best),
278 *, with_regulator: bool, omega_seed: float) -> Tuple[float, float, float]:
279 """Newton/fsolve for the dominant complex root of F(s)=0 near omega_seed. Returns (alpha, omega, |F|)."""
280 from scipy.optimize
import fsolve
283 s = complex(x[0], x[1])
286 with np.errstate(over=
"ignore", invalid=
"ignore"):
288 return [F.real, F.imag]
290 w0 = omega_seed
if (np.isfinite(omega_seed)
and omega_seed > 0)
else 2 * np.pi * 100.0
292 for a0
in (-0.05 * w0, 0.0, 0.05 * w0):
293 for wfac
in (1.0, 0.6, 1.6):
295 sol, info, ier, _ = fsolve(residual, [a0, w0 * wfac], full_output=
True)
297 resF = abs(complex(*residual(sol)))
298 if sol[1] > 0
and (best
is None or sol[0] > best[0]):
300 best = (float(sol[0]), float(sol[1]), float(resF))
304 return float(
"nan"), float(
"nan"), float(
"inf")
308def _dominant_root(streams: List[ChugStream], chamber: ChugChamber, fast_res: Dict[str, float],
309 *, with_regulator: bool =
True) -> Tuple[float, float, float]:
310 """The most unstable root of F(s) = 0: seeded at every negative-real-axis crossing of L(iw)
311 (``fast_res``, from ``chug_margin_fast``), the largest alpha kept. (alpha, omega, |F|)."""
312 seeds = [2 * np.pi * f
for f
in fast_res.get(
"crossings_hz", [])
if np.isfinite(f)
and f > 0]
314 f0 = fast_res.get(
"f_chug_hz", float(
"nan"))
315 seeds = [2 * np.pi * f0
if np.isfinite(f0)
else 2 * np.pi * 100.0]
316 best = (float(
"nan"), float(
"nan"), float(
"inf"))
319 if np.isfinite(a)
and (
not np.isfinite(best[0])
or a > best[0]):
325 """Heuristic: which feed term dominates |Z_feed| at the root -> the limiting physics."""
326 drivers = {
"feed_inertance": 0.0,
"feed_resistance": 0.0,
"injector_stiffness": 0.0,
"regulator": 0.0}
329 drivers[
"feed_inertance"] += abs(st.inertance() * s)
330 drivers[
"feed_resistance"] += abs(st.resistance())
331 drivers[
"injector_stiffness"] += abs(1.0 / G)
if G > 0
else 0.0
332 drivers[
"regulator"] += abs(st.regulator.impedance(s))
333 return max(drivers, key=drivers.get)
337 """Damping ratio of a complex pole ``s = sigma + j*omega``: ``zeta = -sigma / |s|``.
339 This is the standard definition — the cosine of the pole's angle from the negative real axis —
340 and it is what a constant-zeta ray on a root locus means. (An earlier version of this module
341 reported ``-sigma/omega``, which is ``zeta/sqrt(1-zeta^2)``: indistinguishable below zeta ~ 0.1
342 and 15 % off by zeta = 0.5, so a pole plotted against its own reported zeta did not sit on the
343 ray. Fixed here so the diagram and the number agree.)
345 mag = float(np.hypot(sigma, omega))
346 if not np.isfinite(mag)
or mag <= 0:
348 return float(-sigma / mag)
352 *, with_regulator: bool =
True) -> Tuple[float, float, float]:
353 """Single-seed Newton solve for the root of F(s)=0 nearest ``s0``. (alpha, omega, |F|).
355 The continuation step of the root locus: each point seeds from its predecessor, so one fsolve
356 per point is enough. ``_solve_dominant_root`` fans out over nine seeds because it has no
357 predecessor to start from; doing that per locus point would cost ~40x for no extra accuracy.
359 from scipy.optimize
import fsolve
362 with np.errstate(over=
"ignore", invalid=
"ignore"):
364 with_regulator=with_regulator)
365 return [F.real, F.imag]
368 sol, _, ier, _ = fsolve(residual, [s0.real, s0.imag], full_output=
True)
370 return float(
"nan"), float(
"nan"), float(
"inf")
372 return float(
"nan"), float(
"nan"), float(
"inf")
373 resF = abs(complex(*residual(sol)))
374 if resF > 1e-6
or sol[1] <= 0:
375 return float(
"nan"), float(
"nan"), float(
"inf")
376 return float(sol[0]), float(sol[1]), float(resF)
380 """Mass-flow-weighted injector stiffness of the streams."""
381 m = sum(
max(float(s.mdot), 0.0)
for s
in streams)
382 return float(sum(float(s.mdot) * float(s.eta_inj)
for s
in streams) / m)
if m > 0
else float(
"nan")
386 *, eta_values: Optional[np.ndarray] =
None,
387 with_regulator: bool =
True, scale_design: bool =
False) -> List[Dict[str, float]]:
388 """Track the dominant chug pole through the s-plane as injector stiffness sweeps.
390 This is a root locus in the textbook sense: ``eta_inj = dP_inj/Pc`` is the swept gain, and each
391 returned point is the eigenvalue ``s = sigma + j*omega`` of the closed-loop characteristic
392 equation ``1 + L(s) = 0`` at that gain. The imaginary axis is the stability boundary — the
393 branch crosses it where the loop goes neutrally stable, and the crossing frequency is the chug
394 frequency the engine would ring at.
396 Solved by continuation from the softest injector upward, each point seeded on its predecessor,
397 which is the standard way to follow a branch rather than re-discover it. Honest caveat: on every
398 case tried so far (lags 0.8-9 ms, feed runs 0.08-1.5 m, eta from 0.02 to 1.2) a single fixed seed
399 found the same branch, so the continuation is insurance against branch-hopping rather than a
400 demonstrated fix for it. ``test_locus_is_continuous_in_frequency`` checks the OUTPUT is a branch;
401 it does not, and cannot currently, distinguish the two seeding strategies.
403 ``scale_design``: each value is the streams' mass-weighted mean stiffness, reached by scaling
404 every stream's OWN drop by one factor, so the design (unequal drops and all) sits exactly on
405 the branch at its own mean. Off, every stream is set to the same eta.
407 Returns points in ascending ``eta`` with keys ``eta``, ``real``, ``imag``, ``f_hz``, ``zeta``.
408 Points where the branch could not be followed are dropped, so the caller gets a clean polyline.
411 eta0 =
mean_eta(streams)
if scale_design
else float(
"nan")
413 if eta_values
is None:
414 eta_values = np.linspace(0.05, 0.60, 28)
415 etas = np.asarray(sorted(float(e)
for e
in eta_values
if np.isfinite(e)
and e > 0))
419 def scaled(eta: float) -> List[ChugStream]:
423 st2.eta_inj = float(st.eta_inj) * eta / eta0
if scale_design
else float(eta)
429 seed_streams = scaled(etas[0])
430 fast =
chug_margin_fast(seed_streams, chamber, with_regulator=with_regulator)
431 w0 = 2 * np.pi * fast[
"f_chug_hz"]
if np.isfinite(fast[
"f_chug_hz"])
else 2 * np.pi * 100.0
432 s_prev = complex(0.0, w0)
434 pts: List[Dict[str, float]] = []
437 a, w, res =
_solve_root_near(st, chamber, s_prev, with_regulator=with_regulator)
444 with_regulator=with_regulator)
445 if np.isfinite(ad)
and np.isfinite(wd)
and wd > 0
and (
not np.isfinite(a)
or ad > a + 1e-9):
446 a, w, res = ad, wd, rd
447 if not (np.isfinite(a)
and np.isfinite(w)
and w > 0):
450 if not np.isfinite(f2[
"f_chug_hz"]):
452 a, w, res =
_solve_root_near(st, chamber, complex(0.0, 2 * np.pi * f2[
"f_chug_hz"]),
453 with_regulator=with_regulator)
454 if not (np.isfinite(a)
and np.isfinite(w)
and w > 0):
456 s_prev = complex(a, w)
458 "eta": float(eta),
"real": float(a),
"imag": float(w),
465 *, with_regulator: bool =
True) -> Dict[str, float]:
466 """Rich chug analysis: dominant growth rate alpha and frequency from root-find of (3.3).
468 The root-find is seeded at EVERY negative-real-axis crossing of L(iw) and keeps the root with
469 the largest alpha; seeding only at the -pi crossing found a stable root while a -3pi one grew.
471 Returns dict: ``alpha`` [1/s], ``f_chug_hz``, ``zeta`` (= -alpha/|s|), ``margin`` (= 1+zeta),
472 ``stable``, ``regulator_status``, ``alpha_no_reg`` (only when a regulator is modelled; None
473 otherwise), ``driver``, ``residual``.
477 def dominant(with_reg: bool, fast_res: Dict[str, float]) -> Tuple[float, float, float]:
478 return _dominant_root(streams, chamber, fast_res, with_regulator=with_reg)
480 alpha, omega, resF = dominant(with_regulator, fast)
481 out: Dict[str, float] = {
483 "f_chug_hz": float(omega / (2 * np.pi))
if np.isfinite(omega)
else float(
"nan"),
486 if np.isfinite(alpha)
and np.isfinite(omega)
and omega > 0:
488 out[
"zeta"] = float(zeta)
489 out[
"margin"] = float(1.0 + zeta)
490 out[
"stable"] = bool(alpha < 0.0)
494 out[
"zeta"] = float(
"nan")
495 out[
"margin"] = float(fast[
"gain_margin"])
496 out[
"stable"] = bool(fast[
"stable"])
497 out[
"driver"] =
"unknown"
502 if with_regulator
and any(st.regulator.modelled
for st
in streams):
503 out[
"regulator_status"] =
"modelled"
504 a_nr, _, _ = dominant(
False,
chug_margin_fast(streams, chamber, with_regulator=
False))
505 out[
"alpha_no_reg"] = a_nr
507 out[
"regulator_status"] =
"not_modelled"
508 out[
"alpha_no_reg"] =
None
complex Y_ch(self, complex s)
complex Z_feed(self, complex s, *, bool with_regulator=True)
complex impedance(self, complex s)
complex chug_characteristic(complex s, List[ChugStream] streams, ChugChamber chamber, *, bool with_regulator=True)
complex chug_open_loop(complex s, List[ChugStream] streams, ChugChamber chamber, *, bool with_regulator=True)
str _dominant_driver(List[ChugStream] streams, complex s)
Tuple[float, float, float] _solve_dominant_root(List[ChugStream] streams, ChugChamber chamber, *, bool with_regulator, float omega_seed)
Tuple[float, float, float] _dominant_root(List[ChugStream] streams, ChugChamber chamber, Dict[str, float] fast_res, *, bool with_regulator=True)
Tuple[np.ndarray, np.ndarray] _negative_axis_crossings(np.ndarray omega, np.ndarray L)
Dict[str, float] chug_margin_fast(List[ChugStream] streams, ChugChamber chamber, *, bool with_regulator=True, float f_lo=2.0, float f_hi=2000.0)
float mean_eta(List[ChugStream] streams)
np.ndarray _freq_grid(float f_lo=2.0, float f_hi=2000.0, int n=200)
Dict[str, float] chug_growth_rate(List[ChugStream] streams, ChugChamber chamber, *, bool with_regulator=True)
Tuple[float, float, float] _solve_root_near(List[ChugStream] streams, ChugChamber chamber, complex s0, *, bool with_regulator=True)
np.ndarray _open_loop_grid(np.ndarray omega, List[ChugStream] streams, ChugChamber chamber, *, bool with_regulator=True)
List[Dict[str, float]] chug_root_locus(List[ChugStream] streams, ChugChamber chamber, *, Optional[np.ndarray] eta_values=None, bool with_regulator=True, bool scale_design=False)
np.ndarray _freq_grid_cached(float f_lo, float f_hi, int n)
float damping_ratio(float sigma, float omega)