STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
core.py
Go to the documentation of this file.
1"""Shared physics primitives for the combustion/feed stability model.
2
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.
6
7Equation numbers in the docstrings refer to ``docs/stability/combustion_stability_physics.md`` (v0.2).
8
9Sections
10--------
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]
17"""
18
19from __future__ import annotations
20
21from typing import Dict, List, Tuple
22import numpy as np
23
24__all__ = [
25 "sound_speed",
26 "longitudinal_mode_frequencies",
27 "TRANSVERSE_EIGENVALUES",
28 "transverse_mode_frequencies",
29 "n_tau_gain",
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",
37 "vaporization_time",
38 "sensitive_time_lag",
39 "lags_from_smd",
40 "injector_conductance",
41 "feed_inertance",
42 "chamber_gain",
43 "viscous_damping_rate",
44 "nozzle_admittance_compact",
45 "nozzle_damping_rate",
46]
47
48
49# ---------------------------------------------------------------------------
50# 1. Acoustic mode frequencies
51# ---------------------------------------------------------------------------
52
53def sound_speed(gamma: float, R_g: float, T: float) -> float:
54 """Chamber sound speed ``a = sqrt(gamma * R_g * T)`` [m/s]. Returns 0 for non-physical inputs."""
55 val = gamma * R_g * T
56 return float(np.sqrt(val)) if val > 0 else 0.0
57
58
59def longitudinal_mode_frequencies(a: float, L_ch: float, n_modes: int = 5) -> List[float]:
60 """Longitudinal (axial) acoustic mode frequencies [Hz], closed-closed half-wave set.
61
62 ``f_nL = n * a / (2 * L_ch)`` for n = 1..n_modes (1L, 2L, ...). [Phys §4.1]
63
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.
72 """
73 if a <= 0 or L_ch <= 0 or n_modes < 1:
74 return []
75 return [float(n * a / (2.0 * L_ch)) for n in range(1, n_modes + 1)]
76
77
78# Transverse acoustic eigenvalues for a hard-wall cylinder: roots of J'_m (dp/dr = 0 at the wall).
79# f_mode = alpha * a / (pi * D_ch). [Phys §4.1 — the fix vs the current code, which wrongly used the
80# pressure-Bessel roots of J_m (2.405, ...) instead of the velocity roots of J'_m.]
81# Labels: T = tangential (m>=1), R = radial (m=0). Values are standard zeros of J'_m.
82TRANSVERSE_EIGENVALUES: Dict[str, float] = {
83 "1T": 1.84118, # J'_1 first zero — first tangential (most destructive in liquid engines)
84 "2T": 3.05424, # J'_2 first zero — second tangential
85 "1R": 3.83171, # J'_0 first nonzero zero — first radial
86 "3T": 4.20119, # J'_3 first zero
87 "1T1R": 5.33144, # J'_1 second zero — combined tangential/radial
88}
89
90
91def transverse_mode_frequencies(a: float, D_ch: float) -> Dict[str, float]:
92 """Transverse acoustic mode frequencies [Hz] for a hard-wall cylinder.
93
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]
96 """
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()}
100
101
102# ---------------------------------------------------------------------------
103# 2. Combustion response (Crocco n-tau)
104# ---------------------------------------------------------------------------
105
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]
108
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.
114
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].
117 """
118 return complex(n) * (1.0 - np.exp(-1j * float(omega) * float(tau)))
119
120
121# ---------------------------------------------------------------------------
122# 3. Gas dynamics
123# ---------------------------------------------------------------------------
124
125def choked_flow_function(gamma: float) -> float:
126 """Vandenkerckhove choked-flow function ``Gamma = sqrt(gamma) * (2/(gamma+1))**((gamma+1)/(2(gamma-1)))``.
127
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]
130 """
131 g = float(gamma)
132 if g <= 1.0:
133 return float("nan")
134 return float(np.sqrt(g) * (2.0 / (g + 1.0)) ** ((g + 1.0) / (2.0 * (g - 1.0))))
135
136
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]
139
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``.
144 """
145 G = choked_flow_function(gamma)
146 if not np.isfinite(G) or G <= 0 or cstar <= 0 or Lstar <= 0:
147 return float("nan")
148 return float(Lstar / (G * G * cstar))
149
150
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]
153
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.
158 """
159 if Lstar <= 0 or cstar <= 0 or R_g <= 0 or T <= 0:
160 return float("nan")
161 return float(Lstar * cstar / (R_g * T))
162
163
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)))``."""
166 g = float(gamma)
167 if M <= 0 or g <= 1.0:
168 return float("nan")
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))))
170
171
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).
174
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.
178 """
179 if not np.isfinite(area_ratio) or gamma <= 1.0:
180 return float("nan")
181 if area_ratio <= 1.0:
182 return 1.0
183 lo, hi = 1e-6, 1.0
184 for _ in range(80):
185 mid = 0.5 * (lo + hi)
186 if area_ratio_from_mach(mid, gamma) > area_ratio:
187 lo = mid # too subsonic: area ratio still above target -> raise M
188 else:
189 hi = mid
190 return float(0.5 * (lo + hi))
191
192
193# ---------------------------------------------------------------------------
194# 4. Vaporization / time lag
195# ---------------------------------------------------------------------------
196
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).
199
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]
202 """
203 if h_fg <= 0:
204 return float("nan")
205 return float(cp_g * (T_inf - T_boil) / h_fg)
206
207
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]
210
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.
213 """
214 if rho_l <= 0 or cp_g <= 0 or B <= -1.0:
215 return float("nan")
216 return float(8.0 * k_g / (rho_l * cp_g) * np.log(1.0 + B))
217
218
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]
221
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.
224 """
225 if D32 <= 0 or not np.isfinite(K_v) or K_v <= 0:
226 return float("nan")
227 return float(D32 * D32 / K_v)
228
229
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]
232
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.
235 """
236 if not np.isfinite(tau_vap) or tau_vap < 0:
237 return float("nan")
238 chi = float(np.clip(chi, 1e-6, 1.0))
239 return float(chi * tau_vap)
240
241
242def lags_from_smd(
243 D32: float,
244 *,
245 k_g: float,
246 rho_l: float,
247 cp_g: float,
248 T_inf: float,
249 T_boil: float,
250 h_fg: float,
251 chi: float = 0.5,
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]
254
255 Returns
256 -------
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.
259 """
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
265
266
267# ---------------------------------------------------------------------------
268# 5. Feed / injector primitives (support chug.py)
269# ---------------------------------------------------------------------------
270
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]
273
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.
276 """
277 if eta_inj <= 0 or Pc <= 0:
278 return float("nan")
279 return float(mdot / (2.0 * eta_inj * Pc))
280
281
282def feed_inertance(length: float, area: float) -> float:
283 """Feed-line inertance ``I = length / area`` [1/m] (mass-flow convention). [Phys §3.2]
284
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).
287 """
288 if area <= 0 or length < 0:
289 return float("nan")
290 return float(length / area)
291
292
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]
295
296 Steady-state sensitivity dp_c/dmdot_in; the dynamic chamber transfer function is
297 ``Y_ch(s) = K_c / (theta_c * s + 1)``.
298 """
299 if A_t <= 0:
300 return float("nan")
301 return float(cstar / A_t)
302
303
304# ---------------------------------------------------------------------------
305# 6. Acoustic damping
306# ---------------------------------------------------------------------------
307# Nozzle and wall losses have closed forms and are computed here. The injector-face and two-phase
308# terms have none and live in acoustic.DampingCoeffs as stated, uncalibrated fractions; they
309# dominate the budget, which is why the acoustic verdict is report-only by default (acoustic.py).
310
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]
314
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).
322 """
323 if freq <= 0 or D_ch <= 0 or nu_g <= 0:
324 return 0.0
325 omega = 2.0 * np.pi * freq
326 delta = np.sqrt(2.0 * nu_g / omega) # Stokes layer thickness [m]
327 thermal = 0.0
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))
331
332
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.
335
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.
338 """
339 if mach_nozzle_entrance <= 0 or gamma <= 1.0:
340 return 0.0
341 return float(0.5 * (gamma - 1.0) * mach_nozzle_entrance)
342
343
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]
347
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.
354
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.
357 """
358 if a <= 0 or L_ch <= 0:
359 return 0.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)
Definition core.py:59
float sound_speed(float gamma, float R_g, float T)
Definition core.py:53