STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
chug.py
Go to the documentation of this file.
1"""Low-frequency (chug) stability: lumped feed <-> chamber <-> combustion model.
2
3Implements the impedance-form characteristic equation [Phys §3.2, eq 3.3]:
4
5 F(s) = 1 + Y_ch(s) * sum_k exp(-s*tau_conv_k) / Z_feed_k(s) = 0
6
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
9
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).
12
13Two tiers (plan A2):
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.
17
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).
21"""
22
23from __future__ import annotations
24
25from dataclasses import dataclass, field
26from functools import lru_cache
27from typing import Dict, List, Optional, Tuple
28import numpy as np
29
30from engine.pipeline.stability import core
31
32
33# ---------------------------------------------------------------------------
34# Parameter containers
35# ---------------------------------------------------------------------------
36
37@dataclass
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]
43 """
44 corner_hz: float = 3.0
45 Z_hf: float = 0.0 # Pa·s/kg, high-frequency series impedance (unset until the T6 step test)
46 max_excursion_pa: float = 0.0 # forcing bound (reporting only)
47 enabled: bool = True
48
49 @property
50 def modelled(self) -> bool:
51 """True only when the regulator can change the loop: enabled AND a measured Z_hf."""
52 return bool(self.enabled and self.Z_hf > 0.0)
53
54 def impedance(self, s: complex) -> complex:
55 """Series impedance Z_reg(s) [Pa·s/kg]. High-pass toward Z_hf above the corner."""
56 if not self.modelled:
57 return 0.0 + 0.0j
58 wc = 2.0 * np.pi * max(self.corner_hz, 1e-6)
59 return complex(self.Z_hf) * (s / wc) / (1.0 + s / wc)
60
61
62@dataclass
64 """One propellant stream (O or F) feeding the shared chamber."""
65 name: str
66 mdot: float # kg/s
67 eta_inj: float # dP_inj/Pc (injector stiffness ratio)
68 Pc: float # Pa
69 dP_feed: float # Pa, steady feed-line drop (for linearized resistance)
70 feed_length: float # m
71 feed_area: float # m^2 (feed line cross-section)
72 tau_conv: float # s, conversion/transport lag (= tau_vap; from core.lags_from_smd)
73 regulator: Regulator = field(default_factory=Regulator)
74
75 # --- derived primitives ---
76 def G_inj(self) -> float:
77 return core.injector_conductance(self.mdot, self.eta_inj, self.Pc)
78
79 def inertance(self) -> float:
80 return core.feed_inertance(self.feed_length, self.feed_area)
81
82 def resistance(self) -> float:
83 """Linearized feed resistance R = dDP_feed/dmdot ~ 2*DP_feed/mdot (quadratic loss) [Pa·s/kg]."""
84 if self.mdot <= 0:
85 return float("nan")
86 return float(2.0 * max(self.dP_feed, 0.0) / self.mdot)
87
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]."""
90 G = self.G_inj()
91 Zr = self.regulator.impedance(s) if with_regulator else 0.0 + 0.0j
92 return Zr + self.inertance() * s + self.resistance() + (1.0 / G if G > 0 else np.inf)
93
94
95@dataclass
97 cstar: float
98 A_t: float
99 Lstar: float
100 gamma: float
101 theta_factor: float = 1.0 # O(1) calibration on theta_c (mass vs mass+energy) [Phys §3.1]
102 R_gas: Optional[float] = None # J/(kg K), chamber gas constant; with T_c gives theta from the state
103 T_c: Optional[float] = None # K, chamber temperature the rest of the model uses
104
105 def theta_c(self) -> float:
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)
110 else:
111 th = core.chamber_residence_time(self.Lstar, self.cstar, self.gamma)
112 return self.theta_factor * th
113
114 def K_c(self) -> float:
115 return core.chamber_gain(self.cstar, self.A_t)
116
117 def Y_ch(self, s: complex) -> complex:
118 th = self.theta_c()
119 return self.K_c() / (th * s + 1.0)
120
121
122# ---------------------------------------------------------------------------
123# Characteristic equation and open loop
124# ---------------------------------------------------------------------------
125
126def chug_open_loop(s: complex, streams: List[ChugStream], chamber: ChugChamber,
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."""
129 acc = 0.0 + 0.0j
130 for st in streams:
131 Zf = st.Z_feed(s, with_regulator=with_regulator)
132 if Zf == 0:
133 continue
134 acc += np.exp(-s * st.tau_conv) / Zf
135 return chamber.Y_ch(s) * acc
136
137
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.
141
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.
147
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.
152 """
153 s_arr = 1j * np.asarray(omega, dtype=np.float64)
154 acc = np.zeros_like(s_arr)
155 for st in streams:
156 G = st.G_inj()
157 Zr = 0.0 + 0.0j
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
164 # The scalar path skips a stream whose Z_feed is exactly 0 (`continue`);
165 # element-wise that is a zero contribution at those frequencies.
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
168
169
170def chug_characteristic(s: complex, streams: List[ChugStream], chamber: ChugChamber,
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)
174
175
176# ---------------------------------------------------------------------------
177# Fast tier: gain/phase-margin proxy (no transcendental root-find)
178# ---------------------------------------------------------------------------
179
180@lru_cache(maxsize=8)
181def _freq_grid_cached(f_lo: float, f_hi: float, n: int) -> np.ndarray:
182 grid = 2.0 * np.pi * np.logspace(np.log10(f_lo), np.log10(f_hi), n)
183 grid.flags.writeable = False # cached and shared: never mutate in place
184 return grid
185
186
187def _freq_grid(f_lo: float = 2.0, f_hi: float = 2000.0, n: int = 200) -> np.ndarray:
188 # 200 log-spaced points spans the chug band finely enough for crossover detection while keeping
189 # the per-eval cost inside the fast-tier budget. The rich tier refines via root-find anyway.
190 # The grid depends only on its arguments, so it is memoised: rebuilding it per
191 # call cost ~7 us of the ~135 us fast-tier budget, on every candidate.
192 return _freq_grid_cached(f_lo, f_hi, n)
193
194
195def _negative_axis_crossings(omega: np.ndarray, L: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
196 """(omega_c, |L|_c) where L(iw) crosses the negative real axis: Im L changes sign with Re L < 0.
197
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.
200 """
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))
204 if hits.size == 0:
205 return np.empty(0), np.empty(0)
206 di = im[hits] - im[hits + 1]
207 safe = di != 0.0
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])
211 neg = re_c < 0.0
212 return w_c[neg], -re_c[neg]
213
214
215def chug_margin_fast(streams: List[ChugStream], chamber: ChugChamber,
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]
219
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.
224
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).
228 """
229 omega = _freq_grid(f_lo, f_hi)
230 L = _open_loop_grid(omega, streams, chamber, with_regulator=with_regulator)
231 phase = np.unwrap(np.angle(L))
232 mag = np.abs(L)
233
234 gm_best = np.inf
235 f_pc = float("nan")
236 w_c, mag_c = _negative_axis_crossings(omega, L)
237 if w_c.size:
238 k = int(np.argmax(mag_c)) # worst-case (smallest) gain margin; first on a tie
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))
241
242 # Phase margin: the smallest angular distance to the negative real axis over every gain
243 # crossover (|L| = 1). Wrapped, so a crossover near -3pi reads as near 0, not -360 deg.
244 pm_deg = float("nan")
245 h = mag - 1.0
246 h0, h1 = h[:-1], h[1:]
247 gain_hits = np.flatnonzero((h0 == 0.0) | (h0 * h1 < 0.0))
248 if gain_hits.size:
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) # angle above the negative axis, [0, 2pi)
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))]))
255
256 if not np.isfinite(gm_best):
257 # No negative-axis crossing in band. Conservative: 1/max|L| (the old behaviour), which can
258 # only understate the margin.
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
261
262 stable = gm_best > 1.0
263 return {
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),
270 }
271
272
273# ---------------------------------------------------------------------------
274# Rich tier: complex root-find for the dominant (alpha, omega)
275# ---------------------------------------------------------------------------
276
277def _solve_dominant_root(streams: List[ChugStream], chamber: ChugChamber,
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
281
282 def residual(x):
283 s = complex(x[0], x[1])
284 # fsolve probes far into the left half-plane, where exp(-s*tau) overflows; that trial
285 # point is simply rejected, so the warning is noise.
286 with np.errstate(over="ignore", invalid="ignore"):
287 F = chug_characteristic(s, streams, chamber, with_regulator=with_regulator)
288 return [F.real, F.imag]
289
290 w0 = omega_seed if (np.isfinite(omega_seed) and omega_seed > 0) else 2 * np.pi * 100.0
291 best = None
292 for a0 in (-0.05 * w0, 0.0, 0.05 * w0): # try a few growth-rate seeds
293 for wfac in (1.0, 0.6, 1.6):
294 try:
295 sol, info, ier, _ = fsolve(residual, [a0, w0 * wfac], full_output=True)
296 if ier == 1:
297 resF = abs(complex(*residual(sol)))
298 if sol[1] > 0 and (best is None or sol[0] > best[0]): # most unstable, omega>0
299 if resF < 1e-6:
300 best = (float(sol[0]), float(sol[1]), float(resF))
301 except Exception:
302 continue
303 if best is None:
304 return float("nan"), float("nan"), float("inf")
305 return best
306
307
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]
313 if not seeds:
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"))
317 for w0 in seeds:
318 a, w, r = _solve_dominant_root(streams, chamber, with_regulator=with_regulator, omega_seed=w0)
319 if np.isfinite(a) and (not np.isfinite(best[0]) or a > best[0]):
320 best = (a, w, r)
321 return best
322
323
324def _dominant_driver(streams: List[ChugStream], s: complex) -> str:
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}
327 for st in streams:
328 G = st.G_inj()
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)
334
335
336def damping_ratio(sigma: float, omega: float) -> float:
337 """Damping ratio of a complex pole ``s = sigma + j*omega``: ``zeta = -sigma / |s|``.
338
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.)
344 """
345 mag = float(np.hypot(sigma, omega))
346 if not np.isfinite(mag) or mag <= 0:
347 return float("nan")
348 return float(-sigma / mag)
349
350
351def _solve_root_near(streams: List[ChugStream], chamber: ChugChamber, s0: complex,
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|).
354
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.
358 """
359 from scipy.optimize import fsolve
360
361 def residual(x):
362 with np.errstate(over="ignore", invalid="ignore"):
363 F = chug_characteristic(complex(x[0], x[1]), streams, chamber,
364 with_regulator=with_regulator)
365 return [F.real, F.imag]
366
367 try:
368 sol, _, ier, _ = fsolve(residual, [s0.real, s0.imag], full_output=True)
369 except Exception:
370 return float("nan"), float("nan"), float("inf")
371 if ier != 1:
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)
377
378
379def mean_eta(streams: List[ChugStream]) -> float:
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")
383
384
385def chug_root_locus(streams: List[ChugStream], chamber: ChugChamber,
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.
389
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.
395
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.
402
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.
406
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.
409 """
410 import copy
411 eta0 = mean_eta(streams) if scale_design else float("nan")
412
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))
416 if etas.size == 0:
417 return []
418
419 def scaled(eta: float) -> List[ChugStream]:
420 out = []
421 for st in streams:
422 st2 = copy.copy(st)
423 st2.eta_inj = float(st.eta_inj) * eta / eta0 if scale_design else float(eta)
424 out.append(st2)
425 return out
426
427 # Seed the branch from the fast tier's phase crossover at the softest injector, where the loop
428 # is most strongly coupled and the dominant root is least ambiguous.
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)
433
434 pts: List[Dict[str, float]] = []
435 for eta in etas:
436 st = scaled(eta)
437 a, w, res = _solve_root_near(st, chamber, s_prev, with_regulator=with_regulator)
438 # The branch that matters is the DOMINANT root (the one chug_growth_rate reports for the
439 # design). Following one seed from the softest injector can stay on a subdominant root:
440 # on a design with LOX at eta 0.25 and fuel at 0.10 it passed the design at -79 s^-1,
441 # 50 Hz while the design's own pole was -40 s^-1, 23 Hz. So each point also runs the
442 # dominant-root search and keeps whichever root grows faster.
443 ad, wd, rd = _dominant_root(st, chamber, chug_margin_fast(st, chamber, 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):
448 # Lost the branch: re-acquire from the frequency scan rather than abandoning the sweep.
449 f2 = chug_margin_fast(st, chamber, with_regulator=with_regulator)
450 if not np.isfinite(f2["f_chug_hz"]):
451 continue
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):
455 continue
456 s_prev = complex(a, w)
457 pts.append({
458 "eta": float(eta), "real": float(a), "imag": float(w),
459 "f_hz": float(w / (2 * np.pi)), "zeta": damping_ratio(a, w),
460 })
461 return pts
462
463
464def chug_growth_rate(streams: List[ChugStream], chamber: ChugChamber,
465 *, with_regulator: bool = True) -> Dict[str, float]:
466 """Rich chug analysis: dominant growth rate alpha and frequency from root-find of (3.3).
467
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.
470
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``.
474 """
475 fast = chug_margin_fast(streams, chamber, with_regulator=with_regulator)
476
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)
479
480 alpha, omega, resF = dominant(with_regulator, fast)
481 out: Dict[str, float] = {
482 "alpha": alpha,
483 "f_chug_hz": float(omega / (2 * np.pi)) if np.isfinite(omega) else float("nan"),
484 "residual": resF,
485 }
486 if np.isfinite(alpha) and np.isfinite(omega) and omega > 0:
487 zeta = damping_ratio(alpha, omega)
488 out["zeta"] = float(zeta)
489 out["margin"] = float(1.0 + zeta)
490 out["stable"] = bool(alpha < 0.0)
491 out["driver"] = _dominant_driver(streams, complex(alpha, omega))
492 else:
493 # root-find failed; fall back to the fast-tier verdict (sign only)
494 out["zeta"] = float("nan")
495 out["margin"] = float(fast["gain_margin"])
496 out["stable"] = bool(fast["stable"])
497 out["driver"] = "unknown"
498
499 # Regulator: a with/without comparison only means something when Z_reg is modelled. With
500 # Z_hf unset both solves are the same equation, and printing two equal alphas implied a check
501 # that never happened.
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
506 else:
507 out["regulator_status"] = "not_modelled"
508 out["alpha_no_reg"] = None
509 return out
#define max(a, b)
Definition Arduino.h:43
complex Y_ch(self, complex s)
Definition chug.py:117
complex Z_feed(self, complex s, *, bool with_regulator=True)
Definition chug.py:88
complex impedance(self, complex s)
Definition chug.py:54
complex chug_characteristic(complex s, List[ChugStream] streams, ChugChamber chamber, *, bool with_regulator=True)
Definition chug.py:171
complex chug_open_loop(complex s, List[ChugStream] streams, ChugChamber chamber, *, bool with_regulator=True)
Definition chug.py:127
str _dominant_driver(List[ChugStream] streams, complex s)
Definition chug.py:324
Tuple[float, float, float] _solve_dominant_root(List[ChugStream] streams, ChugChamber chamber, *, bool with_regulator, float omega_seed)
Definition chug.py:278
Tuple[float, float, float] _dominant_root(List[ChugStream] streams, ChugChamber chamber, Dict[str, float] fast_res, *, bool with_regulator=True)
Definition chug.py:309
Tuple[np.ndarray, np.ndarray] _negative_axis_crossings(np.ndarray omega, np.ndarray L)
Definition chug.py:195
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)
Definition chug.py:217
float mean_eta(List[ChugStream] streams)
Definition chug.py:379
np.ndarray _freq_grid(float f_lo=2.0, float f_hi=2000.0, int n=200)
Definition chug.py:187
Dict[str, float] chug_growth_rate(List[ChugStream] streams, ChugChamber chamber, *, bool with_regulator=True)
Definition chug.py:465
Tuple[float, float, float] _solve_root_near(List[ChugStream] streams, ChugChamber chamber, complex s0, *, bool with_regulator=True)
Definition chug.py:352
np.ndarray _open_loop_grid(np.ndarray omega, List[ChugStream] streams, ChugChamber chamber, *, bool with_regulator=True)
Definition chug.py:139
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)
Definition chug.py:387
np.ndarray _freq_grid_cached(float f_lo, float f_hi, int n)
Definition chug.py:181
float damping_ratio(float sigma, float omega)
Definition chug.py:336