1"""Spray and mixing models (J, We, SMD, x*; TMR and theta are diagnostic only)"""
3from __future__
import annotations
7from typing
import Optional
8from typing
import Tuple, List
14 Calculate momentum flux ratio J.
16 J = (ρ_O × u_O²) / (ρ_F × u_F²)
21 Oxidizer density [kg/m³]
23 Oxidizer velocity [m/s]
34 return np.inf
if u_O > 0
else 0.0
36 J = (rho_O * u_O**2) / (rho_F * u_F**2)
41 """TMR = J/(1 + MR). DIAGNOSTIC ONLY.
43 Not a published parameter: the pintle literature's TMR is the radial-to-axial momentum ratio
44 (mdot_r v_r)/(mdot_a v_a), which this is not. Nothing in the model reads it; it is reported
45 for continuity with old runs.
47 TMR = J / (1 + MR)
if MR > 0
else J
53 Calculate spray angle from momentum flux ratio J.
55 Uses half-angle tangent form: tan(θ/2) = k × J^n
59 Imagine the spray as a cone coming out of the injector:
61 1. **Momentum Vector Decomposition**:
62 - The fuel (RP-1) flows RADIALLY outward (perpendicular to axis)
63 - The oxidizer (LOX) flows AXIALLY (along the axis, with some angle)
64 - When they collide, the combined spray has both radial and axial momentum
66 2. **Why Half-Angle?**:
67 - The spray is SYMMETRIC about the axis
68 - We measure angles FROM THE AXIS, not from the opposite edge
69 - A particle at the edge of the spray travels at angle θ/2 from the axis
70 - Its momentum vector makes angle θ/2 with the axis (not θ!)
71 - When we decompose momentum: p_radial = p × sin(θ/2), p_axial = p × cos(θ/2)
72 - Full angle θ = 2×(θ/2) is just a convention - we use θ/2 in calculations
75 - In a conical spray, momentum vectors decompose as:
76 * Radial component: p_radial = p_total × sin(θ/2)
77 * Axial component: p_axial = p_total × cos(θ/2)
78 - The RATIO of radial to axial momentum is:
79 p_radial / p_axial = sin(θ/2) / cos(θ/2) = tan(θ/2)
80 - This ratio determines how "spread out" the spray is
82 4. **Connection to J**:
83 - J = (ρ_O u_O²) / (ρ_F u_F²) measures relative momentum flux
84 - High J → more oxidizer momentum → spray more axial (smaller angle)
85 - Low J → more fuel momentum → spray more radial (larger angle)
86 - The spray angle emerges from balancing these momentum components
88 5. **Why Not Direct θ = k×J^n?**:
89 - Direct form treats angle as arbitrary scalar
90 - But spray angle comes from VECTOR balance of momenta
91 - tan(θ/2) captures this vector relationship naturally
92 - Also provides natural bounds: tan(θ/2) → ∞ as θ → 90°
112 tan_half_theta = k * (J ** n)
113 theta = 2 * np.arctan(tan_half_theta)
117 return float(np.clip(theta, 0, np.pi / 2))
121 """theta = arccos(1/(1 + TMR^0.75)) [rad]. DIAGNOSTIC ONLY.
123 An invented law on the invented TMR above; no source gives it, and no model reads the angle
124 (the injectors publish it as ``theta``). The vaporization march takes its axial drop speed
125 from ``spray_axial_velocity``.
129 cos_theta = np.clip(1 / (1 + TMR**0.75), -1, 1)
130 return float(np.arccos(cos_theta))
133def weber_number(rho: float, u: float, d_char: float, sigma: float) -> float:
135 Calculate Weber number.
137 We = (ρ × u² × d_char) / σ
146 Characteristic diameter [m] (port diameter or annulus hydraulic diameter)
148 Surface tension [N/m]
157 We = (rho * u**2 * d_char) / sigma
163 Calculate Ohnesorge number.
165 Oh = μ / √(ρ × σ × d_or)
170 Dynamic viscosity [Pa·s]
174 Surface tension [N/m]
182 if rho <= 0
or sigma <= 0
or d_or <= 0:
186 sqrt_arg = rho * sigma * d_or
187 Oh = mu / np.sqrt(
max(sqrt_arg, 1e-12))
if sqrt_arg > 0
else 0.0
200 Calculate Sauter Mean Diameter (SMD) using Lefebvre correlation.
202 D32 = C × d_or × We^(-m) × (1 + Oh)^p
204 Raises ValueError when We or d_or is not positive and finite (it used to return d_or).
225 if not (np.isfinite(We)
and We > 0
and np.isfinite(d_or)
and d_or > 0):
226 raise ValueError(f
"smd_lefebvre: We={We} and d_or={d_or} must be positive and finite")
232 D32 = C * d_or * (We ** (-m)) * ((1.0 + Oh) ** p)
238TN4222_RHO_AIR = 29.3 * 3386.389 / (287.05 * ((82.0 - 32.0) * 5.0 / 9.0 + 273.15))
239TN4222_HEPTANE_SIGMA = 0.0197
240TN4222_HEPTANE_MU = 3.8e-4
241TN4222_HEPTANE_RHO = 684.0
244TN4222_D32_OVER_D30 = 5.0 / 3.915
248TN4222_PROPERTY_TRANSFERS = (
"tn4087",
"dombrowski_johns",
"none")
252 rho_gas: float) -> float:
253 """D32(liquid, gas) / D32(n-heptane, air at TN 4222's conditions), same jets and speeds.
255 - "tn4087": Ingebo's crossflow exponents (NACA TN 4087: D30 ~ (We Re)^(-1/4)), so
256 (sigma mu_l / rho_l)^(1/4) rho_g^(-1/4).
257 - "dombrowski_johns": the inviscid sheet-breakup length scale of Dombrowski & Johns (Chem. Eng.
258 Sci. 18, 1963), D ~ (sigma^2 / (rho_g rho_l U^4))^(1/6) at fixed sheet geometry and speed, so
259 (sigma^2 / (rho_g rho_l))^(1/6) relative to heptane/air.
260 - "none": 1 (heptane in air as measured).
261 The spread between them is the uncertainty of carrying a single-liquid correlation to
262 LOX/ethanol at chamber density; a measured D32 (smd_scale) replaces it.
264 if transfer
not in TN4222_PROPERTY_TRANSFERS:
265 raise ValueError(f
"TN 4222 property transfer {transfer!r} is not one of {TN4222_PROPERTY_TRANSFERS}")
266 if transfer ==
"none":
268 for name, v
in ((
"rho_liq", rho_liq), (
"mu_liq", mu_liq), (
"sigma", sigma), (
"rho_gas", rho_gas)):
269 if not (np.isfinite(v)
and v > 0):
270 raise ValueError(f
"tn4222_property_factor: {name}={v} must be positive and finite")
271 if transfer ==
"tn4087":
272 prop = (sigma * mu_liq / rho_liq) / (TN4222_HEPTANE_SIGMA * TN4222_HEPTANE_MU / TN4222_HEPTANE_RHO)
273 return float((rho_gas / TN4222_RHO_AIR) ** -0.25 * prop ** 0.25)
274 ref = TN4222_HEPTANE_SIGMA ** 2 / (TN4222_RHO_AIR * TN4222_HEPTANE_RHO)
275 return float((sigma ** 2 / (rho_gas * rho_liq) / ref) ** (1.0 / 6.0))
279 """The property transfer a spray.smd config asks for: 'none' when smd_property_scaling is off,
280 else smd_property_transfer (default 'tn4087')."""
281 if not bool(getattr(smd_cfg,
"smd_property_scaling",
True)):
283 return str(getattr(smd_cfg,
"smd_property_transfer",
"tn4087")
or "tn4087")
294 dv: Optional[float] =
None,
295 scale_properties: bool =
True,
296 transfer: Optional[str] =
None,
298 """Sauter mean diameter of an impinging-jet pair, Ingebo, NACA TN 4222 (1958).
300 Dj / D30 = 2.64 (Dj Vj)^(1/2) + 0.97 Dj dV (Dj, D30 in inches; Vj, dV in ft/s)
301 D32 = (5 / 3.915) D30 (Nukiyama-Tanasawa, as fitted)
303 ``v_jet`` is the stream's own jet velocity and ``dv`` the jet-to-gas velocity difference; a
304 face-injected jet enters gas at rest, dV = Vj (TN 4222 eq. 11), the default.
306 TN 4222 measured n-heptane only. ``transfer`` (see ``tn4222_property_factor``) carries it to
307 other liquids and gas densities; None means "tn4087" when ``scale_properties`` else "none".
308 That transfer is an assumption: measure D32 and set ``spray.smd.smd_scale``.
310 Raises ValueError on a non-positive or non-finite input (it used to return d_jet, a drop the
311 size of the orifice, without saying so).
313 Replaces an earlier function that cited this report but ran TN 4087's crossflow form with the
314 We*Re product on the jets' closing speed, and read its D30 as D32: about half the drop size
317 for name, v
in ((
"d_jet", d_jet), (
"v_jet", v_jet), (
"rho_liq", rho_liq), (
"mu_liq", mu_liq),
318 (
"sigma", sigma), (
"rho_gas", rho_gas)):
319 if not (np.isfinite(v)
and v > 0):
320 raise ValueError(f
"smd_impinging_tn4222: {name}={v} must be positive and finite")
322 transfer =
"tn4087" if scale_properties
else "none"
323 dj_in = d_jet / 0.0254
325 dV = (v_jet
if dv
is None else abs(float(dv))) / 0.3048
326 d30 = dj_in / (2.64 * math.sqrt(dj_in * vj) + 0.97 * dj_in * dV) * 0.0254
327 d32 = TN4222_D32_OVER_D30 * d30
329 sigma=sigma, rho_gas=rho_gas))
340 cp_g: float = 2200.0,
342 C_evap: float = 1.562,
344 """d²-law evaporation constant k_evap [m²/s], derived from propellant properties.
346 Replaces a single hardcoded ``K`` that was identical in all 14 configs and for every
347 propellant. That constant's VALUE was defensible for ethanol at ~3100 K (it implies
348 k_evap = 3.33e-6 m²/s, within 10% of a T^2 extrapolation of measured ethanol data), but its
349 SHAPE was wrong: it could not distinguish ethanol from methane from RP-1, and it did not
350 move with chamber temperature or pressure at all -- so a 200 psi design and a 600 psi design
351 evaporated at identical rates.
353 k_evap = C * (8 * rho_g * D_v / rho_l) * ln(1 + B_M)
354 B_M = cp_g * (Tc - T_boil) / L_vap (Spalding mass transfer number)
355 D_v = D0 * (Tc/300)^1.75 * (101325/Pc) (vapor diffusivity)
357 Every input except C and D0 already exists in the propellant presets, so this needs no new
358 tables. ``C_evap`` is the one calibration constant, anchored so ethanol at 3094 K / 450 psi
359 reproduces the 3.33e-6 m²/s the old fixed K implied -- i.e. this is a re-shaping of a value
360 that was already right for the propellant it was tuned on, not a change of magnitude.
362 The same form is already written (and disabled) in combustion_physics.py under
363 ``SPALDING_DIAGNOSTIC_ENABLED``, where it also hardcodes ``fuel="RP-1"``.
365 if not all(np.isfinite(v)
for v
in (Tc, Pc, rho_g, rho_l, L_vap, T_boil)):
367 if Tc <= 0
or Pc <= 0
or rho_g <= 0
or rho_l <= 0
or L_vap <= 0:
369 D_v = float(D0) * (float(Tc) / 300.0) ** 1.75 * (101325.0 / float(Pc))
370 B_M = float(cp_g) *
max(0.0, float(Tc) - float(T_boil)) / float(L_vap)
371 k = float(C_evap) * (8.0 * float(rho_g) * D_v / float(rho_l)) * float(np.log1p(B_M))
372 return float(k)
if (np.isfinite(k)
and k > 0)
else float(
"nan")
376 """Standard d²-law evaporation time: τ = D32² / k_evap. Note k is in m²/s, NOT its reciprocal."""
377 if not (np.isfinite(D32)
and np.isfinite(k_evap_m2_s))
or k_evap_m2_s <= 0
or D32 <= 0:
379 return float((D32 ** 2) / k_evap_m2_s)
384 mdot_O: float, u_O: float, theta_O_deg: float,
385 mdot_F: float, u_F: float, theta_F_deg: float,
387 """Axial velocity of the combined spray after the doublet collides [m/s].
389 Momentum balance on the pair: the two jets arrive canted at theta_O / theta_F from the
390 chamber axis, and what survives downstream is the momentum-weighted axial component
392 u_axial = (mdot_O*u_O*cos(theta_O) + mdot_F*u_F*cos(theta_F)) / (mdot_O + mdot_F)
394 This is the velocity that TRANSPORTS droplets down the chamber, and therefore the one that
395 belongs in x* = u * tau_evap. The code previously used ``u_rel``, the jet-to-jet RELATIVE
396 velocity -- correct for atomisation (it is what shears the sheet, and it belongs in the
397 Ingebo Weber number) but wrong for transport: droplets do not travel downstream at the
398 closing speed of two jets. Measured on a converged design, u_rel was 28.4 m/s against a true
399 axial 13.8 m/s, so x* -- and hence the required chamber length -- was over-predicted 2.06x.
401 mt = float(mdot_O) + float(mdot_F)
402 if not np.isfinite(mt)
or mt <= 0:
404 ax = (float(mdot_O) * float(u_O) * np.cos(np.deg2rad(float(theta_O_deg)))
405 + float(mdot_F) * float(u_F) * np.cos(np.deg2rad(float(theta_F_deg))))
407 return float(u)
if (np.isfinite(u)
and u > 0)
else float(
"nan")
412 Calculate evaporation time.
419 Sauter Mean Diameter [m]
421 Evaporation constant [s/m²]
431def xstar(U_rel: float, tau_evap: float) -> float:
433 Calculate evaporation length x*.
440 Relative velocity [m/s]
448 x_star = U_rel * tau_evap
457) -> Tuple[bool, List[str]]:
459 Check if spray constraints are satisfied.
468 Oxidizer Weber number
472 Evaporation length [m]
478 constraints_satisfied : bool
479 violations : list[str]
480 List of violated constraint names
485 We_min = config.weber.get(
"We_min", 15.0)
487 violations.append(f
"We_O < We_min ({We_O:.2f} < {We_min:.2f})")
489 violations.append(f
"We_F < We_min ({We_F:.2f} < {We_min:.2f})")
492 if config.evaporation.use_constraint:
493 if x_star >= config.evaporation.x_star_limit:
495 f
"x* >= x_limit ({x_star:.4f} >= {config.evaporation.x_star_limit:.4f} m)"
499 constraints_satisfied = len(violations) == 0
500 return constraints_satisfied, violations
515 Calculate Sauter Mean Diameter (SMD) for pintle injector using relative velocity physics.
517 Formula: SMD = C * L_open * We_rel^(-n) * (1 + B * Oh_f)^p
522 Characteristic length (Pintle Opening / Gap Height) [m]
524 Relative velocity magnitude between streams [m/s]
526 Fuel (sheet) density [kg/m³]
528 Fuel (sheet) dynamic viscosity [Pa·s]
530 Fuel surface tension [N/m]
532 Correlation constants
539 raise ValueError(f
"smd_pintle: L_open must be positive, got {L_open}")
541 raise ValueError(f
"smd_pintle: rho_f must be positive, got {rho_f}")
543 raise ValueError(f
"smd_pintle: sigma_f must be positive, got {sigma_f}")
545 raise ValueError(f
"smd_pintle: mu_f must be non-negative, got {mu_f}")
548 raise ValueError(f
"smd_pintle: C must be positive, got {C}")
550 raise ValueError(f
"smd_pintle: n must be positive, got {n}")
552 raise ValueError(f
"smd_pintle: B must be non-negative, got {B}")
554 raise ValueError(f
"smd_pintle: p must be non-negative, got {p}")
557 raise ValueError(f
"smd_pintle: V_rel must be non-negative, got {V_rel}")
561 warnings.warn(
"smd_pintle: V_rel is 0.0, We=0. SMD calculation relying on Ohnesorge term only.")
565 We_rel = (rho_f * (V_rel ** 2) * L_open) / sigma_f
569 denom = np.sqrt(rho_f * sigma_f * L_open)
570 Oh_f = mu_f / denom
if denom > 0
else 0.0
574 factor_we = (We_rel ** (-n))
if We_rel > 0
else 1.0
575 factor_oh = (1.0 + B * Oh_f) ** p
577 D32 = C * L_open * factor_we * factor_oh
float evaporation_constant_m2_s(*, float Tc, float Pc, float rho_g, float rho_l, float L_vap, float T_boil, float cp_g=2200.0, float D0=2.0e-5, float C_evap=1.562)
float ohnesorge_number(float mu, float rho, float sigma, float d_or)
float tau_evap(float D32, float K)
float spray_axial_velocity(*, float mdot_O, float u_O, float theta_O_deg, float mdot_F, float u_F, float theta_F_deg)
float smd_lefebvre(float d_or, float We, float Oh, float C, float m, float p)
float spray_angle_from_J(float J, float k, float n)
float thrust_momentum_ratio(float J, float MR)
float smd_pintle(float L_open, float V_rel, float rho_f, float mu_f, float sigma_f, float C, float B, float n, float p)
float smd_impinging_tn4222(float d_jet, float v_jet, float rho_liq, float mu_liq, float sigma, float rho_gas, *, Optional[float] dv=None, bool scale_properties=True, Optional[str] transfer=None)
float spray_angle_from_TMR(float TMR)
float tau_evap_from_k(float D32, float k_evap_m2_s)
float weber_number(float rho, float u, float d_char, float sigma)
float tn4222_property_factor(str transfer, *, float rho_liq, float mu_liq, float sigma, float rho_gas)
str tn4222_transfer_model(smd_cfg)
float momentum_flux_ratio(float rho_O, float u_O, float rho_F, float u_F)
Tuple[bool, List[str]] check_spray_constraints(float We_O, float We_F, float x_star, SprayConfig config)
float xstar(float U_rel, float tau_evap)