STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
chamber_solver.py
Go to the documentation of this file.
1"""Chamber pressure solver: solve supply(Pc) = demand(Pc).
2
3This is the authoritative, pure-Python chamber solver used by every general
4evaluation path (frontend solves, time-varying, flight, Layer-1 finalization). The
5native C kernel does not hook in here: it accelerates only the Layer-1 inner loop,
6through a single ``ed_evaluate`` call in
7``engine.accel.evaluate``. Keeping the accelerator out of this solver
8is deliberate — it leaves the general path simple, authoritative, and free of any
9runtime native/Python switch.
10"""
11
12import numpy as np
13import logging
14from scipy.optimize import brentq, newton
15from typing import Tuple, Dict, Any, List, Optional
16
17from engine.pipeline.config_schemas import PintleEngineConfig, ensure_chamber_geometry
18from engine.pipeline.combustion_eff import eta_cstar, calculate_Lstar
19from engine.pipeline.cea_cache import CEACache
20from engine.pipeline.thermal.film_cooling import compute_film_cooling
21from engine.pipeline.thermal.regen_cooling import compute_regen_heat_transfer
22from engine.pipeline.thermal.ablative_cooling import liner_response
24 PhysicalConstraints,
25 NumericalStability,
26 PhysicsValidator,
27 validate_engine_state,
28)
29from engine.pipeline.constants import (
30 DEFAULT_CHAMBER_TEMP_K,
31 DEFAULT_CSTAR_IDEAL_M_S,
32 DEFAULT_GAMMA_ND,
33 DEFAULT_GAS_CONST_J_KG_K,
34 DEFAULT_TURBULENCE_INTENSITY_ND,
35)
36from engine.core.closure import flows
37from engine.core.nozzle import contraction_ratio_of, nozzle_stagnation_loss
38
39
40# Chamber-pressure search window. Mirrored VERBATIM in engine/accel/kernels.evaluate_core -- the
41# parity gate requires the two root-finds to agree, so change both or neither.
42#
43# Floor: the throat must be sonic for the demand model mdot = Pc*At/c* to hold, which needs
44# Pc > P_amb / (2/(gamma+1))^(gamma/(gamma-1)), i.e. ~1.8 atm across gamma 1.1-1.3. Below that the
45# efficiency model collapses, demand blows up, and the residual has a spurious zero crossing near
46# 20 psi: with the old 1 bar floor Brent converged to it and reported NEGATIVE thrust for the
47# shipped default config at its own configured tank pressures.
48PC_CHOKE_FLOOR_PA = 2.0 * 101325.0
49# Ceiling: Pc sits below the lower tank pressure by at least this fraction of it -- a floor on the
50# total feed + injector drop. It is a search bound, not a design rule; the previous 15% "feed loss
51# margin" was a guess that excluded the real operating point of any soft-injector case.
52PC_MIN_TOTAL_DROP_FRAC = 0.02
53# Equal-step scan resolution used to locate the highest-Pc sign change before Brent.
54PC_BRACKET_SCAN_POINTS = 32
55
56
57def _cea_table_range(cache) -> dict:
58 """The CEA table's extent per axis, {"Pc": [Pa, Pa], "MR": [..], "eps": [..]}; {} if unknown.
59
60 ``CEACache.eval`` clamps an off-table input to the edge and says so (``clamped``); a clamped
61 point is solved on the edge's thermochemistry, which can be far from its own (c* falls
62 ~116 m/s per unit O/F just inside the ethalox table's 2.5 edge and is flat past it).
63 """
64 out = {}
65 for axis in ("Pc", "MR", "eps"):
66 try:
67 out[axis] = [float(getattr(cache, f"{axis}_min")), float(getattr(cache, f"{axis}_max"))]
68 except (AttributeError, TypeError, ValueError):
69 continue
70 return out
71
72
74 """Solves for chamber pressure by balancing supply and demand"""
75
76 def __init__(self, config: PintleEngineConfig, cea_cache: CEACache):
77 self.config = config
78 self.cea_cache = cea_cache
79
80 # Ensure chamber_geometry exists
81 ensure_chamber_geometry(config)
82
83 # Cache for spray quality (updated during solve)
85
86 def residual(self, Pc: float, P_tank_O: float, P_tank_F: float) -> float:
87 """
88 Calculate residual: supply(Pc) - demand(Pc)
89
90 Residual = 0 when supply equals demand.
91
92 Parameters:
93 -----------
94 Pc : float
95 Chamber pressure guess [Pa]
96 P_tank_O : float
97 Oxidizer tank pressure [Pa]
98 P_tank_F : float
99 Fuel tank pressure [Pa]
100
101 Returns:
102 --------
103 residual : float [kg/s]
104 """
105 # Validate inputs
106 Pc_val = float(Pc)
107 P_tank_O_val = float(P_tank_O)
108 P_tank_F_val = float(P_tank_F)
109
110 # Physical constraint checks
111 if not np.isfinite(Pc_val) or Pc_val <= 0:
112 return np.nan # Invalid pressure
113 if not np.isfinite(P_tank_O_val) or P_tank_O_val <= 0:
114 return np.nan
115 if not np.isfinite(P_tank_F_val) or P_tank_F_val <= 0:
116 return np.nan
117
118 # Supply side: mass flow from injector (via closure)
119 # flows() takes TANK PRESSURES and solves for mdot
120 # It internally calculates:
121 # 1. Feed losses: P_tank → P_injector
122 # 2. Injector flow: P_injector - Pc → mdot
123 # 3. Spray constraints: validates and adjusts if needed
124 try:
125 mdot_O, mdot_F, diagnostics = flows(
126 P_tank_O_val, # Tank pressure (INPUT)
127 P_tank_F_val, # Tank pressure (INPUT)
128 Pc_val, # Chamber pressure (GUESS - being solved for)
129 self.config
130 )
131 except Exception as e:
132 # If flows() fails, return NaN to signal invalid point
133 import warnings
134 warnings.warn(f"flows() failed in residual at Pc={Pc_val/1e6:.2f} MPa: {e}")
135 return np.nan
136
137 # Validate mass flows
138 if not (np.isfinite(mdot_O) and np.isfinite(mdot_F)):
139 return np.nan
140
141 mdot_supply = mdot_O + mdot_F
142
143 # Update spray quality for efficiency calculation
144 self.spray_quality_good = diagnostics.get("constraints_satisfied", True)
145
146 # Demand side: mass flow required by combustion
147 MR, mr_valid = NumericalStability.safe_divide(mdot_O, mdot_F, 2.5, "MR")
148 if not mr_valid.passed:
149 return np.nan
150
151 # Get CEA properties (IDEAL - infinite area equilibrium)
152 # For 3D cache, use default expansion ratio from config (doesn't affect chamber properties much)
153 try:
154 cg = ensure_chamber_geometry(self.config)
155 eps_default = cg.expansion_ratio
156 cea_props = self.cea_cache.eval(MR, Pc_val, 101325.0, eps_default)
157 except Exception as e:
158 # Log the error for debugging but return NaN to signal failure
159 import warnings
160 warnings.warn(f"CEA cache eval failed in residual: {e}")
161 return np.nan
162
163 cstar_ideal = cea_props.get("cstar_ideal", 0.0)
164 if not np.isfinite(cstar_ideal) or cstar_ideal <= 0:
165 return np.nan
166
167 # Apply combustion efficiency for FINITE CHAMBER
168 # This corrects CEA's infinite-area assumption
169
170
171 # Apply combustion efficiency for FINITE CHAMBER
172 # This corrects CEA's infinite-area assumption
173
174 # Calculate current Lstar and injector diameter dynamically
175 # This is critical for optimization where config is mutated in-place
176 cg = ensure_chamber_geometry(self.config)
177 current_Lstar = calculate_Lstar(
178 cg.volume,
179 cg.A_throat,
180 Lstar_override=cg.Lstar
181 )
182 current_injector_diameter = self._infer_injector_diameter()
183
184
185 cooling_results, cooling_eff, _ = self._evaluate_cooling_models(
186 Pc_val,
187 mdot_O,
188 mdot_F,
189 cea_props,
190 diagnostics,
191 with_profile=False, # root-find: profile is display-only, never needed here
192 )
193
194 geometry = self._get_chamber_geometry()
195 advanced_params = {
196 "Pc": Pc_val,
197 "Tc": cea_props.get("Tc", DEFAULT_CHAMBER_TEMP_K),
198 "cstar_ideal": cea_props.get("cstar_ideal", DEFAULT_CSTAR_IDEAL_M_S),
199 "gamma": cea_props.get("gamma", DEFAULT_GAMMA_ND),
200 "R": cea_props.get("R", DEFAULT_GAS_CONST_J_KG_K),
201 "MR": MR,
202 "Ac": geometry["area_cross"],
203 "At": cg.A_throat, # Added At for residence time calculation
204 "chamber_length": geometry["length"], # Added for mixing models
205 "Dinj": current_injector_diameter,
206 "m_dot_total": mdot_supply,
207 "spray_diagnostics": diagnostics,
208 "turbulence_intensity": diagnostics.get("turbulence_intensity_mix", DEFAULT_TURBULENCE_INTENSITY_ND),
209 "momentum_ratio_R": diagnostics.get("momentum_ratio_R"),
210 "R_opt": self._rupe_R_opt(),
211 "fuel_props": self._get_fuel_props(),
212 "ox_props": self._get_ox_props(),
213 "cea_cache": self.cea_cache, # c*(O/F) for the mixing / vaporized-gas c* ratios
214 }
215
216 # Add injection velocities to advanced_params if available in diagnostics
217 if "u_F" in diagnostics:
218 advanced_params["u_fuel"] = diagnostics["u_F"]
219 if "u_O" in diagnostics:
220 advanced_params["u_lox"] = diagnostics["u_O"]
221
222 if hasattr(self, '_debug') and self._debug:
223 logging.getLogger("evaluate").info(f"[SOLVER_DEBUG] Pc Guess: {Pc_val/1e6:.4f} MPa | Supply mdot: {mdot_supply:.4f} kg/s | MR: {MR:.3f}")
224
225 # Calculate efficiency using advanced physics-based model
226 eta = eta_cstar(
227 current_Lstar,
228 self.config.combustion.efficiency,
229 cooling_eff,
230 advanced_params,
231 debug=self._debug if hasattr(self, '_debug') else False,
232 )
233
234 # Validate efficiency. No upper bound: eta_c* is against c*_ideal at the BULK O/F, and off
235 # the c* peak a spread of stream tubes, or a vaporized gas nearer the peak, can beat it
236 # (combustion_physics.stream_tube_mixing_efficiency / calculate_vaporization_efficiency).
237 if not np.isfinite(eta) or eta <= 0:
238 return np.nan
239
240
241 # Actual c* accounting for finite chamber volume
242 cstar_actual = eta * cstar_ideal
243
244 # Demand: mdot = P0 * At / c*_actual, P0 = Pc / kappa the nozzle stagnation pressure
245 # (Pc is the injector-end pressure; kappa the finite-area combustor's Rayleigh loss).
246 cg = ensure_chamber_geometry(self.config)
247 kappa = nozzle_stagnation_loss(contraction_ratio_of(cg), float(cea_props.get("gamma", DEFAULT_GAMMA_ND)))
248 mdot_demand, demand_valid = NumericalStability.safe_divide(
249 Pc_val / kappa * cg.A_throat,
250 cstar_actual,
251 0.0,
252 "mdot_demand"
253 )
254 if not demand_valid.passed:
255 return np.nan
256
257 residual = mdot_supply - mdot_demand
258
259 # Validate residual is finite
260 if not np.isfinite(residual):
261 return np.nan
262
263 return float(residual)
264
265 def _accel_chamber_pc(self, P_tank_O: float, P_tank_F: float):
266 """Solve Pc with the native C kernel (whole residual loop + Brent) when native
267 is enabled and can handle this config; otherwise return None so the Python
268 Brent solver runs.
269
270 Pure capability routing — no runtime parity self-check or process latch. Parity
271 is enforced by the golden test suite and the load-time ABI assert in
272 the accelerator. A solve that raises or returns a non-finite Pc falls back to the
273 Python solver for that call.
274 """
275 from engine import accel
276 if not accel.enabled():
277 return None
278 try:
279 res = accel.chamber_solve(self.config, self.cea_cache, P_tank_O, P_tank_F)
280 except Exception:
281 # Strict mode (ED_REQUIRE_ACCEL=1, CI parity job): surface a genuine
282 # accelerator failure loudly instead of silently falling back to Python
283 # (which would report a false green). Default runs fall back quietly.
284 if accel.require():
285 raise
286 return None
287 if res is None:
288 return None
289 Pc_native = float(res[0])
290 if not np.isfinite(Pc_native) or Pc_native <= 0.0:
291 return None
292 return Pc_native
293
294 @staticmethod
295 def _highest_sign_change(f, Pc_min: float, Pc_max: float, r_min: float, r_max: float,
296 n: int = PC_BRACKET_SCAN_POINTS):
297 """[lo, hi] holding the HIGHEST-Pc sign change of the residual on [Pc_min, Pc_max], or
298 (None, None). Equal steps scanned from the top; ``r_min``/``r_max`` are reused at the ends.
299 Verbatim mirror of the scan in accel.kernels.evaluate_core (parity)."""
300 pb, rb = Pc_max, r_max
301 for i in range(n - 1, -1, -1):
302 pa = Pc_min + (Pc_max - Pc_min) * (i / n)
303 ra = r_min if i == 0 else f(pa)
304 if np.isfinite(ra) and np.isfinite(rb) and np.sign(ra) != np.sign(rb):
305 return pa, pb
306 pb, rb = pa, ra
307 return None, None
308
309 def _raise_supply_exceeds_demand(self, P_tank_O, P_tank_F, Pc_max, residual_min, residual_max, debug):
310 """Supply > demand even at the ceiling: injector oversized / throat undersized for these
311 pressures. Raise with the supply/demand numbers at Pc_max so the message is actionable."""
312 try:
313 mdot_O_test, mdot_F_test, diag_test = flows(P_tank_O, P_tank_F, Pc_max, self.config)
314 mdot_supply_test = mdot_O_test + mdot_F_test
315 MR_test = mdot_O_test / mdot_F_test if mdot_F_test > 0 else np.inf
316 cg = ensure_chamber_geometry(self.config)
317 cea_props_test = self.cea_cache.eval(MR_test, Pc_max, 101325.0, cg.expansion_ratio)
318 cstar_ideal_test = cea_props_test.get("cstar_ideal", 0.0)
319 geometry_test = self._get_chamber_geometry()
320 advanced_params_test = {
321 "Pc": Pc_max,
322 "Tc": cea_props_test.get("Tc", DEFAULT_CHAMBER_TEMP_K),
323 "cstar_ideal": cstar_ideal_test,
324 "gamma": cea_props_test.get("gamma", DEFAULT_GAMMA_ND),
325 "R": cea_props_test.get("R", DEFAULT_GAS_CONST_J_KG_K),
326 "MR": MR_test,
327 "Ac": geometry_test["area_cross"],
328 "At": cg.A_throat,
329 "chamber_length": geometry_test["length"],
330 "Dinj": self._infer_injector_diameter(),
331 "m_dot_total": mdot_supply_test,
332 "spray_diagnostics": diag_test,
333 "turbulence_intensity": diag_test.get("turbulence_intensity_mix", DEFAULT_TURBULENCE_INTENSITY_ND),
334 "fuel_props": self._get_fuel_props(),
335 "ox_props": self._get_ox_props(),
336 "cea_cache": self.cea_cache, # c*(O/F) for the mixing / vaporized-gas c* ratios
337 }
338 eta_test = eta_cstar(
339 calculate_Lstar(cg.volume, cg.A_throat, Lstar_override=cg.Lstar),
340 self.config.combustion.efficiency,
341 diag_test.get("cooling_efficiency", 1.0),
342 advanced_params_test,
343 debug=debug,
344 )
345 cstar_actual_test = eta_test * cstar_ideal_test
346 kappa_test = nozzle_stagnation_loss(contraction_ratio_of(cg), float(cea_props_test.get("gamma", DEFAULT_GAMMA_ND)))
347 mdot_demand_test = (Pc_max / kappa_test * cg.A_throat) / cstar_actual_test if cstar_actual_test > 0 else np.inf
348 if mdot_demand_test > 0 and mdot_supply_test > mdot_demand_test:
349 Pc_estimate = mdot_supply_test * cstar_actual_test / cg.A_throat * kappa_test
350 raise ValueError(
351 f"No solution: Supply > Demand at all Pc. "
352 f"Residual at Pc_min: {residual_min:.4f} kg/s, at Pc_max: {residual_max:.4f} kg/s. "
353 f"\nDiagnostics at Pc_max ({Pc_max/1e6:.2f} MPa):"
354 f"\n - Supply: {mdot_supply_test:.4f} kg/s (mdot_O={mdot_O_test:.4f}, mdot_F={mdot_F_test:.4f})"
355 f"\n - Demand: {mdot_demand_test:.4f} kg/s (c*_actual={cstar_actual_test:.1f} m/s, At={cg.A_throat*1e6:.2f} mm²)"
356 f"\n - Estimated Pc needed: {Pc_estimate/1e6:.2f} MPa (vs Pc_max={Pc_max/1e6:.2f} MPa)"
357 f"\nPossible fixes:"
358 f"\n 1. Reduce injector orifice areas (currently oversized)"
359 f"\n 2. Increase throat area (currently undersized)"
360 f"\n 3. Increase tank pressures to allow higher Pc_max"
361 f"\n 4. Check combustion efficiency (low efficiency reduces demand)"
362 )
363 raise ValueError(
364 f"No solution: Supply > Demand at all Pc. "
365 f"Residual: [{residual_min:.4f}, {residual_max:.4f}] kg/s. "
366 f"Could not compute detailed diagnostics."
367 )
368 except ValueError:
369 raise
370 except Exception as diag_e:
371 raise ValueError(
372 f"No solution: Supply > Demand at all Pc. "
373 f"Residual at bounds: [{residual_min:.4f}, {residual_max:.4f}] kg/s. "
374 f"Pc_max ({Pc_max/1e6:.2f} MPa) limited by tank pressure. "
375 f"Possible causes: Injector oversized, throat undersized, or combustion efficiency too low. "
376 f"Diagnostic error: {diag_e}"
377 )
378
379 def solve(
380 self,
381 P_tank_O: float,
382 P_tank_F: float,
383 Pc_guess: float = None,
384 debug: bool = False,
385 silent: bool = False,
386 ) -> Tuple[float, Dict[str, Any]]:
387 """
388 Solve for chamber pressure.
389
390 Parameters:
391 -----------
392 P_tank_O : float
393 Oxidizer tank pressure [Pa]
394 P_tank_F : float
395 Fuel tank pressure [Pa]
396 Pc_guess : float, optional
397 Initial guess for chamber pressure [Pa]
398
399 Returns:
400 --------
401 Pc : float [Pa]
402 Solved chamber pressure
403 diagnostics : dict
404 Solution diagnostics
405 """
406 # Search window: choked-flow floor, tank-pressure ceiling (see the constants above), then
407 # the user's solver.Pc_bounds narrow it further.
408 Pc_min = max(PC_CHOKE_FLOOR_PA, self.config.solver.Pc_bounds[0])
409 Pc_max = min(min(P_tank_O, P_tank_F) * (1.0 - PC_MIN_TOTAL_DROP_FRAC), self.config.solver.Pc_bounds[1])
410
411 if Pc_max <= Pc_min:
412 raise ValueError(f"Invalid pressure bounds: Pc_max ({Pc_max}) <= Pc_min ({Pc_min})")
413
414 # Initial guess
415 if Pc_guess is None:
416 Pc_guess = (Pc_min + Pc_max) / 2
417
418 # Create residual function with tank pressures bound
419 # Create residual function with tank pressures bound
420 # Also pass debug to residual if needed in future (currently residual uses instance state, but we can't easily pass debug to it without changing signature broadly or storing state)
421 # However, residual calls eta_cstar which needs debug...
422 # Wait, residual() calls eta_cstar() inside.
423 # I need to update residual() to use debug flag.
424 # I'll store `self._debug = debug` temporarily or modify residual validation.
425 # Storing on self is easiest for this scope.
426 self._debug = debug
427 self._silent = silent # gates the display-only cooling profile in post-processing
428
429 def residual_func(Pc):
430 return self.residual(Pc, P_tank_O, P_tank_F)
431
432 # Accelerated path: the whole residual loop + Brent in the numba kernel when it can
433 # handle this config. Importantly, this path is hit by the ~30% of Layer-1 CMA candidates
434 # that don't converge in the single-call seam and fall back to runner.evaluate -> here, so
435 # keeping it accelerated keeps that fallback fast (a pure-Python Brent solve here is ~100x
436 # slower). Any failure -> Python Brent below.
437 convergence_history: list = []
438 _accel_pc = self._accel_chamber_pc(P_tank_O, P_tank_F)
439 if _accel_pc is not None:
440 Pc = _accel_pc
441 success = True
442 else:
443 residual_min = residual_func(Pc_min)
444 residual_max = residual_func(Pc_max)
445
446 if not np.isfinite(residual_min):
447 # Try to diagnose the issue
448 try:
449 test_res = residual_func((Pc_min + Pc_max) / 2)
450 if not np.isfinite(test_res):
451 raise ValueError(
452 f"Residual function returns non-finite values. "
453 f"Pc_min={Pc_min/1e6:.2f} MPa, Pc_max={Pc_max/1e6:.2f} MPa. "
454 f"Check injector geometry, feed system, or CEA cache."
455 )
456 except Exception as e:
457 raise ValueError(
458 f"Residual function evaluation failed at bounds. "
459 f"Pc_min={Pc_min/1e6:.2f} MPa, Pc_max={Pc_max/1e6:.2f} MPa. "
460 f"Error: {e}"
461 )
462 if not np.isfinite(residual_max):
463 raise ValueError(
464 f"Residual function returns non-finite at Pc_max={Pc_max/1e6:.2f} MPa. "
465 f"Check that tank pressures are sufficient and injector geometry is valid."
466 )
467
468 # The residual is not monotonic in Pc, so neither an endpoint sign test nor a Brent
469 # over the whole window is safe: the operating point is the HIGHEST-Pc root, and the
470 # crossing nearest the floor (if any) is the barely-flowing spurious one. Locate the
471 # top-most sign change and bracket Brent inside it.
472 lo, hi = self._highest_sign_change(residual_func, Pc_min, Pc_max, residual_min, residual_max)
473 if lo is None:
474 if residual_min > 0 and residual_max > 0:
475 self._raise_supply_exceeds_demand(P_tank_O, P_tank_F, Pc_max, residual_min, residual_max, debug)
476 raise ValueError(
477 f"No solution: Supply < Demand at all Pc. "
478 f"Residual at bounds: [{residual_min:.4f}, {residual_max:.4f}] kg/s. "
479 f"Insufficient mass flow. Check tank pressures and injector geometry."
480 )
481
482 def tracked_residual_func(Pc):
483 res = residual_func(Pc)
484 convergence_history.append(float(res))
485 return res
486
487 try:
488 if self.config.solver.method == "brentq":
489 Pc, result = brentq(
490 tracked_residual_func,
491 lo,
492 hi,
493 xtol=self.config.solver.tolerance,
494 rtol=self.config.solver.tolerance * 1e-3, # Relative tolerance
495 maxiter=self.config.solver.max_iterations,
496 full_output=True
497 )
498 success = result.converged
499 else:
500 # Newton from the middle of the bracket (less robust; kept for configs that ask)
501 Pc = newton(
502 tracked_residual_func,
503 0.5 * (lo + hi),
504 tol=self.config.solver.tolerance,
505 maxiter=self.config.solver.max_iterations
506 )
507 success = True
508 conv_check = NumericalStability.check_convergence(
509 convergence_history,
510 self.config.solver.tolerance,
511 min_iterations=3
512 )
513 if not conv_check.passed and conv_check.severity == "error":
514 raise RuntimeError(f"Convergence validation failed: {conv_check.message}")
515 except ValueError:
516 raise
517 except Exception as e:
518 raise RuntimeError(f"Chamber pressure solver failed: {e}")
519
520 # Validate solution
521 Pc_val = float(Pc)
522 if not np.isfinite(Pc_val):
523 raise RuntimeError(f"Solver returned non-finite pressure: {Pc_val}")
524
525 Pc_check = PhysicalConstraints.validate_pressure(Pc_val, "Pc_solution")
526 if not Pc_check.passed and Pc_check.severity == "error":
527 raise RuntimeError(f"Solution validation failed: {Pc_check.message}")
528
529 # Get final diagnostics
530 mdot_O, mdot_F, closure_diag = flows(P_tank_O, P_tank_F, Pc_val, self.config)
531 MR = mdot_O / mdot_F if mdot_F > 0 else 0
532
533 # Calculate current Lstar and injector diameter dynamically
534 # This is critical for optimization where config is mutated in-place
535 cg = ensure_chamber_geometry(self.config)
536 current_Lstar = calculate_Lstar(
537 cg.volume,
538 cg.A_throat,
539 Lstar_override=cg.Lstar
540 )
541 current_injector_diameter = self._infer_injector_diameter()
542
543 # Use current expansion ratio for 3D cache
544 # FIXED: Add safety check for division by zero
545 cg = ensure_chamber_geometry(self.config)
546 eps_current = cg.A_exit / cg.A_throat if cg.A_throat and cg.A_exit and cg.A_throat > 0 else cg.expansion_ratio
547 cea_props = self.cea_cache.eval(MR, Pc_val, 101325.0, eps_current)
548
549 # Calculate total mass flow rate (needed for various calculations below)
550 mdot_total = mdot_O + mdot_F
551
552 # Calculate cooling effects early (needed for conservative reaction kinetics).
553 # The display-only per-segment heat-flux profile is skipped on the silent
554 # (optimizer) path — it's the dominant post-processing cost and doesn't
555 # affect any returned scalar.
556 cooling_results, cooling_eff, effective_Tc = self._evaluate_cooling_models(
557 Pc_val,
558 mdot_O,
559 mdot_F,
560 cea_props,
561 closure_diag,
562 with_profile=not getattr(self, "_silent", False),
563 )
564
565 # Extract and validate mixture diagnostics (diagnostics-only, no efficiency impact)
566 # Enable mixture coupling diagnostics if configured
567 eff_cfg = self.config.combustion.efficiency
568 if getattr(eff_cfg, 'use_mixture_coupling', False) and isinstance(closure_diag, dict):
569 # Extract mixture diagnostics in non-strict mode (warn but don't fail)
570 mixture_diag = self._extract_and_validate_mixture_diagnostics(closure_diag, strict=False)
571 # Log diagnostics for informational purposes
572 if debug:
573 self._log_mixture_diagnostics(mixture_diag)
574 # Store in closure_diag for later analysis/logging
575 closure_diag['mixture_diagnostics'] = mixture_diag
576
577
578 # Build advanced parameters for combustion efficiency calculation
579 geometry = self._get_chamber_geometry()
580
581 advanced_params = {
582 "Pc": Pc_val,
583 "Tc": cea_props["Tc"], # Ideal Tc (Conservative Residence Time)
584 "Tc_kinetics": effective_Tc, # Actual Tc (Conservative Kinetics)
585 "cstar_ideal": cea_props.get("cstar_ideal", DEFAULT_CSTAR_IDEAL_M_S),
586 "gamma": cea_props.get("gamma", DEFAULT_GAMMA_ND),
587 "R": cea_props.get("R", DEFAULT_GAS_CONST_J_KG_K),
588 "MR": MR,
589 "Ac": geometry["area_cross"],
590 "At": cg.A_throat,
591 "chamber_length": geometry["length"],
592 "Dinj": current_injector_diameter,
593 "m_dot_total": mdot_total,
594 "u_fuel": closure_diag.get("u_F"),
595 "u_lox": closure_diag.get("u_O"),
596 "spray_diagnostics": closure_diag,
597 "turbulence_intensity": closure_diag.get("turbulence_intensity_mix", DEFAULT_TURBULENCE_INTENSITY_ND),
598 "momentum_ratio_R": closure_diag.get("momentum_ratio_R"),
599 "R_opt": self._rupe_R_opt(),
600 "fuel_props": self._get_fuel_props(),
601 "ox_props": self._get_ox_props(),
602 "cea_cache": self.cea_cache, # c*(O/F) for the mixing / vaporized-gas c* ratios
603 }
604
605 eta = eta_cstar(
606 current_Lstar,
607 self.config.combustion.efficiency,
608 cooling_eff,
609 advanced_params,
610 debug=debug,
611 )
612
613 # Comprehensive validation of final solution
614 cstar_actual = eta * cea_props["cstar_ideal"]
615 gamma = cea_props["gamma"]
616 R = cea_props["R"]
617
618 if debug:
619 logging.getLogger("evaluate").info(
620 f"[CSTAR] eta={eta:.4f} | c*_ideal={cea_props['cstar_ideal']:.1f} m/s -> c*_actual={cstar_actual:.1f} m/s | "
621 f"ratio={cstar_actual/cea_props['cstar_ideal']:.4f}"
622 )
623
624 # Calculate Isp for validation
625 # CRITICAL FIX: Correct Isp formula
626 # Isp = F / (mdot * g0) = (Cf * Pc * At) / (mdot * g0)
627 # OR equivalently: Isp = cstar * Cf / g0
628 # The previous formula with gamma * sqrt(...) was incorrect
629 # Sanity-check Isp on the delivered basis at sea level (this solver has no site Pa).
630 g0 = 9.80665
631 cg = ensure_chamber_geometry(self.config)
632 kappa = nozzle_stagnation_loss(contraction_ratio_of(cg), float(cea_props["gamma"]))
633 F_check = (cg.nozzle_efficiency * cea_props["Cf_vac"] * Pc_val / kappa * cg.A_throat
634 - 101325.0 * (cg.A_exit or cg.A_throat * cg.expansion_ratio))
635 Isp = F_check / (mdot_total * g0) if mdot_total > 0 else 0.0
636
637 # Validate engine state (use effective temperature after cooling)
638 validation_results = validate_engine_state(
639 Pc_val, MR, mdot_total, cstar_actual, gamma, effective_Tc, Isp, 0.0 # F not calculated yet
640 )
641
642 # Check for critical errors
643 critical_errors = [r for r in validation_results if r.severity == "error" and not r.passed]
644 if critical_errors:
645 error_msgs = [r.message for r in critical_errors]
646 raise RuntimeError(f"Solution validation failed:\n" + "\n".join(error_msgs))
647
648 diagnostics = {
649 "Pc": Pc_val,
650 "mdot_O": mdot_O,
651 "mdot_F": mdot_F,
652 "mdot_total": mdot_total,
653 "MR": MR,
654 "cstar_ideal": cea_props["cstar_ideal"],
655 "Tc_ideal": cea_props["Tc"], # Store original ideal temperature
656 "cstar_actual": cstar_actual,
657 "Pc_ns": Pc_val / kappa, # nozzle stagnation pressure
658 "stagnation_loss_kappa": kappa,
659 "cea_extrapolated": bool(cea_props.get("extrapolated", False)), # converged point off the table
660 "cea_clamped": tuple(cea_props.get("clamped", ())),
661 # The table those were read against, so a report can say how far off it the point sits.
662 "cea_table_range": _cea_table_range(self.cea_cache),
663 "eta_cstar": eta,
664 "cooling_efficiency": cooling_eff,
665 "Tc": effective_Tc, # Use effective temperature after cooling (accounts for energy removal)
666 "Tc_ideal": cea_props["Tc"], # Store original CEA temperature for reference
667 "gamma": gamma,
668 "R": R,
669 "M": cea_props.get("M"), # Molecular weight [kg/kmol]
670 "spray_quality_good": self.spray_quality_good,
671 "validation_results": validation_results, # Include validation results
672 "convergence_history": convergence_history, # Include convergence history
673 **closure_diag,
674 }
675
676 diagnostics["cooling"] = cooling_results
677
678 return Pc_val, diagnostics
679
681 self,
682 closure_diag: Dict[str, Any],
683 strict: bool = True
684 ) -> Dict[str, float]:
685 """
686 Extract and validate mixture quality diagnostics from closure diagnostics.
687
688 DIAGNOSTICS-ONLY - no efficiency impact. Physics comes from:
689 - eta_Lstar: actual SMD via gasification model
690 - eta_mixing: actual turbulence/velocities via diffusion
691 - eta_kinetics: actual T/P via Damköhler number
692
693 Parameters:
694 - closure_diag: Injector diagnostics dict
695 - strict: If True, raise on missing critical data. If False, warn.
696
697 Returns: Dict with actual_smd_microns, x_star_mm, We_O, We_F, etc.
698 """
699 if not isinstance(closure_diag, dict):
700 if strict:
701 raise ValueError("closure_diag must be a dictionary")
702 else:
703 import warnings
704 warnings.warn("closure_diag is not a dictionary, cannot extract mixture diagnostics")
705 return {}
706
707 diagnostics = {}
708
709 # === EXTRACT SMD (CRITICAL) ===
710 D32_O = closure_diag.get("D32_O")
711 D32_F = closure_diag.get("D32_F")
712
713 if D32_O is None and D32_F is None:
714 msg = "Missing SMD data: both D32_O and D32_F are None in closure diagnostics"
715 if strict:
716 raise ValueError(msg)
717 else:
718 import warnings
719 warnings.warn(msg)
720 else:
721 actual_smd_m = max(
722 float(D32_O) if D32_O is not None else 0.0,
723 float(D32_F) if D32_F is not None else 0.0,
724 )
725 actual_smd_microns = actual_smd_m * 1e6
726
727 if not np.isfinite(actual_smd_microns):
728 msg = (
729 f"Non-finite SMD: {actual_smd_microns:.2f} microns. "
730 f"D32_O={D32_O}, D32_F={D32_F}. Check spray breakup calculations."
731 )
732 if strict:
733 raise ValueError(msg)
734 else:
735 import warnings
736 warnings.warn(msg)
737 elif actual_smd_microns <= 0:
738 msg = (
739 f"Invalid SMD: {actual_smd_microns:.2f} microns (must be > 0). "
740 f"D32_O={D32_O}, D32_F={D32_F}. Check spray breakup calculations."
741 )
742 if strict:
743 raise ValueError(msg)
744 else:
745 import warnings
746 warnings.warn(msg)
747 else:
748 diagnostics["actual_smd_microns"] = float(actual_smd_microns)
749
750 # === EXTRACT IMPINGEMENT DISTANCE (CRITICAL) ===
751 x_star_m = closure_diag.get("x_star")
752
753 if x_star_m is None:
754 msg = "Missing x_star in closure diagnostics. Check spray impingement calculations."
755 if strict:
756 raise ValueError(msg)
757 else:
758 import warnings
759 warnings.warn(msg)
760 else:
761 x_star_mm = float(x_star_m) * 1000.0
762
763 if not np.isfinite(x_star_mm):
764 msg = f"Non-finite x_star: {x_star_mm} mm. Must be finite."
765 if strict:
766 raise ValueError(msg)
767 else:
768 import warnings
769 warnings.warn(msg)
770 else:
771 # x_star <= 0 is valid (upstream/no impingement)
772 diagnostics["x_star_mm"] = float(x_star_mm)
773
774 # === EXTRACT WEBER NUMBERS (NON-CRITICAL) ===
775 We_O = closure_diag.get("We_O")
776 We_F = closure_diag.get("We_F")
777
778 if We_O is not None:
779 we_o = float(We_O)
780 if np.isfinite(we_o) and we_o >= 0:
781 diagnostics["We_O"] = we_o
782 elif strict:
783 raise ValueError(f"Invalid We_O: {we_o}. Must be finite and non-negative.")
784
785 if We_F is not None:
786 we_f = float(We_F)
787 if np.isfinite(we_f) and we_f >= 0:
788 diagnostics["We_F"] = we_f
789 elif strict:
790 raise ValueError(f"Invalid We_F: {we_f}. Must be finite and non-negative.")
791
792 # Compute We_min if both present
793 if "We_O" in diagnostics and "We_F" in diagnostics:
794 we_o = diagnostics["We_O"]
795 we_f = diagnostics["We_F"]
796
797 # Use minimum of positive values, or max if one is zero
798 if we_o > 0 and we_f > 0:
799 diagnostics["We_min"] = min(we_o, we_f)
800 else:
801 diagnostics["We_min"] = max(we_o, we_f)
802
803 # === EXTRACT TURBULENCE INTENSITY (OPTIONAL) ===
804 I_mix = closure_diag.get("turbulence_intensity_mix")
805
806 if I_mix is not None:
807 I_mix_val = float(I_mix)
808
809 if not np.isfinite(I_mix_val):
810 msg = f"Non-finite turbulence intensity: {I_mix_val}"
811 if strict:
812 raise ValueError(msg)
813 else:
814 import warnings
815 warnings.warn(msg)
816 elif I_mix_val <= 0 or I_mix_val > 1:
817 msg = (
818 f"Turbulence intensity out of range: {I_mix_val:.4f} (expected 0-1). "
819 f"Check turbulence calculations."
820 )
821 if strict:
822 raise ValueError(msg)
823 else:
824 import warnings
825 warnings.warn(msg)
826 else:
827 diagnostics["turbulence_intensity_mix"] = I_mix_val
828
829 return diagnostics
830
831 def _log_mixture_diagnostics(self, diag: Dict[str, float]) -> None:
832 """
833 Log mixture quality diagnostics for informational purposes.
834
835 DIAGNOSTICS-ONLY - no efficiency impact.
836 """
837 if not diag:
838 return
839
840 parts = []
841
842 if "actual_smd_microns" in diag:
843 parts.append(f"SMD={diag['actual_smd_microns']:.1f} μm")
844
845 if "x_star_mm" in diag:
846 parts.append(f"x*={diag['x_star_mm']:.2f} mm")
847
848 if "We_O" in diag and "We_F" in diag:
849 parts.append(f"We_O={diag['We_O']:.1f}, We_F={diag['We_F']:.1f}")
850 if "We_min" in diag:
851 parts.append(f"We_min={diag['We_min']:.1f}")
852
853 if "turbulence_intensity_mix" in diag:
854 parts.append(f"I_mix={diag['turbulence_intensity_mix']:.3f}")
855
856 if parts:
857 import logging
858 logging.getLogger("evaluate").info(f"[MIXTURE_DIAG] {', '.join(parts)}")
859
861 self,
862 cooling_results: Dict[str, Any],
863 mdot_total: float,
864 Tc: float,
865 gamma: float,
866 R: float,
867 ) -> float:
868 eff_cfg = self.config.combustion.efficiency
869 if not eff_cfg.use_cooling_coupling:
870 return 1.0
871
872 total_heat_removed = 0.0
873 for source in cooling_results.values():
874 if isinstance(source, dict):
875 total_heat_removed += float(source.get("heat_removed", 0.0))
876
877 if total_heat_removed <= 0:
878 return 1.0
879
880 cp = gamma * R / max(gamma - 1.0, 1e-6)
881 available_energy = mdot_total * cp * max(Tc, 1.0)
882 if available_energy <= 0:
883 return 1.0
884
885 factor = 1.0 - total_heat_removed / available_energy
886 return float(np.clip(factor, eff_cfg.cooling_efficiency_floor, 1.0))
887
889 self,
890 Pc: float,
891 mdot_O: float,
892 mdot_F: float,
893 cea_props: Dict[str, float],
894 closure_diag: Dict[str, Any],
895 with_profile: bool = True,
896 ) -> Tuple[Dict[str, Any], float, float]:
897 config = self.config
898 mdot_total = mdot_O + mdot_F
899 cooling_results: Dict[str, Any] = {}
900 Tc = float(cea_props["Tc"])
901
902 if mdot_total <= 0:
903 closure_diag["cooling"] = cooling_results
904 return cooling_results, 1.0, Tc
905
906 fuel_fluid = config.fluids["fuel"]
907 geometry = self._get_chamber_geometry()
908 Pc_val = float(Pc)
909 gamma = float(cea_props["gamma"])
910 R = float(cea_props["R"])
911
912 rho_g = max(Pc_val / (R * max(Tc, 1.0)), 1e-6)
913 area_cross = geometry["area_cross"]
914 velocity_g = mdot_total / (rho_g * area_cross)
915
916 regen_cfg = config.regen_cooling
917 # A disabled regen block describes hardware that is not there: none of its gas-side
918 # numbers (bore, k, Pr, emissivity, turbulence, recovery) may reach the other models.
919 regen_active = regen_cfg is not None and regen_cfg.enabled
920 MR = mdot_O / mdot_F if mdot_F > 0 else float(cea_props.get("MR", 0.0) or 0.0)
921 tr = self.cea_cache.aux.transport(MR, Pc_val, "chamber")
922 mu_g_config = regen_cfg.hot_gas_viscosity if regen_active else tr["mu"]
923 M = cea_props.get("M") # Molecular weight [kg/kmol]
924 if regen_active and M is not None and M > 0 and Tc > 0:
925 from engine.pipeline.thermal.regen_cooling import calculate_gas_viscosity_huzel
926 mu_g_calculated = calculate_gas_viscosity_huzel(Tc, M)
927 else:
928 mu_g_calculated = tr["mu"]
929 mu_g = mu_g_calculated
930 k_g = regen_cfg.hot_gas_thermal_conductivity if regen_active else tr["k"]
931 Pr_g = regen_cfg.hot_gas_prandtl if (regen_active and regen_cfg.hot_gas_prandtl > 0) else tr["Pr"]
932
933 Re_g = rho_g * velocity_g * geometry["diameter"] / max(mu_g, 1e-8)
934 Nu_g = 0.023 * (Re_g ** 0.8) * (Pr_g ** 0.4)
935
936 # Core turbulence of fully developed pipe flow; the film model's erosion input.
937 turbulence_intensity_calc = float(np.clip(0.16 * Re_g ** -0.125, 0.02, 0.25)) if Re_g > 0 else 0.05
938 turbulence_boost = 1.0
939 if regen_active:
940 turbulence_boost = 1.0 + max(regen_cfg.gas_turbulence_intensity, 0.0)
941 turbulence_intensity_calc = max(
942 turbulence_intensity_calc,
943 float(np.clip(regen_cfg.gas_turbulence_intensity, 0.0, 0.5)),
944 )
945
946 h_hot_base = Nu_g * k_g / geometry["diameter"] * turbulence_boost
947
948 gas_props = {
949 "Pc": Pc_val,
950 "Tc": Tc,
951 "gamma": gamma,
952 "R": R,
953 "rho": rho_g,
954 "velocity": velocity_g,
955 "length": geometry["length"],
956 "circumference": geometry["circumference"],
957 "area": geometry["area"],
958 "area_cross": area_cross,
959 "h_hot_base": h_hot_base,
960 "turbulence_intensity": turbulence_intensity_calc,
961 }
962
963 film_cfg = config.film_cooling
964 film_results = {
965 "enabled": False,
966 "mdot_available_for_regen": mdot_F,
967 "effective_gas_temperature": Tc,
968 "heat_removed": 0.0,
969 }
970
971 if film_cfg is not None and film_cfg.enabled:
972 film_results = compute_film_cooling(
973 mdot_total,
974 mdot_F,
975 gas_props,
976 film_cfg,
977 fuel_fluid,
978 )
979 cooling_results["film"] = film_results
980
981 effective_Tc = float(film_results.get("effective_gas_temperature", Tc))
982 comp = self.cea_cache.aux.composition(MR, Pc_val, "chamber")
983 gas_props_regen = {
984 "Pc": Pc_val,
985 "Tc": effective_Tc,
986 "gamma": gamma,
987 "R": R,
988 "M": cea_props.get("M"), # Molecular weight [kg/kmol] for viscosity calculation
989 "chamber_area": geometry["area_cross"],
990 "A_throat": ensure_chamber_geometry(config).A_throat,
991 "chamber_length": geometry["length"],
992 "turbulence_intensity": turbulence_intensity_calc,
993 "x_H2O": comp["H2O"],
994 "x_CO2": comp["CO2"],
995 }
996
997 coolant_props = {
998 "density": float(fuel_fluid.density),
999 "viscosity": float(fuel_fluid.viscosity),
1000 "cp": float(fuel_fluid.specific_heat),
1001 "thermal_conductivity": float(fuel_fluid.thermal_conductivity),
1002 "temperature": float(fuel_fluid.temperature),
1003 }
1004
1005 mdot_coolant = float(film_results.get("mdot_available_for_regen", mdot_F))
1006
1007 if regen_active:
1008 regen_results = compute_regen_heat_transfer(
1009 mdot_coolant,
1010 coolant_props,
1011 gas_props_regen,
1012 regen_cfg,
1013 mdot_total,
1014 )
1015 regen_results["mdot_coolant"] = mdot_coolant
1016 cooling_results["regen"] = regen_results
1017
1018 abl_cfg = config.ablative_cooling
1019 if abl_cfg is not None and abl_cfg.enabled:
1020 from engine.pipeline.thermal.gas_side import HotGasState
1021 cg_now = ensure_chamber_geometry(config)
1022 contour = self._wall_contour()
1023 gas = HotGasState(T0=effective_Tc, P0=Pc_val, gamma=gamma,
1024 mass_flux_throat=mdot_total / cg_now.A_throat, mu=tr["mu"],
1025 cp=tr["cp"], Pr=tr["Pr"], x_H2O=comp["H2O"], x_CO2=comp["CO2"])
1026 ablative_results = liner_response(gas, contour, abl_cfg, self._liner_end(contour),
1027 mdot_total, with_profile=with_profile)
1028 ablative_results["incident_heat_flux"] = (ablative_results["heat_flux_from_gas_convective"]
1029 + ablative_results["heat_flux_from_gas_radiative"])
1030
1031 # Energy the liner takes out of the gas: dT = Q / (mdot cp).
1032 abl_heat_removed = ablative_results.get("heat_removed", 0.0)
1033 if abl_heat_removed > 0 and mdot_total > 0:
1034 cp = gamma * R / max(gamma - 1.0, 1e-6)
1035 delta_T_abl = abl_heat_removed / max(mdot_total * cp, 1e-6)
1036 effective_Tc = max(effective_Tc - delta_T_abl, 1.0)
1037 ablative_results["temperature_reduction"] = float(delta_T_abl)
1038 else:
1039 ablative_results["temperature_reduction"] = 0.0
1040 ablative_results["effective_gas_temperature"] = float(effective_Tc)
1041 cooling_results["ablative"] = ablative_results
1042
1043 cooling_eff = self._compute_cooling_efficiency(
1044 cooling_results,
1045 mdot_total,
1046 effective_Tc,
1047 gamma,
1048 R,
1049 )
1050
1051 # Store metadata for diagnostics
1052 metadata = cooling_results.setdefault("metadata", {})
1053 metadata["gas_turbulence_intensity"] = turbulence_intensity_calc
1054 metadata["effective_gas_temperature"] = float(effective_Tc)
1055 metadata["original_gas_temperature"] = float(Tc)
1056 metadata["gas_viscosity"] = float(mu_g) # Viscosity used in calculations (calculated from Huzel if available)
1057 metadata["gas_viscosity_config"] = float(mu_g_config) # Viscosity from config (for reference)
1058 metadata["gas_viscosity_calculated"] = float(mu_g_calculated) # Viscosity from Huzel formula (for reference)
1059 closure_diag["cooling"] = cooling_results
1060
1061 return cooling_results, cooling_eff, effective_Tc
1062
1063 def _wall_contour(self):
1064 """The drawn gas-side contour for this geometry (cached per geometry)."""
1065 from engine.pipeline.thermal.gas_side import contour_for
1066 cg = ensure_chamber_geometry(self.config)
1067 key = (cg.A_throat, cg.chamber_diameter, cg.volume, cg.A_exit)
1068 cached = getattr(self, "_wall_contour_cache", None)
1069 if cached is None or cached[0] != key:
1070 cached = (key, contour_for(cg))
1072 return cached[1]
1073
1074 def _liner_end(self, contour) -> float:
1075 """Throat-frame x where the ablative liner stops: the graphite insert's upstream edge,
1076 or the throat when there is no insert."""
1077 gr = getattr(self.config, "graphite_insert", None)
1078 if gr is None or not gr.enabled:
1079 return 0.0
1080 half = gr.axial_half_length or gr.axial_half_length_ratio * 2.0 * contour.R_t
1081 return -float(half)
1082
1083 def _infer_injector_diameter(self) -> float:
1084 """Estimate a characteristic injector diameter for mixing models."""
1085 injector_cfg = getattr(self.config, "injector", None)
1086 diameter = None
1087 injector_type = getattr(injector_cfg, "type", None) if injector_cfg is not None else None
1088 try:
1089 if injector_type == "pintle":
1090 diameter = injector_cfg.geometry.fuel.d_pintle_tip
1091 elif injector_type == "coaxial":
1092 diameter = injector_cfg.geometry.core.d_port
1093 elif injector_type == "impinging":
1094 diameter = injector_cfg.geometry.oxidizer.d_jet
1095 except AttributeError:
1096 diameter = None
1097
1098 if diameter is None or diameter <= 0:
1099 geometry = self._get_chamber_geometry()
1100 diameter = np.sqrt(4.0 * geometry["area_cross"] / np.pi)
1101 return float(max(diameter, 1e-5))
1102
1103 def _get_chamber_geometry(self) -> Dict[str, float]:
1104 """
1105 Extract physical chamber geometry from configuration.
1106
1107 Returns a dictionary with:
1108 - length: Total physical length [m]
1109 - diameter: Chamber inner diameter [m]
1110 - area_cross: Cross-sectional area [m²]
1111 - circumference: Chamber circumference [m]
1112 - area: Total wetted surface area [m²] (cylindrical + contraction)
1113 """
1114 # cg is guaranteed to exist because __init__ calls ensure_chamber_geometry
1115 cg = ensure_chamber_geometry(self.config)
1116 regen_cfg = self.config.regen_cooling
1117
1118 # 1. Physical diameter from unified config
1119 diameter = cg.chamber_diameter
1120
1121 # Fallback to regen if not in unified (though ensure_chamber_geometry should handle it)
1122 if (diameter is None or diameter <= 0) and regen_cfg is not None and regen_cfg.chamber_inner_diameter is not None:
1123 diameter = regen_cfg.chamber_inner_diameter
1124
1125 # Final fallback
1126 if diameter is None or diameter <= 0:
1127 diameter = 0.08
1128
1129 diameter = max(diameter, 1e-6)
1130 area_cross = np.pi * (diameter / 2.0)**2
1131 circumference = np.pi * diameter
1132
1133 # 2. Physical lengths
1134 length_total = cg.length
1135 length_cyl = cg.length_cylindrical
1136 length_cont = cg.length_contraction
1137
1138 # 3. Wetted Surface Area
1139 # If we have the breakdown (cylindrical + contraction), calculate accurately
1140 if length_cyl is not None and length_cont is not None:
1141 # Wetted area = Cylindrical part + Contraction part (frustum of a cone)
1142 # Area_cyl = pi * D * L_cyl
1143 area_cyl = circumference * length_cyl
1144
1145 # Area_cont = lateral area of a frustum = pi * (r1 + r2) * slant_height
1146 r1 = diameter / 2.0
1147 # Estimate throat radius from A_throat if available
1148 A_throat = cg.A_throat if cg.A_throat and cg.A_throat > 0 else (area_cross / 3.0)
1149 r2 = np.sqrt(A_throat / np.pi)
1150
1151 slant_height = np.sqrt((r1 - r2)**2 + length_cont**2)
1152 area_cont = np.pi * (r1 + r2) * slant_height
1153
1154 area_wetted = area_cyl + area_cont
1155 else:
1156 # Fallback to simple cylinder if breakdown not available
1157 area_wetted = circumference * length_total
1158
1159 return {
1160 "length": float(length_total),
1161 "diameter": float(diameter),
1162 "area_cross": float(area_cross),
1163 "circumference": float(circumference),
1164 "area": float(area_wetted),
1165 }
1166
1167
1168 def _rupe_R_opt(self) -> float:
1169 """Momentum-ratio optimum for the Rupe mixing model.
1170
1171 Honors an explicit ``combustion.efficiency.R_opt`` override; otherwise derives
1172 it from the impinging-doublet geometry (``sqrt(sin(theta_F)/sin(theta_O))``),
1173 falling back to 1.0 (balanced) for pintle/coaxial or when angles are absent.
1174 """
1175 eff = self.config.combustion.efficiency
1176 override = getattr(eff, "R_opt", None)
1177 if override is not None:
1178 return float(override)
1179 try:
1180 from engine.pipeline.combustion_physics import rupe_R_opt_from_angles
1181 geom = self.config.injector.geometry
1182 theta_O = getattr(getattr(geom, "oxidizer", None), "impingement_angle", None)
1183 theta_F = getattr(getattr(geom, "fuel", None), "impingement_angle", None)
1184 return rupe_R_opt_from_angles(theta_O, theta_F)
1185 except (AttributeError, TypeError):
1186 return 1.0
1187
1188 def _fluid_props(self, which: str) -> Optional[Dict[str, float]]:
1189 """Liquid properties of ``fluids.<which>`` for the c* vaporization march: exactly what the
1190 config declares (no substituted RP-1 values). Missing keys are left out, and the march
1191 records each one it has to assume."""
1192 fl = (getattr(self.config, "fluids", None) or {}).get(which)
1193 if fl is None:
1194 return None
1195 props: Dict[str, float] = {}
1196 for key in ("density", "boiling_point", "latent_heat", "molecular_weight", "specific_heat",
1197 "temperature", "critical_temperature", "viscosity", "surface_tension"):
1198 v = getattr(fl, key, None)
1199 if v is not None:
1200 props[key] = float(v)
1201 props["Pc_ref"] = float(getattr(fl, "Pc_ref", 2.5e6) or 2.5e6)
1202 if getattr(fl, "name", None):
1203 props["name"] = str(fl.name) # CoolProp liquid cp(T) / density in the heat-up
1204 return props
1205
1206 def _get_fuel_props(self) -> Optional[Dict[str, float]]:
1207 """Fuel liquid properties for the c* model (see ``_fluid_props``), plus the fuel-interface
1208 temperature cap the legacy gasification helper reads."""
1209 props = self._fluid_props("fuel")
1210 if props is None:
1211 return None
1212 props["T_star_fuel_cap_K"] = float(getattr(self.config.combustion.efficiency, "T_star_fuel_cap_K", 1000.0))
1213 return props
1214
1215 def _get_ox_props(self) -> Optional[Dict[str, float]]:
1216 """Oxidizer liquid properties for the c* model (see ``_fluid_props``)."""
1217 return self._fluid_props("oxidizer")
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
static board_sleep_info_t info
Definition board_sleep.c:14
_highest_sign_change(f, float Pc_min, float Pc_max, float r_min, float r_max, int n=PC_BRACKET_SCAN_POINTS)
Dict[str, float] _extract_and_validate_mixture_diagnostics(self, Dict[str, Any] closure_diag, bool strict=True)
float _compute_cooling_efficiency(self, Dict[str, Any] cooling_results, float mdot_total, float Tc, float gamma, float R)
float residual(self, float Pc, float P_tank_O, float P_tank_F)
__init__(self, PintleEngineConfig config, CEACache cea_cache)
Tuple[float, Dict[str, Any]] solve(self, float P_tank_O, float P_tank_F, float Pc_guess=None, bool debug=False, bool silent=False)
Optional[Dict[str, float]] _fluid_props(self, str which)
Tuple[Dict[str, Any], float, float] _evaluate_cooling_models(self, float Pc, float mdot_O, float mdot_F, Dict[str, float] cea_props, Dict[str, Any] closure_diag, bool with_profile=True)
Optional[Dict[str, float]] _get_ox_props(self)
None _log_mixture_diagnostics(self, Dict[str, float] diag)
Optional[Dict[str, float]] _get_fuel_props(self)
_accel_chamber_pc(self, float P_tank_O, float P_tank_F)
_raise_supply_exceeds_demand(self, P_tank_O, P_tank_F, Pc_max, residual_min, residual_max, debug)