STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
spalding.py
Go to the documentation of this file.
1"""Spalding number and droplet surface temperature calculations.
2
3This module provides physics-based calculations for:
41. Droplet surface temperature (T_s) estimation
52. Spalding mass transfer number (B_m)
6
7These are used in evaporation time scale and efficiency calculations.
8"""
9
10from __future__ import annotations
11
12from typing import Dict, Optional, Tuple
13import warnings
14
15import numpy as np
16
17from CoolProp.CoolProp import PropsSI
18
19
20
21
22
24 Ts: float,
25 fuel: str = "RP-1",
26) -> float:
27 """
28 Calculate saturation vapor pressure at given temperature.
29
30 Uses CoolProp PropsSI with n-dodecane as surrogate for RP-1.
31
32 Parameters
33 ----------
34 Ts : float
35 Temperature [K]
36 fuel : str
37 Fuel name ("RP-1" uses n-dodecane surrogate)
38
39 Returns
40 -------
41 P_sat : float
42 Saturation vapor pressure [Pa]
43 """
44
45 # Fluid surrogate mapping for CoolProp
46 FUEL_SURROGATES = {
47 "RP-1": "n-Dodecane",
48 "RP1": "n-Dodecane",
49 "rp-1": "n-Dodecane",
50 "rp1": "n-Dodecane",
51 "Ethanol": "Ethanol",
52 "ethanol": "Ethanol",
53 }
54 if not np.isfinite(Ts) or Ts <= 0:
55 raise ValueError(f"Invalid temperature: {Ts} K")
56
57 if fuel not in FUEL_SURROGATES:
58 raise ValueError(f"Invalid fuel: {fuel}")
59
60 # Sanity check for Celsius/Kelvin confusion
61 if Ts < 200.0:
62 warnings.warn(
63 f"[VAPOR_PRESSURE] Ts={Ts:.1f} K is suspiciously low (< 200 K). "
64 "Check for Celsius/Kelvin confusion.",
65 RuntimeWarning,
66 stacklevel=2,
67 )
68
69 # Get CoolProp fluid name from surrogate mapping
70 coolprop_fluid = FUEL_SURROGATES.get(fuel, fuel)
71
72 # Get critical temperature - saturation pressure is undefined at/above T_crit
73 T_crit = PropsSI("Tcrit", coolprop_fluid)
74
75 # NO SILENT CLAMPING: Raise error if Ts >= T_crit
76 # The solver is responsible for enforcing domain boundaries
77 if Ts >= T_crit:
78 raise ValueError(
79 f"[VAPOR_PRESSURE] Ts={Ts:.1f} K >= T_crit={T_crit:.1f} K for {coolprop_fluid}. "
80 "Saturation pressure is undefined above critical temperature. "
81 "The solver must enforce T_s < T_crit."
82 )
83
84 # Use CoolProp PropsSI: P = f(T, Q=0 for saturated liquid)
85 P_sat = PropsSI("P", "T", Ts, "Q", 0, coolprop_fluid)
86 return float(P_sat)
87
88
90 Tc: float,
91 Pc: float,
92 fuel_props: Optional[Dict] = None,
93) -> Tuple[float, float]:
94 """
95 Calculate droplet surface temperature and film temperature.
96
97 The surface temperature T_s is estimated based on:
98 - Boiling point at elevated pressure
99 - Heat transfer from hot gas
100 - Wet-bulb temperature approximation
101
102 Film temperature Tf = 0.5 * (Tg + T_s) is used for property evaluation.
103
104 Parameters
105 ----------
106 Tc : float
107 Chamber/gas temperature [K]
108 Pc : float
109 Chamber pressure [Pa]
110 fuel_props : dict, optional
111 Fuel properties:
112 - T_boil or boiling_point: Boiling point at 1 atm [K]
113 - L_vap or latent_heat: Latent heat of vaporization [J/kg]
114
115 Returns
116 -------
117 T_s : float
118 Droplet surface temperature [K]
119 Tf : float
120 Film temperature [K]
121 """
122 if not np.isfinite(Tc) or Tc <= 0:
123 raise ValueError(f"Invalid gas temperature: {Tc} K")
124 if not np.isfinite(Pc) or Pc <= 0:
125 raise ValueError(f"Invalid pressure: {Pc} Pa")
126
127 # Extract fuel properties (RP-1 defaults)
128 if fuel_props is not None:
129 T_boil = fuel_props.get("T_boil", fuel_props.get("boiling_point", 489.0))
130 else:
131 T_boil = 489.0 # K, RP-1 at 1 atm
132
133 # Estimate droplet surface temperature
134 # At high heat transfer rates, T_s approaches wet-bulb temperature
135 # Approximation: weighted average between boiling point and gas temperature
136 # T_s typically 100-300 K above boiling point in rocket conditions
137 T_s = min(T_boil + 50.0, 0.7 * Tc + 0.3 * T_boil)
138
139 # Clamp to physical bounds
140 T_s = max(T_s, T_boil) # At least boiling point
141 T_s = min(T_s, Tc) # Can't exceed gas temperature
142
143 # Film temperature for property evaluation
144 Tf = 0.5 * (Tc + T_s)
145
146 return float(T_s), float(Tf)
147
148
150 Tc: float,
151 Pc: float,
152 T_s: Optional[float] = None,
153 fuel_props: Optional[Dict] = None,
154 use_reference_pressure: bool = False,
155) -> float:
156 """
157 Calculate Spalding mass transfer number.
158
159 Two formulations are available:
160 1. Thermodynamic: B = cp * (T_infinity - T_s) / L_vap
161 2. Pressure-based: B = Y_s / (1 - Y_s), where Y_s = P_sat(T_s) / Pc
162
163 The thermodynamic formulation is more stable during solver iterations.
164 The pressure-based formulation can optionally use a reference pressure
165 for stability during chamber pressure iteration.
166
167 Parameters
168 ----------
169 Tc : float
170 Chamber/gas temperature [K]
171 Pc : float
172 Chamber pressure [Pa]
173 T_s : float, optional
174 Droplet surface temperature [K]. If None, calculated internally.
175 fuel_props : dict, optional
176 Fuel properties:
177 - T_boil or boiling_point: Boiling point at 1 atm [K]
178 - L_vap or latent_heat: Latent heat of vaporization [J/kg]
179 - cp_gas: Gas specific heat [J/(kg·K)] (default: 1200)
180 - Pc_ref: Reference pressure for stability [Pa] (default: 2.5e6)
181 use_reference_pressure : bool
182 If True, use reference pressure instead of actual Pc for stability
183 during solver iterations. Default False.
184
185 Returns
186 -------
187 B : float
188 Spalding mass transfer number [-]
189 """
190 if not np.isfinite(Tc) or Tc <= 0:
191 raise ValueError(f"Invalid gas temperature: {Tc} K")
192 if not np.isfinite(Pc) or Pc <= 0:
193 raise ValueError(f"Invalid pressure: {Pc} Pa")
194
195 # Extract fuel properties (RP-1 defaults)
196 if fuel_props is not None:
197 T_boil = fuel_props.get("T_boil", fuel_props.get("boiling_point", 489.0))
198 L_vap = fuel_props.get("L_vap", fuel_props.get("latent_heat", 300e3))
199 cp_gas = fuel_props.get("cp_gas", 1200.0)
200 Pc_ref = fuel_props.get("Pc_ref", 2.5e6)
201 else:
202 T_boil = 489.0 # K
203 L_vap = 300e3 # J/kg
204 cp_gas = 1200.0 # J/(kg·K)
205 Pc_ref = 2.5e6 # Pa
206
207 # Calculate T_s if not provided
208 if T_s is None:
209 T_s, _ = calculate_droplet_surface_temperature(Tc, Pc, fuel_props)
210
211 # Use thermodynamic Spalding number (more stable)
212 # B = cp * (T_infinity - T_s) / L_vap
213 delta_T = Tc - T_s
214 if delta_T <= 0:
215 # If gas is cooler than droplet, minimal evaporation
216 return 0.01 # Minimum for numerical stability
217
218 B = cp_gas * delta_T / L_vap
219
220 # Clamp to physically plausible range for rocket conditions
221 # Typical range: 0.05 - 10 for high-temperature combustion
222 B = np.clip(B, 0.01, 15.0)
223
224 return float(B)
225
226
228 Tc: float,
229 Pc: float,
230 T_s: Optional[float] = None,
231 fuel_props: Optional[Dict] = None,
232 use_reference_pressure: bool = True,
233) -> float:
234 """
235 Calculate Spalding number using pressure-based formulation.
236
237 B_m = Y_s / (1 - Y_s), where Y_s = P_sat(T_s) / Pc
238
239 This formulation directly uses vapor pressure at the droplet surface.
240 Can use a reference pressure for stability during solver iterations.
241
242 Parameters
243 ----------
244 Tc : float
245 Chamber/gas temperature [K]
246 Pc : float
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.
254
255 Returns
256 -------
257 B : float
258 Spalding mass transfer number [-]
259 """
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")
264
265 # Extract fuel properties (RP-1 defaults)
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)
269 else:
270 fuel = "RP-1"
271 Pc_ref = 2.5e6 # Pa
272
273 # Calculate T_s if not provided
274 if T_s is None:
275 T_s, _ = calculate_droplet_surface_temperature(Tc, Pc, fuel_props)
276
277 # Calculate saturation vapor pressure using CoolProp
278 P_sat = calculate_vapor_pressure(T_s, fuel=fuel)
279
280 # Choose pressure for mass fraction calculation
281 P_denom = Pc_ref if use_reference_pressure else Pc
282 P_denom = max(P_denom, 1e3) # Prevent division by zero
283
284 # Mass fraction at surface: Y_s = P_sat / P
285 Y_s = P_sat / P_denom
286 Y_s = np.clip(Y_s, 0.0, 0.95) # Prevent division by zero, cap at 95%
287
288 # Pressure-based Spalding number
289 B = Y_s / (1.0 - Y_s)
290
291 # Clamp to physically plausible range
292 B = np.clip(B, 0.05, 1.0) # Narrower range for stability
293
294 return float(B)
295
296
298 Tf: float,
299 Pc: float,
300 D0: float = 1e-5,
301 T0: float = 500.0,
302 P0: float = 1e6,
303) -> float:
304 """
305 Calculate mass diffusivity at film temperature.
306
307 D = D0 * (Tf/T0)^1.5 * (P0/Pc)
308
309 Parameters
310 ----------
311 Tf : float
312 Film temperature [K]
313 Pc : float
314 Chamber pressure [Pa]
315 D0 : float
316 Reference diffusivity [m²/s] at T0, P0. Default 1e-5.
317 T0 : float
318 Reference temperature [K]. Default 500.
319 P0 : float
320 Reference pressure [Pa]. Default 1e6 (1 MPa).
321
322 Returns
323 -------
324 D : float
325 Mass diffusivity [m²/s]
326 """
327 if not np.isfinite(Tf) or Tf <= 0:
328 raise ValueError(f"Invalid film temperature: {Tf} K")
329 if not np.isfinite(Pc) or Pc <= 0:
330 raise ValueError(f"Invalid pressure: {Pc} Pa")
331
332 D = D0 * (Tf / T0) ** 1.5 * (P0 / Pc)
333
334 if not np.isfinite(D) or D <= 0:
335 raise ValueError(f"Non-physical diffusivity: D={D}")
336
337 return float(D)
338
339
341 Ts: float,
342 fuel_props: Optional[Dict] = None,
343) -> float:
344 """
345 Calculate vapor pressure at droplet surface temperature.
346
347 Parameters
348 ----------
349 Ts : float
350 Droplet surface temperature [K]
351 fuel_props : dict, optional
352 Fuel properties (see calculate_spalding_number)
353
354 Returns
355 -------
356 P_sat : float
357 Vapor pressure [Pa] at droplet surface temperature
358 """
359 try:
360 A = float(fuel_props["A_antoine"])
361 B = float(fuel_props["B_antoine"])
362 C = float(fuel_props["C_antoine"])
363 except KeyError as e:
364 raise KeyError(f"Missing Antoine coefficient: {e}. Need A_antoine, B_antoine, C_antoine.") from e
365
366 log10_P_bar = A - B / (Ts + C)
367 P_bar = 10 ** log10_P_bar
368 P_sat = P_bar * 1e5
369 return float(P_sat)
370
371
372# Universal gas constant
373R_UNIVERSAL = 8314.462618 # J/(kmol·K)
374
375
377 T_inf: float,
378 P: float,
379 W_F: float,
380 L_vap: float,
381 gamma: Optional[float] = None,
382 R_gas: Optional[float] = None,
383 fuel: str = "RP-1",
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,
390 alpha: float = 0.3,
391 tol: float = 0.1,
392 max_iter: int = 100,
393 MR: Optional[float] = None,
394 eps: Optional[float] = None,
395 cea_props: Optional[callable] = None,
396) -> Dict[str, float]:
397 """
398 Coupled solver for Spalding Mass Number (B_M) and Droplet Surface Temperature (T_s).
399
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)
403
404 Uses vapor pressure to compute surface mass fraction Y_{F,s} via mole-to-mass
405 fraction conversion.
406
407 **Two modes of operation:**
408
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)`.
412
413 Parameters
414 ----------
415 T_inf : float
416 Far-field gas temperature [K] (e.g., chamber temperature T_c)
417 P : float
418 Gas pressure [Pa] (e.g., chamber pressure P_c)
419 W_F : float
420 Molecular weight of fuel vapor [kg/kmol] (numerically = g/mol)
421 L_vap : float
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.
427 fuel : str, optional
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
449 MR : float, optional
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.
456
457 Returns
458 -------
459 result : dict
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]
475
476 Notes
477 -----
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.
485
486 Raises
487 ------
488 ValueError
489 If input parameters are invalid or non-physical.
490 """
491 # Input validation - basic parameters
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}")
504
505 # Mode selection: CEA accessor mode vs fixed-property mode
506 if cea_props is not None:
507 # CEA accessor mode: get gamma/R from callable
508 if gamma is not None or R_gas is not None:
509 raise ValueError(
510 "When using cea_props, gamma and R_gas must be None. "
511 f"Got gamma={gamma}, R_gas={R_gas}"
512 )
513 if MR is None or eps is None:
514 raise ValueError(
515 "CEA accessor mode requires MR and eps to be provided. "
516 f"Got MR={MR}, eps={eps}"
517 )
518 # Call CEA accessor once (properties are constant during iteration)
519 gamma, R_gas = cea_props(P, MR, eps)
520 use_cea_props = True
521 else:
522 # Fixed-property mode: require gamma and R_gas
523 if gamma is None or R_gas is None:
524 raise ValueError(
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}"
528 )
529 use_cea_props = False
530
531 # Validate gamma and R_gas (now available in both modes)
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)")
536
537 # Store the values used for output
538 gamma_used = float(gamma)
539 R_gas_used = float(R_gas)
540
541 # Precompute gas properties from gamma and R (constant for iteration)
542 # c_p,g = gamma / (gamma - 1) * R
543 c_p_g = (gamma / (gamma - 1.0)) * R_gas
544
545 # Gas mixture molecular weight: W_g = R_u / R
546 W_g = R_UNIVERSAL / R_gas
547
548 # =============================================================================
549 # CRITICAL TEMPERATURE DOMAIN ENFORCEMENT
550 # Saturation pressure is undefined above T_crit for real fluids.
551 # The solver must enforce T_s < T_crit to stay within model validity.
552 # =============================================================================
553 FUEL_SURROGATES = {
554 "RP-1": "n-Dodecane", "RP1": "n-Dodecane", "rp-1": "n-Dodecane", "rp1": "n-Dodecane",
555 "Ethanol": "Ethanol", "ethanol": "Ethanol",
556 }
557 coolprop_fluid = FUEL_SURROGATES.get(fuel, fuel)
558 T_crit = PropsSI("Tcrit", coolprop_fluid)
559 T_crit_margin = 1e-2 # 0.01 K margin below T_crit
560 T_crit_limit = T_crit - T_crit_margin
561
562 # Warn once if T_inf is above T_crit (model is at edge of validity)
563 if T_inf >= T_crit_limit:
564 warnings.warn(
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).",
567 RuntimeWarning,
568 stacklevel=2,
569 )
570
571 # Set temperature bounds, enforcing T_max <= T_crit_limit
572 if T_max is None:
573 T_max = min(T_inf, T_crit_limit)
574 else:
575 T_max = min(T_max, T_crit_limit)
576
577 if T_min is None:
578 T_min = 200.0 # Reasonable lower bound
579
580 # Sanity check: if T_max <= T_min, we have no valid domain
581 if T_max <= T_min:
582 raise ValueError(
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."
585 )
586
587 # Initial guess for surface temperature
588 if T_s_init is not None:
589 T_s = min(T_s_init, T_max) # Clamp to valid domain
590 else:
591 # Default: 90% of far-field, but clamped to valid domain
592 T_s = min(0.9 * T_inf, T_max)
593
594 # Clamp initial guess to bounds
595 T_s = np.clip(T_s, T_min, T_max)
596
597 # Check if effective latent heat should include liquid sensible heating
598 use_sensible_heating = (cp_liquid is not None and T_liquid_in is not None)
599
600 converged = False
601 B_M = 0.0
602 Y_F_s = 0.0
603 X_F_s = 0.0
604 L_eff = L_vap
605
606 # =============================================================================
607 # ITERATION DIAGNOSTICS
608 # Track p_vap stalling and provide periodic status output
609 # =============================================================================
610 DIAG_INTERVAL = 5 # Print diagnostics every N iterations
611 p_s_prev = None
612 warned_pv_stall = False
613 T_s_clipped_count = 0
614 X_F_s_clipped_count = 0
615
616 for iteration in range(max_iter):
617 # Step 1: Vapor pressure at current T_s
618 try:
619 p_s = calculate_vapor_pressure(T_s, fuel=fuel)
620 except Exception as e:
621 raise ValueError(f"Vapor pressure calculation failed at T_s = {T_s:.1f} K: {e}") from e
622
623 # =============================================================================
624 # DIAGNOSTIC: Detect p_vap stall (constant vapor pressure despite T_s changes)
625 # =============================================================================
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:
629 # Check if T_s is also changing - if so, this is a stall
630 T_s_change = abs(T_s - T_s_clipped_count) # Reuse variable for last T_s
631 if T_s_change > 0.1: # T_s is changing but p_vap is not
632 warnings.warn(
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.",
635 RuntimeWarning,
636 stacklevel=2,
637 )
638 warned_pv_stall = True
639
640 # Diagnostic: warn if vapor pressure exceeds total pressure
641 if p_s > P:
642 warnings.warn(
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).",
645 RuntimeWarning,
646 stacklevel=2,
647 )
648
649 # Step 2: Surface mole fraction (with safety clamp)
650 X_F_s_raw = p_s / P
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)
653
654 # Step 3: Convert mole fraction to mass fraction
655 # Y_{F,s} = X_{F,s} * W_F / (X_{F,s} * W_F + (1 - X_{F,s}) * W_g)
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
659
660 # Step 4: Spalding mass transfer number
661 # B_M = (Y_{F,s} - Y_{F,inf}) / (1 - Y_{F,s})
662 den = max(1.0 - Y_F_s, 1e-12)
663 if den <= 1e-12:
664 warnings.warn(
665 f"Y_F_s = {Y_F_s:.6f} ≈ 1, denominator floored to 1e-12.",
666 RuntimeWarning,
667 stacklevel=2,
668 )
669 B_M = (Y_F_s - Y_F_inf) / den
670
671 # Soft guard: when B_M is negative but close to -1, ln(1 + B_M) spikes
672 # and causes wild temperature updates. Clamp to a safe floor.
673 B_M_FLOOR = -0.9 # ln(1 + (-0.9)) = ln(0.1) ≈ -2.3, still manageable
674 if B_M < B_M_FLOOR:
675 warnings.warn(
676 f"B_M = {B_M:.4f} is dangerously close to -1. "
677 f"Clamping to {B_M_FLOOR} to prevent log spike.",
678 RuntimeWarning,
679 stacklevel=2,
680 )
681 B_M = B_M_FLOOR
682
683 # Check for condensation regime (B_M < 0)
684 # ln(1 + B_M) requires B_M > -1
685 if B_M <= -1.0:
686 raise ValueError(
687 f"B_M = {B_M:.4f} <= -1 (condensation regime). "
688 "ln(1 + B_M) is undefined. Check inputs or model assumptions."
689 )
690
691 # Step 5: Effective latent heat
692 if use_sensible_heating:
693 # L_eff = L_vap + c_p,liquid * (T_s - T_liquid,in)
694 L_eff_raw = L_vap + cp_liquid * (T_s - T_liquid_in)
695 else:
696 L_eff_raw = L_vap
697
698 # Floor L_eff to prevent non-physical values, but warn if triggered
699 L_eff = max(L_eff_raw, 0.1 * L_vap)
700 if L_eff_raw <= 0:
701 warnings.warn(
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.",
704 RuntimeWarning,
705 stacklevel=2,
706 )
707
708 # Step 6: Compute unrelaxed temperature update
709 # T_s_new = T_inf - (L_eff / c_p,g) * ln(1 + B_M)
710 # Use log1p for numerical stability when B_M is small
711 log_term = np.log1p(B_M)
712 T_s_unrelaxed = T_inf - (L_eff / c_p_g) * log_term
713
714 # Compute film temperature (diagnostic only, not used for property interpolation)
715 T_film = 0.5 * (T_inf + T_s)
716
717 # Step 7: Under-relaxation
718 T_s_new = (1.0 - alpha) * T_s + alpha * T_s_unrelaxed
719
720 # Step 8: Apply bounds (domain enforcement: T_s must stay below T_crit)
721 # NOTE: This is VALID clipping for iterative solvers - enforcing physical domain
722 # T_s cannot exceed critical temperature (boiling becomes impossible)
723 # However, if clipping happens too frequently, it indicates solver instability
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)
727
728 # Count clipping events - too many indicates problem
729 if T_s_clipped or X_F_s_clipped:
730 clipping_count += 1
731
732 # =============================================================================
733 # DIAGNOSTIC: Periodic iteration logging
734 # =============================================================================
735 if iteration % DIAG_INTERVAL == 0 or iteration == 0:
736 clip_flags = ""
737 if T_s_clipped:
738 clip_flags += "T_s_clip "
739 if X_F_s_clipped:
740 clip_flags += "X_F_s_clip "
741 warnings.warn(
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}",
745 RuntimeWarning,
746 stacklevel=2,
747 )
748
749 # Track previous p_s for stall detection
750 p_s_prev = p_s
751
752 # Check convergence
753 delta_T = abs(T_s_new - T_s)
754 if delta_T < tol:
755 converged = True
756 T_s = T_s_new
757 break
758
759 T_s = T_s_new
760
761 # Check if solver is clipping too frequently (>50% of iterations)
762 # This indicates the solver is unable to stay in the valid domain naturally
763 if clipping_count > max_iter * 0.5:
764 raise RuntimeError(
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."
769 )
770
771 # Final pass: recompute all values with converged T_s for consistency
772 p_s = calculate_vapor_pressure(T_s, fuel=fuel)
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
779
780 if use_sensible_heating:
781 L_eff = max(L_vap + cp_liquid * (T_s - T_liquid_in), 0.1 * L_vap)
782 else:
783 L_eff = L_vap
784
785 # Final diagnostics
786 T_film = 0.5 * (T_inf + T_s)
787 delta_T = T_inf - T_s
788
789 return {
790 "B_M": float(B_M),
791 "T_s": float(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),
796 "W_g": float(W_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),
806 }
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
float calculate_film_diffusivity(float Tf, float Pc, float D0=1e-5, float T0=500.0, float P0=1e6)
Definition spalding.py:303
Tuple[float, float] calculate_droplet_surface_temperature(float Tc, float Pc, Optional[Dict] fuel_props=None)
Definition spalding.py:93
float calculate_vapor_pressure(float Ts, str fuel="RP-1")
Definition spalding.py:26
float calculate_vapor_pressure_antoine(float Ts, Optional[Dict] fuel_props=None)
Definition spalding.py:343
float calculate_spalding_pressure_based(float Tc, float Pc, Optional[float] T_s=None, Optional[Dict] fuel_props=None, bool use_reference_pressure=True)
Definition spalding.py:233
float calculate_spalding_number(float Tc, float Pc, Optional[float] T_s=None, Optional[Dict] fuel_props=None, bool use_reference_pressure=False)
Definition spalding.py:155
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)
Definition spalding.py:396