STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
gas_side.py
Go to the documentation of this file.
1"""Hot-gas side of the chamber and nozzle wall.
2
3Convection is Bartz, station by station along the real contour (Huzel & Huang eq. 4-13,
4Sutton & Biblarz eq. 8-23), with CEA frozen-composition transport at the stagnation state,
5the exact isentropic area-Mach number and the adiabatic wall temperature of a turbulent
6boundary layer, recovery factor r = Pr^(1/3).
7
8Radiation is the H2O and CO2 of the burnt gas: a gray gas in a gray enclosure. Gas
9emissivity is Leckner's correlation (Modest, Radiative Heat Transfer, 3rd ed., sec. 11.10,
10Table 11.3), absorptivity at the wall temperature by Hottel's scaling, and the mean beam
11length is 3.6 V/A of the chamber (Modest sec. 10.x) capped at 0.95 D of the local bore.
12
13No turbulence multiplier and no laminar switch: Bartz is a fit to turbulent rocket-nozzle
14data and already carries the chamber's free-stream turbulence.
15"""
16
17from __future__ import annotations
18
19from dataclasses import dataclass
20from typing import Dict, Optional
21
22import numpy as np
23
24from engine.pipeline.constants import STEFAN_BOLTZMANN_W_M2_K4
25
26#: Bartz boundary-layer property exponent (viscosity ~ T^omega), Bartz 1957 / H&H eq. 4-14.
27BARTZ_OMEGA = 0.6
28#: Throat radii of the drawn contour (engine.core.nozzle_solver.rao): upstream 1.5 Rt,
29#: downstream 0.382 Rt. Bartz's r_c is the throat radius of curvature; the mean of the two.
30THROAT_RC_OVER_RT = 0.5 * (1.5 + 0.382)
31#: Leckner's correlations are fitted to 2500 K (Modest sec. 11.10); the pressure corrections
32#: are evaluated no hotter than this (the H2O one changes sign above ~2700 K).
33LECKNER_T_MAX = 2500.0
34#: Mean beam length of an enclosure, optically-thin value corrected for absorption: 3.6 V/A
35#: (Modest, Radiative Heat Transfer, eq. 10.x); an infinite cylinder to its wall: 0.95 D.
36BEAM_LENGTH_COEFF = 3.6
37CYLINDER_BEAM_LENGTH_OVER_D = 0.95
38
39
40# ── Isentropic flow ───────────────────────────────────────────────────────────
41
42def area_ratio_of_mach(M, gamma):
43 g = gamma
44 M = np.asarray(M, dtype=float)
45 return (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)))
46
47
48def _mach_bisect_py(ar, sup, gamma):
49 """``mach_from_area_ratio``'s bisection as a scalar loop, for compilation (same 64 halvings)."""
50 out = np.empty(ar.size)
51 for i in range(ar.size):
52 lo, hi = (1.0, 50.0) if sup[i] else (1e-9, 1.0)
53 for _ in range(64):
54 mid = 0.5 * (lo + hi)
55 f = (1.0 / mid) * ((2.0 / (gamma + 1.0)) * (1.0 + 0.5 * (gamma - 1.0) * mid * mid)) ** (
56 (gamma + 1.0) / (2.0 * (gamma - 1.0))) - ar[i]
57 if (f < 0.0) if sup[i] else (f > 0.0):
58 lo = mid
59 else:
60 hi = mid
61 out[i] = 1.0 if ar[i] <= 1.0 + 1e-12 else 0.5 * (lo + hi)
62 return out
63
64
65try: # called ~2 per chamber evaluation per station set; compiled it costs microseconds
66 from numba import njit as _njit
67 _mach_bisect = _njit(cache=True)(_mach_bisect_py)
68except ImportError: # pragma: no cover
69 _mach_bisect = None
70#: False forces the NumPy bisection (the reference the compiled one is tested against).
71_USE_COMPILED_BISECT = _mach_bisect is not None
72
73
74def mach_from_area_ratio(area_ratio, gamma: float, supersonic):
75 """Exact inversion of the area-Mach relation on the requested branch (vectorised bisection;
76 A/A* is monotone on each branch). Scalars in, scalar out."""
77 ar = np.atleast_1d(np.asarray(area_ratio, dtype=float))
78 sup = np.broadcast_to(np.atleast_1d(np.asarray(supersonic, dtype=bool)), ar.shape)
79 if _USE_COMPILED_BISECT:
80 M = _mach_bisect(np.ascontiguousarray(ar.ravel()), np.ascontiguousarray(sup.ravel()),
81 float(gamma)).reshape(ar.shape)
82 return float(M[0]) if np.ndim(area_ratio) == 0 else M
83 lo = np.where(sup, 1.0, 1e-9)
84 hi = np.where(sup, 50.0, 1.0)
85 for _ in range(64):
86 mid = 0.5 * (lo + hi)
87 f = area_ratio_of_mach(mid, gamma) - ar
88 # subsonic: A/A* falls with M; supersonic: rises
89 go_up = np.where(sup, f < 0.0, f > 0.0)
90 lo = np.where(go_up, mid, lo)
91 hi = np.where(go_up, hi, mid)
92 M = np.where(ar <= 1.0 + 1e-12, 1.0, 0.5 * (lo + hi))
93 return float(M[0]) if np.ndim(area_ratio) == 0 else M
94
95
96def static_ratios(M, gamma: float):
97 """(T/T0, P/P0) at Mach M."""
98 t = 1.0 / (1.0 + 0.5 * (gamma - 1.0) * M * M)
99 return t, t ** (gamma / (gamma - 1.0))
100
101
102# ── Convection ────────────────────────────────────────────────────────────────
103
104def recovery_temperature(T0: float, gamma: float, M, Pr: float):
105 """Adiabatic wall temperature, turbulent recovery factor r = Pr^(1/3) (H&H eq. 4-10/4-11)."""
106 k = 0.5 * (gamma - 1.0) * M * M
107 r = Pr ** (1.0 / 3.0)
108 return T0 * (1.0 + r * k) / (1.0 + k)
109
110
111def bartz_sigma(Tw, T0: float, gamma: float, M, omega: float = BARTZ_OMEGA):
112 """Bartz property-variation correction (H&H eq. 4-14)."""
113 k = 1.0 + 0.5 * (gamma - 1.0) * M * M
114 return 1.0 / ((0.5 * (Tw / T0) * k + 0.5) ** (0.8 - omega / 5.0) * k ** (omega / 5.0))
115
116
117def bartz_h(Dt: float, rc: float, mu: float, cp: float, Pr: float, mass_flux_throat: float,
118 area_ratio, sigma):
119 """Bartz gas-side coefficient [W/(m^2 K)], SI form of H&H eq. 4-13.
120
121 ``mass_flux_throat`` is Pc/c* = mdot/At; transport properties at the stagnation state.
122 """
123 return (0.026 / Dt ** 0.2 * (mu ** 0.2 * cp / Pr ** 0.6) * mass_flux_throat ** 0.8
124 * (Dt / rc) ** 0.1 * (1.0 / area_ratio) ** 0.9 * sigma)
125
126
127# ── Radiation ─────────────────────────────────────────────────────────────────
128
129_LECKNER_H2O = np.array([[-2.2118, -1.1987, 0.035596],
130 [0.85667, 0.93048, -0.14391],
131 [-0.10838, -0.17156, 0.045915]])
132_LECKNER_CO2 = np.array([[-3.9893, 2.7669, -2.1081, 0.39163],
133 [1.2710, -1.1090, 1.0195, -0.21897],
134 [-0.23678, 0.19731, -0.19544, 0.044644]])
135
136
137def _leckner_eps0(c: np.ndarray, t, paL_barcm):
138 """Zero-pressure emissivity, exp(sum_i a_i x^i), a_i = sum_j c_ij t^j, x = log10(paL/1 bar cm)."""
139 t = np.asarray(t, dtype=float)
140 x = np.log10(np.maximum(paL_barcm, 1e-12))
141 a = [sum(c[i, j] * t ** j for j in range(c.shape[1])) for i in range(3)]
142 return np.exp(a[0] + a[1] * x + a[2] * x * x)
143
144
145def _leckner_species(sp: str, T, P_bar, pa_bar, L_cm):
146 t = np.asarray(T, dtype=float) / 1000.0
147 tc = np.minimum(np.asarray(T, dtype=float), LECKNER_T_MAX) / 1000.0
148 paL = np.asarray(pa_bar, dtype=float) * L_cm
149 if sp == "H2O":
150 e0 = _leckner_eps0(_LECKNER_H2O, t, paL)
151 PE = P_bar + 2.56 * pa_bar / np.sqrt(tc)
152 a = np.where(tc > 0.75, 1.888 - 2.053 * np.log10(tc), 2.144)
153 b = 1.10 / tc ** 1.4
154 cc = 0.5
155 paL_m = 13.2 * tc * tc
156 else:
157 e0 = _leckner_eps0(_LECKNER_CO2, t, paL)
158 PE = P_bar + 0.28 * pa_bar
159 a = 1.0 + 0.1 / tc ** 1.45
160 b = 0.23
161 cc = 1.47
162 paL_m = np.where(tc < 0.7, 0.054 / (tc * tc), 0.225 * tc * tc)
163 ratio = 1.0 - (a - 1.0) * (1.0 - PE) / (a + b - 1.0 + PE) * np.exp(
164 -cc * np.log10(paL_m / np.maximum(paL, 1e-12)) ** 2)
165 return np.where(paL > 0.0, e0 * ratio, 0.0)
166
167
168def _leckner_overlap(p_H2O_bar, p_CO2_bar, L_cm):
169 pH = np.asarray(p_H2O_bar, dtype=float)
170 pC = np.asarray(p_CO2_bar, dtype=float)
171 tot = np.maximum(pH + pC, 1e-30)
172 zeta = pH / tot
173 x = np.log10(np.maximum(tot * L_cm, 1e-30))
174 d = (zeta / (10.7 + 101.0 * zeta) - 0.0089 * zeta ** 10.4) * np.maximum(x, 0.0) ** 2.76
175 return np.where((pH > 0) & (pC > 0), d, 0.0)
176
177
178def leckner_emissivity(T, P_bar, p_H2O_bar, p_CO2_bar, L_m) -> Dict[str, np.ndarray]:
179 """Total emissivity of an H2O-CO2 mixture (Modest eqs. 11.139-11.143, Table 11.3).
180
181 Pressures in bar, path length in m. Returns eps, the two species' contributions, the
182 overlap correction and whether the pressure corrections were held at LECKNER_T_MAX.
183 Scalars or arrays.
184 """
185 L_cm = np.asarray(L_m, dtype=float) * 100.0
186 eH = _leckner_species("H2O", T, P_bar, p_H2O_bar, L_cm)
187 eC = _leckner_species("CO2", T, P_bar, p_CO2_bar, L_cm)
188 d = _leckner_overlap(p_H2O_bar, p_CO2_bar, L_cm)
189 return {"eps": np.maximum(eH + eC - d, 0.0), "eps_H2O": eH, "eps_CO2": eC, "overlap": d,
190 "pressure_correction_clamped": np.asarray(T) > LECKNER_T_MAX}
191
192
193def gas_absorptivity(Tg, Tw, P_bar, p_H2O_bar, p_CO2_bar, L_m):
194 """Absorptivity for wall emission at Tw (Hottel's scaling; Modest eq. 11.144):
195 alpha_i = (Tg/Tw)^n_i eps_i(Tw, p_i L Tw/Tg), n = 0.45 H2O, 0.65 CO2."""
196 s = np.asarray(Tw, dtype=float) / np.asarray(Tg, dtype=float)
197 L_cm = np.asarray(L_m, dtype=float) * 100.0
198 eH = _leckner_species("H2O", Tw, P_bar, np.asarray(p_H2O_bar) * s, L_cm)
199 eC = _leckner_species("CO2", Tw, P_bar, np.asarray(p_CO2_bar) * s, L_cm)
200 d = _leckner_overlap(np.asarray(p_H2O_bar) * s, np.asarray(p_CO2_bar) * s, L_cm)
201 return np.maximum((1.0 / s) ** 0.45 * eH + (1.0 / s) ** 0.65 * eC - d, 0.0)
202
203
204def gray_enclosure_flux(eps_g, alpha_g, eps_w, Tg, Tw):
205 """Net gas-to-wall radiation of an isothermal gray gas in a gray enclosure [W/m^2]."""
206 f = eps_w / (1.0 - (1.0 - eps_w) * (1.0 - alpha_g))
207 return f * STEFAN_BOLTZMANN_W_M2_K4 * (eps_g * Tg ** 4 - alpha_g * Tw ** 4)
208
209
210# ── Contour ───────────────────────────────────────────────────────────────────
211
212@dataclass
214 """Gas-side wall radius r(x), throat at x = 0, face at x_face < 0."""
215 x: np.ndarray
216 r: np.ndarray
217 x_face: float
218 x_cone_start: float
219 x_arc_start: float
220 R_t: float
221 R_c: float
222 volume: float
223 beam_length: float
224
225 def area_elements(self) -> np.ndarray:
226 """Lateral area of the frustum between consecutive stations, per station (half each side)."""
227 dA_seg = np.pi * (self.r[1:] + self.r[:-1]) * np.hypot(np.diff(self.x), np.diff(self.r))
228 dA = np.zeros_like(self.r)
229 dA[:-1] += 0.5 * dA_seg
230 dA[1:] += 0.5 * dA_seg
231 return dA
232
233
234def wall_contour(A_throat: float, chamber_diameter: float, volume: float,
235 A_exit: Optional[float] = None, n_chamber: int = 160, n_nozzle: int = 60,
236 theta: float = np.pi / 4.0) -> WallContour:
237 """The drawn contour: barrel, cone at half-angle theta, 1.5 Rt entrance arc, Rao bell.
238
239 The barrel length is the one that closes the declared volume (chamber_length_calc), the
240 same construction the geometry generator draws; it is not read from a stored length.
241 """
242 from engine.core.chamber_geometry import chamber_length_calc, generate_nozzle
243
244 R_t = float(np.sqrt(A_throat / np.pi))
245 R_c = 0.5 * float(chamber_diameter)
246 CR = (R_c / R_t) ** 2
247 L_cyl = max(float(chamber_length_calc(volume, A_throat, CR, theta)), 0.0)
248 r_tan = R_t * (1.0 + 1.5 * (1.0 - np.cos(theta)))
249 x_arc = -1.5 * R_t * np.sin(theta)
250 x_cone = x_arc - max(R_c - r_tan, 0.0) / np.tan(theta)
251 x_face = x_cone - L_cyl
252
253 n_cyl = max(int(n_chamber * 0.5), 8)
254 n_cone = max(int(n_chamber * 0.25), 6)
255 n_arc = max(n_chamber - n_cyl - n_cone, 6)
256 xc = np.linspace(x_face, x_cone, n_cyl, endpoint=False)
257 rc = np.full_like(xc, R_c)
258 xk = np.linspace(x_cone, x_arc, n_cone, endpoint=False)
259 rk = R_c - (xk - x_cone) * np.tan(theta)
260 ta = np.linspace(-(np.pi / 2.0 + theta), -np.pi / 2.0, n_arc)
261 xa = 1.5 * R_t * np.cos(ta)
262 ra = 1.5 * R_t * np.sin(ta) + 2.5 * R_t
263 x = np.concatenate([xc, xk, xa])
264 r = np.concatenate([rc, rk, ra])
265 if A_exit is not None and A_exit > A_throat:
266 pts = generate_nozzle(A_throat, A_exit, steps=max(n_nozzle, 10), theta=theta)[0]
267 keep = pts[:, 0] > 1e-12
268 x = np.concatenate([x, pts[keep, 0]])
269 r = np.concatenate([r, pts[keep, 1]])
270 order = np.argsort(x, kind="stable")
271 x, r = x[order], r[order]
272 uniq = np.concatenate([[True], np.diff(x) > 1e-12])
273 x, r = x[uniq], r[uniq]
274
275 # Enclosure for the beam length: barrel, cone, arc, face and the throat opening.
276 ch = x <= 0.0
277 dA = np.pi * (r[ch][1:] + r[ch][:-1]) * np.hypot(np.diff(x[ch]), np.diff(r[ch]))
278 A_enc = float(dA.sum()) + np.pi * R_c ** 2 + A_throat
279 L_e = BEAM_LENGTH_COEFF * float(volume) / A_enc
280 return WallContour(x=x, r=r, x_face=float(x_face), x_cone_start=float(x_cone),
281 x_arc_start=float(x_arc), R_t=R_t, R_c=R_c, volume=float(volume),
282 beam_length=float(L_e))
283
284
285def contour_for(cg) -> WallContour:
286 """wall_contour for a ChamberGeometryConfig."""
287 return wall_contour(cg.A_throat, cg.chamber_diameter, cg.volume, A_exit=cg.A_exit)
288
289
290# ── Station fluxes ────────────────────────────────────────────────────────────
291
292@dataclass
294 """Everything the wall sees of the gas: stagnation state, throat mass flux, CEA transport
295 (frozen, chamber) and composition (equilibrium, chamber)."""
296 T0: float
297 P0: float
298 gamma: float
299 mass_flux_throat: float
300 mu: float
301 cp: float
302 Pr: float
303 x_H2O: float
304 x_CO2: float
305
306
307def hot_gas_state(cea_cache, MR: float, Pc: float, T0: float, gamma: float, mdot: float,
308 A_throat: float) -> HotGasState:
309 """HotGasState from the CEA auxiliary tables at (MR, Pc)."""
310 aux = cea_cache.aux
311 tr = aux.transport(MR, Pc, "chamber")
312 comp = aux.composition(MR, Pc, "chamber")
313 return HotGasState(T0=float(T0), P0=float(Pc), gamma=float(gamma),
314 mass_flux_throat=float(mdot) / float(A_throat), mu=tr["mu"], cp=tr["cp"],
315 Pr=tr["Pr"], x_H2O=comp["H2O"], x_CO2=comp["CO2"])
316
317
318def profile(gas: HotGasState, contour: WallContour, Tw, eps_wall, mask=None) -> Dict[str, np.ndarray]:
319 """Convective and radiative flux into the wall at every contour station (or ``mask``ed
320 ones), wall at Tw with emissivity eps_wall (scalars or per-station)."""
321 x = contour.x if mask is None else contour.x[mask]
322 r = contour.r if mask is None else contour.r[mask]
323 Tw = np.broadcast_to(np.asarray(Tw, dtype=float), x.shape).astype(float)
324 ew = np.broadcast_to(np.asarray(eps_wall, dtype=float), x.shape).astype(float)
325 ar = np.maximum((r / contour.R_t) ** 2, 1.0)
326 M = mach_from_area_ratio(ar, gas.gamma, x > 0.0)
327 k = 1.0 + 0.5 * (gas.gamma - 1.0) * M * M
328 tT = 1.0 / k
329 tP = tT ** (gas.gamma / (gas.gamma - 1.0))
330 Taw = recovery_temperature(gas.T0, gas.gamma, M, gas.Pr)
331 sigma = bartz_sigma(Tw, gas.T0, gas.gamma, M)
332 Dt = 2.0 * contour.R_t
333 h = bartz_h(Dt, THROAT_RC_OVER_RT * contour.R_t, gas.mu, gas.cp, gas.Pr,
334 gas.mass_flux_throat, ar, sigma)
335 Tg = gas.T0 * tT
336 P_bar = gas.P0 * tP / 1e5
337 L = np.minimum(contour.beam_length, CYLINDER_BEAM_LENGTH_OVER_D * 2.0 * r)
338 pH, pC = gas.x_H2O * P_bar, gas.x_CO2 * P_bar
339 le = leckner_emissivity(Tg, P_bar, pH, pC, L)
340 alpha = gas_absorptivity(Tg, np.minimum(Tw, Tg), P_bar, pH, pC, L)
341 q_rad = gray_enclosure_flux(le["eps"], alpha, ew, Tg, Tw)
342 return {"x": x, "r": r, "area_ratio": ar, "M": M, "h": h, "Taw": Taw,
343 "q_conv": h * (Taw - Tw), "q_rad": q_rad, "eps_gas": le["eps"], "alpha_gas": alpha,
344 "T_static": Tg, "P_static": gas.P0 * tP, "beam_length": L,
345 "leckner_clamped": le["pressure_correction_clamped"]}
346
347
348def station_flux(gas: HotGasState, contour: WallContour, x: float, Tw: float, eps_wall: float) -> Dict[str, float]:
349 """profile() at one axial position, radius interpolated on the contour."""
350 r = float(np.interp(x, contour.x, contour.r))
351 one = WallContour(x=np.array([float(x)]), r=np.array([r]), x_face=contour.x_face,
352 x_cone_start=contour.x_cone_start, x_arc_start=contour.x_arc_start,
353 R_t=contour.R_t, R_c=contour.R_c, volume=contour.volume,
354 beam_length=contour.beam_length)
355 p = profile(gas, one, Tw, eps_wall)
356 return {k: (float(v[0]) if np.ndim(v) else v) for k, v in p.items()}
#define max(a, b)
Definition Arduino.h:43
bartz_h(float Dt, float rc, float mu, float cp, float Pr, float mass_flux_throat, area_ratio, sigma)
Definition gas_side.py:118
_leckner_species(str sp, T, P_bar, pa_bar, L_cm)
Definition gas_side.py:145
Dict[str, np.ndarray] profile(HotGasState gas, WallContour contour, Tw, eps_wall, mask=None)
Definition gas_side.py:318
_leckner_overlap(p_H2O_bar, p_CO2_bar, L_cm)
Definition gas_side.py:168
Dict[str, np.ndarray] leckner_emissivity(T, P_bar, p_H2O_bar, p_CO2_bar, L_m)
Definition gas_side.py:178
gray_enclosure_flux(eps_g, alpha_g, eps_w, Tg, Tw)
Definition gas_side.py:204
_mach_bisect_py(ar, sup, gamma)
Definition gas_side.py:48
recovery_temperature(float T0, float gamma, M, float Pr)
Definition gas_side.py:104
mach_from_area_ratio(area_ratio, float gamma, supersonic)
Definition gas_side.py:74
Dict[str, float] station_flux(HotGasState gas, WallContour contour, float x, float Tw, float eps_wall)
Definition gas_side.py:348
_leckner_eps0(np.ndarray c, t, paL_barcm)
Definition gas_side.py:137
WallContour wall_contour(float A_throat, float chamber_diameter, float volume, Optional[float] A_exit=None, int n_chamber=160, int n_nozzle=60, float theta=np.pi/4.0)
Definition gas_side.py:236
HotGasState hot_gas_state(cea_cache, float MR, float Pc, float T0, float gamma, float mdot, float A_throat)
Definition gas_side.py:308
bartz_sigma(Tw, float T0, float gamma, M, float omega=BARTZ_OMEGA)
Definition gas_side.py:111
gas_absorptivity(Tg, Tw, P_bar, p_H2O_bar, p_CO2_bar, L_m)
Definition gas_side.py:193
static_ratios(M, float gamma)
Definition gas_side.py:96