STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
impinging.py
Go to the documentation of this file.
1"""Impinging injector model implementation."""
2
3from __future__ import annotations
4
5import logging
6import math
7import os
8from typing import Tuple, Dict, Any
9
10import numpy as np
11
12_LOG = logging.getLogger(__name__)
13_print_raw = os.environ.get("ENGINE_PRINT_IMPINGING_FEED_CLOSURE", "")
14_PRINT_FEED_ORIFICE_CLOSURE = _print_raw != "" and str(_print_raw).lower() not in ("0", "false", "no")
15
16_STREAM_ROOT_MAX_EVAL = 200
17
18from engine.pipeline.config_schemas import PintleEngineConfig, ImpingingInjectorConfig
19from engine.pipeline.feed_loss import delta_p_feed
20from engine.pipeline.thermal.regen_cooling import delta_p_regen_channels
21from engine.core.discharge import (
22 cd_from_re,
23 cd_inf_from_inlet_geometry,
24 cd_inf_from_orifice_diameter,
25 calculate_reynolds_number,
26)
27from engine.pipeline.assumptions import assume
28from engine.core.spray import (
29 evaporation_constant_m2_s,
30 tau_evap_from_k,
31 spray_axial_velocity,
32 momentum_flux_ratio,
33 thrust_momentum_ratio,
34 spray_angle_from_J,
35 spray_angle_from_TMR,
36 weber_number,
37 ohnesorge_number,
38 smd_impinging_tn4222,
39 tau_evap,
40 xstar,
41 check_spray_constraints,
42)
43
44from . import InjectorModel
45from engine.core.injectors.layout import effective_discharge
47 effective_flow_areas_from_cd,
48 merge_effective_area_warnings,
49)
50
51
52
53def _stream_flow(Pc, P_tank, rho, A, d_hyd, mu, discharge, T_in, cd_cap, feed_dp,
54 *, cav=None, network=None, detail=None):
55 """One stream's closure at a fixed Pc: ṁ = Cd(Re(ṁ), P_inj) A √(2ρ (P_tank − Δp_feed(ṁ) − Pc)).
56
57 g(ṁ) = ṁ − Cd A √(2ρ Δp_inj) is negative at ṁ = 0 and non-negative at
58 ṁ_max = Cd_cap A √(2ρ (P_tank − Pc)) (Δp_feed ≥ 0, Cd ≤ Cd_cap), so the root is bracketed and
59 found by Illinois false position. A tank at or below Pc does not flow.
60 Mirrored by ``engine.accel.kernels._stream_flow``.
61
62 Returns (ṁ, Cd, P_inj, Δp_feed, Δp_inj, evaluations).
63 """
64
65 def cd_of(Re, P_in):
66 Cd = float(min(cd_from_re(Re, discharge, P_inlet=P_in, T_inlet=T_in, d_hyd_m=d_hyd), cd_cap))
67 if cav is not None and P_in > Pc:
68 # Cavitating orifice (Nurick 1976): Cd = Cc sqrt(K), K = (P_in - P_v)/(P_in - Pc).
69 P_v, Cc = cav
70 K = (P_in - P_v) / (P_in - Pc)
71 Cd = float(min(Cd, Cc * math.sqrt(max(K, 0.0))))
72 return Cd
73
74 def state(m):
75 dpf = float(feed_dp(m))
76 Pi = float(P_tank - dpf)
77 dpi = Pi - Pc
78 if network is not None:
79 # Cd stays the holes' own (flow-weighted); the manifold's loss shows as the gap
80 # between the port head and the flow, reported as Cd_eff_manifold.
81 m_b, holes, Cd = network.march(m, Pi, Pc, cd_of)
82 if detail is not None:
83 detail["holes"] = holes
84 detail["cd_eff"] = (float(m_b / (A * math.sqrt(2.0 * rho * dpi)))
85 if dpi > 0 and m_b > 0 else float("nan"))
86 else:
87 u = m / (rho * A) if A > 0 else 0.0
88 Re = calculate_reynolds_number(rho, u, d_hyd, mu)
89 Cd = cd_of(Re, Pi)
90 m_b = float(Cd * A * np.sqrt(2.0 * rho * dpi)) if dpi > 0 else 0.0
91 return m - m_b, Cd, Pi, dpf, max(0.0, dpi)
92
93 if not A > 0 or not P_tank > Pc:
94 _, Cd, Pi, dpf, dpi = state(0.0)
95 return 0.0, Cd, Pi, dpf, dpi, 1
96 hi = float(cd_cap * A * np.sqrt(2.0 * rho * (P_tank - Pc)))
97 a, b = 0.0, hi
98 ga, gb = state(a)[0], state(b)[0]
99 n = 2
100 tol = 1e-13 * hi
101 if gb <= 0.0:
102 m = b
103 elif ga >= 0.0: # Cd = 0 at rest: nothing drives the flow
104 m = a
105 else:
106 side = 0
107 m = b
108 for _ in range(_STREAM_ROOT_MAX_EVAL):
109 m = (a * gb - b * ga) / (gb - ga)
110 gm = state(m)[0]
111 n += 1
112 if abs(gm) <= tol or b - a <= tol:
113 break
114 if gm > 0.0:
115 b, gb = m, gm
116 if side == -1:
117 ga *= 0.5
118 side = -1
119 else:
120 a, ga = m, gm
121 if side == 1:
122 gb *= 0.5
123 side = 1
124 else:
125 _LOG.warning("impinging injector: stream flow root not within tolerance after %d evaluations", n)
126 _, Cd, Pi, dpf, dpi = state(m)
127 return float(m), Cd, Pi, dpf, dpi, n + 1
128
129
130
131def _churchill_f(Re: float, rel_rough: float) -> float:
132 """Darcy friction factor, Churchill (1977), laminar through fully rough in one expression."""
133 if not Re > 0:
134 return 0.0
135 A = (2.457 * math.log(1.0 / ((7.0 / Re) ** 0.9 + 0.27 * rel_rough))) ** 16
136 B = (37530.0 / Re) ** 16
137 return 8.0 * ((8.0 / Re) ** 12 + 1.0 / (A + B) ** 1.5) ** (1.0 / 12.0)
138
139
141 """One stream's back channel as a dividing-flow ring, marched hole by hole.
142
143 Each of ``n_ports`` ports splits two ways, so ``2 n_ports`` identical branches each feed
144 ``n_holes / (2 n_ports)`` holes, the first half a pitch from the port (port between holes).
145 Along a branch:
146
147 - into the branch from the port (after the feed line's velocity head is dumped there):
148 p = P_port - (1 + K_ent) rho u^2 / 2;
149 - between holes, Darcy friction (Churchill) on the channel's hydraulic diameter;
150 - each hole flows from the LOCAL STATIC pressure, Cd A sqrt(2 rho (p - Pc)) -- with the
151 approach flow normal to the hole no velocity head is recovered (Rohde, Richards & Metger,
152 NASA TN D-5467, 1969); Cd from the hole's own Reynolds number (and cavitation);
153 - past each hole the branch slows and regains C_R rho (u1^2 - u2^2)/2 of static pressure
154 (dividing-flow manifold; Acrivos, Babcock & Pigford 1959; Bajura 1971).
155
156 The outer root on the stream's total flow closes the branch flow to zero at the dead end.
157 Not modelled: the extra lip separation of crossflow over the hole inlet (TN D-5467 Fig. 7;
158 Strakey & Talley), which lowers Cd further near the port -- measure it by cold flow.
159 """
160
161 def __init__(self, *, n_holes, n_ports, A_ch, D_h, r_ring, A_hole, d_hole, rho, mu,
162 K_ent, C_R, roughness):
163 self.n_ports = int(max(1, n_ports))
164 per = n_holes / (2.0 * self.n_ports)
165 self.h = int(round(per))
166 if abs(per - self.h) > 1e-9 or self.h < 1:
167 from engine.pipeline.assumptions import assume
168 assume("injector.plate.channel_inlets", self.n_ports,
169 reason=f"{n_holes} holes do not split evenly over {2 * self.n_ports} branches; "
170 f"{self.h} per branch used")
171 self.h = max(1, self.h)
172 self.A_ch, self.D_h, self.A_hole, self.d_hole = A_ch, D_h, A_hole, d_hole
173 self.rho, self.mu, self.K_ent, self.C_R = rho, mu, K_ent, C_R
174 self.s = 2.0 * math.pi * r_ring / float(n_holes)
175 self.eps = roughness / D_h if D_h > 0 else 0.0
176 self.scale = float(n_holes) / (2.0 * self.n_ports * self.h)
177
178 def march(self, m_total, P_port, Pc, cd_of):
179 rho, A_ch = self.rho, self.A_ch
180 mb = m_total / (2.0 * self.n_ports)
181 u = mb / (rho * A_ch)
182 p = P_port - (1.0 + self.K_ent) * 0.5 * rho * u * u
183 holes, cds = [], []
184 for j in range(self.h):
185 seg = 0.5 * self.s if j == 0 else self.s
186 Re_ch = rho * abs(u) * self.D_h / self.mu
187 p -= _churchill_f(Re_ch, self.eps) * (seg / self.D_h) * 0.5 * rho * u * abs(u)
188 dp = p - Pc
189 q = 0.0
190 if dp > 0:
191 q = cd_of(0.0, p) * self.A_hole * math.sqrt(2.0 * rho * dp)
192 for _ in range(3): # the hole's Cd at its own Reynolds number
193 Re_h = rho * (q / (rho * self.A_hole)) * self.d_hole / self.mu
194 q = cd_of(Re_h, p) * self.A_hole * math.sqrt(2.0 * rho * dp)
195 cds.append(q / (self.A_hole * math.sqrt(2.0 * rho * dp)) if dp > 0 and q > 0 else cd_of(0.0, p))
196 mb -= q
197 u_new = mb / (rho * A_ch)
198 p += self.C_R * 0.5 * rho * (u * u - u_new * u_new)
199 u = u_new
200 holes.append(q)
201 total = 2.0 * self.n_ports * self.scale * sum(holes)
202 cd_hole = (sum(c * q for c, q in zip(cds, holes)) / sum(holes)) if sum(holes) > 0 else cds[0]
203 return total, holes, cd_hole
204
205
206def _ring_manifold_for(config, side, rho, mu, n_holes, d_hole):
207 """The stream's ring network when the declared plate has back channels, else None."""
208 plate = getattr(config.injector, "plate", None)
209 if (plate is None or getattr(plate, "back", None) != "channels"
210 or getattr(plate, "manifold_model", "plenum") != "ring_network"):
211 return None
212 from engine.core.injectors.layout import layout_from_config
213 lay = layout_from_config(config, drawings=False, checks=False)
214 if lay is None:
215 return None
216 ch = lay["passages"]["O" if side == "oxidizer" else "F"].get("channel")
217 if not ch or not ch.get("flow_area", 0.0) > 0:
218 return None
219 w, h = float(ch["width"]), float(ch.get("flow_height") or ch["depth"])
220 D_h = float(ch.get("hydraulic_diameter") or 4.0 * w * h / (2.0 * (w + h))) # drawn section, else w x h
221 return _RingManifold(
222 n_holes=int(n_holes), n_ports=int(getattr(plate, "channel_inlets", 1) or 1),
223 A_ch=float(ch["flow_area"]), D_h=D_h, r_ring=float(ch["r_center"]),
224 A_hole=math.pi * d_hole ** 2 / 4.0, d_hole=d_hole, rho=rho, mu=mu,
225 K_ent=float(getattr(plate, "channel_entry_K", 0.5)),
226 C_R=float(getattr(plate, "channel_pressure_regain", 1.0)),
227 roughness=float(getattr(plate, "channel_roughness", 3.2e-6)))
228
229
230def _manifold_diagnostics(net_O, net_F, detail, mdot_O, mdot_F):
231 """Per-hole flow along each channel branch (port first) and the element mixture ratios."""
232 if net_O is None and net_F is None:
233 return {"manifold_model": "plenum"}
234 out = {"manifold_model": "ring_network"}
235 per = {}
236 for k, net, md in (("O", net_O, mdot_O), ("F", net_F, mdot_F)):
237 holes = detail[k].get("holes") if net is not None else None
238 if holes:
239 mean = md / (2.0 * net.n_ports * net.scale * len(holes)) if md > 0 else float("nan")
240 per[k] = list(holes)
241 out[f"element_mass_flows_{k}"] = [float(q) for q in holes]
242 out[f"element_flow_ratio_min_{k}"] = float(min(holes) / mean) if mean > 0 else float("nan")
243 out[f"element_flow_ratio_max_{k}"] = float(max(holes) / mean) if mean > 0 else float("nan")
244 out[f"manifold_branch_velocity_{k}"] = float(md / (2.0 * net.n_ports) / (net.rho * net.A_ch))
245 out[f"manifold_ports_{k}"] = int(net.n_ports)
246 out[f"Cd_eff_manifold_{k}"] = float(detail[k].get("cd_eff", float("nan")))
247 if "O" in per and "F" in per and len(per["O"]) == len(per["F"]):
248 mrs = [o / f if f > 0 else float("inf") for o, f in zip(per["O"], per["F"])]
249 out["element_mixture_ratios"] = mrs
250 out["element_mass_flows"] = [o + f for o, f in zip(per["O"], per["F"])]
251 out["element_mixture_ratio_min"] = float(min(mrs))
252 out["element_mixture_ratio_max"] = float(max(mrs))
253 return out
254
255
257 n_elements: float,
258 spacing_O_m: float,
259 spacing_F_m: float,
260 angle_O_deg: float,
261 angle_F_deg: float,
262) -> float:
263 """Axial distance from the injector FACE to where a doublet's two jets meet [m].
264
265 The two streams of an unlike doublet sit on DIFFERENT pitch circles::
266
267 D_pitch = n * spacing / pi (spacing = arc between adjacent same-stream holes)
268 dr = |D_pitch_O - D_pitch_F| / 2
269
270 ``dr`` is the RADIAL gap between the two rings at the face. Each jet is inclined toward
271 the other and closes its share of that gap at rate ``tan(theta)`` per unit axial
272 distance, so they meet at::
273
274 L_imp = dr / (tan(theta_O) + tan(theta_F))
275
276 NOTE the offset is ``dr``, not the element spacing. ``spacing`` is the circumferential
277 distance between ADJACENT holes of the SAME stream around its own ring -- it says how
278 densely one ring is populated and has nothing to do with how far the O and F jets of a
279 single doublet are from each other. An earlier version used ``0.5*(s_O + s_F)`` here,
280 which is the average ring density: dimensionally a length, physically the wrong one, and
281 it under-reported the standoff (2.89 mm vs 4.35 mm on a real design, i.e. L/d 1.59 vs
282 2.39) while also shortening the spray length that feeds the residence-time correction.
283
284 Returns NaN when the rings coincide or both jets are axial -- in either case there is no
285 crossing point.
286 """
287 import numpy as _np
288
289 n = max(1.0, float(n_elements))
290 dp_O = n * float(spacing_O_m) / _np.pi
291 dp_F = n * float(spacing_F_m) / _np.pi
292 dr = 0.5 * abs(dp_O - dp_F)
293 tan_sum = float(
294 _np.tan(_np.deg2rad(float(angle_O_deg))) + _np.tan(_np.deg2rad(float(angle_F_deg)))
295 )
296 if not _np.isfinite(tan_sum) or tan_sum <= 1e-9 or dr <= 0.0:
297 return float("nan")
298 val = dr / tan_sum
299 return float(val) if _np.isfinite(val) else float("nan")
300
302 rho_O: float,
303 rho_F: float,
304 v_O_bulk: float,
305 v_F_bulk: float,
306) -> float:
307 """Jet momentum-flux ratio R = sqrt(rho_O*v_O^2 / (rho_F*v_F^2)), used by Layer 1.
308
309 ``v_*_bulk`` are bulk speeds per stream: mdot / (rho * n_elements * A_jet). Since
310 mdot = Cd A sqrt(2 rho dp), rho v^2 = 2 Cd^2 dp and R = (Cd_O/Cd_F) sqrt(dp_O/dp_F): the
311 injector's Cd-dp split, with no density or hole size in it. It is not Rupe's mixing
312 criterion; that is rupe_mixing_ratio.
313 """
314 if (
315 rho_O <= 0
316 or rho_F <= 0
317 or not np.isfinite(v_O_bulk)
318 or not np.isfinite(v_F_bulk)
319 or v_F_bulk == 0.0
320 ):
321 return float("nan")
322 num_mom = rho_O * v_O_bulk ** 2
323 den_mom = rho_F * v_F_bulk ** 2
324 if den_mom <= 0 or num_mom < 0:
325 return float("nan")
326 return float(np.sqrt(num_mom / den_mom))
327
328
330 rho_O: float, v_O: float, d_O: float, rho_F: float, v_F: float, d_F: float,
331) -> float:
332 """Rupe's mixing ratio for an unlike doublet, M = rho_O v_O^2 d_O / (rho_F v_F^2 d_F).
333
334 Rupe (JPL PR 20-195), restated by Elverum & Morey, JPL Memo 30-5 (1959) eq. 1: the most
335 uniform mixture-ratio distribution is at M = 1 (stream momentum ratio equal to the diameter
336 ratio). No angle term; the spray's direction is a separate quantity. M = R^2 d_O / d_F.
337 """
338 den = rho_F * v_F ** 2 * d_F
339 if not (np.isfinite(den) and den > 0 and np.isfinite(v_O) and rho_O > 0 and d_O > 0):
340 return float("nan")
341 return float(rho_O * v_O ** 2 * d_O / den)
342
343
344def injector_face_pressure(Pc: float, gamma: float, contraction_ratio: float) -> float:
345 """Static pressure at the injector face for a nozzle-inlet stagnation pressure ``Pc``.
346
347 Heat addition in a constant-area chamber (Rayleigh line) costs stagnation pressure between
348 the face, where the gas is at rest, and the nozzle inlet at M_c, the subsonic root of
349 A_c/A_t: p_face / p0 = (1 + gamma M_c^2) / (1 + (gamma-1)/2 M_c^2)^(gamma/(gamma-1)).
350 Sutton & Biblarz ch. 3: ~1.05 at A_c/A_t = 2, under 1 % above ~5. Not applied by the solve
351 yet (dp_inj = P_inj - Pc); it needs the CEA gamma the chamber solver holds.
352 """
353 g, cr = float(gamma), float(contraction_ratio)
354 if not (np.isfinite(Pc) and g > 1.0 and cr > 1.0):
355 return float(Pc)
356 ex = (g + 1.0) / (2.0 * (g - 1.0))
357 area = lambda M: ((2.0 / (g + 1.0)) * (1.0 + 0.5 * (g - 1.0) * M * M)) ** ex / M # noqa: E731
358 lo, hi = 1e-12, 1.0
359 for _ in range(200): # A/A* falls monotonically on the subsonic branch
360 mid = 0.5 * (lo + hi)
361 lo, hi = (mid, hi) if area(mid) > cr else (lo, mid)
362 M2 = (0.5 * (lo + hi)) ** 2
363 return float(Pc * (1.0 + g * M2) / (1.0 + 0.5 * (g - 1.0) * M2) ** (g / (g - 1.0)))
364
365
367 """Twin-jet impinging injector solver."""
368
369 injector_config: ImpingingInjectorConfig
370
371 def __init__(self, engine_config: PintleEngineConfig):
372 super().__init__(engine_config)
373 injector_cfg = engine_config.injector
374 if not isinstance(injector_cfg, ImpingingInjectorConfig):
375 raise TypeError("ImpingingInjector requires ImpingingInjectorConfig")
376 self.injector_config = injector_cfg
377
378 def solve(
379 self,
380 P_tank_O: float,
381 P_tank_F: float,
382 Pc: float,
383 ) -> Tuple[float, float, Dict[str, Any]]:
384 config = self.engine_config
385 geometry = self.injector_config.geometry
386
387 # The land the plate actually gives when the config asks for it (l_over_d_source:
388 # plate); otherwise the declared blocks, unchanged.
389 discharge_O = effective_discharge(config, "oxidizer")
390 discharge_F = effective_discharge(config, "fuel")
391 feed_O = config.feed_system["oxidizer"]
392 feed_F = config.feed_system["fuel"]
393 spray_cfg = config.spray
394 fluids = config.fluids
395
396 rho_O = fluids["oxidizer"].density
397 mu_O = fluids["oxidizer"].viscosity
398 sigma_O = fluids["oxidizer"].surface_tension
399
400 rho_F = fluids["fuel"].density
401 mu_F = fluids["fuel"].viscosity
402 sigma_F = fluids["fuel"].surface_tension
403
404 # Inlet temperatures for Cd(Re): match pintle — use fluid properties from config when set
405 T_tank_O = getattr(fluids["oxidizer"], "temperature", 90.0)
406 T_tank_F = getattr(fluids["fuel"], "temperature", 300.0)
407
408 A_O = geometry.oxidizer.n_elements * np.pi * (geometry.oxidizer.d_jet / 2.0) ** 2
409 A_F = geometry.fuel.n_elements * np.pi * (geometry.fuel.d_jet / 2.0) ** 2
410 d_hyd_O = geometry.oxidizer.d_jet
411 d_hyd_F = geometry.fuel.d_jet
412
413 mdot_O = 0.1
414 mdot_F = 0.1
415
416 if float(config.solver.closure.Cd_reduction_factor) != 1.0:
417 assume("solver.closure.Cd_reduction_factor", 1.0,
418 reason="Cd is orifice geometry and Re; a spray-constraint violation is reported, not traded for Cd")
419 for _side, _dc in (("oxidizer", discharge_O), ("fuel", discharge_F)):
420 if cd_inf_from_inlet_geometry(_dc) is None and getattr(_dc, "use_geometry_cd", False):
421 assume(f"discharge.{_side}.inlet_geometry", "thin plate",
422 reason="no inlet declared: thin-plate Cd, not the drilled L/d passage the layout draws "
423 "(sharp, L/d 4: 0.79, Lichtarowicz 1965)")
424 Cd_O_eff = cd_inf_from_orifice_diameter(d_hyd_O, discharge_O)
425 Cd_F_eff = cd_inf_from_orifice_diameter(d_hyd_F, discharge_F)
426
427 diagnostics = {
428 "iterations": 0,
429 "constraints_satisfied": False,
430 "violations": [],
431 "J": None,
432 "TMR": None,
433 "theta": None,
434 "We_O": None,
435 "We_F": None,
436 "D32_O": None,
437 "D32_F": None,
438 "x_star": None,
439 "injector_type": "impinging",
440 "turbulence_intensity_O": None,
441 "turbulence_intensity_F": None,
442 "turbulence_length_O": None,
443 "turbulence_length_F": None,
444 "turbulence_intensity_mix": None,
445 }
446
447 # Typical opposing doublet: each side's ``impingement_angle`` is measured from chamber axis toward
448 # the impingement plane; separation between jet centerlines is θ_O + θ_F (schema: included angle per stream).
449 _imp_sep_deg = float(geometry.oxidizer.impingement_angle + geometry.fuel.impingement_angle)
450 _imp_sep_deg = float(np.clip(_imp_sep_deg, 1.0, 179.0))
451 imp_angle_rad = np.deg2rad(_imp_sep_deg)
452
453 def _injector_turbulence_fields(uo: float, uf: float) -> Dict[str, float]:
454 """Same Reynolds-based turbulence model as pintle (shear-layer mixing intensity)."""
455 Re_Oloc = calculate_reynolds_number(rho_O, uo, d_hyd_O, mu_O)
456 Re_Floc = calculate_reynolds_number(rho_F, uf, d_hyd_F, mu_F)
457 ti_O = 0.16 * (Re_Oloc ** -0.125) if Re_Oloc > 0 else 0.1
458 ti_F = 0.16 * (Re_Floc ** -0.125) if Re_Floc > 0 else 0.1
459 ti_O = float(np.clip(ti_O, 0.02, 0.3))
460 ti_F = float(np.clip(ti_F, 0.02, 0.3))
461 tl_O = float(0.07 * d_hyd_O)
462 tl_F = float(0.07 * d_hyd_F)
463 velocity_total = max(uo + uf, 1e-6)
464 ti_mix = (ti_O * uo + ti_F * uf) / velocity_total
465 ti_mix = float(np.clip(ti_mix, 0.02, 0.35))
466 return {
467 "turbulence_intensity_O": ti_O,
468 "turbulence_intensity_F": ti_F,
469 "turbulence_length_O": tl_O,
470 "turbulence_length_F": tl_F,
471 "turbulence_intensity_mix": ti_mix,
472 }
473
474 def _converge_feed_orifice_coupling(
475 mdot_o0: float,
476 mdot_f0: float,
477 cd_eff_o: float,
478 cd_eff_f: float,
479 ) -> Tuple[
480 float,
481 float,
482 float,
483 float,
484 float,
485 float,
486 float,
487 float,
488 float,
489 float,
490 int,
491 ]:
492 """Feed loss, inlet pressure and orifice flow at this Pc, each stream by ``_stream_flow``.
493
494 With Pc fixed the streams do not interact, so each is its own bracketed root. The relaxed
495 fixed point this replaces stalled at its 150-step cap whenever a probed Pc sat above a
496 tank, and stopped ~2e-6 short of the root otherwise.
497
498 Returns
499 -------
500 mdot_o, mdot_f, cd_o, cd_f, Pi_o, Pi_f, dpf_o, dpf_f, dpi_o, dpi_f, evaluations
501 """
502
503 def _feed_o(m):
504 return delta_p_feed(m, rho_O, feed_O, P_tank_O, mu=mu_O)
505
506 def _feed_f(m):
507 dp = delta_p_feed(m, rho_F, feed_F, P_tank_F, mu=mu_F)
508 if config.regen_cooling is not None and config.regen_cooling.enabled:
509 dp += delta_p_regen_channels(m, rho_F, mu_F, config.regen_cooling, P_tank_F)
510 return dp
511
512 mo, Cdo, Pi_o, dpf_o, dpi_o, n_o = _stream_flow(
513 Pc, P_tank_O, rho_O, A_O, d_hyd_O, mu_O, discharge_O, T_tank_O, cd_eff_o, _feed_o,
514 cav=cav_O, network=net_O, detail=net_detail["O"],
515 )
516 mf, Cdf, Pi_f, dpf_f, dpi_f, n_f = _stream_flow(
517 Pc, P_tank_F, rho_F, A_F, d_hyd_F, mu_F, discharge_F, T_tank_F, cd_eff_f, _feed_f,
518 cav=cav_F, network=net_F, detail=net_detail["F"],
519 )
520 fp_it = n_o + n_f
521
522 mdot_bn_o = float(Cdo * A_O * np.sqrt(2.0 * rho_O * dpi_o)) if dpi_o > 0 else 0.0
523 mdot_bn_f = float(Cdf * A_F * np.sqrt(2.0 * rho_F * dpi_f)) if dpi_f > 0 else 0.0
524
525 _LOG.debug(
526 "impinging feed-orifice closure: fp_iters=%d mdot_solver_O=%s mdot_from_Bernoulli_O=%s "
527 "mdot_solver_F=%s mdot_from_Bernoulli_F=%s",
528 fp_it,
529 repr(mo),
530 repr(mdot_bn_o),
531 repr(mf),
532 repr(mdot_bn_f),
533 )
534 if _PRINT_FEED_ORIFICE_CLOSURE:
535 print(
536 "impinging feed-orifice closure",
537 f"fp_iters={fp_it}",
538 f"mdot_solver_O={mo}",
539 f"mdot_from_Bernoulli_O={mdot_bn_o}",
540 f"mdot_solver_F={mf}",
541 f"mdot_from_Bernoulli_F={mdot_bn_f}",
542 )
543
544 return mo, mf, Cdo, Cdf, Pi_o, Pi_f, dpf_o, dpf_f, dpi_o, dpi_f, fp_it
545
546 # Cavitation limit on every hole (Nurick 1976); a no-op wherever K > (Cd/Cc)^2.
547 from engine.core.discharge import contraction_coefficient, inlet_radius_ratio_of
548
549 def _cav(side, dc):
550 pv = getattr(fluids[side], "vapor_pressure", None)
551 if pv is None or not np.isfinite(float(pv)):
552 return None
553 return float(pv), contraction_coefficient(inlet_radius_ratio_of(dc))
554
555 cav_O, cav_F = _cav("oxidizer", discharge_O), _cav("fuel", discharge_F)
556 # Back channels as dividing-flow rings (None: the manifold is a plenum at one pressure).
557 net_O = _ring_manifold_for(config, "oxidizer", rho_O, mu_O,
558 geometry.oxidizer.n_elements, geometry.oxidizer.d_jet)
559 net_F = _ring_manifold_for(config, "fuel", rho_F, mu_F,
560 geometry.fuel.n_elements, geometry.fuel.d_jet)
561 net_detail = {"O": {}, "F": {}}
562
563 feed_orifice_fp_last = 0
564
565 # One pass. Cd is orifice geometry and Reynolds number (Lichtarowicz 1965; SP-8089), not a
566 # lever for the spray constraints: an x* or We violation is reported in diagnostics. The old
567 # loop shrank Cd by Cd_reduction_factor per violation, which also lengthened x*.
568 mdot_O, mdot_F, Cd_O, Cd_F, P_inj_O, P_inj_F, delta_p_feed_O, delta_p_feed_F, delta_p_inj_O, delta_p_inj_F, feed_orifice_fp_last = _converge_feed_orifice_coupling(
569 mdot_O,
570 mdot_F,
571 Cd_O_eff,
572 Cd_F_eff,
573 )
574
575 u_O = mdot_O / (rho_O * A_O) if A_O > 0 else 0.0
576 u_F = mdot_F / (rho_F * A_F) if A_F > 0 else 0.0
577
578 # Impingement relative velocity: law of cosines on the two jet velocity vectors,
579 # whose centerlines are separated by the included angle ``imp_angle_rad``. This is the
580 # velocity at which the unlike jets collide and shear into a sheet, and it is what
581 # drives both breakup (Weber) and the residence/evaporation length (x*).
582 u_rel = float(
583 np.sqrt(u_O ** 2 + u_F ** 2 - 2 * u_O * u_F * np.cos(imp_angle_rad))
584 )
585 turb_fields = _injector_turbulence_fields(u_O, u_F)
586
587 # Representative chamber gas density for the aerodynamic (breakup) Weber number.
588 # rho_g = Pc / (R_gas · T_gas); R_gas and T_gas are configured representative
589 # combustion-gas values because the injector solve runs before the CEA chamber state
590 # is available. Gas density (not liquid inertia) governs primary atomization.
591 rho_gas = float(
592 max(Pc / (spray_cfg.smd.chamber_gas_R * spray_cfg.smd.chamber_gas_T), 1e-6)
593 )
594
595 J = momentum_flux_ratio(rho_O, u_O, rho_F, u_F)
596 MR = mdot_O / mdot_F if mdot_F > 0 else np.inf
597 TMR = thrust_momentum_ratio(J, MR)
598
599 if spray_cfg.spray_angle.model == "J":
600 theta = spray_angle_from_J(J, spray_cfg.spray_angle.k, spray_cfg.spray_angle.n)
601 else:
602 theta = spray_angle_from_TMR(TMR)
603
604 Oh_O = ohnesorge_number(mu_O, rho_O, sigma_O, geometry.oxidizer.d_jet)
605 Oh_F = ohnesorge_number(mu_F, rho_F, sigma_F, geometry.fuel.d_jet)
606
607 # Impinging doublets always use Ingebo (u_rel-based aerodynamic breakup). Legacy Lefebvre
608 # on this path used uncalibrated C/m and reported bogus ~1 µm D32 when spray.smd.model was
609 # left at "lefebvre" from pintle-era YAML (see dispatch impinging bindings).
610 We_O = weber_number(rho_gas, u_rel, geometry.oxidizer.d_jet, sigma_O)
611 We_F = weber_number(rho_gas, u_rel, geometry.fuel.d_jet, sigma_F)
612 # NACA TN 4222: each stream at its own jet velocity, entering gas at rest (dV = Vj).
613 _smd = spray_cfg.smd
614 _prop = bool(getattr(_smd, "smd_property_scaling", True))
615 _scale = float(getattr(_smd, "smd_scale", 1.0) or 1.0)
616 from engine.core.spray import tn4222_transfer_model
617 _transfer = tn4222_transfer_model(_smd)
618 # A stream that does not flow (tank at or below Pc) has no spray: NaN, not an error.
619 D32_O = (_scale * smd_impinging_tn4222(
620 geometry.oxidizer.d_jet, u_O, rho_O, mu_O, sigma_O, rho_gas,
621 scale_properties=_prop, transfer=_transfer) if u_O > 0 else float("nan"))
622 D32_F = (_scale * smd_impinging_tn4222(
623 geometry.fuel.d_jet, u_F, rho_F, mu_F, sigma_F, rho_gas,
624 scale_properties=_prop, transfer=_transfer) if u_F > 0 else float("nan"))
625 # Measured D32 (measurements.d32_*_um) replaces the correlation where it was measured.
626 _mO, _mF = getattr(_smd, "d32_measured_O", None), getattr(_smd, "d32_measured_F", None)
627 if _mO and u_O > 0:
628 D32_O = float(_mO)
629 if _mF and u_F > 0:
630 D32_F = float(_mF)
631
632 # Evaporation constant from propellant properties + chamber state, not a single
633 # hardcoded K. Falls back to the legacy tau = K*D32^2 when the derivation cannot run
634 # (missing latent heat / boiling point on a custom fluid), so old configs still work.
635 _ev = spray_cfg.evaporation
636 _use_derived = getattr(_ev, "model", "derived") == "derived"
637 k_evap_O = k_evap_F = float("nan")
638 if _use_derived:
639 k_evap_O = evaporation_constant_m2_s(
640 Tc=spray_cfg.smd.chamber_gas_T, Pc=Pc, rho_g=rho_gas, rho_l=rho_O,
641 L_vap=float(getattr(fluids["oxidizer"], "latent_heat", 0.0) or 0.0),
642 T_boil=float(getattr(fluids["oxidizer"], "boiling_point", 0.0) or 0.0),
643 cp_g=float(getattr(_ev, "cp_gas", 2200.0)),
644 C_evap=float(getattr(_ev, "C_evap", 1.562)),
645 )
646 k_evap_F = evaporation_constant_m2_s(
647 Tc=spray_cfg.smd.chamber_gas_T, Pc=Pc, rho_g=rho_gas, rho_l=rho_F,
648 L_vap=float(getattr(fluids["fuel"], "latent_heat", 0.0) or 0.0),
649 T_boil=float(getattr(fluids["fuel"], "boiling_point", 0.0) or 0.0),
650 cp_g=float(getattr(_ev, "cp_gas", 2200.0)),
651 C_evap=float(getattr(_ev, "C_evap", 1.562)),
652 )
653 if np.isfinite(k_evap_O) and np.isfinite(k_evap_F):
654 tau_evap_O = tau_evap_from_k(D32_O, k_evap_O)
655 tau_evap_F = tau_evap_from_k(D32_F, k_evap_F)
656 else:
657 tau_evap_O = tau_evap(D32_O, _ev.K)
658 tau_evap_F = tau_evap(D32_F, _ev.K)
659
660 # TRANSPORT velocity, not the jet-to-jet relative velocity. u_rel shears the sheet
661 # (it belongs in the Ingebo Weber number above); what carries droplets DOWN the
662 # chamber is the momentum-weighted axial resultant of the collision. Using u_rel here
663 # over-predicted x* by ~2x on a measured design.
664 u_axial = spray_axial_velocity(
665 mdot_O=mdot_O, u_O=u_O, theta_O_deg=geometry.oxidizer.impingement_angle,
666 mdot_F=mdot_F, u_F=u_F, theta_F_deg=geometry.fuel.impingement_angle,
667 )
668 u_transport = u_axial if np.isfinite(u_axial) and u_axial > 0 else u_rel
669 _xs = [v for v in (xstar(u_transport, tau_evap_O), xstar(u_transport, tau_evap_F)) if np.isfinite(v)]
670 x_star = max(_xs) if _xs else float("nan") # NaN only when neither stream flows
671
672 # ---- Impinging-doublet geometry (standoff + ring pitch) -------------------------------
673 # Each stream's ``impingement_angle`` is the jet inclination from the chamber axis; the
674 # two jets of a doublet lean toward each other and collide on the bisector. The offset
675 # they close is RADIAL -- the gap between the two pitch circles --
676 # dr = |D_pitch_O - D_pitch_F| / 2, D_pitch = n·spacing/π
677 # L_imp = dr / (tan θ_O + tan θ_F)
678 # (purely geometric: each jet closes its half of the offset at rate tan θ). This block
679 # used to say ``s_pair`` -- the average RING DENSITY, 0.5*(s_O + s_F). That is
680 # dimensionally a length and physically the wrong one; see impingement_standoff_m,
681 # which has been computing dr correctly. ``s_pair`` survives below as a diagnostic
682 # only. Nothing reads it for the standoff.
683 theta_O = float(np.deg2rad(geometry.oxidizer.impingement_angle))
684 theta_F = float(np.deg2rad(geometry.fuel.impingement_angle))
685 s_O = float(getattr(geometry.oxidizer, "spacing", 0.0) or 0.0)
686 s_F = float(getattr(geometry.fuel, "spacing", 0.0) or 0.0)
687 s_pair = 0.5 * (s_O + s_F)
688 tan_sum = float(np.tan(theta_O) + np.tan(theta_F))
690 0.5 * (geometry.oxidizer.n_elements + geometry.fuel.n_elements),
691 s_O, s_F,
692 geometry.oxidizer.impingement_angle,
693 geometry.fuel.impingement_angle,
694 )
695 nO_geom = max(1, int(geometry.oxidizer.n_elements))
696 nF_geom = max(1, int(geometry.fuel.n_elements))
697 D_pitch_O = float(nO_geom * s_O / np.pi)
698 D_pitch_F = float(nF_geom * s_F / np.pi)
699 # Circumferential clearance between adjacent same-stream orifices (must stay positive).
700 gap_O = float(s_O - geometry.oxidizer.d_jet)
701 gap_F = float(s_F - geometry.fuel.d_jet)
702 # Axial length the spray needs before it is fully vaporized, measured from the face:
703 # standoff to impingement plus the droplet evaporation length x*.
704 # Sheet breakup length between impingement and droplet formation. Mass conservation
705 # in a radially spreading sheet gives h(r) = d^2/(4r); Kelvin-Helmholtz growth on a
706 # thin liquid sheet in gas gives t_b ~ (h/u)*sqrt(rho_l/rho_g), so L_b = u*t_b. At the
707 # Weber numbers here (3e4-6e4, far past the ~2e3 regime transition) this comes out
708 # small next to L_imp -- the sheet shreds almost immediately -- but it is the step
709 # between "jets meet" and "droplets exist", so the spray length is not complete
710 # without it.
711 _d_avg = 0.5 * (float(geometry.oxidizer.d_jet) + float(geometry.fuel.d_jet))
712 _u_sheet = max(float(u_rel), 1e-6)
713 _rho_l_avg = 0.5 * (float(rho_O) + float(rho_F))
714 # rho_gas carries a 1e-6 floor for solver robustness, and L_b ~ sqrt(rho_l/rho_g),
715 # so at that floor the sqrt is ~2.8e4 and L_b explodes -- during Pc bracketing that
716 # drove the spray length past the whole chamber, collapsed eta_Lstar, and made the
717 # chamber solve report "Supply < Demand at all Pc". Bound it to a physically sane
718 # multiple of the jet diameter: at these Weber numbers the sheet shreds in ~1 d_jet,
719 # and anything past ~20 is not a doublet sheet any more.
720 if np.isfinite(L_imp) and L_imp > 0 and _d_avg > 0 and rho_gas > 1e-4:
721 _h_sheet = (_d_avg ** 2) / (4.0 * L_imp)
722 L_b = float(_h_sheet * np.sqrt(_rho_l_avg / rho_gas))
723 L_b = float(np.clip(L_b, 0.0, 20.0 * _d_avg))
724 else:
725 L_b = 0.0
726 vaporization_length_total = float(
727 (L_imp if np.isfinite(L_imp) else 0.0) + L_b + x_star
728 )
729
730 constraints_ok, violations = check_spray_constraints(We_O, We_F, x_star, spray_cfg)
731
732 diagnostics.update(
733 {
734 "iterations": 1,
735 "constraints_satisfied": constraints_ok,
736 "violations": violations,
737 "J": J,
738 "TMR": TMR,
739 "theta": theta,
740 "u_rel": float(u_rel),
741 "rho_gas_breakup": float(rho_gas),
742 "Oh_O": float(Oh_O),
743 "Oh_F": float(Oh_F),
744 "We_O": We_O,
745 "We_F": We_F,
746 "D32_O": D32_O,
747 "D32_F": D32_F,
748 "x_star": x_star,
749 "u_axial_spray": float(u_transport),
750 "k_evap_O": float(k_evap_O),
751 "k_evap_F": float(k_evap_F),
752 "tau_evap_O": float(tau_evap_O),
753 "tau_evap_F": float(tau_evap_F),
754 "L_imp": L_imp,
755 "L_sheet_breakup": float(L_b),
756 "D_pitch_O": D_pitch_O,
757 "D_pitch_F": D_pitch_F,
758 "s_pair": float(s_pair),
759 "element_gap_O": gap_O,
760 "element_gap_F": gap_F,
761 "vaporization_length_total": vaporization_length_total,
762 # The spray-zone tau_res switch travels WITH the spray diagnostics.
763 # calculate_combustion_efficiency_advanced() only receives a
764 # CombustionEfficiencyConfig, so it used to read this off that object --
765 # where the field does not exist -- and a bare except swallowed the
766 # mismatch, making the switch permanently False and unreachable.
767 "apply_tau_res_correction": bool(
768 getattr(spray_cfg.evaporation, "apply_tau_res_correction", False)
769 ),
770 "impingement_angle_deg": np.rad2deg(imp_angle_rad),
771 "V_rel": float(u_rel),
772 "breakup_multiplier": 1.0,
773 "penetration_multiplier": 1.0,
774 **turb_fields,
775 # Discharge coefficients
776 "Cd_O": float(Cd_O),
777 "Cd_F": float(Cd_F),
778 }
779 )
780
781 # Diagnostics must match last feed–orifice coupling (recomputing from ṁ duplicates float path and drifted Cd).
782 delta_p_feed_O_final = float(delta_p_feed_O)
783 delta_p_feed_F_final = float(delta_p_feed_F)
784 P_inj_O_final = float(P_inj_O)
785 P_inj_F_final = float(P_inj_F)
786 delta_p_inj_O_final = float(delta_p_inj_O)
787 delta_p_inj_F_final = float(delta_p_inj_F)
788
789 mdot_from_bernoulli_O = (
790 float(Cd_O * A_O * np.sqrt(2.0 * rho_O * delta_p_inj_O_final))
791 if delta_p_inj_O_final > 0
792 else 0.0
793 )
794 mdot_from_bernoulli_F = (
795 float(Cd_F * A_F * np.sqrt(2.0 * rho_F * delta_p_inj_F_final))
796 if delta_p_inj_F_final > 0
797 else 0.0
798 )
799
800 u_O_final = mdot_O / (rho_O * A_O) if A_O > 0 else 0.0
801 u_F_final = mdot_F / (rho_F * A_F) if A_F > 0 else 0.0
802 # Impingement relative velocity (vector difference of the two jet velocities separated by
803 # the included angle); reported as V_rel for the chamber/combustion models.
804 u_rel_final = float(
805 np.sqrt(
806 u_O_final ** 2
807 + u_F_final ** 2
808 - 2 * u_O_final * u_F_final * np.cos(imp_angle_rad)
809 )
810 )
811 turb_final = _injector_turbulence_fields(u_O_final, u_F_final)
812
813 # Momentum metrics on v = mdot / (rho * n_elements * A_jet), A_jet = pi*(d_jet/2)^2:
814 # R, the Cd-dp split, and Rupe's M, the mixing criterion.
815 n_O = max(1, int(geometry.oxidizer.n_elements))
816 n_F = max(1, int(geometry.fuel.n_elements))
817 djo = float(geometry.oxidizer.d_jet)
818 djf = float(geometry.fuel.d_jet)
819 A_jet_O = np.pi * (djo / 2.0) ** 2
820 A_jet_F = np.pi * (djf / 2.0) ** 2
821 denom_O = rho_O * float(n_O) * A_jet_O
822 denom_F = rho_F * float(n_F) * A_jet_F
823 v_O_bulk = mdot_O / denom_O if denom_O > 0 else np.nan
824 v_F_bulk = mdot_F / denom_F if denom_F > 0 else np.nan
825 momentum_ratio_R = momentum_ratio_R_from_bulk_velocities(
826 float(rho_O), float(rho_F), float(v_O_bulk), float(v_F_bulk)
827 )
828 rupe_M = rupe_mixing_ratio(
829 float(rho_O), float(v_O_bulk), djo, float(rho_F), float(v_F_bulk), djf
830 )
831
832 mom_update: Dict[str, Any] = {
833 "A_jet_O": float(A_jet_O),
834 "A_jet_F": float(A_jet_F),
835 "momentum_ratio_n_elements_O": int(n_O),
836 "momentum_ratio_n_elements_F": int(n_F),
837 "d_jet_O": float(djo),
838 "d_jet_F": float(djf),
839 }
840 if np.isfinite(v_O_bulk):
841 mom_update["v_O_bulk"] = float(v_O_bulk)
842 if np.isfinite(v_F_bulk):
843 mom_update["v_F_bulk"] = float(v_F_bulk)
844 if rho_O > 0:
845 mom_update["rho_O_momentum"] = float(rho_O)
846 if rho_F > 0:
847 mom_update["rho_F_momentum"] = float(rho_F)
848 if np.isfinite(momentum_ratio_R) and momentum_ratio_R > 0:
849 mom_update["momentum_ratio_R"] = momentum_ratio_R
850 if np.isfinite(rupe_M) and rupe_M > 0:
851 mom_update["rupe_M"] = rupe_M
852
853 diagnostics.update(
854 {
855 # Required by chamber_solver / combustion efficiency (parity with pintle)
856 "u_O": float(u_O_final),
857 "u_F": float(u_F_final),
858 "V_rel": u_rel_final,
859 **turb_final,
860 "P_injector_O": float(P_inj_O_final),
861 "P_injector_F": float(P_inj_F_final),
862 "delta_p_injector_O": float(delta_p_inj_O_final),
863 "delta_p_injector_F": float(delta_p_inj_F_final),
864 "delta_p_feed_O": float(delta_p_feed_O_final),
865 "delta_p_feed_F": float(delta_p_feed_F_final),
866 "feed_orifice_coupling_iterations": int(feed_orifice_fp_last),
867 **_manifold_diagnostics(net_O, net_F, net_detail, mdot_O, mdot_F),
868 "mdot_from_bernoulli_O": float(mdot_from_bernoulli_O),
869 "mdot_from_bernoulli_F": float(mdot_from_bernoulli_F),
870 **mom_update,
871 }
872 )
873
874 # Geometric vs effective flow areas (A_eff = Cd × A_geom); reuse converged Cd_O/Cd_F
875 A_eff_O, A_eff_F, eff_warns = effective_flow_areas_from_cd(diagnostics, A_O, A_F)
876 diagnostics["A_geom_O"] = float(A_O)
877 diagnostics["A_geom_F"] = float(A_F)
878 diagnostics["A_eff_O"] = float(A_eff_O)
879 diagnostics["A_eff_F"] = float(A_eff_F)
880 if eff_warns:
881 merge_effective_area_warnings(diagnostics, eff_warns)
882
883 return mdot_O, mdot_F, diagnostics
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Tuple[float, float, Dict[str, Any]] solve(self, float P_tank_O, float P_tank_F, float Pc)
Definition impinging.py:383
__init__(self, PintleEngineConfig engine_config)
Definition impinging.py:371
__init__(self, *, n_holes, n_ports, A_ch, D_h, r_ring, A_hole, d_hole, rho, mu, K_ent, C_R, roughness)
Definition impinging.py:162
march(self, m_total, P_port, Pc, cd_of)
Definition impinging.py:178
static ActuatorControllerState state
Definition main.cpp:57
float injector_face_pressure(float Pc, float gamma, float contraction_ratio)
Definition impinging.py:344
_stream_flow(Pc, P_tank, rho, A, d_hyd, mu, discharge, T_in, cd_cap, feed_dp, *, cav=None, network=None, detail=None)
Definition impinging.py:54
float impingement_standoff_m(float n_elements, float spacing_O_m, float spacing_F_m, float angle_O_deg, float angle_F_deg)
Definition impinging.py:262
float _churchill_f(float Re, float rel_rough)
Definition impinging.py:131
_manifold_diagnostics(net_O, net_F, detail, mdot_O, mdot_F)
Definition impinging.py:230
float momentum_ratio_R_from_bulk_velocities(float rho_O, float rho_F, float v_O_bulk, float v_F_bulk)
Definition impinging.py:306
float rupe_mixing_ratio(float rho_O, float v_O, float d_O, float rho_F, float v_F, float d_F)
Definition impinging.py:331
_ring_manifold_for(config, side, rho, mu, n_holes, d_hole)
Definition impinging.py:206