230 T_s: Optional[float] =
None,
231 fuel_props: Optional[Dict] =
None,
232 use_reference_pressure: bool =
True,
235 Calculate Spalding number using pressure-based formulation.
237 B_m = Y_s / (1 - Y_s), where Y_s = P_sat(T_s) / Pc
239 This formulation directly uses vapor pressure at the droplet surface.
240 Can use a reference pressure for stability during solver iterations.
245 Chamber/gas temperature [K]
247 Chamber pressure [Pa]
248 T_s : float, optional
249 Droplet surface temperature [K]. If None, calculated internally.
250 fuel_props : dict, optional
251 Fuel properties (see calculate_spalding_number)
252 use_reference_pressure : bool
253 If True, use Pc_ref instead of Pc for stability. Default True.
258 Spalding mass transfer number [-]
260 if not np.isfinite(Tc)
or Tc <= 0:
261 raise ValueError(f
"Invalid gas temperature: {Tc} K")
262 if not np.isfinite(Pc)
or Pc <= 0:
263 raise ValueError(f
"Invalid pressure: {Pc} Pa")
266 if fuel_props
is not None:
267 fuel = fuel_props.get(
"fuel", fuel_props.get(
"name",
"RP-1"))
268 Pc_ref = fuel_props.get(
"Pc_ref", 2.5e6)
281 P_denom = Pc_ref
if use_reference_pressure
else Pc
282 P_denom =
max(P_denom, 1e3)
285 Y_s = P_sat / P_denom
286 Y_s = np.clip(Y_s, 0.0, 0.95)
289 B = Y_s / (1.0 - Y_s)
292 B = np.clip(B, 0.05, 1.0)
381 gamma: Optional[float] =
None,
382 R_gas: Optional[float] =
None,
384 Y_F_inf: float = 0.0,
385 cp_liquid: Optional[float] =
None,
386 T_liquid_in: Optional[float] =
None,
387 T_s_init: Optional[float] =
None,
388 T_min: Optional[float] =
None,
389 T_max: Optional[float] =
None,
393 MR: Optional[float] =
None,
394 eps: Optional[float] =
None,
395 cea_props: Optional[callable] =
None,
396) -> Dict[str, float]:
398 Coupled solver for Spalding Mass Number (B_M) and Droplet Surface Temperature (T_s).
400 Solves a two-equation coupled system by fixed-point iteration with under-relaxation:
401 1. B_M = (Y_{F,s} - Y_{F,inf}) / (1 - Y_{F,s})
402 2. T_s = T_inf - (L_eff / c_p,g) * ln(1 + B_M)
404 Uses vapor pressure to compute surface mass fraction Y_{F,s} via mole-to-mass
407 **Two modes of operation:**
409 1. **Fixed-property mode** (legacy): Provide `gamma` and `R_gas` directly.
410 2. **CEA accessor mode**: Provide `cea_props` callable with `MR` and `eps`.
411 The callable has signature `cea_props(Pc, MR, eps) -> (gamma, R)`.
416 Far-field gas temperature [K] (e.g., chamber temperature T_c)
418 Gas pressure [Pa] (e.g., chamber pressure P_c)
420 Molecular weight of fuel vapor [kg/kmol] (numerically = g/mol)
422 Latent heat of vaporization [J/kg]
423 gamma : float, optional
424 Specific heat ratio of the gas mixture [-]. Required if cea_props is None.
425 R_gas : float, optional
426 Mixture-specific gas constant [J/(kg·K)]. Required if cea_props is None.
428 Fuel name for vapor pressure lookup (default: "RP-1")
429 Y_F_inf : float, optional
430 Far-field fuel vapor mass fraction [-] (default: 0.0)
431 cp_liquid : float, optional
432 Liquid fuel specific heat [J/(kg·K)]. If provided with T_liquid_in,
433 used to compute effective latent heat.
434 T_liquid_in : float, optional
435 Incoming liquid fuel temperature [K]. If provided with cp_liquid,
436 used to compute effective latent heat.
437 T_s_init : float, optional
438 Initial guess for surface temperature [K]. Default: 0.9 * T_inf
439 T_min : float, optional
440 Minimum allowed surface temperature [K]. Default: 200 K
441 T_max : float, optional
442 Maximum allowed surface temperature [K]. Default: T_inf
443 alpha : float, optional
444 Under-relaxation factor (0 < alpha <= 1). Default: 0.3
445 tol : float, optional
446 Convergence tolerance for T_s [K]. Default: 0.1 K
447 max_iter : int, optional
448 Maximum iterations. Default: 100
450 Mixture ratio (O/F) for CEA accessor mode. Required if cea_props is not None.
451 eps : float, optional
452 Expansion ratio for CEA accessor mode. Required if cea_props is not None.
453 cea_props : callable, optional
454 CEA property accessor with signature `cea_props(Pc, MR, eps) -> (gamma, R)`.
455 If provided, gamma and R_gas must be None.
460 Dictionary containing:
461 - "B_M": Spalding mass transfer number [-]
462 - "T_s": Droplet surface temperature [K]
463 - "Y_F_s": Surface fuel mass fraction [-]
464 - "X_F_s": Surface fuel mole fraction [-]
465 - "L_eff": Effective latent heat [J/kg]
466 - "c_p_g": Gas heat capacity [J/(kg·K)]
467 - "W_g": Gas mixture molecular weight [kg/kmol]
468 - "T_film": Film temperature (diagnostic) [K]
469 - "delta_T": Temperature difference T_inf - T_s [K]
470 - "gamma_used": Specific heat ratio used [-]
471 - "R_gas_used": Gas constant used [J/(kg·K)]
472 - "use_cea_props": Whether CEA accessor mode was used [bool]
473 - "iterations": Number of iterations [-]
474 - "converged": Whether solver converged [bool]
478 - Uses CoolProp for vapor pressure via calculate_vapor_pressure()
479 - The relation ln(1+B_M) = c_p,g * (T_inf - T_s) / L_eff is an approximation
480 valid for Le ≈ 1 and quasi-steady boundary layer.
481 - T_s converges to a wet-bulb-like equilibrium, not necessarily T_boil(P).
482 - If p_vap(T_s) > P, a warning is issued but computation continues.
483 - T_film is diagnostic only; no film-temperature interpolation of γ(T) or R(T)
484 is performed since the CEA table lacks a temperature axis.
489 If input parameters are invalid or non-physical.
492 if not np.isfinite(T_inf)
or T_inf <= 0:
493 raise ValueError(f
"Invalid far-field temperature: T_inf = {T_inf} K")
494 if not np.isfinite(P)
or P <= 0:
495 raise ValueError(f
"Invalid pressure: P = {P} Pa")
496 if not np.isfinite(W_F)
or W_F <= 0:
497 raise ValueError(f
"Invalid fuel molecular weight: W_F = {W_F} kg/kmol")
498 if not np.isfinite(L_vap)
or L_vap <= 0:
499 raise ValueError(f
"Invalid latent heat: L_vap = {L_vap} J/kg")
500 if not 0 <= Y_F_inf < 1:
501 raise ValueError(f
"Invalid far-field mass fraction: Y_F_inf = {Y_F_inf}")
502 if not 0 < alpha <= 1:
503 raise ValueError(f
"Invalid relaxation factor: alpha = {alpha}")
506 if cea_props
is not None:
508 if gamma
is not None or R_gas
is not None:
510 "When using cea_props, gamma and R_gas must be None. "
511 f
"Got gamma={gamma}, R_gas={R_gas}"
513 if MR
is None or eps
is None:
515 "CEA accessor mode requires MR and eps to be provided. "
516 f
"Got MR={MR}, eps={eps}"
519 gamma, R_gas = cea_props(P, MR, eps)
523 if gamma
is None or R_gas
is None:
525 "Either provide (gamma, R_gas) directly, or provide "
526 "(cea_props, MR, eps) for CEA accessor mode. "
527 f
"Got gamma={gamma}, R_gas={R_gas}, cea_props={cea_props}"
529 use_cea_props =
False
532 if not np.isfinite(gamma)
or gamma <= 1:
533 raise ValueError(f
"Invalid specific heat ratio: gamma = {gamma}")
534 if not np.isfinite(R_gas)
or R_gas <= 0:
535 raise ValueError(f
"Invalid gas constant: R_gas = {R_gas} J/(kg·K)")
538 gamma_used = float(gamma)
539 R_gas_used = float(R_gas)
543 c_p_g = (gamma / (gamma - 1.0)) * R_gas
546 W_g = R_UNIVERSAL / R_gas
554 "RP-1":
"n-Dodecane",
"RP1":
"n-Dodecane",
"rp-1":
"n-Dodecane",
"rp1":
"n-Dodecane",
555 "Ethanol":
"Ethanol",
"ethanol":
"Ethanol",
557 coolprop_fluid = FUEL_SURROGATES.get(fuel, fuel)
558 T_crit = PropsSI(
"Tcrit", coolprop_fluid)
560 T_crit_limit = T_crit - T_crit_margin
563 if T_inf >= T_crit_limit:
565 f
"[SPALDING_REGIME] T_inf={T_inf:.1f} K >= T_crit_limit={T_crit_limit:.1f} K "
566 f
"for {coolprop_fluid}. Model validity is limited (supercritical regime).",
573 T_max =
min(T_inf, T_crit_limit)
575 T_max =
min(T_max, T_crit_limit)
583 f
"[SPALDING_DOMAIN] No valid T_s domain: T_min={T_min:.1f} K >= T_max={T_max:.1f} K. "
584 f
"T_crit_limit={T_crit_limit:.1f} K. Check fuel surrogate or input temperatures."
588 if T_s_init
is not None:
589 T_s =
min(T_s_init, T_max)
592 T_s =
min(0.9 * T_inf, T_max)
595 T_s = np.clip(T_s, T_min, T_max)
598 use_sensible_heating = (cp_liquid
is not None and T_liquid_in
is not None)
612 warned_pv_stall =
False
613 T_s_clipped_count = 0
614 X_F_s_clipped_count = 0
616 for iteration
in range(max_iter):
620 except Exception
as e:
621 raise ValueError(f
"Vapor pressure calculation failed at T_s = {T_s:.1f} K: {e}")
from e
626 if p_s_prev
is not None:
627 delta_pv_rel = abs(p_s - p_s_prev) /
max(p_s, 1.0)
628 if delta_pv_rel < 1e-6
and iteration > 1
and not warned_pv_stall:
630 T_s_change = abs(T_s - T_s_clipped_count)
633 f
"[SPALDING_DIAG] p_vap stalled: p_s={p_s:.0f} Pa unchanged for T_s≈{T_s:.1f} K. "
634 "Check if T_s is near T_crit or if fuel surrogate data is wrong.",
638 warned_pv_stall =
True
643 f
"Vapor pressure p_vap({T_s:.1f} K) = {p_s:.0f} Pa > P = {P:.0f} Pa. "
644 "Model is outside intended regime (possible boiling).",
651 X_F_s = np.clip(X_F_s_raw, 0.0, 0.999999)
652 X_F_s_clipped = (X_F_s != X_F_s_raw)
656 numerator = X_F_s * W_F
657 denominator = X_F_s * W_F + (1.0 - X_F_s) * W_g
658 Y_F_s = numerator / denominator
662 den =
max(1.0 - Y_F_s, 1e-12)
665 f
"Y_F_s = {Y_F_s:.6f} ≈ 1, denominator floored to 1e-12.",
669 B_M = (Y_F_s - Y_F_inf) / den
676 f
"B_M = {B_M:.4f} is dangerously close to -1. "
677 f
"Clamping to {B_M_FLOOR} to prevent log spike.",
687 f
"B_M = {B_M:.4f} <= -1 (condensation regime). "
688 "ln(1 + B_M) is undefined. Check inputs or model assumptions."
692 if use_sensible_heating:
694 L_eff_raw = L_vap + cp_liquid * (T_s - T_liquid_in)
699 L_eff =
max(L_eff_raw, 0.1 * L_vap)
702 f
"L_eff_raw = {L_eff_raw:.0f} J/kg <= 0 (bad cp_liquid/T_liquid_in?). "
703 f
"Floored to {L_eff:.0f} J/kg.",
711 log_term = np.log1p(B_M)
712 T_s_unrelaxed = T_inf - (L_eff / c_p_g) * log_term
715 T_film = 0.5 * (T_inf + T_s)
718 T_s_new = (1.0 - alpha) * T_s + alpha * T_s_unrelaxed
724 T_s_pre_clip = T_s_new
725 T_s_new = np.clip(T_s_new, T_min, T_max)
726 T_s_clipped = (T_s_new != T_s_pre_clip)
729 if T_s_clipped
or X_F_s_clipped:
735 if iteration % DIAG_INTERVAL == 0
or iteration == 0:
738 clip_flags +=
"T_s_clip "
740 clip_flags +=
"X_F_s_clip "
742 f
"[SPALDING_ITER {iteration:3d}] T_s={T_s:.1f}→{T_s_new:.1f} K (unrel={T_s_unrelaxed:.1f}), "
743 f
"p_s={p_s:.0f} Pa, X_F_s={X_F_s:.4f}, Y_F_s={Y_F_s:.4f}, B_M={B_M:.4f}, "
744 f
"log1p(B_M)={log_term:.4f} {clip_flags}",
753 delta_T = abs(T_s_new - T_s)
763 if clipping_count > max_iter * 0.5:
765 f
"Spalding solver clipping excessively: {clipping_count}/{max_iter} iterations. "
766 f
"This indicates the solver is unable to stay in valid domain (T_s in [{T_min:.1f}, {T_max:.1f}] K, X_F_s in [0, 1]). "
767 f
"Final T_s={T_s:.1f} K, T_inf={T_inf:.1f} K, P={P:.1e} Pa. "
768 f
"Check input conditions or reduce under-relaxation factor alpha."
773 X_F_s = np.clip(p_s / P, 0.0, 0.999999)
774 numerator = X_F_s * W_F
775 denominator = X_F_s * W_F + (1.0 - X_F_s) * W_g
776 Y_F_s = numerator / denominator
777 den =
max(1.0 - Y_F_s, 1e-12)
778 B_M = (Y_F_s - Y_F_inf) / den
780 if use_sensible_heating:
781 L_eff =
max(L_vap + cp_liquid * (T_s - T_liquid_in), 0.1 * L_vap)
786 T_film = 0.5 * (T_inf + T_s)
787 delta_T = T_inf - T_s
792 "Y_F_s": float(Y_F_s),
793 "X_F_s": float(X_F_s),
794 "L_eff": float(L_eff),
795 "c_p_g": float(c_p_g),
797 "T_film": float(T_film),
798 "delta_T": float(delta_T),
799 "gamma_used": gamma_used,
800 "R_gas_used": R_gas_used,
801 "use_cea_props": use_cea_props,
802 "iterations": iteration + 1,
803 "converged": converged,
804 "T_crit": float(T_crit),
805 "T_crit_limit": float(T_crit_limit),
Dict[str, float] solve_spalding_coupled(float T_inf, float P, float W_F, float L_vap, Optional[float] gamma=None, Optional[float] R_gas=None, str fuel="RP-1", float Y_F_inf=0.0, Optional[float] cp_liquid=None, Optional[float] T_liquid_in=None, Optional[float] T_s_init=None, Optional[float] T_min=None, Optional[float] T_max=None, float alpha=0.3, float tol=0.1, int max_iter=100, Optional[float] MR=None, Optional[float] eps=None, Optional[callable] cea_props=None)