STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
runner.py
Go to the documentation of this file.
1"""Main pipeline orchestrator - runs full tank pressure to thrust pipeline"""
2
3import math
4import numpy as np
5import logging
6import os
7from typing import Dict, Any, Optional, Union
8import copy
9
10from engine.pipeline.config_schemas import PintleEngineConfig, ensure_chamber_geometry
11from engine.pipeline.cea_cache import CEACache
12from engine.core.chamber_solver import ChamberSolver
13from engine.core.nozzle import calculate_thrust
15 calculate_chamber_pressure_profile,
16 calculate_chamber_intrinsics,
17)
19 update_chamber_geometry_from_ablation,
20 update_nozzle_exit_from_ablation,
21 calculate_local_recession_rate,
22)
24 compute_graphite_recession,
25 calculate_throat_heuristic_multiplier,
26)
27from engine.pipeline.constants import DEFAULT_GAMMA_ND
28
29
30def compute_ambient_pressure_from_elevation(elevation_m: float) -> float:
31 """Ambient pressure [Pa] at ``elevation_m`` above sea level: the 1976 U.S. Standard Atmosphere.
32
33 Adapted from ``fluids.atmosphere.ATMOSPHERE_1976`` (U.S. Standard Atmosphere 1976, NOAA/NASA/
34 USAF). This used to be an isothermal exponential ``P0 exp(-M g h / (R T0))`` with T0 held at
35 288.15 K, which the docstring called a standard atmosphere but is not one: it ignores the
36 6.5 K/km lapse, so it reads high by 49 Pa at the LE4 site (626.67 m: 94 069.7 vs 94 020.4 Pa),
37 0.26 % at 1.4 km and 1.25 % at 3 km.
38 """
39 from fluids.atmosphere import ATMOSPHERE_1976
40
41 return float(ATMOSPHERE_1976(float(elevation_m)).P)
42
43
45 """Main pipeline runner - orchestrates full tank pressure to thrust calculation"""
46
47 def __init__(self, config: PintleEngineConfig):
48 """
49 Initialize pipeline runner.
50
51 Parameters:
52 -----------
53 config : PintleEngineConfig
54 Engine configuration (should be a deep copy if isolation is needed)
55 """
56 # Store config reference - caller is responsible for ensuring isolation
57 # (deep copy is done in Layer 3 before passing config to runner)
58 # Measured values replace the assumptions they were taken to settle (engine/pipeline/
59 # measurements.py); a config without measurements is used as given.
60 from engine.pipeline.measurements import apply_measurements
61 self.config = apply_measurements(config)
62 config = self.config
63 cg = ensure_chamber_geometry(self.config)
64
65 # Initialize CEA cache
66 self.cea_cache = CEACache(config.combustion.cea)
67
68 # Initialize chamber solver
69 self.solver = ChamberSolver(config, self.cea_cache)
70
71 def _get_ambient_pressure(self, P_ambient: Optional[float] = None) -> float:
72 """Get ambient pressure, computing from config elevation if not provided.
73
74 Parameters:
75 -----------
76 P_ambient : float, optional
77 Explicit ambient pressure [Pa]. If None, computed from config.
78
79 Returns:
80 --------
81 float
82 Ambient pressure [Pa]
83 """
84 if P_ambient is not None:
85 return P_ambient
86
87 # Compute from config environment elevation if available
88 if hasattr(self.config, 'environment') and self.config.environment is not None:
89 elevation = getattr(self.config.environment, 'elevation', None)
90 if elevation is not None and elevation >= 0:
92
93 # Default to sea level
94 return 101325.0
95
96 def _get_elevation(self) -> float:
97 """Get elevation from config, or 0.0 if not available."""
98 if hasattr(self.config, 'environment') and self.config.environment is not None:
99 return getattr(self.config.environment, 'elevation', 0.0) or 0.0
100 return 0.0
101
103 self,
104 P_tank_O: float,
105 P_tank_F: float,
106 Pc_guess: Optional[float] = None,
107 P_ambient: Optional[float] = None,
108 debug: bool = False,
109 silent: bool = False,
110 rich_stability: bool = False,
111 stability_overrides: Optional[Dict[str, float]] = None,
112 ) -> Dict[str, Any]:
113 """
114 Evaluate engine performance at given tank pressures.
115
116 Parameters:
117 -----------
118 P_tank_O : float
119 Oxidizer tank pressure [Pa]
120 P_tank_F : float
121 Fuel tank pressure [Pa]
122 Pc_guess : float, optional
123 Initial guess for chamber pressure [Pa]
124 P_ambient : float, optional
125 Ambient pressure [Pa]. If None, computed from config environment
126 elevation using standard atmosphere model.
127 debug : bool, optional
128 If True, enables debug logging to output/logs/evaluate.log and prints
129 detailed execution traces. Defaults to False.
130
131 Returns:
132 --------
133 results : dict
134 Dictionary containing all performance metrics:
135 - Pc: Chamber pressure [Pa]
136 - mdot_O: Oxidizer mass flow [kg/s]
137 - mdot_F: Fuel mass flow [kg/s]
138 - MR: Mixture ratio (O/F)
139 - F: Thrust [N]
140 - Isp: Specific impulse [s]
141 - cstar_actual: Actual characteristic velocity [m/s]
142 - P_ambient: Ambient pressure used [Pa]
143 - elevation: Elevation from config [m]
144 - diagnostics: Detailed diagnostics dict
145
146 Raises:
147 -------
148 ValueError
149 If any physics validation fails (invalid values, out of range, etc.)
150 KeyError
151 If required data is missing from results
152 RuntimeError
153 If solver fails to converge or calculation fails
154 """
155 # Wrap entire evaluation in try-except to log errors before re-raising
156 try:
157 return self._evaluate_internal(
158 P_tank_O, P_tank_F, Pc_guess, P_ambient, debug, silent,
159 rich_stability=rich_stability,
160 stability_overrides=stability_overrides,
161 )
162 except Exception as e:
163 # Log error with full traceback if debug is enabled
164 if debug:
165 logger = logging.getLogger("evaluate")
166 logger.error("="*80)
167 logger.error("[EVALUATION FAILED]")
168 logger.error(f"Error type: {type(e).__name__}")
169 logger.error(f"Error message: {str(e)}")
170 logger.error(f"Inputs: P_tank_O={P_tank_O/6894.76:.2f} psi, P_tank_F={P_tank_F/6894.76:.2f} psi")
171 logger.error("Full traceback:", exc_info=True)
172 logger.error("="*80)
173 # Re-raise the exception to crash the program as intended
174 raise
175
177 self,
178 P_tank_O: float,
179 P_tank_F: float,
180 Pc_guess: Optional[float] = None,
181 P_ambient: Optional[float] = None,
182 debug: bool = False,
183 silent: bool = False,
184 rich_stability: bool = False,
185 stability_overrides: Optional[Dict[str, float]] = None,
186 ) -> Dict[str, Any]:
187 """Internal evaluation implementation (wrapped by evaluate() for error logging)."""
188 # Configure logging
189 logger = logging.getLogger("evaluate")
190 logger.propagate = False # Prevent double logging to root logger
191
192 if debug:
193 # Where the debug log goes, and how much of it reaches the console.
194 # Unset, exactly as before: output/logs under the working directory,
195 # INFO to the console. A stack that runs from a working tree (the
196 # demo) points both away from the repo and the presenter's console.
197 log_dir = os.environ.get("ENGINE_DESIGN_LOG_DIR") or "output/logs"
198 os.makedirs(log_dir, exist_ok=True)
199 log_file = os.path.join(log_dir, "evaluate.log")
200
201 # Remove existing handlers to avoid duplicates
202 for handler in logger.handlers[:]:
203 logger.removeHandler(handler)
204
205 logger.setLevel(logging.DEBUG)
206
207 # File handler
208 fh = logging.FileHandler(log_file, mode='w')
209 fh.setLevel(logging.DEBUG)
210 file_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
211 fh.setFormatter(file_formatter)
212 logger.addHandler(fh)
213
214 # Console handler
215 ch = logging.StreamHandler()
216 level = logging.getLevelName(os.environ.get("ENGINE_DESIGN_CONSOLE_LOG_LEVEL", "INFO").upper())
217 ch.setLevel(level if isinstance(level, int) else logging.INFO)
218 console_formatter = logging.Formatter('%(message)s')
219 ch.setFormatter(console_formatter)
220 logger.addHandler(ch)
221
222 logger.info("Debug logging enabled. Writing to %s", log_file)
223 else:
224 # Remove existing handlers
225 for handler in logger.handlers[:]:
226 logger.removeHandler(handler)
227
228 # Silent path: raise the level so logger.info(...) short-circuits before
229 # the expensive logging machinery (findCaller/makeRecord) — there is no
230 # handler anyway, so output is unchanged. Saves ~0.1 ms/eval in the
231 # optimizer hot loop. Non-silent keeps INFO + a console handler as before.
232 logger.setLevel(logging.WARNING if silent else logging.INFO)
233
234 # Only add console handler if not silent
235 if not silent:
236 ch = logging.StreamHandler()
237 ch.setLevel(logging.INFO)
238 console_formatter = logging.Formatter('%(message)s')
239 ch.setFormatter(console_formatter)
240 logger.addHandler(ch)
241
242 # Helper to log messages
243 def log_info(msg):
244 logger.info(msg)
245
246 # Get ambient pressure (from config elevation if not provided)
247 Pa = self._get_ambient_pressure(P_ambient)
248 elevation = self._get_elevation()
249
250 # Ensure chamber_geometry exists
251 cg = ensure_chamber_geometry(self.config)
252
253 # Print chamber geometry at start of evaluation (exclude design parameters)
254 # Print chamber geometry at start of evaluation (exclude design parameters)
255 log_info("\n" + "="*80)
256 log_info("[CHAMBER GEOMETRY] Loaded configuration:")
257 log_info(f" Volume: {cg.volume*1e6:.2f} cm³ ({cg.volume:.6e} m³)")
258 log_info(f" A_throat: {cg.A_throat*1e6:.4f} mm² ({cg.A_throat:.6e} m²)")
259 log_info(f" A_exit: {cg.A_exit*1e6:.4f} mm² ({cg.A_exit:.6e} m²)")
260 log_info(f" Expansion Ratio: {cg.expansion_ratio:.4f}")
261 log_info(f" Nozzle Efficiency:{cg.nozzle_efficiency:.4f}")
262 if cg.length:
263 log_info(f" Length: {cg.length*1000:.2f} mm")
264 if cg.Lstar:
265 log_info(f" L* (config): {cg.Lstar*1000:.2f} mm")
266 if cg.chamber_diameter:
267 log_info(f" Chamber Diameter: {cg.chamber_diameter*1000:.2f} mm")
268 # if cg.contraction_ratio:
269 # log_info(f" Contraction Ratio:{cg.contraction_ratio:.4f}")
270 log_info("="*80 + "\n")
271
272 # Solve for chamber pressure (silent => skip display-only cooling profile)
273 Pc, diagnostics = self.solver.solve(P_tank_O, P_tank_F, Pc_guess, debug=debug, silent=silent)
274
275
276 At = cg.A_throat
277
278 # Validate all required diagnostics are present
279 required_keys = ["cstar_actual", "mdot_O", "mdot_F", "MR", "gamma", "R", "Tc"]
280 for key in required_keys:
281 if key not in diagnostics:
282 raise KeyError(f"Missing required diagnostic '{key}' from chamber solver")
283
284 cstar = diagnostics["cstar_actual"]
285 mdot = diagnostics["mdot_O"] + diagnostics["mdot_F"]
286
287 # Validate cstar and At before using
288 if At <= 0:
289 raise ValueError(f"Invalid throat area: At={At:.6e} m². Must be positive.")
290
291 if cstar <= 0:
292 raise ValueError(f"Invalid c*: {cstar:.2f} m/s. Must be positive.")
293
294 if mdot <= 0:
295 raise ValueError(f"Invalid mass flow: mdot={mdot:.4f} kg/s. Must be positive.")
296
297 mdot_choked = Pc * At / cstar
298 ratio = mdot / mdot_choked
299
300 # Get final mass flow rates
301 mdot_O = diagnostics["mdot_O"]
302 mdot_F = diagnostics["mdot_F"]
303 mdot_total = mdot_O + mdot_F
304 MR = diagnostics["MR"]
305
306 cstar_actual = diagnostics["cstar_actual"]
307 gamma = diagnostics["gamma"]
308 R = diagnostics["R"]
309 Tc = diagnostics["Tc"]
310
311 # Calculate implied c* from choked flow equation
312 if mdot_total <= 0:
313 raise ValueError(f"Invalid total mass flow: mdot_total={mdot_total} kg/s. Must be positive.")
314
315 cstar_implied = Pc * At / mdot_total
316 cstar_cea = cstar_actual
317
318 # Validate cstar_cea for ratio calculation
319 if cstar_cea <= 0:
320 raise ValueError(f"Invalid cstar_cea: {cstar_cea} m/s. Must be positive.")
321
322 if mdot_choked <= 0:
323 raise ValueError(f"Invalid mdot_choked: {mdot_choked} kg/s. Must be positive.")
324
325 log_info(
326 f"[COUPLING] Pc={Pc/6894.757:.1f} psi, At={At*1e6:.1f} mm^2, "
327 f"c*_CEA={cstar_cea:.1f} m/s, c*_implied={cstar_implied:.1f} m/s, "
328 f"ratio={cstar_implied/cstar_cea:.3f}, "
329 f"mdot={mdot_total:.3f} kg/s, mdot_choked={mdot_choked:.3f} kg/s, "
330 f"mdot_ratio={mdot_total/mdot_choked:.3f}"
331 )
332
333 # Calculate thrust using computed ambient pressure
334
335
336 log_info(
337 f"[RUNNER][GEOM] At={cg.A_throat:.6e} m^2, Ae={cg.A_exit:.6e} m^2, "
338 f"eps={cg.expansion_ratio:.4f}, eff={cg.nozzle_efficiency:.3f}"
339 )
340 log_info(f"[RUNNER][STATE] Pc={Pc:.3e} Pa, Pa={Pa:.3e} Pa, MR={MR:.4f}, mdot={mdot_total:.4f} kg/s")
341
342 reaction_progress = diagnostics.get("reaction_progress", None)
343
344 cea_noz = self.cea_cache.eval(MR, Pc, Pa, cg.expansion_ratio)
345 log_info(
346 f"[THERMO] chamber: Tc={Tc:.1f} K gamma={gamma:.4f} R={R:.2f} c*_actual={cstar_actual:.1f} | "
347 f"CEA(noz): Tc={cea_noz['Tc']:.1f} K gamma={cea_noz['gamma']:.4f} R={cea_noz['R']:.2f} c*_ideal={cea_noz['cstar_ideal']:.1f}"
348 )
349
350
351 thrust_results = calculate_thrust(
352 Pc,
353 MR,
354 mdot_total,
355 self.cea_cache,
356 self.config,
357 Pa,
358 reaction_progress=reaction_progress, # for reaction-completeness diagnostics
359 debug=debug,
360 )
361
362 # Extract thrust results - all required fields
363 required_thrust_keys = ["F", "Isp", "v_exit", "P_exit", "P_throat", "T_exit", "T_throat",
364 "M_exit", "Cf_actual", "Cf_ideal", "Cf_theoretical"]
365 for key in required_thrust_keys:
366 if key not in thrust_results:
367 raise KeyError(
368 f"Missing required thrust result '{key}'. "
369 f"calculate_thrust() must return all required fields."
370 )
371
372 F = thrust_results["F"]
373 Isp = thrust_results["Isp"]
374 v_exit = thrust_results["v_exit"]
375 P_exit = thrust_results["P_exit"]
376 P_throat = thrust_results["P_throat"]
377 T_exit = thrust_results["T_exit"]
378 T_throat = thrust_results["T_throat"]
379 M_exit = thrust_results["M_exit"]
380
381 # Shifting equilibrium results - these have defaults since they're only present when enabled
382 gamma_exit = thrust_results.get("gamma_exit")
383 R_exit = thrust_results.get("R_exit")
384
385 if gamma_exit is None:
386 gamma_exit = gamma # Use chamber gamma if shifting equilibrium not enabled
387 if R_exit is None:
388 R_exit = R # Use chamber R if shifting equilibrium not enabled
389
390 Cf_actual = thrust_results["Cf_actual"]
391 Cf_ideal = thrust_results["Cf_ideal"]
392 Cf_theoretical = thrust_results["Cf_theoretical"]
393 temperature_profile = thrust_results.get("temperature_profile") # Optional
394
395 # Cooling results - optional
396 cooling_results = diagnostics.get("cooling")
397 if cooling_results is None:
398 cooling_results = {} # Empty dict if no cooling
399
400 # Use Lstar from config if available, otherwise calculate from geometry
401 current_Lstar = cg.Lstar if cg.Lstar is not None else (cg.volume / cg.A_throat if cg.A_throat > 0 else 0)
402
403 # Calculate chamber pressure profile along length
404 pressure_profile = None
405 try:
406 pressure_profile = calculate_chamber_pressure_profile(
407 Pc=Pc,
408 Lstar=current_Lstar,
409 mdot_total=mdot_total,
410 gamma=gamma,
411 R=R,
412 Tc=Tc,
413 A_throat=cg.A_throat,
414 n_points=30,
415 )
416 except Exception as e:
417 import warnings
418 warnings.warn(f"Pressure profile calculation failed: {e}")
419
420 # Calculate chamber intrinsics
421 # Pass ambient pressure (Pa) for choking check, or use 0.9 * 1 atm as fallback
422 chamber_intrinsics = None
423 try:
424 chamber_intrinsics = calculate_chamber_intrinsics(
425 Pc=Pc,
426 Tc=Tc,
427 mdot_total=mdot_total,
428 gamma=gamma,
429 R=R,
430 V_chamber=cg.volume,
431 A_throat=cg.A_throat,
432 Lstar=current_Lstar,
433 MR=MR,
434 P_back=Pa, # Pass ambient pressure for choking verification
435 # Real chamber cross-section, so "mean chamber velocity" is the CHAMBER
436 # velocity and not the throat velocity (see calculate_chamber_intrinsics).
437 A_chamber=(math.pi / 4.0 * float(cg.chamber_diameter) ** 2
438 if getattr(cg, "chamber_diameter", None) else None),
439 )
440 except Exception as e:
441 import traceback
442 log_info(f"WARNING: Chamber intrinsics calculation failed: {type(e).__name__}: {e}")
443 if debug:
444 log_info(f"Traceback: {''.join(traceback.format_exc())}")
445 import warnings
446 warnings.warn(f"Chamber intrinsics calculation failed: {e}")
447
448 # Extract injector pressure diagnostics from closure diagnostics
449 injector_pressure_diagnostics = {
450 "P_injector_O": diagnostics.get("P_injector_O"),
451 "P_injector_F": diagnostics.get("P_injector_F"),
452 "delta_p_injector_O": diagnostics.get("delta_p_injector_O"),
453 "delta_p_injector_F": diagnostics.get("delta_p_injector_F"),
454 "delta_p_feed_O": diagnostics.get("delta_p_feed_O"),
455 "delta_p_feed_F": diagnostics.get("delta_p_feed_F"),
456 }
457
458 # Extract discharge coefficients
459 Cd_O = diagnostics.get("Cd_O", np.nan)
460 Cd_F = diagnostics.get("Cd_F", np.nan)
461
462 # Orifice cavitation margin (Nurick 1976). Reporting only: it flags an orifice whose
463 # vena contracta reaches vapour pressure, where Cd and the momentum ratio stop being
464 # what the solve assumed. Vapour pressure is the configured fluid's.
465 injector_cavitation: Dict[str, Any] = {}
466 try:
467 from engine.core.discharge import cavitation_margin, inlet_radius_ratio_of
468 for side, key, cd in (("oxidizer", "O", Cd_O), ("fuel", "F", Cd_F)):
469 p_in = diagnostics.get(f"P_injector_{key}")
470 pv = getattr(self.config.fluids[side], "vapor_pressure", None)
471 if p_in is None or pv is None:
472 continue
473 injector_cavitation[key] = cavitation_margin(
474 P_in=float(p_in), Pc=float(Pc), Pv=float(pv), Cd=float(cd),
475 r_over_d=inlet_radius_ratio_of(self.config.discharge[side]))
476 except Exception as e: # never let a report break an evaluation
477 injector_cavitation = {"error": str(e)}
478
479 # Calculate stability analysis if enabled
480 stability_results = {
481 "stability_state": "unstable",
482 "stability_score": 0.0,
483 "is_stable": False,
484 "chugging": {"frequency": 0.0, "stability_margin": 0.0, "period": 0.0, "tau_residence": 0.0, "Lstar": 0.0},
485 "acoustic": {"stability_margin": 0.0, "modes": {}, "longitudinal_modes": [], "transverse_modes": [], "sound_speed": 0.0},
486 "feed_system": {"pogo_frequency": 0.0, "surge_frequency": 0.0, "water_hammer_margin": 0.0, "stability_margin": 0.0, "sound_speed": 0.0},
487 "mode_coupling": [],
488 "Lstar": 0.0,
489 "issues": ["Stability analysis not available"],
490 "recommendations": [],
491 }
492 try:
493 from engine.pipeline.stability.analysis import comprehensive_stability_analysis
494
495 stability_results = comprehensive_stability_analysis(
496 config=self.config,
497 Pc=Pc,
498 MR=MR,
499 mdot_total=mdot_total,
500 cstar=cstar_actual,
501 gamma=gamma,
502 R=R,
503 Tc=Tc,
504 diagnostics=diagnostics,
505 )
506 except Exception as e:
507 import warnings
508 warnings.warn(f"Stability analysis failed: {e}")
509 # Keep default empty structure above
510
511 # Compile results
512 results = {
513 "injector_cavitation": injector_cavitation,
514 "Pc": Pc,
515 "mdot_O": mdot_O,
516 "mdot_F": mdot_F,
517 "mdot_total": mdot_total,
518 "MR": MR,
519 "F": F,
520 "Isp": Isp,
521 "v_exit": v_exit,
522 "M_exit": M_exit, # Exit Mach number
523 "P_exit": P_exit,
524 "P_throat": P_throat,
525 "T_exit": T_exit,
526 "T_throat": T_throat,
527 "Tc": Tc,
528 "Cf": Cf_actual, # Actual measured Cf
529 "Cf_actual": Cf_actual,
530 "Cf_ideal": Cf_ideal,
531 "Cf_theoretical": Cf_theoretical,
532 "temperature_profile": temperature_profile,
533 "eps": cg.expansion_ratio, # Expansion ratio
534 "A_throat": cg.A_throat,
535 "A_exit": cg.A_exit,
536 "cstar_actual": cstar_actual,
537 "cstar_ideal": diagnostics["cstar_ideal"],
538 "eta_cstar": diagnostics["eta_cstar"],
539 "gamma": gamma,
540 "gamma_exit": gamma_exit, # Exit gamma (for shifting equilibrium)
541 "R": R,
542 "R_exit": R_exit, # Exit gas constant (for shifting equilibrium)
543 "Cd_O": Cd_O,
544 "Cd_F": Cd_F,
545 "A_geom_O": diagnostics.get("A_geom_O"),
546 "A_geom_F": diagnostics.get("A_geom_F"),
547 "A_eff_O": diagnostics.get("A_eff_O"),
548 "A_eff_F": diagnostics.get("A_eff_F"),
549 "cooling": cooling_results,
550 "stability": stability_results,
551 "stability_results": stability_results, # Alias for compatibility with optimization code
552 "pressure_profile": pressure_profile,
553 "chamber_intrinsics": chamber_intrinsics,
554 "injector_pressure": injector_pressure_diagnostics,
555 "P_ambient": Pa, # Ambient pressure used for thrust calculation
556 "elevation": elevation, # Elevation from config (0 if not set)
557 "diagnostics": diagnostics,
558 }
559
560 if rich_stability:
561 try:
562 from engine.pipeline.stability.report import build_rich_report
563 results["stability_rich"] = build_rich_report(
564 self.config, Pc, MR, mdot_total, cstar_actual, gamma, R, Tc,
565 diagnostics, cg, overrides=stability_overrides,
566 )
567 except Exception as e:
568 import warnings
569 warnings.warn(f"Rich stability report failed: {e}")
570
571 # Print comprehensive results summary
572 log_info("\n" + "="*80)
573 log_info("[PERFORMANCE SUMMARY]")
574 log_info(f" Thrust: {F/1000:.3f} kN ({F:.2f} N)")
575 log_info(f" Specific Impulse: {Isp:.2f} s")
576 log_info(f" Chamber Pressure: {Pc/6894.76:.2f} psi ({Pc/1e6:.4f} MPa)")
577 log_info(f" Mixture Ratio: {MR:.4f} (O/F)")
578 log_info(f" Total Mass Flow: {mdot_total:.4f} kg/s (O: {mdot_O:.4f}, F: {mdot_F:.4f})")
579 log_info(f" c* (actual): {cstar_actual:.2f} m/s")
580 log_info(f" c* (ideal): {diagnostics['cstar_ideal']:.2f} m/s")
581 log_info(f" η_c*: {diagnostics['eta_cstar']:.4f}")
582 log_info(f" Cf (actual): {Cf_actual:.4f}")
583 log_info(f" Cf (ideal): {Cf_ideal:.4f}")
584 log_info(f" Exit Velocity: {v_exit:.2f} m/s")
585 log_info(f" Exit Mach: {M_exit:.3f}")
586 log_info(f" Exit Pressure: {P_exit/6894.76:.2f} psi ({P_exit/1e3:.2f} kPa)")
587 log_info(f" Temperatures: Tc={Tc:.1f} K, T_throat={T_throat:.1f} K, T_exit={T_exit:.1f} K")
588 log_info(f" γ (chamber/exit): {gamma:.4f} / {gamma_exit:.4f}")
589 log_info(f" R (chamber/exit): {R:.2f} / {R_exit:.2f} J/(kg·K)")
590 log_info("-"*80)
591
592 log_info("[INJECTOR PRESSURE DROPS]")
593 if injector_pressure_diagnostics.get("P_injector_O") is not None:
594 log_info(f" P_injector (LOX): {injector_pressure_diagnostics['P_injector_O']/6894.76:.2f} psi")
595 if injector_pressure_diagnostics.get("P_injector_F") is not None:
596 log_info(f" P_injector (Fuel):{injector_pressure_diagnostics['P_injector_F']/6894.76:.2f} psi")
597 if injector_pressure_diagnostics.get("delta_p_injector_O") is not None:
598 log_info(f" ΔP_inj (LOX): {injector_pressure_diagnostics['delta_p_injector_O']/6894.76:.2f} psi")
599 if injector_pressure_diagnostics.get("delta_p_injector_F") is not None:
600 log_info(f" ΔP_inj (Fuel): {injector_pressure_diagnostics['delta_p_injector_F']/6894.76:.2f} psi")
601 if injector_pressure_diagnostics.get("delta_p_feed_O") is not None:
602 log_info(f" ΔP_feed (LOX): {injector_pressure_diagnostics['delta_p_feed_O']/6894.76:.2f} psi")
603 if injector_pressure_diagnostics.get("delta_p_feed_F") is not None:
604 log_info(f" ΔP_feed (Fuel): {injector_pressure_diagnostics['delta_p_feed_F']/6894.76:.2f} psi")
605 log_info("-"*80)
606
607 log_info("[CHAMBER INTRINSICS]")
608 if chamber_intrinsics:
609 log_info(f" L*: {chamber_intrinsics.get('Lstar', 0)*1000:.2f} mm")
610 log_info(f" Residence Time: {chamber_intrinsics.get('residence_time', 0)*1000:.3f} ms")
611 log_info(f" Velocity (mean): {chamber_intrinsics.get('velocity_mean', 0):.2f} m/s")
612 log_info(f" Velocity (throat):{chamber_intrinsics.get('velocity_throat', 0):.2f} m/s")
613 log_info(f" Gas Density: {chamber_intrinsics.get('density', 0):.3f} kg/m³")
614 log_info(f" Sound Speed: {chamber_intrinsics.get('sound_speed', 0):.2f} m/s")
615 log_info(f" Mach (chamber): {chamber_intrinsics.get('mach_number', 0):.4f}")
616 log_info(f" Reynolds Number: {chamber_intrinsics.get('reynolds_number', 0):.2e}")
617 else:
618 log_info(" (Chamber intrinsics calculation failed)")
619 log_info("="*80 + "\n")
620
621 return results # End of _evaluate_internal()
622
624 self,
625 P_tank_O: Union[np.ndarray, list],
626 P_tank_F: Union[np.ndarray, list],
627 P_ambient: Optional[float] = None
628 ) -> Dict[str, np.ndarray]:
629 """
630 Evaluate engine performance for arrays of tank pressures (time series).
631
632 Parameters:
633 -----------
634 P_tank_O : array-like
635 Array of oxidizer tank pressures [Pa]
636 P_tank_F : array-like
637 Array of fuel tank pressures [Pa]
638 P_ambient : float, optional
639 Ambient pressure [Pa]. If None, defaults to sea level (101325 Pa).
640 This is used for thrust calculation and should match the target exit pressure
641 for accurate performance evaluation.
642
643 Returns:
644 --------
645 results : dict
646 Dictionary with arrays of all performance metrics
647 """
648 P_tank_O = np.asarray(P_tank_O)
649 P_tank_F = np.asarray(P_tank_F)
650
651 if P_tank_O.shape != P_tank_F.shape:
652 raise ValueError("P_tank_O and P_tank_F must have same shape")
653
654 # Initialize result arrays
655 n = P_tank_O.size
656 results = {
657 "Pc": np.full(n, np.nan),
658 "mdot_O": np.full(n, np.nan),
659 "mdot_F": np.full(n, np.nan),
660 "mdot_total": np.full(n, np.nan),
661 "MR": np.full(n, np.nan),
662 "F": np.full(n, np.nan),
663 "Isp": np.full(n, np.nan),
664 "v_exit": np.full(n, np.nan),
665 "P_exit": np.full(n, np.nan),
666 "eps": np.full(n, np.nan), # Expansion ratio
667 "A_throat": np.full(n, np.nan),
668 "A_exit": np.full(n, np.nan),
669 "cstar_actual": np.full(n, np.nan),
670 "cstar_ideal": np.full(n, np.nan),
671 "eta_cstar": np.full(n, np.nan),
672 "Tc": np.full(n, np.nan),
673 "gamma": np.full(n, np.nan),
674 "R": np.full(n, np.nan),
675 "Cd_O": np.full(n, np.nan),
676 "Cd_F": np.full(n, np.nan),
677 "diagnostics": [],
678 }
679
680 # Evaluate at each point
681 for i in range(n):
682 try:
683 point_results = self.evaluate(
684 float(P_tank_O.flat[i]),
685 float(P_tank_F.flat[i]),
686 P_ambient=P_ambient
687 )
688
689 # Store scalar results (including eps for 3D CEA cache)
690 for key in ["Pc", "mdot_O", "mdot_F", "mdot_total", "MR", "F", "Isp",
691 "v_exit", "P_exit", "eps", "A_throat", "A_exit",
692 "cstar_actual", "cstar_ideal", "eta_cstar",
693 "Tc", "gamma", "R", "Cd_O", "Cd_F"]:
694 results[key][i] = point_results[key]
695
696 # Store diagnostics
697 results["diagnostics"].append(point_results["diagnostics"])
698
699 except Exception as e:
700 # If solve fails, leave NaN values
701 results["diagnostics"].append({"error": str(e)})
702 continue
703
704 return results
705
707 self,
708 times: Union[np.ndarray, list],
709 P_tank_O: Union[np.ndarray, list],
710 P_tank_F: Union[np.ndarray, list],
711 track_ablative_geometry: Optional[bool] = None,
712 use_coupled_solver: bool = True, # NEW: Use fully-coupled solver
713 P_ambient: Optional[float] = None,
714 strict_erosion: bool = False,
715 chug_eroded_geometry: bool = False,
716 ) -> Dict[str, np.ndarray]:
717 """
718 Evaluate engine performance over time with ablative geometry evolution.
719
720 This method tracks cumulative ablative recession and updates chamber
721 geometry (V_chamber, A_throat, L*) at each time step, providing
722 accurate performance predictions for ablative engines.
723
724 Parameters:
725 -----------
726 times : array-like
727 Time points [s]
728 P_tank_O : array-like
729 Array of oxidizer tank pressures [Pa]
730 P_tank_F : array-like
731 Array of fuel tank pressures [Pa]
732 track_ablative_geometry : bool, optional
733 Override config setting for geometry tracking
734 strict_erosion : bool
735 With geometry tracking on, never take the legacy constant-geometry loop: a failure of
736 the coupled solver is raised instead of warned and fallen back from. Off by default
737 (the previous behaviour). Layer X's replay sets it, so an eroding engine can no longer
738 replay with zero erosion and report it as a converged burn (Layer X audit 5.3).
739 chug_eroded_geometry : bool
740 Passed to TimeVaryingCoupledSolver: the chug analysis sees each step's eroded geometry
741 instead of the design point. Off by default (the previous behaviour).
742 P_ambient : float, optional
743 Ambient pressure [Pa]. If None, defaults to sea level (101325 Pa).
744 This is used for thrust calculation and should match the target exit pressure
745 for accurate performance evaluation.
746
747 Returns:
748 --------
749 results : dict
750 Dictionary with arrays of all performance metrics plus:
751 - Lstar: Time-varying characteristic length [m]
752 - V_chamber: Time-varying chamber volume [m³]
753 - A_throat: Time-varying throat area [m²]
754 - recession_chamber: Cumulative chamber recession [m]
755 - recession_throat: Cumulative throat recession [m]
756 """
757 times = np.asarray(times)
758 P_tank_O = np.asarray(P_tank_O)
759 P_tank_F = np.asarray(P_tank_F)
760
761 if times.shape != P_tank_O.shape or times.shape != P_tank_F.shape:
762 raise ValueError("times, P_tank_O, and P_tank_F must have same shape")
763
764 if len(times) < 2:
765 raise ValueError("Need at least 2 time points for time-varying analysis")
766
767 # The coupled solver of the last call, for read-outs that need its wall state after the
768 # burn (soak-back); None when the call did not take the coupled path.
770 # Check if ablative geometry tracking is enabled
771 ablative_cfg = self.config.ablative_cooling
772 if track_ablative_geometry is None:
773 track_ablative_geometry = (
774 ablative_cfg is not None
775 and ablative_cfg.enabled
776 and ablative_cfg.track_geometry_evolution
777 )
778
779 # Use fully-coupled solver if enabled (recommended for complete analysis)
780 # This solver integrates ALL systems simultaneously:
781 # - Reaction chemistry → shifting equilibrium
782 # - Geometry evolution → chamber dynamics
783 # - Ablative/graphite recession → geometry
784 # - Stability analysis → all time-varying effects
785 if use_coupled_solver and track_ablative_geometry:
786 try:
787 from engine.pipeline.time_varying_solver import TimeVaryingCoupledSolver
788
789 # Same ambient resolution as evaluate(): explicit, else the site elevation.
790 solver = TimeVaryingCoupledSolver(
791 self.config, self.cea_cache,
792 P_ambient=self._get_ambient_pressure(P_ambient),
793 chug_eroded_geometry=chug_eroded_geometry,
794 )
795 states = solver.solve_time_series(times, P_tank_O, P_tank_F)
796 results = solver.get_results_dict()
797 self.last_time_varying_solver = solver
798
799 # Add additional metrics for compatibility
800 results["mdot_O"] = results["mdot_total"] * results["MR"] / (1.0 + results["MR"])
801 results["mdot_F"] = results["mdot_total"] / (1.0 + results["MR"])
802 # cstar_ideal / cstar_actual / eta_cstar come from the chamber solve at each step.
803 results["gamma"] = results["gamma_chamber"]
804 results["R"] = results["R_chamber"]
805 # diagnostics now comes from get_results_dict() - contains ablative heat flux profiles
806
807 return results
808 except Exception as e:
809 # STRICT MODE: if graphite insert is enabled, do NOT silently fall back.
810 # The legacy solver does not have enough information to run the strict
811 # graphite oxidation model without hidden defaults.
812 graphite_cfg = getattr(self.config, "graphite_insert", None)
813 if strict_erosion or (graphite_cfg is not None and getattr(graphite_cfg, "enabled", False)):
814 raise
815
816 import warnings
817 warnings.warn(f"Fully-coupled solver failed: {e}. Falling back to standard solver.")
818 # Fall through to standard solver
819 elif strict_erosion and track_ablative_geometry:
820 raise ValueError("strict_erosion: geometry tracking was asked for without the coupled "
821 "time-varying solver (use_coupled_solver=False); the legacy loop would "
822 "replay this engine at constant or heuristic geometry")
823
824 # Initialize result arrays
825 n = len(times)
826 results = {
827 "Pc": np.full(n, np.nan),
828 "mdot_O": np.full(n, np.nan),
829 "mdot_F": np.full(n, np.nan),
830 "mdot_total": np.full(n, np.nan),
831 "MR": np.full(n, np.nan),
832 "F": np.full(n, np.nan),
833 "Isp": np.full(n, np.nan),
834 "v_exit": np.full(n, np.nan),
835 "P_exit": np.full(n, np.nan),
836 "cstar_actual": np.full(n, np.nan),
837 "cstar_ideal": np.full(n, np.nan),
838 "eta_cstar": np.full(n, np.nan),
839 "Tc": np.full(n, np.nan),
840 "gamma": np.full(n, np.nan),
841 "R": np.full(n, np.nan),
842 "Cd_O": np.full(n, np.nan),
843 "Cd_F": np.full(n, np.nan),
844 "Lstar": np.full(n, np.nan),
845 "V_chamber": np.full(n, np.nan),
846 "A_throat": np.full(n, np.nan),
847 "A_exit": np.full(n, np.nan),
848 "eps": np.full(n, np.nan), # Expansion ratio
849 "recession_chamber": np.full(n, 0.0),
850 "recession_throat": np.full(n, 0.0),
851 "recession_exit": np.full(n, 0.0),
852 "throat_recession_multiplier": np.full(n, np.nan),
853 "diagnostics": [],
854 # Stability arrays (comprehensive analysis for all 3 types)
855 "chugging_stability_margin": np.full(n, np.nan),
856 "stability_score": np.full(n, np.nan),
857 "stability_state": np.full(n, "unstable", dtype=object),
858 }
859
860 # Ensure chamber_geometry exists
861 self.config.chamber_geometry = ensure_chamber_geometry(self.config)
862 cg = self.config.chamber_geometry
863
864 # Initial geometry
865 V_chamber_initial = cg.volume
866 A_throat_initial = cg.A_throat
867 A_exit_initial = cg.A_exit
868 L_chamber = cg.length if cg.length else 0.18
869 # FIXED: Add safety checks for sqrt operations
870 D_chamber_initial = np.sqrt(max(0, 4 * V_chamber_initial / (np.pi * L_chamber))) if L_chamber > 0 else 0.1
871 D_throat_initial = np.sqrt(max(0, 4 * A_throat_initial / np.pi)) if A_throat_initial > 0 else 0.015
872 D_exit_initial = np.sqrt(max(0, 4 * A_exit_initial / np.pi)) if A_exit_initial > 0 else 0.1
873
874 # Track cumulative recession
875 cumulative_recession_chamber = 0.0
876 cumulative_recession_throat = 0.0
877 cumulative_recession_exit = 0.0
878
879 # Create a mutable config copy for geometry updates
880 config_copy = copy.deepcopy(self.config)
881
882 # Evaluate at each time point
883 for i in range(n):
884 dt = times[i] - times[i-1] if i > 0 else 0.0
885
886 try:
887 # Update solver with current geometry
888 solver_temp = ChamberSolver(config_copy, self.cea_cache)
889
890 # Evaluate performance using the updated solver
891 # (NOT self.evaluate which uses the original geometry!)
892 Pc, diagnostics = solver_temp.solve(
893 float(P_tank_O[i]),
894 float(P_tank_F[i]),
895 Pc_guess=None
896 )
897
898 # Calculate thrust and performance
899 # Use provided ambient pressure or default to sea level (101325 Pa)
900 Pa = P_ambient if P_ambient is not None else 101325.0 # Ambient pressure
901
902 # Ensure expansion ratio is consistent for calculate_thrust
903 cg = config_copy.chamber_geometry
904 if cg.A_throat and cg.A_exit:
905 cg.expansion_ratio = cg.A_exit / cg.A_throat
906
907 thrust_results = calculate_thrust(
908 Pc,
909 diagnostics["MR"],
910 diagnostics["mdot_total"],
911 self.cea_cache,
912 config_copy,
913 Pa,
914 )
915 thrust = thrust_results["F"]
916 v_exit = thrust_results["v_exit"]
917 P_exit = thrust_results["P_exit"]
918
919 # Package results like evaluate() does
920 point_results = {
921 "Pc": Pc,
922 "mdot_O": diagnostics["mdot_O"],
923 "mdot_F": diagnostics["mdot_F"],
924 "mdot_total": diagnostics["mdot_total"],
925 "MR": diagnostics["MR"],
926 "F": thrust,
927 "Isp": thrust / (diagnostics["mdot_total"] * 9.80665) if diagnostics["mdot_total"] > 0 else 0.0,
928 "v_exit": v_exit,
929 "P_exit": P_exit,
930 "cstar_actual": diagnostics["cstar_actual"],
931 "cstar_ideal": diagnostics["cstar_ideal"],
932 "eta_cstar": diagnostics["eta_cstar"],
933 "Tc": diagnostics["Tc"],
934 "gamma": diagnostics["gamma"],
935 "R": diagnostics["R"],
936 "Cd_O": diagnostics.get("Cd_O", np.nan),
937 "Cd_F": diagnostics.get("Cd_F", np.nan),
938 "diagnostics": diagnostics,
939 }
940
941 # Store scalar results
942 for key in ["Pc", "mdot_O", "mdot_F", "mdot_total", "MR", "F", "Isp",
943 "v_exit", "P_exit", "cstar_actual", "cstar_ideal", "eta_cstar",
944 "Tc", "gamma", "R", "Cd_O", "Cd_F"]:
945 results[key][i] = point_results[key]
946
947 # Store current geometry
948 if track_ablative_geometry:
949 # If geometry is evolving, calculate the instantaneous L*
950 results["Lstar"][i] = cg.volume / cg.A_throat if cg.A_throat > 0 else (cg.Lstar or 0)
951 else:
952 # Otherwise use the nominal value from config
953 results["Lstar"][i] = cg.Lstar if cg.Lstar is not None else (cg.volume / cg.A_throat if cg.A_throat > 0 else 0)
954 results["V_chamber"][i] = cg.volume
955 results["A_throat"][i] = cg.A_throat
956 results["A_exit"][i] = cg.A_exit
957 results["eps"][i] = cg.expansion_ratio # Store expansion ratio
958 results["recession_chamber"][i] = cumulative_recession_chamber
959 results["recession_throat"][i] = cumulative_recession_throat
960 results["recession_exit"][i] = cumulative_recession_exit
961
962 # Store diagnostics
963 results["diagnostics"].append(point_results["diagnostics"])
964
965 # Calculate comprehensive stability analysis (accounts for chugging, acoustic, and feed system)
966 try:
967 from engine.pipeline.stability.analysis import comprehensive_stability_analysis
968
969 # Build diagnostics dict for comprehensive analysis
970 # The closure's drops and SMDs too, as forward mode passes them; without them
971 # the chug margin rests on placeholder 0.30*Pc / 0.10*Pc and 80/60 um.
972 stability_diagnostics = {
973 **diagnostics,
974 "P_tank_O": float(P_tank_O[i]),
975 "P_tank_F": float(P_tank_F[i]),
976 }
977
978 stability_results = comprehensive_stability_analysis(
979 config=config_copy,
980 Pc=Pc,
981 MR=diagnostics["MR"],
982 mdot_total=diagnostics["mdot_total"],
983 cstar=diagnostics["cstar_actual"],
984 gamma=diagnostics["gamma"],
985 R=diagnostics["R"],
986 Tc=diagnostics["Tc"],
987 diagnostics=stability_diagnostics,
988 )
989
990 # Extract stability metrics
991 results["chugging_stability_margin"][i] = stability_results.get("chugging", {}).get("stability_margin", np.nan)
992 results["stability_score"][i] = stability_results.get("stability_score", np.nan)
993 results["stability_state"][i] = stability_results.get("stability_state", "unstable")
994 except Exception as e:
995 import warnings
996 warnings.warn(f"Stability analysis failed at time step {i}: {e}")
997 # Leave as NaN/unstable (already initialized)
998
999 # Update geometry for next time step (if ablative tracking enabled)
1000 if track_ablative_geometry and dt > 0 and i < n - 1:
1001 # Get ablative recession rate from diagnostics
1002 cooling_diag = point_results.get("diagnostics", {}).get("cooling", {})
1003 ablative_diag = cooling_diag.get("ablative", {})
1004
1005 # Check for graphite insert (separate from chamber ablator)
1006 graphite_cfg = config_copy.graphite_insert
1007 use_graphite_throat = graphite_cfg is not None and graphite_cfg.enabled
1008
1009 recession_rate_chamber = 0.0
1010 recession_rate_throat = 0.0
1011
1012 # Chamber recession (from ablative cooling)
1013 if ablative_diag.get("enabled", False):
1014 recession_rate_chamber = ablative_diag.get("recession_rate", 0.0)
1015
1016 # Throat recession (from graphite insert if enabled, else from ablator)
1017 if use_graphite_throat:
1018 # Check for simplified mode
1019 simplified_mode = getattr(graphite_cfg, "simplified_graphite_oxidation", False)
1020
1021 if not simplified_mode:
1022 # STRICT MODE NOTE:
1023 # The graphite oxidation model no longer assumes hidden defaults for gas properties
1024 # (oxygen mass fraction, viscosity, backside temperature, etc.). The legacy
1025 # runner time-march path does not have enough information to supply these
1026 # quantities consistently. Use the fully-coupled solver path instead.
1027 raise ValueError(
1028 "Graphite throat oxidation is running in strict mode and requires explicit gas/thermal inputs "
1029 "(e.g., oxygen_mass_fraction, hot-gas viscosity, backside temperature, etc.). "
1030 "Please run with use_coupled_solver=True (fully-coupled time-varying solver) or set "
1031 "'simplified_graphite_oxidation: true' in your graphite_insert config."
1032 )
1033
1034 # Use graphite insert properties for throat recession
1035 Pc = point_results["Pc"]
1036 Tc = point_results["Tc"]
1037
1038 # Get throat heat flux from cooling diagnostics
1039 # Throat heat flux is typically higher than chamber
1040 chamber_heat_flux = ablative_diag.get("incident_heat_flux", 1e6) if ablative_diag.get("enabled", False) else 1e6
1041
1042 # Estimate throat heat flux (higher than chamber due to sonic conditions)
1043 # Use heuristic multiplier scaling
1044 gamma = point_results.get("gamma", DEFAULT_GAMMA_ND)
1045 throat_heat_flux_mult = calculate_throat_heuristic_multiplier(
1046 Pc, 50.0, 1000.0, chamber_heat_flux, gamma # Approximate velocities
1047 )
1048 throat_heat_flux = chamber_heat_flux * throat_heat_flux_mult
1049
1050 # Calculate graphite recession rate (passing dummy values for unused strict inputs in simplified mode)
1051 graphite_results = compute_graphite_recession(
1052 net_heat_flux=throat_heat_flux,
1053 throat_temperature=Tc * 0.85, # Approximate throat temperature
1054 gas_temperature=Tc,
1055 graphite_config=graphite_cfg,
1056 throat_area=cg.A_throat,
1057 pressure=Pc,
1058 gas_density=1.0, # Placeholder
1059 gas_viscosity=4e-5, # Placeholder
1060 oxygen_mass_fraction=0.0, # Placeholder
1061 characteristic_length=0.01, # Placeholder
1062 gas_velocity=1000.0, # Placeholder
1063 heat_transfer_coefficient=1000.0, # Placeholder
1064 backside_temperature=300.0, # Placeholder
1065 effective_thickness=0.01, # Placeholder
1066 )
1067
1068 # CRITICAL FIX: For cumulative recession tracking (diagnostics), use recession_rate_calculated
1069 # even though recession_rate is 0 (which prevents throat area from changing).
1070 # The recession_rate = 0 is only to keep throat area constant, but we still want to
1071 # track cumulative recession for diagnostics and display.
1072 recession_rate_throat = graphite_results.get("recession_rate_calculated", 0.0)
1073 if recession_rate_throat == 0.0:
1074 # Fallback to recession_rate if calculated is not available
1075 recession_rate_throat = graphite_results.get("recession_rate", 0.0)
1076
1077 # Apply graphite coverage fraction
1078 recession_rate_throat *= graphite_cfg.coverage_fraction
1079
1080 # If ablator also covers throat (partial coverage), add ablator contribution
1081 if ablative_diag.get("enabled", False) and graphite_cfg.coverage_fraction < 1.0:
1082 ablator_coverage = 1.0 - graphite_cfg.coverage_fraction
1083 recession_rate_ablator_throat = recession_rate_chamber * throat_heat_flux_mult
1084 recession_rate_throat += recession_rate_ablator_throat * ablator_coverage
1085 elif ablative_diag.get("enabled", False):
1086 # Use ablative properties for throat (original behavior)
1087 # Calculate throat recession multiplier from flow conditions
1088 if ablative_cfg.throat_recession_multiplier is not None:
1089 throat_mult = ablative_cfg.throat_recession_multiplier
1090 else:
1091 # Calculate from physics
1092 Pc = point_results["Pc"]
1093 mdot_total = point_results["mdot_total"]
1094 gamma = point_results["gamma"]
1095 R = point_results["R"]
1096 Tc = point_results["Tc"]
1097
1098 # Chamber velocity
1099 rho_chamber = Pc / (R * Tc)
1100 A_chamber = np.pi * (D_chamber_initial ** 2) / 4.0
1101 v_chamber = mdot_total / (rho_chamber * A_chamber) if rho_chamber > 0 else 0.0
1102
1103 # Throat velocity (sonic)
1104 v_throat = np.sqrt(gamma * R * Tc / (gamma + 1))
1105
1106 # Heat flux (from cooling diagnostics)
1107 chamber_heat_flux = ablative_diag.get("incident_heat_flux", 1e6)
1108
1109 throat_mult = calculate_throat_heuristic_multiplier(
1110 Pc, v_chamber, v_throat, chamber_heat_flux, gamma
1111 )
1112
1113 results["throat_recession_multiplier"][i] = throat_mult
1114 recession_rate_throat = recession_rate_chamber * throat_mult
1115
1116 # Update cumulative recession
1117 recession_increment_chamber = recession_rate_chamber * dt
1118 recession_increment_throat = recession_rate_throat * dt
1119
1120 cumulative_recession_chamber += recession_increment_chamber
1121 cumulative_recession_throat += recession_increment_throat
1122
1123 # Update geometry
1124 # Use ablative coverage for chamber, graphite coverage for throat
1125 chamber_coverage = ablative_cfg.coverage_fraction if ablative_cfg and ablative_cfg.enabled else 1.0
1126 throat_coverage = graphite_cfg.coverage_fraction if use_graphite_throat and graphite_cfg else chamber_coverage
1127
1128 V_new, A_throat_new, D_chamber_new, D_throat_new, geom_diag = update_chamber_geometry_from_ablation(
1129 V_chamber_initial,
1130 A_throat_initial,
1131 D_chamber_initial,
1132 D_throat_initial,
1133 L_chamber,
1134 cumulative_recession_chamber,
1135 cumulative_recession_throat,
1136 chamber_coverage, # Chamber coverage from ablator
1137 None, # Don't use multiplier here, we already calculated throat recession
1138 )
1139
1140 # Update config for next iteration
1141 config_copy.chamber_geometry.volume = V_new
1142 config_copy.chamber_geometry.A_throat = A_throat_new
1143
1144 # Update L* if specified
1145 if config_copy.chamber_geometry.Lstar is not None:
1146 config_copy.chamber_geometry.Lstar = V_new / A_throat_new
1147
1148 # Update nozzle exit geometry if nozzle is ablative
1149 if ablative_cfg and ablative_cfg.enabled and ablative_cfg.nozzle_ablative:
1150 # Nozzle exit recedes at similar rate to chamber (can be tuned)
1151 # For now, assume exit recession rate = 0.8 × chamber rate (less severe than throat)
1152 recession_increment_exit = recession_rate_chamber * 0.8 * dt
1153 cumulative_recession_exit += recession_increment_exit
1154
1155 A_exit_new, D_exit_new, exit_diag = update_nozzle_exit_from_ablation(
1156 A_exit_initial,
1157 D_exit_initial,
1158 cumulative_recession_exit,
1159 ablative_cfg.coverage_fraction,
1160 )
1161
1162 # Update nozzle config
1163 config_copy.chamber_geometry.A_exit = A_exit_new
1164
1165 # Expansion ratio will be recalculated on next iteration
1166
1167 except Exception as e:
1168 # If solve fails, leave NaN values
1169 # Suppress warnings/prints for performance - errors still stored in diagnostics
1170 results["diagnostics"].append({"error": str(e)})
1171 continue
1172
1173 return results
1174
#define max(a, b)
Definition Arduino.h:43
float _get_ambient_pressure(self, Optional[float] P_ambient=None)
Definition runner.py:71
Dict[str, np.ndarray] evaluate_arrays(self, Union[np.ndarray, list] P_tank_O, Union[np.ndarray, list] P_tank_F, Optional[float] P_ambient=None)
Definition runner.py:628
Dict[str, Any] _evaluate_internal(self, float P_tank_O, float P_tank_F, Optional[float] Pc_guess=None, Optional[float] P_ambient=None, bool debug=False, bool silent=False, bool rich_stability=False, Optional[Dict[str, float]] stability_overrides=None)
Definition runner.py:186
__init__(self, PintleEngineConfig config)
Definition runner.py:47
Dict[str, Any] evaluate(self, float P_tank_O, float P_tank_F, Optional[float] Pc_guess=None, Optional[float] P_ambient=None, bool debug=False, bool silent=False, bool rich_stability=False, Optional[Dict[str, float]] stability_overrides=None)
Definition runner.py:112
Dict[str, np.ndarray] evaluate_arrays_with_time(self, Union[np.ndarray, list] times, Union[np.ndarray, list] P_tank_O, Union[np.ndarray, list] P_tank_F, Optional[bool] track_ablative_geometry=None, bool use_coupled_solver=True, Optional[float] P_ambient=None, bool strict_erosion=False, bool chug_eroded_geometry=False)
Definition runner.py:716
float compute_ambient_pressure_from_elevation(float elevation_m)
Definition runner.py:30