51 """Returns (gain_margin, f_chug_hz, phase_margin_deg, stable, crossings_omega).
53 Per-stream inputs are the s-independent primitives already resolved by the
54 Python wrapper: transport lag, feed inertance, linearised resistance, 1/G_inj
55 (inf when G<=0), and the regulator high-frequency impedance and corner.
57 Mirrors chug.chug_margin_fast: the gain margin is 1/max|L| over EVERY crossing of
58 the negative real axis (Im L changes sign with interpolated Re L < 0 -- phases
59 -pi, -3pi, ...), not only the unwrapped -pi one; the chug frequency is the worst
60 crossing's; the phase margin is the wrapped distance to the negative axis at the
61 gain crossover nearest to it.
71 s = complex(0.0, omega[i])
72 acc = complex(0.0, 0.0)
74 Zr = complex(0.0, 0.0)
76 Zr = Zhf[k] * (s / wc[k]) / (1.0 + s / wc[k])
77 Zf = Zr + inert[k] * s + res[k] + invG[k]
80 acc += np.exp(-s * tau[k]) / Zf
81 L = K_c / (theta_c * s + 1.0) * acc
85 ang[i] = np.arctan2(L.imag, L.real)
90 w_all = np.empty(
max(n - 1, 0))
95 for i
in range(n - 1):
98 if i0 == 0.0
or i0 * i1 < 0.0:
100 frac = i0 / di
if di != 0.0
else 0.0
101 re_c = Lre[i] + frac * (Lre[i + 1] - Lre[i])
103 w_c = omega[i] + frac * (omega[i + 1] - omega[i])
107 if mag_c > mag_worst:
109 gm_best = 1.0 / mag_c
if mag_c > 0
else np.inf
115 for i
in range(n - 1):
117 h1 = mag[i + 1] - 1.0
118 if h0 == 0.0
or h0 * h1 < 0.0:
120 frac = h0 / dh
if dh != 0.0
else 0.0
121 ph_c = phase[i] + frac * (phase[i + 1] - phase[i])
122 dist = (ph_c + np.pi) % _TWO_PI
124 dist = dist - _TWO_PI
125 if abs(dist) < best_abs:
127 pm_deg = np.degrees(dist)
129 if not np.isfinite(gm_best):
132 gm_best = 1.0 / (mmax
if mmax > 1e-12
else 1e-12)
133 if mmax < 1.0
and gm_best < 1.0:
136 return gm_best, f_pc, pm_deg, gm_best > 1.0, w_all[:nc].copy()
140 f_lo: float = 2.0, f_hi: float = 2000.0):
141 """Drop-in for chug.chug_margin_fast / native_injector.chug_margin_fast.
143 Resolves the s-independent per-stream primitives once here, in Python, then
144 hands the kernel nothing but arrays and floats.
148 omega = np.ascontiguousarray(_freq_grid(f_lo, f_hi), dtype=np.float64)
150 tau = np.empty(ns); inert = np.empty(ns); res = np.empty(ns)
151 invG = np.empty(ns); Zhf = np.zeros(ns); wc = np.ones(ns)
152 for k, st
in enumerate(streams):
155 inert[k] = st.inertance()
156 res[k] = st.resistance()
157 invG[k] = (1.0 / G)
if G > 0
else np.inf
158 if with_regulator
and st.regulator.enabled
and st.regulator.Z_hf > 0.0:
159 Zhf[k] = float(st.regulator.Z_hf)
160 wc[k] = 2.0 * np.pi *
max(st.regulator.corner_hz, 1e-6)
163 omega, tau, inert, res, invG, Zhf, wc,
164 float(chamber.K_c()), float(chamber.theta_c()))
166 "gain_margin": float(gm),
167 "stable": bool(stable),
168 "f_chug_hz": float(f_pc),
169 "crossings_hz": [float(w / (2.0 * np.pi))
for w
in w_c],
170 "phase_margin_deg": float(pm),