1"""Shared physics primitives for the combustion/feed stability model.
3Pure functions only — no config coupling, no I/O — so every primitive is unit-testable against a
4closed form. Integration (pulling gas properties from CEA, choosing defaults for ``n``/``chi``, etc.)
5lives in ``chug.py`` / ``acoustic.py`` / ``analysis.py``, not here.
7Equation numbers in the docstrings refer to ``docs/stability/combustion_stability_physics.md`` (v0.2).
111. Acoustic mode frequencies — longitudinal half-wave (closed-closed); transverse via J'_m roots
122. Combustion response (n-tau) — Crocco linearized burning-rate gain [Phys §2]
133. Gas dynamics — choked-flow function, chamber residence time [Phys §3.1]
144. Vaporization / time lag — Spalding B, d^2-law K_v, tau_vap, sensitive lag [Phys §5]
155. Feed / injector primitives — injector conductance, feed inertance, chamber gain [Phys §3.2]
166. Acoustic damping — compact choked-nozzle admittance, Kirchhoff wall layer [Phys §4.2]
19from __future__
import annotations
21from typing
import Dict, List, Tuple
26 "longitudinal_mode_frequencies",
27 "TRANSVERSE_EIGENVALUES",
28 "transverse_mode_frequencies",
30 "choked_flow_function",
31 "chamber_residence_time",
32 "chamber_residence_time_from_state",
33 "area_ratio_from_mach",
34 "mach_from_area_ratio_subsonic",
35 "spalding_transfer_number_heat",
36 "d2_law_evaporation_constant",
40 "injector_conductance",
43 "viscous_damping_rate",
44 "nozzle_admittance_compact",
45 "nozzle_damping_rate",
54 """Chamber sound speed ``a = sqrt(gamma * R_g * T)`` [m/s]. Returns 0 for non-physical inputs."""
56 return float(np.sqrt(val))
if val > 0
else 0.0
60 """Longitudinal (axial) acoustic mode frequencies [Hz], closed-closed half-wave set.
62 ``f_nL = n * a / (2 * L_ch)`` for n = 1..n_modes (1L, 2L, ...). [Phys §4.1]
64 Both ends are acoustically closed. The injector face is a rigid wall. A choked compact nozzle
65 has normalised admittance ``(gamma-1)*M_ne/2`` (Crocco; Marble & Candel 1977), ~0.005 at
66 M_ne 0.07: a near-rigid end, not a pressure-release one. ``tan(kL) = i*Y`` then gives
67 ``Re(kL) = n*pi`` exactly -- the admittance damps the mode (``nozzle_damping_rate``) and
68 does not move it. This is the q-branch of Harrje & Reardon SP-194 ch. 1's closed-cylinder
69 ``f = (a/2) sqrt((q/L)^2 + (alpha_mn/(pi R))^2)``, and the passive spectrum the hi-fi V3
70 case checks (engine/stability_hifi/validation/v3_ntau_duct.py). The old quarter-wave set
71 ``(2n-1) a/(4L)`` is the open-end answer and put 1L a factor 2 low.
73 if a <= 0
or L_ch <= 0
or n_modes < 1:
75 return [float(n * a / (2.0 * L_ch))
for n
in range(1, n_modes + 1)]
82TRANSVERSE_EIGENVALUES: Dict[str, float] = {
91def transverse_mode_frequencies(a: float, D_ch: float) -> Dict[str, float]:
92 """Transverse acoustic mode frequencies [Hz] for a hard-wall cylinder.
94 ``f = alpha_mn * a / (pi * D_ch)`` with ``alpha_mn`` the zeros of ``J'_m`` (TRANSVERSE_EIGENVALUES).
95 Returns a dict keyed by mode name (``1T``, ``2T``, ``1R``, ...). [Phys §4.1]
97 if a <= 0
or D_ch <= 0:
98 return {k: 0.0
for k
in TRANSVERSE_EIGENVALUES}
99 return {name: float(alpha * a / (np.pi * D_ch))
for name, alpha
in TRANSVERSE_EIGENVALUES.items()}
106def n_tau_gain(omega: float, n: float, tau: float) -> complex:
107 """Linearized burning-rate response gain ``n * (1 - exp(-i*omega*tau))``. [Phys §2, eq 2.1]
109 This is ``(dm_b'/m_b) / (p'/Pc)`` for a single sinusoid at angular frequency ``omega``. The
110 REAL part ``n*(1 - cos(omega*tau))`` is the component of q' in phase with p' -- the Rayleigh
111 driving (cycle average <p'q'>/<p'^2>); it is >= 0 and peaks at omega*tau = pi (Crocco's
112 stability bucket, Harrje & Reardon SP-194 ch. 4). The imaginary part ``n*sin(omega*tau)``
113 is in quadrature with p' and shifts the frequency, not the growth rate.
115 Note: ``tau`` here is the **sensitive** lag tau_sens (= chi * tau_vap) for acoustic driving — NOT
116 the chug transport lag tau_conv. See the two-lags box in [Phys §5].
118 return complex(n) * (1.0 - np.exp(-1j * float(omega) * float(tau)))
125def choked_flow_function(gamma: float) -> float:
126 """Vandenkerckhove choked-flow function ``Gamma = sqrt(gamma) * (2/(gamma+1))**((gamma+1)/(2(gamma-1)))``.
128 Relates throat mass flow and c*: ``mdot = Pc * A_t * Gamma / sqrt(R_g*T_c) = Pc*A_t/c*`` so that
129 ``R_g*T_c = (Gamma*c*)**2``. ``Gamma**2 ~ 0.4`` for ``gamma ~ 1.2``. [Phys §3.1]
134 return float(np.sqrt(g) * (2.0 / (g + 1.0)) ** ((g + 1.0) / (2.0 * (g - 1.0))))
137def chamber_residence_time(Lstar: float, cstar: float, gamma: float) -> float:
138 """Chamber gas-dynamic time constant ``theta_c = L* / (Gamma**2 * c*)`` [s]. [Phys §3.1, eq 3.1]
140 This is the gas residence (stay) time m_gas/mdot_out, the first-order chamber relaxation constant
141 used in the chug loop. NOTE the prefactor is ``1/Gamma**2`` (~2.4 at gamma~1.2), *not* ``1/gamma``.
142 It uses ``R*T = (Gamma*c*)**2``, which holds for the ideal c* only; when the chamber state
143 (R, T) is known use ``chamber_residence_time_from_state``.
145 G = choked_flow_function(gamma)
146 if not np.isfinite(G)
or G <= 0
or cstar <= 0
or Lstar <= 0:
148 return float(Lstar / (G * G * cstar))
151def chamber_residence_time_from_state(Lstar: float, cstar: float, R_g: float, T: float) -> float:
152 """``theta_c = m_gas / mdot = (Pc V / (R T)) / (Pc A_t / c*) = L* c* / (R T)`` [s]. [Phys §3.1]
154 The mass balance itself, with the throat flow set by the c* the engine actually delivers and
155 the gas mass by the chamber state the rest of the stability model uses (rho_g = Pc/(R T)).
156 Equal to ``chamber_residence_time`` when c* is ideal; with c*_actual it is shorter by
157 eta_c*^2 (~9 % on the 6500 N), which the Gamma form hides by assuming RT = (Gamma c*_act)^2.
159 if Lstar <= 0
or cstar <= 0
or R_g <= 0
or T <= 0:
161 return float(Lstar * cstar / (R_g * T))
164def area_ratio_from_mach(M: float, gamma: float) -> float:
165 """Isentropic area ratio ``A/A* = (1/M) * [(2/(g+1)) * (1 + (g-1)/2 * M^2)]^((g+1)/(2(g-1)))``."""
167 if M <= 0
or g <= 1.0:
169 return float((1.0 / M) * ((2.0 / (g + 1.0)) * (1.0 + 0.5 * (g - 1.0) * M * M)) ** ((g + 1.0) / (2.0 * (g - 1.0))))
172def mach_from_area_ratio_subsonic(area_ratio: float, gamma: float) -> float:
173 """Subsonic Mach number at a station with ``A/A* = area_ratio`` (isentropic, one-dimensional).
175 This is the mean Mach at the nozzle entrance when ``area_ratio`` is the contraction ratio
176 ``A_chamber / A_throat``, which is what sets the convective (nozzle) acoustic damping. Bisection
177 on [1e-6, 1]: A/A* is monotone decreasing in M on the subsonic branch. ``area_ratio <= 1`` -> 1.0.
179 if not np.isfinite(area_ratio)
or gamma <= 1.0:
181 if area_ratio <= 1.0:
185 mid = 0.5 * (lo + hi)
186 if area_ratio_from_mach(mid, gamma) > area_ratio:
190 return float(0.5 * (lo + hi))
197def spalding_transfer_number_heat(cp_g: float, T_inf: float, T_boil: float, h_fg: float) -> float:
198 """Spalding heat-transfer number ``B_T = cp_g * (T_inf - T_boil) / h_fg`` (evaporating droplet).
200 ``cp_g`` gas-phase specific heat in the film [J/(kg·K)], ``T_inf`` ambient gas temp [K], ``T_boil``
201 droplet surface (boiling) temp [K], ``h_fg`` latent heat [J/kg]. [Phys §5.1]
205 return float(cp_g * (T_inf - T_boil) / h_fg)
208def d2_law_evaporation_constant(k_g: float, rho_l: float, cp_g: float, B: float) -> float:
209 """d^2-law evaporation constant ``K_v = 8*k_g/(rho_l*cp_g) * ln(1+B)`` [m^2/s]. [Phys §5.1, eq 5.1]
211 ``k_g`` gas-phase thermal conductivity in the film [W/(m·K)], ``rho_l`` liquid density [kg/m^3],
212 ``cp_g`` gas-phase specific heat [J/(kg·K)], ``B`` Spalding transfer number. Godsave/Spalding.
214 if rho_l <= 0
or cp_g <= 0
or B <= -1.0:
216 return float(8.0 * k_g / (rho_l * cp_g) * np.log(1.0 + B))
219def vaporization_time(D32: float, K_v: float) -> float:
220 """Droplet vaporization lifetime ``tau_vap = D32**2 / K_v`` [s]. [Phys §5.1, eq 5.2/5.3]
222 ``D32`` is the spray Sauter mean diameter [m] (the code's Ingebo SMD). This equals the chug
223 transport lag ``tau_conv`` to leading order. **tau_vap ∝ SMD^2** — atomization is a quadratic lever.
225 if D32 <= 0
or not np.isfinite(K_v)
or K_v <= 0:
227 return float(D32 * D32 / K_v)
230def sensitive_time_lag(tau_vap: float, chi: float = 0.5) -> float:
231 """Sensitive (pressure-responsive) lag ``tau_sens = chi * tau_vap`` [s]. [Phys §5, eq 5.4]
233 ``chi in (0, 1]`` is the sensitive fraction (default 0.5; the single largest modeling uncertainty,
234 swept in the rich tier). Used for the acoustic n-tau driving — NOT the chug transport lag.
236 if not np.isfinite(tau_vap)
or tau_vap < 0:
238 chi = float(np.clip(chi, 1e-6, 1.0))
239 return float(chi * tau_vap)
252) -> Tuple[float, float, float]:
253 """Convenience: SMD -> (tau_conv, tau_sens, K_v). Composes B_T, K_v, tau_vap, tau_sens. [Phys §5]
257 (tau_conv, tau_sens, K_v) where tau_conv = tau_vap (chug transport lag) and tau_sens = chi*tau_vap
258 (acoustic sensitive lag). All NaN-safe.
260 B = spalding_transfer_number_heat(cp_g, T_inf, T_boil, h_fg)
261 K_v = d2_law_evaporation_constant(k_g, rho_l, cp_g, B)
262 tau_vap = vaporization_time(D32, K_v)
263 tau_sens = sensitive_time_lag(tau_vap, chi)
264 return tau_vap, tau_sens, K_v
271def injector_conductance(mdot: float, eta_inj: float, Pc: float) -> float:
272 """Injector flow conductance ``G_inj = mdot / (2 * eta_inj * Pc)`` [kg/(s·Pa)]. [Phys §3.2, eq 3.2]
274 ``eta_inj = dP_inj/Pc`` is the injector stiffness ratio. ``G_inj = |dm/dP_c|`` from Bernoulli; a
275 stiffer injector (larger eta_inj) gives smaller G_inj => weaker chug coupling.
277 if eta_inj <= 0
or Pc <= 0:
279 return float(mdot / (2.0 * eta_inj * Pc))
282def feed_inertance(length: float, area: float) -> float:
283 """Feed-line inertance ``I = length / area`` [1/m] (mass-flow convention). [Phys §3.2]
285 Impedance contribution is ``Z_I = I * s`` [Pa·s/kg]. Note: ``length/area``, NOT ``rho*length/area``
286 — the density cancels under the mass-flow through-variable convention (see nomenclature note).
288 if area <= 0
or length < 0:
290 return float(length / area)
293def chamber_gain(cstar: float, A_t: float) -> float:
294 """Chamber gain ``K_c = c* / A_t`` [Pa·s/kg] (burned-flow -> chamber pressure). [Phys §3.2]
296 Steady-state sensitivity dp_c/dmdot_in; the dynamic chamber transfer function is
297 ``Y_ch(s) = K_c / (theta_c * s + 1)``.
301 return float(cstar / A_t)
311def viscous_damping_rate(freq: float, D_ch: float, nu_g: float,
312 gamma: float |
None =
None, prandtl: float |
None =
None) -> float:
313 """Kirchhoff acoustic boundary-layer damping on the side wall [1/s]. [Phys §4.2]
315 ``alpha = (omega * delta_nu / D) * [1 + (gamma-1)/sqrt(Pr)]`` with the Stokes thickness
316 ``delta_nu = sqrt(2 nu / omega)``: ``c`` times Kirchhoff's tube-wall attenuation
317 ``beta = sqrt(omega nu / 2) [1 + (gamma-1)/sqrt(Pr)] / (r c)`` (Kinsler & Frey, Fundamentals
318 of Acoustics, wall losses in pipes). Without ``gamma`` and ``Pr`` only the viscous half is
319 counted -- a lower bound. Exact for a longitudinal wave along the barrel; for transverse modes
320 it is an O(1) estimate (their wall velocity is partly normal to the wall). The previous form
321 carried an unsourced factor 4 (3.4x this).
323 if freq <= 0
or D_ch <= 0
or nu_g <= 0:
325 omega = 2.0 * np.pi * freq
326 delta = np.sqrt(2.0 * nu_g / omega)
328 if gamma
is not None and prandtl
is not None and gamma > 1.0
and prandtl > 0.0:
329 thermal = (gamma - 1.0) / np.sqrt(prandtl)
330 return float(omega * delta / D_ch * (1.0 + thermal))
333def nozzle_admittance_compact(mach_nozzle_entrance: float, gamma: float) -> float:
334 """Normalised acoustic admittance ``Y = rho a u'/p' = (gamma-1) M_ne / 2`` of a choked compact nozzle.
336 From mdot ~ p/sqrt(T) at the throat with isentropic perturbations (Crocco; Marble & Candel,
337 J. Sound Vib. 55, 1977). Real and positive: the nozzle absorbs acoustic energy.
339 if mach_nozzle_entrance <= 0
or gamma <= 1.0:
341 return float(0.5 * (gamma - 1.0) * mach_nozzle_entrance)
344def nozzle_damping_rate(a: float, L_ch: float, mach_nozzle_entrance: float, gamma: float,
345 *, end_weight: float = 1.0) -> float:
346 """Nozzle (convective) damping rate [1/s] through a compact choked nozzle. [Phys §4.2]
348 Energy flux out of the nozzle plane over twice the modal energy: ``alpha = end_weight *
349 a * Y / L`` with ``Y = nozzle_admittance_compact``. ``end_weight`` is the nozzle-plane share of
350 <p'^2> relative to the volume mean: 1 for a longitudinal mode (antinode at the end), 1/2 for a
351 transverse mode (uniform in x). The longitudinal case is the passive root of ``tanh(sL) = -Y``
352 (hi-fi V3: sigma ~ -Y c / L). For transverse modes the compact admittance is a lower bound
353 (Bell & Zinn give larger real parts), so this errs toward less damping.
355 The previous form, ``pi f (gamma-1) M``, was pi times this for the half-wave 1L and grew with
356 mode number, which the end loss does not.
358 if a <= 0
or L_ch <= 0:
360 Y = nozzle_admittance_compact(mach_nozzle_entrance, gamma)
361 return float(end_weight * a * Y / L_ch)
List[float] longitudinal_mode_frequencies(float a, float L_ch, int n_modes=5)
float sound_speed(float gamma, float R_g, float T)