180 Pc_guess: Optional[float] =
None,
181 P_ambient: Optional[float] =
None,
183 silent: bool =
False,
184 rich_stability: bool =
False,
185 stability_overrides: Optional[Dict[str, float]] =
None,
187 """Internal evaluation implementation (wrapped by evaluate() for error logging)."""
189 logger = logging.getLogger(
"evaluate")
190 logger.propagate =
False
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")
202 for handler
in logger.handlers[:]:
203 logger.removeHandler(handler)
205 logger.setLevel(logging.DEBUG)
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)
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)
222 logger.info(
"Debug logging enabled. Writing to %s", log_file)
225 for handler
in logger.handlers[:]:
226 logger.removeHandler(handler)
232 logger.setLevel(logging.WARNING
if silent
else logging.INFO)
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)
251 cg = ensure_chamber_geometry(self.
config)
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}")
263 log_info(f
" Length: {cg.length*1000:.2f} mm")
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")
270 log_info(
"="*80 +
"\n")
273 Pc, diagnostics = self.
solver.solve(P_tank_O, P_tank_F, Pc_guess, debug=debug, silent=silent)
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")
284 cstar = diagnostics[
"cstar_actual"]
285 mdot = diagnostics[
"mdot_O"] + diagnostics[
"mdot_F"]
289 raise ValueError(f
"Invalid throat area: At={At:.6e} m². Must be positive.")
292 raise ValueError(f
"Invalid c*: {cstar:.2f} m/s. Must be positive.")
295 raise ValueError(f
"Invalid mass flow: mdot={mdot:.4f} kg/s. Must be positive.")
297 mdot_choked = Pc * At / cstar
298 ratio = mdot / mdot_choked
301 mdot_O = diagnostics[
"mdot_O"]
302 mdot_F = diagnostics[
"mdot_F"]
303 mdot_total = mdot_O + mdot_F
304 MR = diagnostics[
"MR"]
306 cstar_actual = diagnostics[
"cstar_actual"]
307 gamma = diagnostics[
"gamma"]
309 Tc = diagnostics[
"Tc"]
313 raise ValueError(f
"Invalid total mass flow: mdot_total={mdot_total} kg/s. Must be positive.")
315 cstar_implied = Pc * At / mdot_total
316 cstar_cea = cstar_actual
320 raise ValueError(f
"Invalid cstar_cea: {cstar_cea} m/s. Must be positive.")
323 raise ValueError(f
"Invalid mdot_choked: {mdot_choked} kg/s. Must be positive.")
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}"
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}"
340 log_info(f
"[RUNNER][STATE] Pc={Pc:.3e} Pa, Pa={Pa:.3e} Pa, MR={MR:.4f}, mdot={mdot_total:.4f} kg/s")
342 reaction_progress = diagnostics.get(
"reaction_progress",
None)
344 cea_noz = self.
cea_cache.eval(MR, Pc, Pa, cg.expansion_ratio)
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}"
351 thrust_results = calculate_thrust(
358 reaction_progress=reaction_progress,
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:
368 f
"Missing required thrust result '{key}'. "
369 f
"calculate_thrust() must return all required fields."
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"]
382 gamma_exit = thrust_results.get(
"gamma_exit")
383 R_exit = thrust_results.get(
"R_exit")
385 if gamma_exit
is None:
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")
396 cooling_results = diagnostics.get(
"cooling")
397 if cooling_results
is None:
401 current_Lstar = cg.Lstar
if cg.Lstar
is not None else (cg.volume / cg.A_throat
if cg.A_throat > 0
else 0)
404 pressure_profile =
None
406 pressure_profile = calculate_chamber_pressure_profile(
409 mdot_total=mdot_total,
413 A_throat=cg.A_throat,
416 except Exception
as e:
418 warnings.warn(f
"Pressure profile calculation failed: {e}")
422 chamber_intrinsics =
None
424 chamber_intrinsics = calculate_chamber_intrinsics(
427 mdot_total=mdot_total,
431 A_throat=cg.A_throat,
437 A_chamber=(math.pi / 4.0 * float(cg.chamber_diameter) ** 2
438 if getattr(cg,
"chamber_diameter",
None)
else None),
440 except Exception
as e:
442 log_info(f
"WARNING: Chamber intrinsics calculation failed: {type(e).__name__}: {e}")
444 log_info(f
"Traceback: {''.join(traceback.format_exc())}")
446 warnings.warn(f
"Chamber intrinsics calculation failed: {e}")
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"),
459 Cd_O = diagnostics.get(
"Cd_O", np.nan)
460 Cd_F = diagnostics.get(
"Cd_F", np.nan)
465 injector_cavitation: Dict[str, Any] = {}
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:
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:
477 injector_cavitation = {
"error": str(e)}
480 stability_results = {
481 "stability_state":
"unstable",
482 "stability_score": 0.0,
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},
489 "issues": [
"Stability analysis not available"],
490 "recommendations": [],
495 stability_results = comprehensive_stability_analysis(
499 mdot_total=mdot_total,
504 diagnostics=diagnostics,
506 except Exception
as e:
508 warnings.warn(f
"Stability analysis failed: {e}")
513 "injector_cavitation": injector_cavitation,
517 "mdot_total": mdot_total,
524 "P_throat": P_throat,
526 "T_throat": T_throat,
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,
534 "A_throat": cg.A_throat,
536 "cstar_actual": cstar_actual,
537 "cstar_ideal": diagnostics[
"cstar_ideal"],
538 "eta_cstar": diagnostics[
"eta_cstar"],
540 "gamma_exit": gamma_exit,
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,
552 "pressure_profile": pressure_profile,
553 "chamber_intrinsics": chamber_intrinsics,
554 "injector_pressure": injector_pressure_diagnostics,
556 "elevation": elevation,
557 "diagnostics": diagnostics,
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,
567 except Exception
as e:
569 warnings.warn(f
"Rich stability report failed: {e}")
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)")
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")
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}")
618 log_info(
" (Chamber intrinsics calculation failed)")
619 log_info(
"="*80 +
"\n")
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,
713 P_ambient: Optional[float] =
None,
714 strict_erosion: bool =
False,
715 chug_eroded_geometry: bool =
False,
716 ) -> Dict[str, np.ndarray]:
718 Evaluate engine performance over time with ablative geometry evolution.
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.
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.
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]
757 times = np.asarray(times)
758 P_tank_O = np.asarray(P_tank_O)
759 P_tank_F = np.asarray(P_tank_F)
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")
765 raise ValueError(
"Need at least 2 time points for time-varying analysis")
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
785 if use_coupled_solver
and track_ablative_geometry:
790 solver = TimeVaryingCoupledSolver(
793 chug_eroded_geometry=chug_eroded_geometry,
795 states = solver.solve_time_series(times, P_tank_O, P_tank_F)
796 results = solver.get_results_dict()
800 results[
"mdot_O"] = results[
"mdot_total"] * results[
"MR"] / (1.0 + results[
"MR"])
801 results[
"mdot_F"] = results[
"mdot_total"] / (1.0 + results[
"MR"])
803 results[
"gamma"] = results[
"gamma_chamber"]
804 results[
"R"] = results[
"R_chamber"]
808 except Exception
as e:
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)):
817 warnings.warn(f
"Fully-coupled solver failed: {e}. Falling back 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")
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),
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),
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),
861 self.
config.chamber_geometry = ensure_chamber_geometry(self.
config)
862 cg = self.
config.chamber_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
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
875 cumulative_recession_chamber = 0.0
876 cumulative_recession_throat = 0.0
877 cumulative_recession_exit = 0.0
880 config_copy = copy.deepcopy(self.
config)
884 dt = times[i] - times[i-1]
if i > 0
else 0.0
892 Pc, diagnostics = solver_temp.solve(
900 Pa = P_ambient
if P_ambient
is not None else 101325.0
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
907 thrust_results = calculate_thrust(
910 diagnostics[
"mdot_total"],
915 thrust = thrust_results[
"F"]
916 v_exit = thrust_results[
"v_exit"]
917 P_exit = thrust_results[
"P_exit"]
922 "mdot_O": diagnostics[
"mdot_O"],
923 "mdot_F": diagnostics[
"mdot_F"],
924 "mdot_total": diagnostics[
"mdot_total"],
925 "MR": diagnostics[
"MR"],
927 "Isp": thrust / (diagnostics[
"mdot_total"] * 9.80665)
if diagnostics[
"mdot_total"] > 0
else 0.0,
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,
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]
948 if track_ablative_geometry:
950 results[
"Lstar"][i] = cg.volume / cg.A_throat
if cg.A_throat > 0
else (cg.Lstar
or 0)
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
958 results[
"recession_chamber"][i] = cumulative_recession_chamber
959 results[
"recession_throat"][i] = cumulative_recession_throat
960 results[
"recession_exit"][i] = cumulative_recession_exit
963 results[
"diagnostics"].append(point_results[
"diagnostics"])
972 stability_diagnostics = {
974 "P_tank_O": float(P_tank_O[i]),
975 "P_tank_F": float(P_tank_F[i]),
978 stability_results = comprehensive_stability_analysis(
981 MR=diagnostics[
"MR"],
982 mdot_total=diagnostics[
"mdot_total"],
983 cstar=diagnostics[
"cstar_actual"],
984 gamma=diagnostics[
"gamma"],
986 Tc=diagnostics[
"Tc"],
987 diagnostics=stability_diagnostics,
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:
996 warnings.warn(f
"Stability analysis failed at time step {i}: {e}")
1000 if track_ablative_geometry
and dt > 0
and i < n - 1:
1002 cooling_diag = point_results.get(
"diagnostics", {}).get(
"cooling", {})
1003 ablative_diag = cooling_diag.get(
"ablative", {})
1006 graphite_cfg = config_copy.graphite_insert
1007 use_graphite_throat = graphite_cfg
is not None and graphite_cfg.enabled
1009 recession_rate_chamber = 0.0
1010 recession_rate_throat = 0.0
1013 if ablative_diag.get(
"enabled",
False):
1014 recession_rate_chamber = ablative_diag.get(
"recession_rate", 0.0)
1017 if use_graphite_throat:
1019 simplified_mode = getattr(graphite_cfg,
"simplified_graphite_oxidation",
False)
1021 if not simplified_mode:
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."
1035 Pc = point_results[
"Pc"]
1036 Tc = point_results[
"Tc"]
1040 chamber_heat_flux = ablative_diag.get(
"incident_heat_flux", 1e6)
if ablative_diag.get(
"enabled",
False)
else 1e6
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
1048 throat_heat_flux = chamber_heat_flux * throat_heat_flux_mult
1051 graphite_results = compute_graphite_recession(
1052 net_heat_flux=throat_heat_flux,
1053 throat_temperature=Tc * 0.85,
1055 graphite_config=graphite_cfg,
1056 throat_area=cg.A_throat,
1060 oxygen_mass_fraction=0.0,
1061 characteristic_length=0.01,
1062 gas_velocity=1000.0,
1063 heat_transfer_coefficient=1000.0,
1064 backside_temperature=300.0,
1065 effective_thickness=0.01,
1072 recession_rate_throat = graphite_results.get(
"recession_rate_calculated", 0.0)
1073 if recession_rate_throat == 0.0:
1075 recession_rate_throat = graphite_results.get(
"recession_rate", 0.0)
1078 recession_rate_throat *= graphite_cfg.coverage_fraction
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):
1088 if ablative_cfg.throat_recession_multiplier
is not None:
1089 throat_mult = ablative_cfg.throat_recession_multiplier
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"]
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
1104 v_throat = np.sqrt(gamma * R * Tc / (gamma + 1))
1107 chamber_heat_flux = ablative_diag.get(
"incident_heat_flux", 1e6)
1109 throat_mult = calculate_throat_heuristic_multiplier(
1110 Pc, v_chamber, v_throat, chamber_heat_flux, gamma
1113 results[
"throat_recession_multiplier"][i] = throat_mult
1114 recession_rate_throat = recession_rate_chamber * throat_mult
1117 recession_increment_chamber = recession_rate_chamber * dt
1118 recession_increment_throat = recession_rate_throat * dt
1120 cumulative_recession_chamber += recession_increment_chamber
1121 cumulative_recession_throat += recession_increment_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
1128 V_new, A_throat_new, D_chamber_new, D_throat_new, geom_diag = update_chamber_geometry_from_ablation(
1134 cumulative_recession_chamber,
1135 cumulative_recession_throat,
1141 config_copy.chamber_geometry.volume = V_new
1142 config_copy.chamber_geometry.A_throat = A_throat_new
1145 if config_copy.chamber_geometry.Lstar
is not None:
1146 config_copy.chamber_geometry.Lstar = V_new / A_throat_new
1149 if ablative_cfg
and ablative_cfg.enabled
and ablative_cfg.nozzle_ablative:
1152 recession_increment_exit = recession_rate_chamber * 0.8 * dt
1153 cumulative_recession_exit += recession_increment_exit
1155 A_exit_new, D_exit_new, exit_diag = update_nozzle_exit_from_ablation(
1158 cumulative_recession_exit,
1159 ablative_cfg.coverage_fraction,
1163 config_copy.chamber_geometry.A_exit = A_exit_new
1167 except Exception
as e:
1170 results[
"diagnostics"].append({
"error": str(e)})