diff --git a/EngineDesign/backend/routers/evaluate.py b/EngineDesign/backend/routers/evaluate.py
index c3e82ac89..1f0237afd 100644
--- a/EngineDesign/backend/routers/evaluate.py
+++ b/EngineDesign/backend/routers/evaluate.py
@@ -20,7 +20,9 @@
class StabilityOverrides(BaseModel):
"""Optional forward-mode knobs for rich stability re-evaluation."""
eta_inj_O: float | None = Field(default=None, gt=0, le=0.6, description="Oxidizer ΔP_inj/Pc")
- smd_um: float | None = Field(default=None, gt=0, le=200, description="Oxidizer spray SMD [µm]")
+ smd_um: float | None = Field(default=None, gt=0, le=400, description="Oxidizer spray SMD [µm]")
+ smd_F_um: float | None = Field(default=None, gt=0, le=400, description="Fuel spray SMD [µm]")
+ eta_inj_F: float | None = Field(default=None, gt=0, le=0.6, description="Fuel ΔP_inj/Pc")
n_interaction: float | None = Field(default=None, gt=0, le=2, description="Combustion interaction index n")
chi_acoustic: float | None = Field(default=None, gt=0, le=1, description="Acoustic sensitive-fraction χ")
time_lag_model: Literal["leonardi_dtl", "d2_law"] | None = Field(
diff --git a/EngineDesign/backend/routers/optimizer.py b/EngineDesign/backend/routers/optimizer.py
index e7862d7d7..c4a61d3b7 100644
--- a/EngineDesign/backend/routers/optimizer.py
+++ b/EngineDesign/backend/routers/optimizer.py
@@ -42,6 +42,43 @@ def layer1_design_is_valid(results: Dict[str, Any]) -> tuple[bool, list]:
if (key.endswith("_check_passed") or key.endswith("_gate_passed")) and passed is False:
reasons.append(f"{key} = False")
return (not reasons), reasons
+
+
+def merge_design_requirements(
+ old: Optional[Dict[str, Any]], incoming: Dict[str, Any]
+) -> Dict[str, Any]:
+ """Lay a (possibly partial) requirements payload over the requirements already loaded.
+
+ A key ABSENT from ``incoming`` keeps its current value. A key sent as an explicit
+ ``None`` is cleared. Those are different intents -- a form that only knows a dozen
+ fields must not, by not mentioning them, reset the ~100 ``layer1_*`` knobs, the injector
+ face limits and the pinned seed to schema defaults. It did: the save route rebuilt
+ ``design_requirements`` from the payload alone, and the next run optimised a different
+ problem than the one the file described. ``frozen_parameters`` merges key by key with the
+ same null-clears rule, as it already did.
+ """
+ merged: Dict[str, Any] = dict(old or {})
+ prev_fp = merged.get("frozen_parameters")
+ old_fp: Dict[str, Any] = (
+ {k: v for k, v in prev_fp.items() if v is not None} if isinstance(prev_fp, dict) else {}
+ )
+ for key, value in incoming.items():
+ if key != "frozen_parameters":
+ merged[key] = value
+ if "frozen_parameters" in incoming:
+ new_fp = incoming.get("frozen_parameters")
+ fp = dict(old_fp)
+ for k, v in (new_fp.items() if isinstance(new_fp, dict) else ()):
+ if v is None:
+ fp.pop(k, None)
+ else:
+ fp[k] = v
+ merged["frozen_parameters"] = fp if fp else None
+ elif old_fp:
+ merged["frozen_parameters"] = old_fp
+ elif "frozen_parameters" in merged:
+ merged["frozen_parameters"] = None
+ return merged
from engine.pipeline.config_schemas import DesignRequirementsConfig
from engine.optimizer.layers.layer1_static_optimization import run_layer1_optimization
from engine.optimizer.layers.layer2_pressure import run_layer2_pressure
@@ -132,26 +169,12 @@ async def save_design_requirements(
)
try:
- # Merge frozen_parameters so a partial UI payload cannot silently drop YAML pins.
- req_in = dict(request.requirements)
+ # Overlay the payload on what is loaded; a partial payload must not reset the rest.
old_dr = session.app_state.config.design_requirements
- old_fp: dict = {}
- if old_dr is not None and old_dr.frozen_parameters is not None:
- old_fp = old_dr.frozen_parameters.model_dump(exclude_none=True)
- if "frozen_parameters" not in req_in:
- if old_fp:
- req_in["frozen_parameters"] = old_fp
- else:
- new_fp = req_in.get("frozen_parameters")
- if not isinstance(new_fp, dict):
- new_fp = {}
- merged_fp = dict(old_fp)
- for k, v in new_fp.items():
- if v is None:
- merged_fp.pop(k, None)
- else:
- merged_fp[k] = v
- req_in["frozen_parameters"] = merged_fp if merged_fp else None
+ req_in = merge_design_requirements(
+ old_dr.model_dump() if old_dr is not None else None,
+ dict(request.requirements),
+ )
# Validate requirements using Pydantic
requirements = DesignRequirementsConfig(**req_in)
diff --git a/EngineDesign/configs/default.yaml b/EngineDesign/configs/default.yaml
index ad6b8f8a0..7ee049b53 100644
--- a/EngineDesign/configs/default.yaml
+++ b/EngineDesign/configs/default.yaml
@@ -22,9 +22,14 @@ fluids:
specific_heat: 2300.0
thermal_conductivity: 0.15
temperature: 90.0
- latent_heat: null
- boiling_point: null
- molecular_weight: null
+ # Stability-only inputs (droplet vaporization lag); the forward performance path does not read
+ # them, so filling them in cannot move thrust/Isp or the golden anchors. Left null, the chug
+ # model silently substituted these same handbook numbers on every evaluation of the default
+ # config and reported three fallbacks it should never have needed.
+ latent_heat: 213000.0 # J/kg, NIST oxygen at 1 atm
+ boiling_point: 90.19 # K, NIST oxygen at 1 atm
+ molecular_weight: 32.0 # g/mol
+ bulk_modulus_pa: 1500000000.0 # order-of-magnitude LOX; refine via water-hammer test T5
injector:
type: impinging
geometry:
diff --git a/EngineDesign/configs/ethalox_180lb_8to1.yaml b/EngineDesign/configs/ethalox_180lb_8to1.yaml
new file mode 100644
index 000000000..24e966d97
--- /dev/null
+++ b/EngineDesign/configs/ethalox_180lb_8to1.yaml
@@ -0,0 +1,718 @@
+# CalSTAR ethalox -- 180 lb, 8:1, O/F 1.50, 24 doublets, MSA G1 45 scf COPV. 2026-09-15.
+#
+# Audit: python3 scripts/design_audit.py configs/ethalox_180lb_8to1.yaml
+# Re-run: python3 scripts/layer1_run.py --config configs/ethalox_180lb_8to1.yaml
+# THIS FILE is the design. A re-run is a new candidate, not a reproduction:
+# the hybrid search ignored layer1_random_seed until 2026-09-18, and the
+# objective is flat across the injectors it lands on (99.99 % of it is the
+# chamber-mass shaping term; every requirement term is ~0).
+#
+# HARD CONSTRAINTS, ALL EXACT
+# wet mass 81.6466 kg = 180.0000 lb
+# thrust 6405.5 N = 1440.0 lbf -> T/W = 8.0000 : 1
+# liquid 11.3810 L + COPV 4.6190 L = 16.0000 L
+#
+# THE COPV IS NOW A REAL PART, NOT AN ESTIMATE
+# MSA G1, 45 ft3 / 4500 psi, 30-minute standard carbon cylinder.
+# The specsheet quotes 10.40 lb FULL, and that is full of breathing AIR, not oxygen:
+# full 10.40 lb = 4.717 kg
+# air charge 3.37 lb = 1.529 kg (45 ft3 of free air at 1 atm / 70 F)
+# -> DRY 7.03 lb = 3.188 kg
+# water volume 4.619 L (charge mass / real air density at 4500 psi)
+# + GN2 at 4000 psi 2.89 lb = 1.312 kg -> installed 9.92 lb = 4.500 kg
+# This replaces three disagreeing internal numbers: 2.969 kg at 4.5 L in eight
+# EngineDesign configs (no source field at all), 3.500 kg at 4.687 L in the feed-twin
+# drawing (tagged "estimated", reference said "weigh it"), and the 3.300 kg at 5.000 L
+# this design carried. On kg/L those were -4.4 %, +8.2 % and -4.4 % against the real part.
+# The real bottle being SMALLER than the assumed 5 L frees 0.381 L into propellant.
+#
+# Dome-regulated: tanks need 0.587 kg, the bottle delivers 1.098 kg -> 1.87x, and it
+# ends the burn near 2010 psi against a 582 psi setpoint, so the regulator holds.
+# NOTE a 4500 psi cylinder filled to 4000 is not a lighter cylinder -- you save the
+# 0.29 lb of nitrogen and nothing else.
+#
+# ENGINE
+# O/F 1.4993 Pc 433.97 psia Isp 236.90 s eta_c* 0.9499
+# 24 doublets on a 15.000 deg pitch (360/24 exactly)
+# theta 43 / 46 deg (included 89), d_jet 1.536 / 1.411 mm
+# bore 127.000 mm = 5.0000 in, throat 43.85 mm, exit 103.76 mm, L* 1.0000 m
+# burn 3.994 s, impulse 25581 N.s
+# LOX-limited with 3 g of residual -- the delivered O/F 1.4993 lands on the 1.5000
+# load, so essentially nothing is left in either tank. Two other seeds had higher Isp
+# (237.15 and 237.22) and LESS total impulse, because they delivered O/F 1.511-1.513
+# against a 1.500 load and stranded 33-39 g of fuel. Isp is not the figure of merit
+# when the load ratio is fixed.
+# web 7.47 / 10.64 mm, centre clear O66.73, wall land 16.47 mm
+#
+# TANKS AT 10 % ULLAGE
+# mass kg mass lb liquid L liquid gal TANK L TANK gal
+# LOX 6.6086 14.570 5.7970 1.5314 6.4412 1.7016
+# ethanol 4.4057 9.713 5.5840 1.4751 6.2044 1.6390
+# TOTAL 11.0144 24.283 11.3810 3.0065 12.6456 3.3406
+# Buy 1.70 gal of LOX tank and 1.64 gal of fuel tank.
+#
+# ENGINE MASS 20.28 lb -- SLEEVE RUNS PAST THE GAS BOUNDARY
+# The injector plugs INTO the sleeve, so the sleeve is longer than the chamber and the
+# injector is sized to the sleeve BORE, not the engine OD:
+# gas boundary (face -> throat) 128.97 mm
+# injector insertion 40.00 mm 0.5 in face + O-ring land + pilot
+# steel sleeve 168.97 mm bore O152.4, OD O165.1
+# injector OD O152.4 it has to fit inside
+#
+# mild steel sleeve 0.250 in 4.201 kg 9.26 lb
+# 2 flanges + bolts + seals 1.440 kg 3.18 lb
+# aluminium injector 1.290 kg 2.84 lb
+# ablative liner 0.500 in 1.194 kg 2.63 lb
+# nozzle, 12 mm abl + 3 mm alu 0.730 kg 1.61 lb
+# igniter + instrument bosses 0.250 kg 0.55 lb
+# graphite throat insert 0.093 kg 0.21 lb
+# ENGINE 9.198 kg 20.28 lb
+#
+# MASS BUDGET
+# propellant 11.014 kg 24.28 lb
+# pressurant GN2 1.312 kg 2.89 lb 4.619 L at 4000 psi
+# engine + plumbing 14.398 kg 31.74 lb engine 20.28 + valves/lines 11.46
+# LOX tank 4.082 kg 9.00 lb given
+# fuel tank 4.082 kg 9.00 lb given
+# COPV 3.188 kg 7.03 lb MSA G1 specsheet, air backed out
+# airframe + recovery 43.571 kg 96.05 lb what is left
+# == WET 81.647 kg 180.000 lb
+#
+# APOGEE -- READ THIS BEFORE COMMITTING
+# eta_c* 0.9499 (modelled) 4026 m = 13208 ft <- 208 ft OVER the 13000 ft ceiling
+# eta_c* 0.9309 3892 m = 12769 ft
+# eta_c* 0.9119 3760 m = 12334 ft
+# eta_c* 0.8929 3628 m = 11904 ft
+# eta_c* 0.8169 3118 m = 10231 ft
+# The modelled nominal busts the ceiling by 1.6 %. It only gets there if the engine
+# performs to a 0.95 eta_c*, which is optimistic against the 0.87 published comparable
+# -- the realistic 0.88-0.91 band lands 11900-12400 ft, comfortably inside.
+# If you want the MODELLED nominal under 13000 as well, de-load 0.099 kg (fill the
+# tanks to 89.2 % instead of 90 %) and it comes to 13000 exactly. 0.194 kg gets 12800.
+# Sensitivity is 0.908 ft per N.s, so this is a fill-level decision on the pad, not a
+# redesign.
+#
+# FILL FACTOR: this file declares 0.90 (the 10 % ullage asked for). RocketPy's tank
+# model refuses above ~0.80, so the apogee numbers were produced on a larger tank
+# ENVELOPE at identical propellant mass -- verified insensitive, 0.80/0.75/0.70 all
+# returned the same apogee, because the trajectory depends on mass and not on how much
+# empty tank surrounds it.
+#
+# MATERIALS AS DECIDED
+# Aluminium injector. FUEL plenum against the face with LOX routed through it -- that
+# one is oxygen compatibility, not thermal. Nozzle is 12 mm of ablative inside a 3 mm
+# aluminium shell; bare 5 mm aluminium reaches 1067 K at the exit even at 0.6x Bartz
+# against a 933 K melt. Put two or three thermocouples in the face on the first fire.
+#
+# STILL REQUIRES HARDWARE
+# FLOW-TEST the injector. Cd 0.80 is a correlation. Cd 0.72-0.88 moves thrust -4.5/+3.8 %
+# and keeps dP/Pc inside 0.20-0.40 throughout.
+# Spot-face every orifice normal to its own axis -- incidence is 47 / 44 deg.
+propellant_preset: ethalox
+fluids:
+ fuel:
+ name: Ethanol
+ density: 789.0
+ viscosity: 0.0012
+ surface_tension: 0.0223
+ vapor_pressure: 5800.0
+ specific_heat: 2440.0
+ thermal_conductivity: 0.17
+ temperature: 293.0
+ latent_heat: 838000.0
+ boiling_point: 351.4
+ molecular_weight: 46.07
+ bulk_modulus_pa: 1060000000.0
+ critical_temperature: 514.71
+ injection_phase: null
+ oxidizer:
+ name: LOX
+ density: 1140.0
+ viscosity: 0.00018
+ surface_tension: 0.013
+ vapor_pressure: 101325.0
+ specific_heat: 2300.0
+ thermal_conductivity: 0.15
+ temperature: 90.0
+ latent_heat: 213000.0
+ boiling_point: 90.2
+ molecular_weight: 32.0
+ bulk_modulus_pa: 1500000000.0
+ critical_temperature: 154.6
+ injection_phase: null
+injector:
+ type: impinging
+ geometry:
+ oxidizer:
+ n_elements: 24
+ d_jet: 0.0015361478255479359
+ impingement_angle: 43.0
+ spacing: 0.009010387728636004
+ fuel:
+ n_elements: 24
+ d_jet: 0.0014105372348721397
+ impingement_angle: 46.0
+ spacing: 0.012046846972657375
+feed_system:
+ fuel:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 2.019
+ K1: 0.0
+ phi_type: none
+ length: 0.9144
+ oxidizer:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 0.643
+ K1: 0.0
+ phi_type: none
+ length: 0.1016
+regen_cooling:
+ enabled: false
+ d_inlet: 0.009525
+ L_inlet: 0.5
+ n_channels: 100
+ channel_width: 0.0009
+ channel_height: 0.001
+ channel_length: 0.18162
+ d_outlet: null
+ L_outlet: 0.1
+ roughness: 0.0
+ K_manifold_split: 0.5
+ K_manifold_merge: 0.3
+ Cd_entrance_inf: 0.8
+ a_Re_entrance: 0.1
+ Cd_entrance_min: 0.6
+ Cd_exit_inf: 0.9
+ a_Re_exit: 0.1
+ Cd_exit_min: 0.7
+ use_heat_transfer: true
+ wall_thickness: 0.002
+ wall_thermal_conductivity: 320.0
+ chamber_inner_diameter: 0.08491
+ hot_gas_prandtl: 0.7
+ hot_gas_viscosity: 4.0e-05
+ hot_gas_thermal_conductivity: 0.12
+ radiation_emissivity_hot: 0.85
+ radiation_view_factor: 1.0
+ n_segments: 20
+ gas_turbulence_intensity: 0.1
+ coolant_turbulence_intensity: 0.05
+ recovery_factor: null
+film_cooling:
+ enabled: false
+ mass_fraction: 0.05
+ injection_temperature: null
+ effectiveness_ref: 0.45
+ decay_length: 0.05
+ apply_to_fraction_of_length: 0.6
+ slot_height: 0.00035
+ reference_blowing_ratio: 0.6
+ blowing_exponent: 0.62
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.0
+ turbulence_exponent: 1.0
+ turbulence_min_multiplier: 0.5
+ reference_wall_temperature: 1100.0
+ density_override: null
+ cp_override: null
+ablative_cooling:
+ enabled: true
+ material_density: 1600.0
+ heat_of_ablation: 2500000.0
+ thermal_conductivity: 0.35
+ specific_heat: 1500.0
+ initial_thickness: 0.0127
+ surface_temperature_limit: 1200.0
+ coverage_fraction: 0.9
+ pyrolysis_temperature: 950.0
+ blowing_efficiency: 0.75
+ use_physics_based_blowing: true
+ blowing_coefficient: 0.5
+ blowing_min_reduction_factor: 0.1
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.5
+ turbulence_exponent: 1.0
+ turbulence_max_multiplier: 3.0
+ throat_recession_multiplier: null
+ char_layer_conductivity: 0.2
+ char_layer_thickness: 0.001
+ surface_emissivity: 0.85
+ ambient_temperature: 300.0
+ radiative_sink_minimum_threshold: 400.0
+ radiative_sink_fallback_temperature: 600.0
+ track_geometry_evolution: true
+ nozzle_ablative: false
+graphite_insert:
+ enabled: true
+ material_density: 2260.0
+ heat_of_ablation: 15000000.0
+ thermal_conductivity: 100.0
+ specific_heat: 710.0
+ initial_thickness: 0.006
+ surface_temperature_limit: 2500.0
+ oxidation_temperature: 800.0
+ oxidation_rate: 1.0e-06
+ activation_energy: 190000.0
+ oxidation_reference_temperature: 973.0
+ oxidation_reference_pressure: 21000.0
+ recession_multiplier: null
+ sizing_only_mode: false
+ simplified_graphite_oxidation: false
+ simplified_oxidation_rate: 1.0e-05
+ sizing_recession_rate: 1.0e-08
+ axial_half_length_ratio: 0.75
+ axial_half_length: null
+ char_layer_conductivity: 5.0
+ char_layer_thickness: 0.0005
+ coverage_fraction: 1.0
+ emissivity: 0.8
+ ambient_temperature: 300.0
+ feedback_fraction_min: 0.0
+ feedback_fraction_max: 0.2
+ oxidation_enthalpy: 32800000.0
+ ablation_surface_temperature: 3000.0
+ ablation_transition_width: 200.0
+ oxidation_pressure_exponent: 0.5
+ oxidation_pre_exponential: null
+ mixture_mw: 0.024
+ oxidation_stoichiometry_ratio: 1.0
+ oxygen_mass_fraction: 0.05
+ oxygen_mole_fraction: null
+ friction_coefficient_override: null
+ reference_diffusivity: null
+ reference_diffusivity_temperature: 1500.0
+ reference_diffusivity_pressure: 1000000.0
+stainless_steel_case: null
+discharge:
+ fuel:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 300.0
+ a_T: 0.0
+ oxidizer:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 90.0
+ a_T: 0.0
+spray:
+ momentum_flux_ratio: true
+ spray_angle:
+ model: TMR
+ k: 0.5
+ n: 0.5
+ weber:
+ We_min: 15
+ smd:
+ model: ingebo
+ C: 0.5
+ m: 0.6
+ p: 0.0
+ C_ingebo: 3.9
+ chamber_gas_R: 389.0
+ chamber_gas_T: 3094.0
+ we_corr_max: null
+ pintle:
+ C: 15.0
+ B: 2.0
+ n: 0.5
+ p: 0.2
+ evaporation:
+ model: derived
+ C_evap: 1.562
+ cp_gas: 2200.0
+ apply_tau_res_correction: false
+ K: 300000.0
+ x_star_limit: 0.05
+ use_constraint: true
+ use_turbulence_corrections: false
+ turbulence_breakup_gain: 1.0
+ turbulence_penetration_gain: 0.5
+combustion:
+ cea:
+ use_parallel_cea_build: false
+ cea_parallel_workers: null
+ ox_name: LOX
+ fuel_name: Ethanol
+ expansion_ratio: 5.598521540485944
+ cache_file: output/cache/cea_cache_LOX_Ethanol_3D.npz
+ Pc_range:
+ - 1000000.0
+ - 9000000.0
+ MR_range:
+ - 1.0
+ - 2.5
+ eps_range:
+ - 4.0
+ - 15.0
+ n_points: 34
+ efficiency:
+ model: exponential
+ C: 0.3
+ K: 0.15
+ use_spray_correction: false
+ spray_penalty_factor: 0.8
+ use_mixture_coupling: false
+ use_cooling_coupling: true
+ use_turbulence_coupling: true
+ Em_peak: 0.96
+ mixing_sigma: 1.5
+ R_opt: null
+ mixture_efficiency_floor: 0.25
+ cooling_efficiency_floor: 0.25
+ turbulence_efficiency_floor: 0.3
+ target_turbulence_intensity: null
+ turbulence_penalty_exponent: null
+ target_smd_microns: null
+ xstar_limit_mm: null
+ xstar_penalty_exponent: null
+ we_reference: null
+ we_penalty_exponent: null
+ smd_penalty_exponent: null
+ use_advanced_model: true
+ Pc_gate: 1000000.0
+ use_finite_rate_chemistry: true
+ use_shifting_equilibrium: true
+ tau_ref: 1.0e-05
+ tau_ref_P: 4000000.0
+ tau_ref_T: 3500.0
+ n_pressure: 0.8
+ tau_Tc_floor_K: null
+ T_star_fuel_cap_K: 500.0
+ A0_hydrocarbon: 10000000.0
+ Ea_hydrocarbon: 80000.0
+ n_pre_hydrocarbon: 0.3
+ A0_ethanol: 50000000.0
+ Ea_ethanol: 140000.0
+ n_pre_ethanol: 0.25
+ A0_hydrogen: 1000000000.0
+ Ea_hydrogen: 40000.0
+ n_pre_hydrogen: 0.2
+chamber_geometry:
+ design_pressure: 2992123.1854115925
+ design_thrust: 6405.486128556911
+ design_MR: 1.4992994717934465
+ chamber_diameter: 0.127
+ Lstar: 1.0000002573548417
+ exit_diameter: 0.1037600811326615
+ expansion_ratio: 5.598521540485944
+ nozzle_efficiency: 0.95
+ A_throat: 0.0015103483768447478
+ A_exit: 0.008455717921403302
+ volume: 0.001510348765540215
+ length: 0.1289737160011753
+ length_cylindrical: 0.09703298776603714
+ length_contraction: 0.031940728235138174
+ Cf: 1.4174100000015877
+chamber: null
+nozzle: null
+solver:
+ method: brentq
+ Pc_bounds:
+ - 100000.0
+ - 8000000.0
+ tolerance: 1.0e-06
+ max_iterations: 100
+ closure:
+ max_iterations: 6
+ Cd_reduction_factor: 1.0
+ tolerance: 0.0001
+stability:
+ n_interaction: 0.5
+ chi_acoustic: 0.15
+ mach_nozzle_entrance: null
+ damping_injector_frac: 0.02
+ damping_twophase_frac: 0.03
+ droplet_loading: 1.0
+ acoustic_gate_alpha_offset: 350.0
+ time_lag_model: leonardi_dtl
+ convection_model: none
+ mixing_lag_fraction: 0.5
+ regulator_enabled: true
+ regulator_corner_hz: 3.0
+ regulator_Z_hf: 0.0
+ regulator_max_excursion_psi: 0.0
+optimizer:
+ mode: hybrid_cma_blocks
+ hybrid:
+ elite_k: 50
+ block_method: corr_greedy
+ num_blocks: 3
+ overlap_fraction: 0.0
+ cycles: 3
+ lambda0: 0.001
+ lambda_mult: 10.0
+ lambda_max: 1.0
+ lambda_normalize: true
+ per_block_budget_fraction: 0.5
+ refresh_every_pass: true
+ refresh_budget_fraction: 0.1
+ refresh_sigma_scale: 0.2
+ num_tracks: 1
+lox_tank:
+ lox_h: 0.42022395489166414
+ lox_radius: 0.06985
+ ox_tank_pos: 0.8
+ mass: 6.608621213686249
+ initial_pressure_psi: 584.2669657943025
+ tank_volume_m3: 0.006441151280395955
+fuel_tank:
+ rp1_h: 0.3401263456893168
+ rp1_radius: 0.0762
+ fuel_tank_pos: 3.0
+ mass: 4.405747475790832
+ initial_pressure_psi: 584.2669657943025
+ tank_volume_m3: 0.006204404275159601
+press_tank:
+ press_h: 0.31334079903733364
+ press_radius: 0.0685
+ pres_tank_pos: 3.6
+ dry_mass: 3.188
+ initial_gas_mass: 1.312
+ mass: null
+ free_volume_L: 4.619
+rocket:
+ airframe_mass: 43.56959525052292
+ engine_mass: 14.398
+ lox_tank_structure_mass: 4.082331330000001
+ fuel_tank_structure_mass: 4.082331330000001
+ engine_cm_offset: 0.15
+ propulsion_dry_mass: 21.0
+ propulsion_cm_offset: 0.4
+ copv_dry_mass: 3.188
+ inertia:
+ - 8.0
+ - 8.0
+ - 0.5
+ radius: 0.078359
+ rocket_length: 6.432614614439114
+ motor_position: 0.0
+ fins:
+ no_fins: 4
+ root_chord: 0.626872
+ tip_chord: 0.20066
+ fin_span: 0.20066
+ fin_position: 1.054535
+ nose_kind: vonKarman
+ nose_fineness_ratio: 4.5
+ nose_length: null
+ avionics_payload_length_m: 4.0
+ mass: null
+ cm_wo_motor: 3.861725449
+ dry_mass: null
+ motor_inertia: null
+ motor: null
+environment:
+ date:
+ - 2026
+ - 1
+ - 30
+ - 18
+ latitude: 35.34722
+ longitude: -117.8099547
+ elevation: 626.67
+ atmosphere_model: standard_atmosphere
+thrust:
+ burn_time: 3.994
+design_requirements:
+ target_thrust: 6405.439125975119
+ target_chamber_pressure_psi: 430.0
+ target_apogee: 3890.7
+ optimal_of_ratio: 1.5
+ target_burn_time: 3.994
+ max_lox_tank_pressure_psi: 600.0
+ max_fuel_tank_pressure_psi: 600.0
+ max_P_tank_O: null
+ max_P_tank_F: null
+ max_engine_length: 0.4
+ max_chamber_outer_diameter: 0.1651
+ metal_wall_thickness_per_side_m: 0.00635
+ max_nozzle_exit_diameter: 0.2032
+ min_Lstar: 1.0
+ max_Lstar: 1.0
+ min_stability_score: 0.58
+ require_stable_state: false
+ stability_margin_handicap: 0.0
+ min_stability_margin: 1.05
+ chugging_margin_min: 0.2
+ acoustic_margin_min: 0.1
+ feed_stability_min: 0.15
+ lox_tank_capacity_kg: 6.608621213686249
+ fuel_tank_capacity_kg: 4.405747475790832
+ propellant_tank_fill_factor: 0.9
+ copv_free_volume_L: 4.619
+ copv_free_volume_m3: null
+ injector_dp_ratio_O_min: 0.2
+ injector_dp_ratio_O_max: 0.4
+ injector_dp_ratio_F_min: 0.2
+ injector_dp_ratio_F_max: 0.4
+ feed_pressure_model: dome_regulated
+ W_geom_ao_af_momentum: 3500.0
+ W_MOM: 75.0
+ impinging_momentum_R_min: 0.95
+ impinging_momentum_R_max: 1.05
+ layer1_momentum_log_deadband_rel: null
+ layer1_impinging_angle_deg_min: 80.0
+ layer1_impinging_jet_angle_min_deg: 40.0
+ layer1_impinging_angle_deg_max: 90.0
+ W_IMPINGING_ANGLE: 400.0
+ W_IMPINGING_JET_ASYM: 180.0
+ layer1_impinging_jet_angle_max_asym_deg: 10.0
+ W_SMD: 0.0
+ target_smd_microns: 50.0
+ layer1_smd_rel_tol: 0.2
+ W_TANK_EQUAL: 800.0
+ layer1_tank_equal_scale_psi: 100.0
+ layer1_chamber_od_increment_in: 0.5
+ layer1_lock_tank_pressures: null
+ layer1_thrust_deadband_rel: null
+ layer1_derive_tank_from_dp_ratio: null
+ layer1_dp_ratio_target: null
+ layer1_derive_fuel_jet_from_of: null
+ layer1_tank_equal_inband_frac: null
+ layer1_chamber_od_snap_target: null
+ layer1_Lstar_from_smd: null
+ layer1_Lstar_smd_ref_um: null
+ layer1_Lstar_ref_m: null
+ layer1_Lstar_smd_exponent: null
+ layer1_Lstar_deadband_m: null
+ layer1_impingement_Ld_target: 4.0
+ layer1_resultant_tilt_max_deg: null
+ layer1_resultant_tilt_gate_tol_deg: 0.5
+ layer1_resultant_tilt_scale_deg: null
+ layer1_momentum_wall_side_multiplier: null
+ layer1_momentum_scale: null
+ layer1_momentum_gate_safe_slack: null
+ layer1_derive_impingement_spacing: null
+ layer1_impingement_Ld_tol: 1.0
+ layer1_ring_order_fuel_outboard: null
+ layer1_integer_jet_angles: null
+ layer1_derive_expansion_ratio: null
+ layer1_derive_throat_from_thrust: null
+ layer1_derive_max_iters: null
+ layer1_derive_thrust_tol_rel: null
+ layer1_tank_equal_tol_psi: null
+ layer1_of_deadband_rel: null
+ layer1_exit_pressure_deadband_rel: null
+ layer1_W_LSTAR: null
+ layer1_Lstar_target_m: null
+ layer1_W_MASS: 3000.0
+ layer1_contraction_half_angle_deg: null
+ layer1_min_Lcyl_over_D: null
+ layer1_max_element_pitch_m: 0.0225
+ layer1_chamber_wall_density_kg_m3: 3400.0
+ layer1_chamber_mass_ref_kg: 5.0
+ layer1_W_EXIT: null
+ W_IMP_GEOM: 1500.0
+ layer1_exit_pressure_inside_quad_scale: 0.38
+ layer1_impinging_n_doublets_max: 30
+ layer1_random_seed: 37
+ layer1_cma_warmstart_trials: 16
+ layer1_cma_warmstart_sigma_frac: 0.04
+ layer1_cma_restart0_sigma_scale: 0.48
+ layer1_lbfgs_gtol: 1.0e-09
+ layer1_lbfgs_second_pass: true
+ W_DP: 800.0
+ W_DP_O: 12000.0
+ W_DP_F: 175000.0
+ W_DP_HIGH: 25000.0
+ W_DP_CENTER: null
+ W_DP_O_FLOOR: null
+ injector_dp_ratio_O_soft_floor: null
+ layer1_A_throat_mm2_min: null
+ layer1_A_throat_mm2_max: null
+ layer1_cf_upper_bound_for_throat_floor: null
+ layer1_pc_fraction_for_throat_floor: null
+ layer1_enforce_ring_geometry: true
+ layer1_injector_spray_radius_frac: 0.7071
+ layer1_injector_spray_radius_tol: 0.08
+ layer1_injector_plate_thickness_m: 0.0127
+ layer1_injector_min_face_incidence_deg: 40.0
+ layer1_injector_counterbore_dia_m: 0.004
+ layer1_injector_center_clear_dia_m: 0.0381
+ layer1_injector_min_web_m: 0.002
+ layer1_injector_wall_clearance_m: 0.008
+ layer1_resultant_tilt_from_reach: true
+ layer1_resultant_tilt_reach_margin: 1.5
+ layer1_impingement_Ld_min: 3.0
+ layer1_impingement_Ld_max: 5.0
+ layer1_momentum_band_width: null
+ layer1_momentum_low_side_multiplier: null
+ layer1_generations_per_restart: null
+ max_chamber_length_m: null
+ objective_cache_rel: null
+ report_every_n: null
+ layer1_infeasibility_gate_eps: 0.002
+ layer1_W_THRUST: 60000.0
+ layer1_W_PC: null
+ layer1_W_OF: 20000.0
+ layer1_W_OF_low_MR_scale: 1.0
+ layer1_W_OF_high_MR_scale: 1.0
+ layer1_of_validation_tol: null
+ layer1_thrust_validation_rel_tol: null
+ W_CHAMBER_SHAPE: 2500.0
+ layer1_chamber_dt_ratio_min: 2.2
+ layer1_chamber_dt_ratio_max: 3.2
+ layer1_chamber_ld_ratio_min: 1.0
+ layer1_chamber_ld_ratio_max: 3.2
+ layer1_stagnation_pressure_frac_min: 0.35
+ layer1_stagnation_pressure_frac_max: 1.0
+ layer1_expansion_ratio_min: 3.0
+ layer1_expansion_ratio_max: 14.0
+ layer1_P_O_start_psi_min: null
+ layer1_P_O_start_psi_max: null
+ layer1_P_F_start_psi_min: null
+ layer1_P_F_start_psi_max: null
+ frozen_parameters:
+ A_throat_mm2: null
+ Lstar_mm: null
+ expansion_ratio: null
+ D_chamber_outer_mm: 165.1
+ d_pintle_tip_mm: null
+ h_gap_mm: null
+ n_orifices: null
+ d_orifice_mm: null
+ n_doublets: 24
+ d_jet_O_mm: null
+ d_jet_F_mm: null
+ impingement_angle_O_deg: null
+ impingement_angle_F_deg: null
+ spacing_O_mm: null
+ spacing_F_mm: null
+ P_O_start_psi: null
+ P_F_start_psi: null
+pressure_curves: null
+design_valid_for: null
diff --git a/EngineDesign/configs/ethalox_6500N.yaml b/EngineDesign/configs/ethalox_6500N.yaml
new file mode 100644
index 000000000..9f00cec8d
--- /dev/null
+++ b/EngineDesign/configs/ethalox_6500N.yaml
@@ -0,0 +1,682 @@
+# CalSTAR ethalox -- 180 lb wet, 6.500 kN, O/F 1.50, 24 doublets, MSA G1 COPV. 2026-09-16.
+#
+# Audit: python3 scripts/design_audit.py configs/ethalox_6500N.yaml
+# Re-run: python3 scripts/layer1_run.py --config configs/ethalox_6500N.yaml
+# THIS FILE is the design. A re-run is a new candidate, not a reproduction:
+# the hybrid search ignored layer1_random_seed until 2026-09-18 (three runs at
+# seed 37 gave 89 / 87 / 83 deg injectors), and the objective is flat across
+# those -- 99.99 % of it is the chamber-mass shaping term, every requirement
+# term is ~0 -- so which one a run lands on is not a quality difference.
+#
+# WHAT CHANGED FROM ethalox_180lb_8to1.yaml
+# Thrust 6405.5 -> 6500.0 N exactly, for margin. Nothing else was re-optimised: the
+# SMD blend, the 43/46 deg angles, 24 doublets on a 15.000 deg pitch, the 5.000 in
+# bore, eps 5.5985, L* 1.000 m and dP/Pc all carry over unchanged.
+# With Pc held, F = zeta_n*Cf_vac*Pc*At - Pa*Ae is linear in At, so the engine scales
+# by ONE factor k = 1.015457435, solved (not assumed) because Cf_vac moves with eps:
+# A_throat, A_exit, chamber volume x k -> eps and L* land back on their old values
+# d_jet (both streams) x sqrt(k) -> same injection velocity, same SMD, same dP
+# mdot follows At, so Pc = mdot*c*/At never moves and the injector keeps its schedule.
+# D_throat 43.85 -> 44.19 mm D_exit 103.76 -> 104.56 mm
+# d_jet 1.536 -> 1.548 mm (O) 1.411 -> 1.421 mm (F)
+# engine mass +142 g, taken out of the airframe to hold 180.000 lb wet.
+#
+# THE 16 L RULE, WITH THE COPV COUNTED INSIDE IT
+# COPV water volume 4.6190 L MSA G1 45 scf, specsheet, air backed out
+# liquid propellant 11.3810 L = 16.0000 - 4.6190
+# -> LOX 5.7995 L = 6.6114 kg ethanol 5.5815 L = 4.4038 kg at the DELIVERED O/F
+# The split is set from the delivered 1.5013, not the 1.5000 target, so both tanks
+# run dry in the same instant: residual 0.0 g on each side.
+#
+# TANKS AT 10 % ULLAGE (seamlesstanks 6.625 in OD; the length model is fit to their own
+# 24 in / 2.89 gal point: barrel 30.450 in^2, 2.076 in of end)
+# mass kg mass lb liquid L liquid gal TANK L TANK gal LENGTH in
+# LOX 6.6114 14.5757 5.7995 1.5321 6.4439 1.7023 14.99
+# ethanol 4.4038 9.7087 5.5815 1.4745 6.2017 1.6383 14.50
+# Buy 15 in of LOX tank and 14.5 in of fuel tank. The shells hold 12.6456 L between
+# them but only 11.3810 L of that is propellant at T-0, which is what the rule counts.
+#
+# BURN, on a FLAT dome-regulated tank curve at 584.27 psi through the time-varying solver
+# thrust 6500.0 -> 6601.9 N T/W 8.1181 -> 8.2454, mean 8.1848
+# Pc 433.65 -> 427.41 psia (-1.44 %)
+# mdot 2.7976 -> 2.8550 kg/s (+2.05 %)
+# The graphite throat recedes ~0.4 mm radially over the burn; with the regulator holding
+# tank pressure flat that RAISES dP across the injector, so mdot climbs and Pc sags.
+# A steady-state point at t=0 does not see this and under-loads the tanks by ~1 %.
+# BURN TIME 3.8978 s impulse 25544 N.s -- burn time is an OUTPUT of the integration
+# here, not m_prop/mdot at one operating point.
+#
+# APOGEE -- ceiling is 15000 ft
+# 12760 ft (3889.3 m) at the modelled eta_c* 0.9499, 2240 ft of margin.
+# Verified envelope-insensitive: tank fill 0.80 / 0.75 / 0.70 all return 3889.3 m.
+# Lower eta_c* only lowers it (~2240 ft per 0.10 of eta_c*), so the ceiling is not at
+# risk in any direction a real engine can go.
+# The 6.5 kN bump COSTS ~440 ft against the 6405 N build at identical propellant: a
+# shorter, harder burn spends more of its velocity low in the atmosphere. That is the
+# price of the thrust margin, and there is room for it.
+#
+# WHAT BINDS, AND THE ONLY LEVER LEFT
+# Propellant is capped by the 16 L rule, NOT by apogee -- there are 2240 unused feet.
+# Every litre taken out of the COPV is a litre of propellant, worth roughly 2000 ft.
+# The bottle currently runs 2.08x on deliverable gas (needs 0.528 kg, delivers ~1.098),
+# so a smaller bottle is arguable -- but it is a real part and its own analysis.
+#
+# PRESSURANT
+# Ground pre-pressurisation charges the initial ullage; the flight COPV only replaces
+# expelled liquid: 11.3810 L at 46.4 kg/m3 = 0.5281 kg. Independent of tank size.
+#
+# STILL REQUIRES HARDWARE
+# FLOW-TEST the injector. Cd 0.80 is a correlation, not a measurement.
+# NOTE: engine/pipeline/time_varying_solver.py hardcodes Pa = 101325 while this config
+# declares elevation 626.67 m (94070 Pa). The time-series numbers above were corrected
+# by hand for that (+61.4 N per step). Fix the solver before trusting its raw output.
+#
+propellant_preset: ethalox
+fluids:
+ fuel:
+ name: Ethanol
+ density: 789.0
+ viscosity: 0.0012
+ surface_tension: 0.0223
+ vapor_pressure: 5800.0
+ specific_heat: 2440.0
+ thermal_conductivity: 0.17
+ temperature: 293.0
+ latent_heat: 838000.0
+ boiling_point: 351.4
+ molecular_weight: 46.07
+ bulk_modulus_pa: 1060000000.0
+ critical_temperature: 514.71
+ injection_phase: null
+ oxidizer:
+ name: LOX
+ density: 1140.0
+ viscosity: 0.00018
+ surface_tension: 0.013
+ vapor_pressure: 101325.0
+ specific_heat: 2300.0
+ thermal_conductivity: 0.15
+ temperature: 90.0
+ latent_heat: 213000.0
+ boiling_point: 90.2
+ molecular_weight: 32.0
+ bulk_modulus_pa: 1500000000.0
+ critical_temperature: 154.6
+ injection_phase: null
+injector:
+ type: impinging
+ geometry:
+ oxidizer:
+ n_elements: 24
+ d_jet: 0.0015479747499138553
+ impingement_angle: 43.0
+ spacing: 0.009010387728636004
+ fuel:
+ n_elements: 24
+ d_jet: 0.0014213970733035063
+ impingement_angle: 46.0
+ spacing: 0.012046846972657375
+feed_system:
+ fuel:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 2.019
+ K1: 0.0
+ phi_type: none
+ length: 0.9144
+ oxidizer:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 0.643
+ K1: 0.0
+ phi_type: none
+ length: 0.1016
+regen_cooling:
+ enabled: false
+ d_inlet: 0.009525
+ L_inlet: 0.5
+ n_channels: 100
+ channel_width: 0.0009
+ channel_height: 0.001
+ channel_length: 0.18162
+ d_outlet: null
+ L_outlet: 0.1
+ roughness: 0.0
+ K_manifold_split: 0.5
+ K_manifold_merge: 0.3
+ Cd_entrance_inf: 0.8
+ a_Re_entrance: 0.1
+ Cd_entrance_min: 0.6
+ Cd_exit_inf: 0.9
+ a_Re_exit: 0.1
+ Cd_exit_min: 0.7
+ use_heat_transfer: true
+ wall_thickness: 0.002
+ wall_thermal_conductivity: 320.0
+ chamber_inner_diameter: 0.08491
+ hot_gas_prandtl: 0.7
+ hot_gas_viscosity: 4.0e-05
+ hot_gas_thermal_conductivity: 0.12
+ radiation_emissivity_hot: 0.85
+ radiation_view_factor: 1.0
+ n_segments: 20
+ gas_turbulence_intensity: 0.1
+ coolant_turbulence_intensity: 0.05
+ recovery_factor: null
+film_cooling:
+ enabled: false
+ mass_fraction: 0.05
+ injection_temperature: null
+ effectiveness_ref: 0.45
+ decay_length: 0.05
+ apply_to_fraction_of_length: 0.6
+ slot_height: 0.00035
+ reference_blowing_ratio: 0.6
+ blowing_exponent: 0.62
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.0
+ turbulence_exponent: 1.0
+ turbulence_min_multiplier: 0.5
+ reference_wall_temperature: 1100.0
+ density_override: null
+ cp_override: null
+ablative_cooling:
+ enabled: true
+ material_density: 1600.0
+ heat_of_ablation: 2500000.0
+ thermal_conductivity: 0.35
+ specific_heat: 1500.0
+ initial_thickness: 0.0127
+ surface_temperature_limit: 1200.0
+ coverage_fraction: 0.9
+ pyrolysis_temperature: 950.0
+ blowing_efficiency: 0.75
+ use_physics_based_blowing: true
+ blowing_coefficient: 0.5
+ blowing_min_reduction_factor: 0.1
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.5
+ turbulence_exponent: 1.0
+ turbulence_max_multiplier: 3.0
+ throat_recession_multiplier: null
+ char_layer_conductivity: 0.2
+ char_layer_thickness: 0.001
+ surface_emissivity: 0.85
+ ambient_temperature: 300.0
+ radiative_sink_minimum_threshold: 400.0
+ radiative_sink_fallback_temperature: 600.0
+ track_geometry_evolution: true
+ nozzle_ablative: false
+graphite_insert:
+ enabled: true
+ material_density: 2260.0
+ heat_of_ablation: 15000000.0
+ thermal_conductivity: 100.0
+ specific_heat: 710.0
+ initial_thickness: 0.006
+ surface_temperature_limit: 2500.0
+ oxidation_temperature: 800.0
+ oxidation_rate: 1.0e-06
+ activation_energy: 190000.0
+ oxidation_reference_temperature: 973.0
+ oxidation_reference_pressure: 21000.0
+ recession_multiplier: null
+ sizing_only_mode: false
+ simplified_graphite_oxidation: false
+ simplified_oxidation_rate: 1.0e-05
+ sizing_recession_rate: 1.0e-08
+ axial_half_length_ratio: 0.75
+ axial_half_length: null
+ char_layer_conductivity: 5.0
+ char_layer_thickness: 0.0005
+ coverage_fraction: 1.0
+ emissivity: 0.8
+ ambient_temperature: 300.0
+ feedback_fraction_min: 0.0
+ feedback_fraction_max: 0.2
+ oxidation_enthalpy: 32800000.0
+ ablation_surface_temperature: 3000.0
+ ablation_transition_width: 200.0
+ oxidation_pressure_exponent: 0.5
+ oxidation_pre_exponential: null
+ mixture_mw: 0.024
+ oxidation_stoichiometry_ratio: 1.0
+ oxygen_mass_fraction: 0.05
+ oxygen_mole_fraction: null
+ friction_coefficient_override: null
+ reference_diffusivity: null
+ reference_diffusivity_temperature: 1500.0
+ reference_diffusivity_pressure: 1000000.0
+stainless_steel_case: null
+discharge:
+ fuel:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 300.0
+ a_T: 0.0
+ oxidizer:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 90.0
+ a_T: 0.0
+spray:
+ momentum_flux_ratio: true
+ spray_angle:
+ model: TMR
+ k: 0.5
+ n: 0.5
+ weber:
+ We_min: 15
+ smd:
+ model: ingebo
+ C: 0.5
+ m: 0.6
+ p: 0.0
+ C_ingebo: 3.9
+ chamber_gas_R: 389.0
+ chamber_gas_T: 3094.0
+ we_corr_max: null
+ pintle:
+ C: 15.0
+ B: 2.0
+ n: 0.5
+ p: 0.2
+ evaporation:
+ model: derived
+ C_evap: 1.562
+ cp_gas: 2200.0
+ apply_tau_res_correction: false
+ K: 300000.0
+ x_star_limit: 0.05
+ use_constraint: true
+ use_turbulence_corrections: false
+ turbulence_breakup_gain: 1.0
+ turbulence_penetration_gain: 0.5
+combustion:
+ cea:
+ use_parallel_cea_build: false
+ cea_parallel_workers: null
+ ox_name: LOX
+ fuel_name: Ethanol
+ expansion_ratio: 5.598521540485944
+ cache_file: output/cache/cea_cache_LOX_Ethanol_3D.npz
+ Pc_range:
+ - 1000000.0
+ - 9000000.0
+ MR_range:
+ - 1.0
+ - 2.5
+ eps_range:
+ - 4.0
+ - 15.0
+ n_points: 34
+ efficiency:
+ model: exponential
+ C: 0.3
+ K: 0.15
+ use_spray_correction: false
+ spray_penalty_factor: 0.8
+ use_mixture_coupling: false
+ use_cooling_coupling: true
+ use_turbulence_coupling: true
+ Em_peak: 0.96
+ mixing_sigma: 1.5
+ R_opt: null
+ mixture_efficiency_floor: 0.25
+ cooling_efficiency_floor: 0.25
+ turbulence_efficiency_floor: 0.3
+ target_turbulence_intensity: null
+ turbulence_penalty_exponent: null
+ target_smd_microns: null
+ xstar_limit_mm: null
+ xstar_penalty_exponent: null
+ we_reference: null
+ we_penalty_exponent: null
+ smd_penalty_exponent: null
+ use_advanced_model: true
+ Pc_gate: 1000000.0
+ use_finite_rate_chemistry: true
+ use_shifting_equilibrium: true
+ tau_ref: 1.0e-05
+ tau_ref_P: 4000000.0
+ tau_ref_T: 3500.0
+ n_pressure: 0.8
+ tau_Tc_floor_K: null
+ T_star_fuel_cap_K: 500.0
+ A0_hydrocarbon: 10000000.0
+ Ea_hydrocarbon: 80000.0
+ n_pre_hydrocarbon: 0.3
+ A0_ethanol: 50000000.0
+ Ea_ethanol: 140000.0
+ n_pre_ethanol: 0.25
+ A0_hydrogen: 1000000000.0
+ Ea_hydrogen: 40000.0
+ n_pre_hydrogen: 0.2
+chamber_geometry:
+ design_pressure: 2992123.1854115925
+ design_thrust: 6500.0
+ design_MR: 1.4992994717934465
+ chamber_diameter: 0.127
+ Lstar: 1.0000002573548417
+ exit_diameter: 0.10455893825523037
+ expansion_ratio: 5.598521540485944
+ nozzle_efficiency: 0.95
+ A_throat: 0.001533694488707181
+ A_exit: 0.00858642163155173
+ volume: 0.0015336948834108832
+ length: 0.13096731883297194
+ length_cylindrical: 0.09853286886728646
+ length_contraction: 0.03243444996568549
+ Cf: 1.4174100000015877
+chamber: null
+nozzle: null
+solver:
+ method: brentq
+ Pc_bounds:
+ - 100000.0
+ - 8000000.0
+ tolerance: 1.0e-06
+ max_iterations: 100
+ closure:
+ max_iterations: 6
+ Cd_reduction_factor: 1.0
+ tolerance: 0.0001
+stability:
+ n_interaction: 0.5
+ chi_acoustic: 0.15
+ mach_nozzle_entrance: null
+ damping_injector_frac: 0.02
+ damping_twophase_frac: 0.03
+ droplet_loading: 1.0
+ acoustic_gate_alpha_offset: 350.0
+ time_lag_model: leonardi_dtl
+ convection_model: none
+ mixing_lag_fraction: 0.5
+ regulator_enabled: true
+ regulator_corner_hz: 3.0
+ regulator_Z_hf: 0.0
+ regulator_max_excursion_psi: 0.0
+optimizer:
+ mode: hybrid_cma_blocks
+ hybrid:
+ elite_k: 50
+ block_method: corr_greedy
+ num_blocks: 3
+ overlap_fraction: 0.0
+ cycles: 3
+ lambda0: 0.001
+ lambda_mult: 10.0
+ lambda_max: 1.0
+ lambda_normalize: true
+ per_block_budget_fraction: 0.5
+ refresh_every_pass: true
+ refresh_budget_fraction: 0.1
+ refresh_sigma_scale: 0.2
+ num_tracks: 1
+lox_tank:
+ lox_h: 0.42040357648186094
+ lox_radius: 0.06985
+ ox_tank_pos: 0.8
+ mass: 6.6114460194537275
+ initial_pressure_psi: 584.2669657943025
+ tank_volume_m3: 0.006443904502391548
+fuel_tank:
+ rp1_h: 0.33997541365866535
+ rp1_radius: 0.0762
+ fuel_tank_pos: 3.0
+ mass: 4.403792412851763
+ initial_pressure_psi: 584.2669657943025
+ tank_volume_m3: 0.006201651053164008
+press_tank:
+ press_h: 0.31334079903733364
+ press_radius: 0.0685
+ pres_tank_pos: 3.6
+ dry_mass: 3.188
+ initial_gas_mass: 1.312
+ mass: null
+ free_volume_L: 4.619
+rocket:
+ airframe_mass: 43.427417763392924
+ engine_mass: 14.54017748713
+ lox_tank_structure_mass: 4.082331330000001
+ fuel_tank_structure_mass: 4.082331330000001
+ engine_cm_offset: 0.15
+ propulsion_dry_mass: 21.0
+ propulsion_cm_offset: 0.4
+ copv_dry_mass: 3.188
+ inertia:
+ - 8.0
+ - 8.0
+ - 0.5
+ radius: 0.078359
+ rocket_length: 6.432614614439114
+ motor_position: 0.0
+ fins:
+ no_fins: 4
+ root_chord: 0.626872
+ tip_chord: 0.20066
+ fin_span: 0.20066
+ fin_position: 1.054535
+ nose_kind: vonKarman
+ nose_fineness_ratio: 4.5
+ nose_length: null
+ avionics_payload_length_m: 4.0
+ mass: null
+ cm_wo_motor: 3.861725449
+ dry_mass: null
+ motor_inertia: null
+ motor: null
+environment:
+ date:
+ - 2026
+ - 1
+ - 30
+ - 18
+ latitude: 35.34722
+ longitude: -117.8099547
+ elevation: 626.67
+ atmosphere_model: standard_atmosphere
+thrust:
+ burn_time: 3.994
+ design_thrust: 6500.0
+design_requirements:
+ target_thrust: 6500.0
+ target_chamber_pressure_psi: 430.0
+ target_apogee: 3890.7
+ optimal_of_ratio: 1.5
+ target_burn_time: 3.994
+ max_lox_tank_pressure_psi: 600.0
+ max_fuel_tank_pressure_psi: 600.0
+ max_P_tank_O: null
+ max_P_tank_F: null
+ max_engine_length: 0.4
+ max_chamber_outer_diameter: 0.1651
+ metal_wall_thickness_per_side_m: 0.00635
+ max_nozzle_exit_diameter: 0.2032
+ min_Lstar: 1.0
+ max_Lstar: 1.0
+ min_stability_score: 0.58
+ require_stable_state: false
+ stability_margin_handicap: 0.0
+ min_stability_margin: 1.05
+ chugging_margin_min: 0.2
+ acoustic_margin_min: 0.1
+ feed_stability_min: 0.15
+ lox_tank_capacity_kg: 6.6114460194537275
+ fuel_tank_capacity_kg: 4.403792412851763
+ propellant_tank_fill_factor: 0.9
+ copv_free_volume_L: 4.619
+ copv_free_volume_m3: null
+ injector_dp_ratio_O_min: 0.2
+ injector_dp_ratio_O_max: 0.4
+ injector_dp_ratio_F_min: 0.2
+ injector_dp_ratio_F_max: 0.4
+ feed_pressure_model: dome_regulated
+ W_geom_ao_af_momentum: 3500.0
+ W_MOM: 75.0
+ impinging_momentum_R_min: 0.95
+ impinging_momentum_R_max: 1.05
+ layer1_momentum_log_deadband_rel: null
+ layer1_impinging_angle_deg_min: 80.0
+ layer1_impinging_jet_angle_min_deg: 40.0
+ layer1_impinging_angle_deg_max: 90.0
+ W_IMPINGING_ANGLE: 400.0
+ W_IMPINGING_JET_ASYM: 180.0
+ layer1_impinging_jet_angle_max_asym_deg: 10.0
+ W_SMD: 0.0
+ target_smd_microns: 50.0
+ layer1_smd_rel_tol: 0.2
+ W_TANK_EQUAL: 800.0
+ layer1_tank_equal_scale_psi: 100.0
+ layer1_chamber_od_increment_in: 0.5
+ layer1_lock_tank_pressures: null
+ layer1_thrust_deadband_rel: null
+ layer1_derive_tank_from_dp_ratio: null
+ layer1_dp_ratio_target: null
+ layer1_derive_fuel_jet_from_of: null
+ layer1_tank_equal_inband_frac: null
+ layer1_chamber_od_snap_target: null
+ layer1_Lstar_from_smd: null
+ layer1_Lstar_smd_ref_um: null
+ layer1_Lstar_ref_m: null
+ layer1_Lstar_smd_exponent: null
+ layer1_Lstar_deadband_m: null
+ layer1_impingement_Ld_target: 4.0
+ layer1_resultant_tilt_max_deg: null
+ layer1_resultant_tilt_gate_tol_deg: 0.5
+ layer1_resultant_tilt_scale_deg: null
+ layer1_momentum_wall_side_multiplier: null
+ layer1_momentum_scale: null
+ layer1_momentum_gate_safe_slack: null
+ layer1_derive_impingement_spacing: null
+ layer1_impingement_Ld_tol: 1.0
+ layer1_ring_order_fuel_outboard: null
+ layer1_integer_jet_angles: null
+ layer1_derive_expansion_ratio: null
+ layer1_derive_throat_from_thrust: null
+ layer1_derive_max_iters: null
+ layer1_derive_thrust_tol_rel: null
+ layer1_tank_equal_tol_psi: null
+ layer1_of_deadband_rel: null
+ layer1_exit_pressure_deadband_rel: null
+ layer1_W_LSTAR: null
+ layer1_Lstar_target_m: null
+ layer1_W_MASS: 3000.0
+ layer1_contraction_half_angle_deg: null
+ layer1_min_Lcyl_over_D: null
+ layer1_max_element_pitch_m: 0.0225
+ layer1_chamber_wall_density_kg_m3: 3400.0
+ layer1_chamber_mass_ref_kg: 5.0
+ layer1_W_EXIT: null
+ W_IMP_GEOM: 1500.0
+ layer1_exit_pressure_inside_quad_scale: 0.38
+ layer1_impinging_n_doublets_max: 30
+ layer1_random_seed: 37
+ layer1_cma_warmstart_trials: 16
+ layer1_cma_warmstart_sigma_frac: 0.04
+ layer1_cma_restart0_sigma_scale: 0.48
+ layer1_lbfgs_gtol: 1.0e-09
+ layer1_lbfgs_second_pass: true
+ W_DP: 800.0
+ W_DP_O: 12000.0
+ W_DP_F: 175000.0
+ W_DP_HIGH: 25000.0
+ W_DP_CENTER: null
+ W_DP_O_FLOOR: null
+ injector_dp_ratio_O_soft_floor: null
+ layer1_A_throat_mm2_min: null
+ layer1_A_throat_mm2_max: null
+ layer1_cf_upper_bound_for_throat_floor: null
+ layer1_pc_fraction_for_throat_floor: null
+ layer1_enforce_ring_geometry: true
+ layer1_injector_spray_radius_frac: 0.7071
+ layer1_injector_spray_radius_tol: 0.08
+ layer1_injector_plate_thickness_m: 0.0127
+ layer1_injector_min_face_incidence_deg: 40.0
+ layer1_injector_counterbore_dia_m: 0.004
+ layer1_injector_center_clear_dia_m: 0.0381
+ layer1_injector_min_web_m: 0.002
+ layer1_injector_wall_clearance_m: 0.008
+ layer1_resultant_tilt_from_reach: true
+ layer1_resultant_tilt_reach_margin: 1.5
+ layer1_impingement_Ld_min: 3.0
+ layer1_impingement_Ld_max: 5.0
+ layer1_momentum_band_width: null
+ layer1_momentum_low_side_multiplier: null
+ layer1_generations_per_restart: null
+ max_chamber_length_m: null
+ objective_cache_rel: null
+ report_every_n: null
+ layer1_infeasibility_gate_eps: 0.002
+ layer1_W_THRUST: 60000.0
+ layer1_W_PC: null
+ layer1_W_OF: 20000.0
+ layer1_W_OF_low_MR_scale: 1.0
+ layer1_W_OF_high_MR_scale: 1.0
+ layer1_of_validation_tol: null
+ layer1_thrust_validation_rel_tol: null
+ W_CHAMBER_SHAPE: 2500.0
+ layer1_chamber_dt_ratio_min: 2.2
+ layer1_chamber_dt_ratio_max: 3.2
+ layer1_chamber_ld_ratio_min: 1.0
+ layer1_chamber_ld_ratio_max: 3.2
+ layer1_stagnation_pressure_frac_min: 0.35
+ layer1_stagnation_pressure_frac_max: 1.0
+ layer1_expansion_ratio_min: 3.0
+ layer1_expansion_ratio_max: 14.0
+ layer1_P_O_start_psi_min: null
+ layer1_P_O_start_psi_max: null
+ layer1_P_F_start_psi_min: null
+ layer1_P_F_start_psi_max: null
+ frozen_parameters:
+ A_throat_mm2: null
+ Lstar_mm: null
+ expansion_ratio: null
+ D_chamber_outer_mm: 165.1
+ d_pintle_tip_mm: null
+ h_gap_mm: null
+ n_orifices: null
+ d_orifice_mm: null
+ n_doublets: 24
+ d_jet_O_mm: null
+ d_jet_F_mm: null
+ impingement_angle_O_deg: null
+ impingement_angle_F_deg: null
+ spacing_O_mm: null
+ spacing_F_mm: null
+ P_O_start_psi: null
+ P_F_start_psi: null
+pressure_curves: null
+design_valid_for: null
diff --git a/EngineDesign/configs/ethalox_6500N_375psi.yaml b/EngineDesign/configs/ethalox_6500N_375psi.yaml
new file mode 100644
index 000000000..3da5765f1
--- /dev/null
+++ b/EngineDesign/configs/ethalox_6500N_375psi.yaml
@@ -0,0 +1,669 @@
+# CalSTAR ethalox -- 180 lb wet, 6.500 kN, Pc 375 psia / tanks 505 psi. 2026-09-16.
+#
+# Audit: python3 scripts/design_audit.py configs/ethalox_6500N_375psi.yaml -> CLEAN
+#
+# WHY THIS FILE EXISTS
+# A lower-chamber-pressure cut of ethalox_6500N.yaml, to bring tank pressure down from
+# 584.27 to 505.12 psi. Thrust, O/F, the 5.000 in bore, L* 1.000 m, 24 doublets and
+# dP/Pc 0.3470 are all held; the nozzle is RE-MATCHED (eps 5.5985 -> 5.0238) so the
+# comparison is not a rigged one against a nozzle left at the wrong area ratio.
+#
+# Pc 374.998 psia P_tank 505.125 psi F 6499.97 N O/F 1.50000 Pe/Pa 0.9992
+# D_throat 47.9145 mm (was 44.1900) D_exit 107.3947 mm (was 104.5589)
+# chamber length 153.974 mm (was 130.967) -- L* 1.0 m at a bigger throat needs more volume
+# contraction ratio 7.025 (was 8.26)
+#
+# THE INJECTOR HAD TO BE RE-LAID-OUT -- 43/46 DOES NOT SURVIVE HERE
+# The 17.6 % longer chamber drops the reach-based tilt allowance to 5.497 deg, while the
+# larger orifices raise the resultant tilt. At the inherited 43/46 the tilt is +6.4385 deg
+# -- design_audit.py FAILED it. Re-split to 40/49 (included 89, still under the SP-8089
+# 90 deg face-heating threshold) with element spacing +5 %:
+# tilt +3.4385 deg against a 5.4967 deg allowance, 63 % used, face terms exactly 0.
+# Spacing had to move in BOTH the face-layout check and the tilt-allowance check; a first
+# pass that scaled it in only one of the two produced a layout that was not actually feasible.
+#
+# WHAT THE PRESSURE REDUCTION ACTUALLY BUYS, MEASURED
+# 433.65 psia 375.00 psia delta
+# tank pressure psi 584.3 505.1 -79.1 <- the point
+# throat heat flux rel 1.000 0.878 -12.2 % <- the other point
+# COPV fill required psi 3278 2795 -483
+# chug gain margin 1.9767 1.8919 -0.0847 (gate is 1.0)
+# Isp s 236.92 232.36 -1.92 %
+# impulse N.s 25544 25060 -485
+# apogee ft 12760 12462 -298 (ceiling 15000)
+# SMD effective um 60.92 69.47 +14.0 %
+# peak Mach 0.8470 0.8365 subsonic either way
+#
+# THE MASS LEDGER -- AND WHY IT DOES NOT PAY
+# tanks, IF custom-built to pressure +2.44 lb
+# tanks, off-the-shelf 18 in Seamless +0.00 lb <- fixed MAWP, no credit
+# nitrogen +0.31 lb
+# engine (longer chamber, bigger throat) -2.65 lb
+# NET with custom tanks +0.09 lb
+# NET with off-the-shelf tanks -2.35 lb <- HEAVIER
+# At fixed thrust, dropping Pc grows the engine faster than it shrinks the tanks. Engine
+# mass is built up component by component: sleeve and ablative liner follow chamber LENGTH,
+# nozzle and graphite insert follow throat AREA, injector plate and bosses do not grow.
+# ENGINE 10.5433 kg = 23.244 lb (was 9.198 kg / 20.28 lb)
+#
+# PROPELLANT AND TANKS -- UNCHANGED, the 16 L rule still binds
+# COPV 4.6190 L + liquid 11.3810 L = 16.0000 L
+# LOX 6.6086 kg 5.7970 L tank 1.7016 gal ~15.0 in
+# ethanol 4.4057 kg 5.5840 L tank 1.6390 gal ~14.5 in
+# Burn 3.8312 s on the flat dome-regulated curve (down from 3.8978 s).
+#
+# THE HONEST READ
+# This is not a mass save. It is 79 psi of pressure-vessel margin and 12 % less throat
+# heat flux, bought for 298 ft of apogee out of a 2538 ft cushion. Worth it only because
+# nobody has produced a MAWP for the Seamless tanks yet. Get that number and this file
+# may become unnecessary.
+#
+propellant_preset: ethalox
+fluids:
+ fuel:
+ name: Ethanol
+ density: 789.0
+ viscosity: 0.0012
+ surface_tension: 0.0223
+ vapor_pressure: 5800.0
+ specific_heat: 2440.0
+ thermal_conductivity: 0.17
+ temperature: 293.0
+ latent_heat: 838000.0
+ boiling_point: 351.4
+ molecular_weight: 46.07
+ bulk_modulus_pa: 1060000000.0
+ critical_temperature: 514.71
+ injection_phase: null
+ oxidizer:
+ name: LOX
+ density: 1140.0
+ viscosity: 0.00018
+ surface_tension: 0.013
+ vapor_pressure: 101325.0
+ specific_heat: 2300.0
+ thermal_conductivity: 0.15
+ temperature: 90.0
+ latent_heat: 213000.0
+ boiling_point: 90.2
+ molecular_weight: 32.0
+ bulk_modulus_pa: 1500000000.0
+ critical_temperature: 154.6
+ injection_phase: null
+injector:
+ type: impinging
+ geometry:
+ oxidizer:
+ n_elements: 24
+ d_jet: 0.0016289408441572382
+ impingement_angle: 40.0
+ spacing: 0.009460907115067805
+ fuel:
+ n_elements: 24
+ d_jet: 0.0015057912485489935
+ impingement_angle: 49.0
+ spacing: 0.012649189321290244
+feed_system:
+ fuel:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 2.019
+ K1: 0.0
+ phi_type: none
+ length: 0.9144
+ oxidizer:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 0.643
+ K1: 0.0
+ phi_type: none
+ length: 0.1016
+regen_cooling:
+ enabled: false
+ d_inlet: 0.009525
+ L_inlet: 0.5
+ n_channels: 100
+ channel_width: 0.0009
+ channel_height: 0.001
+ channel_length: 0.18162
+ d_outlet: null
+ L_outlet: 0.1
+ roughness: 0.0
+ K_manifold_split: 0.5
+ K_manifold_merge: 0.3
+ Cd_entrance_inf: 0.8
+ a_Re_entrance: 0.1
+ Cd_entrance_min: 0.6
+ Cd_exit_inf: 0.9
+ a_Re_exit: 0.1
+ Cd_exit_min: 0.7
+ use_heat_transfer: true
+ wall_thickness: 0.002
+ wall_thermal_conductivity: 320.0
+ chamber_inner_diameter: 0.08491
+ hot_gas_prandtl: 0.7
+ hot_gas_viscosity: 4.0e-05
+ hot_gas_thermal_conductivity: 0.12
+ radiation_emissivity_hot: 0.85
+ radiation_view_factor: 1.0
+ n_segments: 20
+ gas_turbulence_intensity: 0.1
+ coolant_turbulence_intensity: 0.05
+ recovery_factor: null
+film_cooling:
+ enabled: false
+ mass_fraction: 0.05
+ injection_temperature: null
+ effectiveness_ref: 0.45
+ decay_length: 0.05
+ apply_to_fraction_of_length: 0.6
+ slot_height: 0.00035
+ reference_blowing_ratio: 0.6
+ blowing_exponent: 0.62
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.0
+ turbulence_exponent: 1.0
+ turbulence_min_multiplier: 0.5
+ reference_wall_temperature: 1100.0
+ density_override: null
+ cp_override: null
+ablative_cooling:
+ enabled: true
+ material_density: 1600.0
+ heat_of_ablation: 2500000.0
+ thermal_conductivity: 0.35
+ specific_heat: 1500.0
+ initial_thickness: 0.0127
+ surface_temperature_limit: 1200.0
+ coverage_fraction: 0.9
+ pyrolysis_temperature: 950.0
+ blowing_efficiency: 0.75
+ use_physics_based_blowing: true
+ blowing_coefficient: 0.5
+ blowing_min_reduction_factor: 0.1
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.5
+ turbulence_exponent: 1.0
+ turbulence_max_multiplier: 3.0
+ throat_recession_multiplier: null
+ char_layer_conductivity: 0.2
+ char_layer_thickness: 0.001
+ surface_emissivity: 0.85
+ ambient_temperature: 300.0
+ radiative_sink_minimum_threshold: 400.0
+ radiative_sink_fallback_temperature: 600.0
+ track_geometry_evolution: true
+ nozzle_ablative: false
+graphite_insert:
+ enabled: true
+ material_density: 2260.0
+ heat_of_ablation: 15000000.0
+ thermal_conductivity: 100.0
+ specific_heat: 710.0
+ initial_thickness: 0.006
+ surface_temperature_limit: 2500.0
+ oxidation_temperature: 800.0
+ oxidation_rate: 1.0e-06
+ activation_energy: 190000.0
+ oxidation_reference_temperature: 973.0
+ oxidation_reference_pressure: 21000.0
+ recession_multiplier: null
+ sizing_only_mode: false
+ simplified_graphite_oxidation: false
+ simplified_oxidation_rate: 1.0e-05
+ sizing_recession_rate: 1.0e-08
+ axial_half_length_ratio: 0.75
+ axial_half_length: null
+ char_layer_conductivity: 5.0
+ char_layer_thickness: 0.0005
+ coverage_fraction: 1.0
+ emissivity: 0.8
+ ambient_temperature: 300.0
+ feedback_fraction_min: 0.0
+ feedback_fraction_max: 0.2
+ oxidation_enthalpy: 32800000.0
+ ablation_surface_temperature: 3000.0
+ ablation_transition_width: 200.0
+ oxidation_pressure_exponent: 0.5
+ oxidation_pre_exponential: null
+ mixture_mw: 0.024
+ oxidation_stoichiometry_ratio: 1.0
+ oxygen_mass_fraction: 0.05
+ oxygen_mole_fraction: null
+ friction_coefficient_override: null
+ reference_diffusivity: null
+ reference_diffusivity_temperature: 1500.0
+ reference_diffusivity_pressure: 1000000.0
+stainless_steel_case: null
+discharge:
+ fuel:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 300.0
+ a_T: 0.0
+ oxidizer:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 90.0
+ a_T: 0.0
+spray:
+ momentum_flux_ratio: true
+ spray_angle:
+ model: TMR
+ k: 0.5
+ n: 0.5
+ weber:
+ We_min: 15
+ smd:
+ model: ingebo
+ C: 0.5
+ m: 0.6
+ p: 0.0
+ C_ingebo: 3.9
+ chamber_gas_R: 389.0
+ chamber_gas_T: 3094.0
+ we_corr_max: null
+ pintle:
+ C: 15.0
+ B: 2.0
+ n: 0.5
+ p: 0.2
+ evaporation:
+ model: derived
+ C_evap: 1.562
+ cp_gas: 2200.0
+ apply_tau_res_correction: false
+ K: 300000.0
+ x_star_limit: 0.05
+ use_constraint: true
+ use_turbulence_corrections: false
+ turbulence_breakup_gain: 1.0
+ turbulence_penetration_gain: 0.5
+combustion:
+ cea:
+ use_parallel_cea_build: false
+ cea_parallel_workers: null
+ ox_name: LOX
+ fuel_name: Ethanol
+ expansion_ratio: 5.598521540485944
+ cache_file: output/cache/cea_cache_LOX_Ethanol_3D.npz
+ Pc_range:
+ - 1000000.0
+ - 9000000.0
+ MR_range:
+ - 1.0
+ - 2.5
+ eps_range:
+ - 4.0
+ - 15.0
+ n_points: 34
+ efficiency:
+ model: exponential
+ C: 0.3
+ K: 0.15
+ use_spray_correction: false
+ spray_penalty_factor: 0.8
+ use_mixture_coupling: false
+ use_cooling_coupling: true
+ use_turbulence_coupling: true
+ Em_peak: 0.96
+ mixing_sigma: 1.5
+ R_opt: null
+ mixture_efficiency_floor: 0.25
+ cooling_efficiency_floor: 0.25
+ turbulence_efficiency_floor: 0.3
+ target_turbulence_intensity: null
+ turbulence_penalty_exponent: null
+ target_smd_microns: null
+ xstar_limit_mm: null
+ xstar_penalty_exponent: null
+ we_reference: null
+ we_penalty_exponent: null
+ smd_penalty_exponent: null
+ use_advanced_model: true
+ Pc_gate: 1000000.0
+ use_finite_rate_chemistry: true
+ use_shifting_equilibrium: true
+ tau_ref: 1.0e-05
+ tau_ref_P: 4000000.0
+ tau_ref_T: 3500.0
+ n_pressure: 0.8
+ tau_Tc_floor_K: null
+ T_star_fuel_cap_K: 500.0
+ A0_hydrocarbon: 10000000.0
+ Ea_hydrocarbon: 80000.0
+ n_pre_hydrocarbon: 0.3
+ A0_ethanol: 50000000.0
+ Ea_ethanol: 140000.0
+ n_pre_ethanol: 0.25
+ A0_hydrogen: 1000000000.0
+ Ea_hydrogen: 40000.0
+ n_pre_hydrogen: 0.2
+chamber_geometry:
+ design_pressure: 2585522.0881709303
+ design_thrust: 6500.0
+ design_MR: 1.5000001346180996
+ chamber_diameter: 0.127
+ Lstar: 1.0000002573548417
+ exit_diameter: 0.10739474706886201
+ expansion_ratio: 5.023789746571696
+ nozzle_efficiency: 0.95
+ A_throat: 0.0018031194794888722
+ A_exit: 0.00905849315289989
+ volume: 0.0018031194794888722
+ length: 0.15397442287428612
+ length_cylindrical: 0.11584219447400496
+ length_contraction: 0.03813222840028117
+ Cf: 1.394242235139658
+chamber: null
+nozzle: null
+solver:
+ method: brentq
+ Pc_bounds:
+ - 100000.0
+ - 8000000.0
+ tolerance: 1.0e-06
+ max_iterations: 100
+ closure:
+ max_iterations: 6
+ Cd_reduction_factor: 1.0
+ tolerance: 0.0001
+stability:
+ n_interaction: 0.5
+ chi_acoustic: 0.15
+ mach_nozzle_entrance: null
+ damping_injector_frac: 0.02
+ damping_twophase_frac: 0.03
+ droplet_loading: 1.0
+ acoustic_gate_alpha_offset: 350.0
+ time_lag_model: leonardi_dtl
+ convection_model: none
+ mixing_lag_fraction: 0.5
+ regulator_enabled: true
+ regulator_corner_hz: 3.0
+ regulator_Z_hf: 0.0
+ regulator_max_excursion_psi: 0.0
+optimizer:
+ mode: hybrid_cma_blocks
+ hybrid:
+ elite_k: 50
+ block_method: corr_greedy
+ num_blocks: 3
+ overlap_fraction: 0.0
+ cycles: 3
+ lambda0: 0.001
+ lambda_mult: 10.0
+ lambda_max: 1.0
+ lambda_normalize: true
+ per_block_budget_fraction: 0.5
+ refresh_every_pass: true
+ refresh_budget_fraction: 0.1
+ refresh_sigma_scale: 0.2
+ num_tracks: 1
+lox_tank:
+ lox_h: 0.42022397339521833
+ lox_radius: 0.06985
+ ox_tank_pos: 0.8
+ mass: 6.608621504681038
+ initial_pressure_psi: 505.125
+ tank_volume_m3: 0.006441151564016606
+fuel_tank:
+ rp1_h: 0.3401263301411914
+ rp1_radius: 0.0762
+ fuel_tank_pos: 3.0
+ mass: 4.405747274391809
+ initial_pressure_psi: 505.125
+ tank_volume_m3: 0.006204403991538949
+press_tank:
+ press_h: 0.31334079903733364
+ press_radius: 0.0685
+ pres_tank_pos: 3.6
+ dry_mass: 3.188
+ initial_gas_mass: 1.312
+ mass: null
+ free_volume_L: 4.619
+rocket:
+ airframe_mass: 41.81159478496249
+ engine_mass: 15.743286591410694
+ lox_tank_structure_mass: 4.082331330000001
+ fuel_tank_structure_mass: 4.082331330000001
+ engine_cm_offset: 0.15
+ propulsion_dry_mass: 21.0
+ propulsion_cm_offset: 0.4
+ copv_dry_mass: 3.188
+ inertia:
+ - 8.0
+ - 8.0
+ - 0.5
+ radius: 0.078359
+ rocket_length: 6.432614614439114
+ motor_position: 0.0
+ fins:
+ no_fins: 4
+ root_chord: 0.626872
+ tip_chord: 0.20066
+ fin_span: 0.20066
+ fin_position: 1.054535
+ nose_kind: vonKarman
+ nose_fineness_ratio: 4.5
+ nose_length: null
+ avionics_payload_length_m: 4.0
+ mass: null
+ cm_wo_motor: 3.861725449
+ dry_mass: null
+ motor_inertia: null
+ motor: null
+environment:
+ date:
+ - 2026
+ - 1
+ - 30
+ - 18
+ latitude: 35.34722
+ longitude: -117.8099547
+ elevation: 626.67
+ atmosphere_model: standard_atmosphere
+thrust:
+ burn_time: 3.994
+ design_thrust: 6500.0
+design_requirements:
+ target_thrust: 6500.0
+ target_chamber_pressure_psi: 430.0
+ target_apogee: 3890.7
+ optimal_of_ratio: 1.5
+ target_burn_time: 3.994
+ max_lox_tank_pressure_psi: 600.0
+ max_fuel_tank_pressure_psi: 600.0
+ max_P_tank_O: null
+ max_P_tank_F: null
+ max_engine_length: 0.4
+ max_chamber_outer_diameter: 0.1651
+ metal_wall_thickness_per_side_m: 0.00635
+ max_nozzle_exit_diameter: 0.2032
+ min_Lstar: 1.0
+ max_Lstar: 1.0
+ min_stability_score: 0.58
+ require_stable_state: false
+ stability_margin_handicap: 0.0
+ min_stability_margin: 1.05
+ chugging_margin_min: 0.2
+ acoustic_margin_min: 0.1
+ feed_stability_min: 0.15
+ lox_tank_capacity_kg: 6.608621504681038
+ fuel_tank_capacity_kg: 4.405747274391809
+ propellant_tank_fill_factor: 0.9
+ copv_free_volume_L: 4.619
+ copv_free_volume_m3: null
+ injector_dp_ratio_O_min: 0.2
+ injector_dp_ratio_O_max: 0.4
+ injector_dp_ratio_F_min: 0.2
+ injector_dp_ratio_F_max: 0.4
+ feed_pressure_model: dome_regulated
+ W_geom_ao_af_momentum: 3500.0
+ W_MOM: 75.0
+ impinging_momentum_R_min: 0.95
+ impinging_momentum_R_max: 1.05
+ layer1_momentum_log_deadband_rel: null
+ layer1_impinging_angle_deg_min: 80.0
+ layer1_impinging_jet_angle_min_deg: 40.0
+ layer1_impinging_angle_deg_max: 90.0
+ W_IMPINGING_ANGLE: 400.0
+ W_IMPINGING_JET_ASYM: 180.0
+ layer1_impinging_jet_angle_max_asym_deg: 10.0
+ W_SMD: 0.0
+ target_smd_microns: 50.0
+ layer1_smd_rel_tol: 0.2
+ W_TANK_EQUAL: 800.0
+ layer1_tank_equal_scale_psi: 100.0
+ layer1_chamber_od_increment_in: 0.5
+ layer1_lock_tank_pressures: null
+ layer1_thrust_deadband_rel: null
+ layer1_derive_tank_from_dp_ratio: null
+ layer1_dp_ratio_target: null
+ layer1_derive_fuel_jet_from_of: null
+ layer1_tank_equal_inband_frac: null
+ layer1_chamber_od_snap_target: null
+ layer1_Lstar_from_smd: null
+ layer1_Lstar_smd_ref_um: null
+ layer1_Lstar_ref_m: null
+ layer1_Lstar_smd_exponent: null
+ layer1_Lstar_deadband_m: null
+ layer1_impingement_Ld_target: 4.0
+ layer1_resultant_tilt_max_deg: null
+ layer1_resultant_tilt_gate_tol_deg: 0.5
+ layer1_resultant_tilt_scale_deg: null
+ layer1_momentum_wall_side_multiplier: null
+ layer1_momentum_scale: null
+ layer1_momentum_gate_safe_slack: null
+ layer1_derive_impingement_spacing: null
+ layer1_impingement_Ld_tol: 1.0
+ layer1_ring_order_fuel_outboard: null
+ layer1_integer_jet_angles: null
+ layer1_derive_expansion_ratio: null
+ layer1_derive_throat_from_thrust: null
+ layer1_derive_max_iters: null
+ layer1_derive_thrust_tol_rel: null
+ layer1_tank_equal_tol_psi: null
+ layer1_of_deadband_rel: null
+ layer1_exit_pressure_deadband_rel: null
+ layer1_W_LSTAR: null
+ layer1_Lstar_target_m: null
+ layer1_W_MASS: 3000.0
+ layer1_contraction_half_angle_deg: null
+ layer1_min_Lcyl_over_D: null
+ layer1_max_element_pitch_m: 0.0225
+ layer1_chamber_wall_density_kg_m3: 3400.0
+ layer1_chamber_mass_ref_kg: 5.0
+ layer1_W_EXIT: null
+ W_IMP_GEOM: 1500.0
+ layer1_exit_pressure_inside_quad_scale: 0.38
+ layer1_impinging_n_doublets_max: 30
+ layer1_random_seed: 37
+ layer1_cma_warmstart_trials: 16
+ layer1_cma_warmstart_sigma_frac: 0.04
+ layer1_cma_restart0_sigma_scale: 0.48
+ layer1_lbfgs_gtol: 1.0e-09
+ layer1_lbfgs_second_pass: true
+ W_DP: 800.0
+ W_DP_O: 12000.0
+ W_DP_F: 175000.0
+ W_DP_HIGH: 25000.0
+ W_DP_CENTER: null
+ W_DP_O_FLOOR: null
+ injector_dp_ratio_O_soft_floor: null
+ layer1_A_throat_mm2_min: null
+ layer1_A_throat_mm2_max: null
+ layer1_cf_upper_bound_for_throat_floor: null
+ layer1_pc_fraction_for_throat_floor: null
+ layer1_enforce_ring_geometry: true
+ layer1_injector_spray_radius_frac: 0.7071
+ layer1_injector_spray_radius_tol: 0.08
+ layer1_injector_plate_thickness_m: 0.0127
+ layer1_injector_min_face_incidence_deg: 40.0
+ layer1_injector_counterbore_dia_m: 0.004
+ layer1_injector_center_clear_dia_m: 0.0381
+ layer1_injector_min_web_m: 0.002
+ layer1_injector_wall_clearance_m: 0.008
+ layer1_resultant_tilt_from_reach: true
+ layer1_resultant_tilt_reach_margin: 1.5
+ layer1_impingement_Ld_min: 3.0
+ layer1_impingement_Ld_max: 5.0
+ layer1_momentum_band_width: null
+ layer1_momentum_low_side_multiplier: null
+ layer1_generations_per_restart: null
+ max_chamber_length_m: null
+ objective_cache_rel: null
+ report_every_n: null
+ layer1_infeasibility_gate_eps: 0.002
+ layer1_W_THRUST: 60000.0
+ layer1_W_PC: null
+ layer1_W_OF: 20000.0
+ layer1_W_OF_low_MR_scale: 1.0
+ layer1_W_OF_high_MR_scale: 1.0
+ layer1_of_validation_tol: null
+ layer1_thrust_validation_rel_tol: null
+ W_CHAMBER_SHAPE: 2500.0
+ layer1_chamber_dt_ratio_min: 2.2
+ layer1_chamber_dt_ratio_max: 3.2
+ layer1_chamber_ld_ratio_min: 1.0
+ layer1_chamber_ld_ratio_max: 3.2
+ layer1_stagnation_pressure_frac_min: 0.35
+ layer1_stagnation_pressure_frac_max: 1.0
+ layer1_expansion_ratio_min: 3.0
+ layer1_expansion_ratio_max: 14.0
+ layer1_P_O_start_psi_min: null
+ layer1_P_O_start_psi_max: null
+ layer1_P_F_start_psi_min: null
+ layer1_P_F_start_psi_max: null
+ frozen_parameters:
+ A_throat_mm2: null
+ Lstar_mm: null
+ expansion_ratio: null
+ D_chamber_outer_mm: 165.1
+ d_pintle_tip_mm: null
+ h_gap_mm: null
+ n_orifices: null
+ d_orifice_mm: null
+ n_doublets: 24
+ d_jet_O_mm: null
+ d_jet_F_mm: null
+ impingement_angle_O_deg: null
+ impingement_angle_F_deg: null
+ spacing_O_mm: null
+ spacing_F_mm: null
+ P_O_start_psi: null
+ P_F_start_psi: null
+pressure_curves: null
+design_valid_for: null
diff --git a/EngineDesign/docs/stability/chug-double-time-lag.md b/EngineDesign/docs/stability/chug-double-time-lag.md
index 78364d88b..5f4264c46 100644
--- a/EngineDesign/docs/stability/chug-double-time-lag.md
+++ b/EngineDesign/docs/stability/chug-double-time-lag.md
@@ -159,8 +159,51 @@ the trustworthy output.
| η sweep fixed at 0.08–0.45 for every engine | window anchored to the design point (`_eta_window`) |
| `T_crit` absent — no model needed it | `FluidConfig.critical_temperature`, config → CoolProp → handbook, every fallback recorded |
| frontend legend hardcoded "O (LOX)" / "F (fuel)" | actual fluid names and phases from the payload |
+| vaporization card, radar and SMD slider all oxidizer-only | both streams; headline and radar follow `rate_limiting_stream` (§4b) |
+| `fallbacks_used` accumulated across runs and propellants | `assumptions.scope()` per report (§4b) |
+| Forward Mode kept the previous propellant's stability panel on screen | results and sensitivity overrides cleared when the engine identity changes (`lib/engineIdentity.ts`) |
| jet diameter unavailable to the lag model | `_jet_geometry` resolves it for impinging / coaxial / pintle, and returns NaN (recorded) rather than a stand-in when the injector type has no equivalent dimension |
+## 4b. Reporting the right stream, and the right run
+
+Three reporting defects sat downstream of the physics and survived the §4 pass, because each is
+*correct on methalox* and only wrong on a propellant whose fuel is the slower vaporizer.
+
+**The vaporization card described the oxidizer, not the rate-limiting stream.** `L_vap`,
+`tau_conv`, SMD and the completion percentage were hardwired to the O side. On LOX/CH₄ the oxidizer
+happens to be slower (3.7 ms vs 2.9 ms), so the card read correctly by luck. On LOX/ethanol it does
+not (13 ms vs 25 ms): the card reported LOX needing 211 mm in a 203 mm chamber — marginal — while
+ethanol, the stream actually setting the lag, needed **426 mm**. The one-glance health radar scores
+its "vaporization" axis off those same keys, so it read 0.96 (nearly passing) instead of 0.48.
+`_vaporization_profile` now computes **both** streams and the headline keys follow
+`rate_limiting_stream`; the UI draws both curves and names which one paces the burn.
+
+**The SMD slider could not reach the stream that mattered.** `smd_um` overrode `D32_O` only. On an
+engine whose fuel is rate-limiting, the atomization lever moved a number that was not setting the
+lag. Added `smd_F_um` and `eta_inj_F`; the panel marks the rate-limiting stream with ★ and sizes
+each slider's range off that design's own spray (a fixed 30–120 µm window put an ethanol doublet's
+180 µm spray off the end of its own slider).
+
+**The "fallbacks used" note was cumulative across runs.** `assumptions.py` documented `clear()` at
+the start of an evaluation and nothing called it, so the registry was process-global and monotone:
+after a methalox run recorded "fluids.oxidizer.latent_heat missing", an ethalox run whose preset
+supplies every field still announced *the previous propellant's* gaps. Fixed with
+`assumptions.scope()` — a re-entrant, thread-local collector that the rich report wraps itself in —
+rather than `clear()`, which would have destroyed the process-wide diagnostic record the logs want.
+The note also now says *where* the missing values live; "load a propellant preset" was printed even
+for feed-line lengths, which no preset supplies.
+
+Alongside these, `configs/default.yaml` carried `latent_heat: null` and `boiling_point: null` for
+LOX, so every evaluation of the default config silently substituted handbook values and reported
+three fallbacks it never needed. Those are stability-only inputs — the forward performance path does
+not read them — so filling them in cannot move thrust, Isp, or the golden anchors.
+
+**Still propellant-independent, by choice:** the Crocco interaction index `n` and the sensitive
+fraction `chi_acoustic` are calibration constants, not propellant data. Ethanol, methane and RP-1
+get the same combustion response. Giving each preset its own value would mean inventing three
+numbers where the literature supports none, so instead the panel says outright that they do not
+switch and that `chi` is the largest modelling uncertainty in the card. Sweep them.
+
## 5. The root locus
`chug.chug_root_locus` tracks the dominant eigenvalue of $1 + L(s) = 0$ through the s-plane as
@@ -187,6 +230,14 @@ using L17's own lags, (B) each lag model vs the experiment-derived τ_vap, (C) L
Ranz–Marshall, (D) blast radius on STAR-class engines, (E) the decider — each model end-to-end
against both measured quantities. Exit code is non-zero if any criterion regresses.
-Unit tests live in `tests/test_stability_timelag.py`. **Note that `tests/test_stability_*.py` is
-gitignored repo-wide** (`.gitignore:93`, "local-only tests"), so neither these nor the pre-existing
-stability tests run in CI.
+Unit tests live in `tests/test_stability_timelag.py`, and the multi-propellant regression in
+`tests/test_stability_propellants.py`. The latter exists because every other stability test loads
+`configs/default.yaml` — methalox lineage, where "the oxidizer" and "the rate-limiting stream" are
+the same thing, so a LOX/CH₄ assumption is invisible. It runs the extraction across all three
+shipped presets with each one's real spray, and carries an explicit guard that at least one preset
+actually has a slower fuel: without it the key assertion would pass vacuously against the very bug
+it guards.
+
+**Note that `tests/test_stability_*.py` is gitignored repo-wide** (`.gitignore:93`, "local-only
+tests"), so neither these nor the pre-existing stability tests run in CI.
+`scripts/chug_timelag_benchmark.py` is tracked and is the only enforceable gate.
diff --git a/EngineDesign/engine/core/runner.py b/EngineDesign/engine/core/runner.py
index 1ec113081..8854d941b 100644
--- a/EngineDesign/engine/core/runner.py
+++ b/EngineDesign/engine/core/runner.py
@@ -763,7 +763,11 @@ def evaluate_arrays_with_time(
try:
from engine.pipeline.time_varying_solver import TimeVaryingCoupledSolver
- solver = TimeVaryingCoupledSolver(self.config, self.cea_cache)
+ # Same ambient resolution as evaluate(): explicit, else the site elevation.
+ solver = TimeVaryingCoupledSolver(
+ self.config, self.cea_cache,
+ P_ambient=self._get_ambient_pressure(P_ambient),
+ )
states = solver.solve_time_series(times, P_tank_O, P_tank_F)
results = solver.get_results_dict()
diff --git a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py
index 9ab82949b..730e2889e 100644
--- a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py
+++ b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py
@@ -7922,6 +7922,8 @@ def __init__(self, x, fun, success=True):
eval_cache=eval_cache,
make_cache_key_fn=_make_eval_cache_key,
stop_event=stop_event,
+ seed=int((layer1_seed_base + track_i * 7919) % (2 ** 31)),
+ popsize=popsize,
)
if t_f < best_f_global:
@@ -7950,6 +7952,8 @@ def __init__(self, x, fun, success=True):
eval_cache=eval_cache,
make_cache_key_fn=_make_eval_cache_key,
stop_event=stop_event,
+ seed=int(layer1_seed_base),
+ popsize=popsize,
)
else:
@@ -9970,9 +9974,22 @@ def run_hybrid_optimization(
eval_cache: Optional[dict] = None,
make_cache_key_fn: Optional[Callable[[np.ndarray], Tuple[int, ...]]] = None,
stop_event: Optional[Any] = None, # threading.Event for stop signal
+ seed: Optional[int] = None,
+ popsize: int = 16,
) -> Tuple[np.ndarray, float, int]:
"""
Run Hybrid CMA-ES + Block Re-optimization.
+
+ ``seed`` makes the whole search a deterministic function of its inputs. It was not:
+ this function built ``np.random.default_rng()`` with no seed and called ``run_cma_core``
+ without ``seed=`` in Stage A, in every block and in every refresh, so CMA seeded itself
+ from the clock. ``layer1_random_seed`` reached the warm start and nothing after it --
+ measured, three runs of one config at seed 37 gave three different injectors (included
+ angle 89 / 87 / 83 deg). Every ``run_cma_core`` call below now takes a seed derived from
+ this one, distinct per stage so no two stages replay the same sample stream.
+
+ ``popsize`` is the Stage A / refresh population. It was hardcoded to 16 while the caller
+ computed and logged 48; the block stage keeps its own smaller population.
Logic:
1. Stage A: Global Exploration (Standard CMA-ES)
@@ -10001,6 +10018,13 @@ def run_hybrid_optimization(
# 1. Initialize Elite Pool
elite_pool = ElitePool(k=hybrid_config.elite_k)
+
+ # One generator for everything this function samples (Stage A kick, block partition),
+ # and one derived CMA seed per stage. ``None`` keeps the old fresh-entropy behaviour.
+ rng = np.random.default_rng(seed)
+
+ def _sub_seed(k: int) -> Optional[int]:
+ return None if seed is None else int((int(seed) + k * 1_000_003) % (2 ** 31))
# 2. Budget allocation
# Reserve slice for Stage A
@@ -10042,7 +10066,7 @@ def run_hybrid_optimization(
# Run 1
x_res, f_res, evs = run_cma_core(
objective, x0, sigma0, bounds, budget_a1,
- popsize=16, cma_stds=cma_stds, elite_pool=elite_pool,
+ popsize=popsize, cma_stds=cma_stds, elite_pool=elite_pool, seed=_sub_seed(1),
valley_escape_tracker=valley_escape_tracker, logger=logger,
# Parallel evaluation
executor=executor, integer_dims=integer_dims, eval_cache=eval_cache,
@@ -10055,15 +10079,25 @@ def run_hybrid_optimization(
best_f_global = f_res
best_x_global = x_res
- # Run 2 (Restart from best or random?)
- # Valid restart: Perturb best logic
- rng = np.random.default_rng()
- x0_2 = best_x_global + rng.standard_normal(dim) * (0.01 * span) # Small perturbation
+ # Run 2: GLOBAL re-exploration, not a second polish of run 1.
+ #
+ # This used to restart from the incumbent with a 1 % kick and half the step size -- a
+ # local refine -- and nothing downstream (blocks, refreshes) ever leaves the incumbent's
+ # neighbourhood either. So after run 1 stagnated (typically ~5k of a 25k Stage A budget)
+ # the entire remaining budget polished one basin, and the objective's flat directions
+ # (injector angle, O/F inside its band) were settled by whichever basin run 1 happened
+ # to stop in. The legacy CMA path's odd restarts kick 30 % of each dimension's span off
+ # the incumbent at full sigma -- anchored so the start is not almost-surely infeasible,
+ # wide enough to leave the basin -- and that is what run 2 does now.
+ x0_2 = np.clip(best_x_global + rng.standard_normal(dim) * (0.30 * span),
+ lower_bounds, upper_bounds)
+ if logger:
+ logger.info("Stage A run 2: global re-exploration, 30 %% span kick off f=%.5f", best_f_global)
if budget_a2 > 100:
x_res, f_res, evs = run_cma_core(
- objective, x0_2, sigma0 * 0.5, bounds, budget_a2,
- popsize=16, cma_stds=cma_stds, elite_pool=elite_pool,
+ objective, x0_2, sigma0, bounds, budget_a2,
+ popsize=popsize, cma_stds=cma_stds, elite_pool=elite_pool, seed=_sub_seed(2),
valley_escape_tracker=valley_escape_tracker, logger=logger,
# Parallel evaluation
executor=executor, integer_dims=integer_dims, eval_cache=eval_cache,
@@ -10170,6 +10204,7 @@ def block_obj_fn(z):
z_best, z_f, z_evals = run_cma_core(
block_obj_fn, z0, z_sigma, block_bounds, budget_per_block,
popsize=max(8, 4 + int(3 * np.log(len(z0)+1))), # Smaller pop for blocks
+ seed=_sub_seed(100 + 10 * cycle_idx + b_i),
elite_pool=None,
true_objective_fn=block_obj_fn,
valley_escape_tracker=valley_escape_tracker, logger=logger,
@@ -10218,7 +10253,8 @@ def block_obj_fn(z):
# refresh walk element counts and jet angles off their integer grid.
x_ref_res, f_ref_res, evs_ref = run_cma_core(
objective, x_ref, sigma_ref, bounds, ref_budget,
- popsize=16, cma_stds=cma_stds, elite_pool=elite_pool,
+ popsize=popsize, cma_stds=cma_stds, elite_pool=elite_pool,
+ seed=_sub_seed(1000 + cycle_idx),
valley_escape_tracker=valley_escape_tracker, logger=logger,
executor=executor, integer_dims=integer_dims,
od_index=od_index, od_step_m=od_step_m, fixed_variables=fixed_variables,
diff --git a/EngineDesign/engine/pipeline/assumptions.py b/EngineDesign/engine/pipeline/assumptions.py
index a399915d1..0b3443532 100644
--- a/EngineDesign/engine/pipeline/assumptions.py
+++ b/EngineDesign/engine/pipeline/assumptions.py
@@ -19,14 +19,31 @@
from __future__ import annotations
+import contextlib
import logging
import threading
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, Iterator, List, Optional
_log = logging.getLogger(__name__)
_lock = threading.Lock()
_registry: Dict[str, Dict[str, Any]] = {}
+# Active `scope()` collectors, per thread. The registry itself is process-global and cumulative on
+# purpose -- it is the diagnostic record of everything this process has assumed. But a REPORT must
+# describe one evaluation, and the global registry cannot do that: after a methalox run recorded
+# "fluids.oxidizer.latent_heat missing", an ethalox run whose preset supplies every field still
+# printed "N physics input(s) fell back to recorded defaults", naming the previous propellant's
+# gaps. Scopes solve that without destroying the global record (which `clear()` would).
+_local = threading.local()
+
+
+def _active_scopes() -> List[Dict[str, Dict[str, Any]]]:
+ scopes = getattr(_local, "scopes", None)
+ if scopes is None:
+ scopes = []
+ _local.scopes = scopes
+ return scopes
+
def assume(name: str, value: Any, *, unit: str = "", reason: str = "") -> Any:
"""Record that ``value`` is being ASSUMED (config did not provide it) and return it.
@@ -42,9 +59,46 @@ def assume(name: str, value: Any, *, unit: str = "", reason: str = "") -> Any:
else:
entry["count"] += 1
entry["value"] = value
+ # Also record into every open scope, so a report can describe its own run. Nested scopes all
+ # see it: an outer scope must not miss what an inner one collected.
+ for collected in _active_scopes():
+ scoped = collected.get(name)
+ if scoped is None:
+ collected[name] = {"value": value, "unit": unit, "reason": reason, "count": 1}
+ else:
+ scoped["count"] += 1
+ scoped["value"] = value
return value
+@contextlib.contextmanager
+def scope() -> Iterator[Dict[str, Dict[str, Any]]]:
+ """Collect the assumptions recorded inside this block, leaving the global registry alone.
+
+ Use it around one evaluation whose report must say what *that* evaluation assumed::
+
+ with assumptions.scope() as used:
+ ...
+ payload["fallbacks_used"] = assumptions.as_list(used)
+
+ Thread-local and re-entrant. It does NOT suppress the global record -- `get_assumptions()` still
+ returns everything the process has assumed, which is what the logs and the future
+ /api/assumptions endpoint want.
+ """
+ collected: Dict[str, Dict[str, Any]] = {}
+ scopes = _active_scopes()
+ scopes.append(collected)
+ try:
+ yield collected
+ finally:
+ scopes.remove(collected)
+
+
+def as_list(registry: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
+ """Compact list form of a scope's collection, matching ``fallbacks_used()``."""
+ return [{"name": k, **v} for k, v in sorted(registry.items())]
+
+
def get_assumptions() -> Dict[str, Dict[str, Any]]:
"""Snapshot of all assumptions used so far in this process."""
with _lock:
diff --git a/EngineDesign/engine/pipeline/config_schemas.py b/EngineDesign/engine/pipeline/config_schemas.py
index d20eaf4ed..b8deaba23 100644
--- a/EngineDesign/engine/pipeline/config_schemas.py
+++ b/EngineDesign/engine/pipeline/config_schemas.py
@@ -2158,8 +2158,14 @@ class HybridOptimizerConfig(BaseModel):
cycles: int = Field(default=3, gt=0, description="Number of re-optimization cycles")
- # Soft freezing / Penalty parameters
- lambda0: float = Field(default=1e-3, gt=0, description="Initial penalty weight base")
+ # Soft freezing / Penalty parameters.
+ #
+ # NOT WIRED. ``run_hybrid_optimization`` computes ``base_lambda`` and ``f_scale`` from
+ # these every cycle and then never applies them: the block objective stitches the block's
+ # coordinates into the incumbent and evaluates the plain objective, with no penalty on
+ # leaving the incumbent. Blocks are therefore hard-frozen, and changing any of these four
+ # fields changes nothing. Kept so shipped configs still validate; do not tune them.
+ lambda0: float = Field(default=1e-3, gt=0, description="Initial penalty weight base (currently unused -- see note above)")
lambda_mult: float = Field(default=10.0, gt=1.0, description="Multiplier for lambda per cycle")
lambda_max: float = Field(default=1.0, gt=0, description="Maximum lambda (relative to f-scale)")
lambda_normalize: bool = Field(default=True, description="Normalize lambda using objective function scale magnitude")
diff --git a/EngineDesign/engine/pipeline/stability/analysis.py b/EngineDesign/engine/pipeline/stability/analysis.py
index 12777eb49..3488af834 100644
--- a/EngineDesign/engine/pipeline/stability/analysis.py
+++ b/EngineDesign/engine/pipeline/stability/analysis.py
@@ -521,14 +521,24 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta
reason="closure produced no fuel SMD; order-of-magnitude liquid-fuel spray")
D32_F = float(D32_F)
ov = overrides or {}
+ # The SMD sliders. `smd_um` has always meant the OXIDIZER spray and keeps that meaning for
+ # back-compatibility; `smd_F_um` was missing entirely, so on an engine whose FUEL is the
+ # rate-limiting vaporizer (LOX/ethanol: 25 ms vs 13 ms) the atomization slider could not move
+ # the quantity that sets the lag.
if ov.get("smd_um") is not None:
D32_O = float(ov["smd_um"]) * 1e-6
+ if ov.get("smd_F_um") is not None:
+ D32_F = float(ov["smd_F_um"]) * 1e-6
if ov.get("eta_inj_O") is not None:
eta_O = float(ov["eta_inj_O"])
dpiO = eta_O * Pc
else:
eta_O = dpiO / Pc if Pc > 0 else 0.3
- eta_F = dpiF / Pc if Pc > 0 else 0.3
+ if ov.get("eta_inj_F") is not None:
+ eta_F = float(ov["eta_inj_F"])
+ dpiF = eta_F * Pc
+ else:
+ eta_F = dpiF / Pc if Pc > 0 else 0.3
rho_O = _fluid_thermo(config, "oxidizer", "density")
rho_F = _fluid_thermo(config, "fuel", "density")
@@ -651,6 +661,11 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta
"rho_O": rho_O, "rho_F": rho_F, "K_bulk_O": K_bulk_O,
"feed_length_O": L_feed_O, "feed_length_F": L_feed_F,
"u_O": diagnostics.get("u_O"), "Cd_O": diagnostics.get("Cd_O"),
+ "u_F": diagnostics.get("u_F"), "Cd_F": diagnostics.get("Cd_F"),
+ # Which stream actually paces the burn. Everything that reports "the" vaporization length,
+ # "the" lag or "the" SMD has to follow this, not the oxidizer by position.
+ "rate_limiting_stream": ("O" if (np.isfinite(tau_conv_O) and tau_conv_O >= tau_conv_F)
+ else "F"),
"Pc": Pc, "wh_pressure_pa": None,
}
diff --git a/EngineDesign/engine/pipeline/stability/report.py b/EngineDesign/engine/pipeline/stability/report.py
index eaa3bfae8..b0a56d6c4 100644
--- a/EngineDesign/engine/pipeline/stability/report.py
+++ b/EngineDesign/engine/pipeline/stability/report.py
@@ -72,50 +72,88 @@ def gm_at(kfac: float) -> float:
return curve
-def _vaporization_profile(inp: Dict[str, Any], Pc: float, n_pts: int = 40) -> Dict[str, Any]:
- """Viz #5: d^2-law droplet decay along the chamber + vaporization length vs chamber length."""
- D32 = inp["D32_O"]
- K_v = inp["K_v_O"]
- L_ch = inp["L_ch"]
- # Config-sourced via build_stability_inputs (P2c). The old `inp.get("rho_O", 1140.0)` put LOX's
- # density behind every oxidizer as an invisible default; build_stability_inputs always supplies
- # it now, and a missing one is recorded rather than substituted.
- rho_O = inp.get("rho_O")
- if rho_O is None or not np.isfinite(float(rho_O)) or float(rho_O) <= 0.0:
- from engine.pipeline.assumptions import assume
- rho_O = assume("stability.viz.rho_oxidizer", 1140.0, unit="kg/m^3",
- reason="oxidizer density missing when drawing the vaporization profile")
- rho_O = float(rho_O)
- eta = inp["eta_inj_O"]
- # Representative droplet axial speed: the solved oxidizer injection velocity when the closure
- # provides it, else Bernoulli with the solved Cd (a fixed Cd of 0.6 used to sit here).
- u_O = inp.get("u_O")
- if u_O is not None and np.isfinite(float(u_O)) and float(u_O) > 0.0:
- v_drop = float(u_O)
+def _stream_vaporization(inp: Dict[str, Any], Pc: float, key: str, n_pts: int) -> Dict[str, Any]:
+ """d^2-law droplet decay for ONE stream. ``key`` is "O" or "F"."""
+ from engine.pipeline.assumptions import assume
+
+ D32 = float(inp[f"D32_{key}"])
+ L_ch = float(inp["L_ch"])
+ phase = str(inp.get(f"phase_{key}", "liquid"))
+ fluid = str(inp.get(f"fluid_name_{key}", key))
+ tau_vap = float(inp[f"tau_conv_{key}"])
+ side = "oxidizer" if key == "O" else "fuel"
+
+ if phase.lower().startswith("g"):
+ # A gas has no droplets to track. Say so rather than drawing a decay curve for it.
+ return {"stream": key, "fluid": fluid, "phase": phase, "smd_um": None,
+ "tau_conv_s": tau_vap, "L_vap_m": None, "L_ch_m": L_ch,
+ "vaporized_in_chamber": True, "d2_profile": [],
+ "note": f"{fluid} is injected as a gas — no atomization or vaporization to plot."}
+
+ rho = inp.get(f"rho_{key}")
+ if rho is None or not np.isfinite(float(rho)) or float(rho) <= 0.0:
+ rho = assume(f"stability.viz.rho_{side}", 1140.0 if key == "O" else 800.0, unit="kg/m^3",
+ reason=f"{side} density missing when drawing the vaporization profile")
+ rho = float(rho)
+ eta = float(inp[f"eta_inj_{key}"])
+
+ # Representative droplet axial speed: the solved injection velocity when the closure provides
+ # it, else Bernoulli with the solved Cd.
+ u = inp.get(f"u_{key}")
+ if u is not None and np.isfinite(float(u)) and float(u) > 0.0:
+ v_drop = float(u)
else:
- Cd = inp.get("Cd_O")
+ Cd = inp.get(f"Cd_{key}")
if Cd is None or not np.isfinite(float(Cd)) or float(Cd) <= 0.0:
- from engine.pipeline.assumptions import assume
- Cd = assume("stability.viz.Cd_oxidizer", 0.6, unit="-",
- reason="solved oxidizer discharge coefficient unavailable for the droplet "
- "velocity; sharp-edged-orifice value")
- Cd = float(Cd)
- v_drop = Cd * float(np.sqrt(max(2.0 * eta * Pc / rho_O, 1.0)))
- tau_vap = inp["tau_conv_O"]
+ Cd = assume(f"stability.viz.Cd_{side}", 0.6, unit="-",
+ reason=f"solved {side} discharge coefficient unavailable for the droplet "
+ f"velocity; sharp-edged-orifice value")
+ v_drop = float(Cd) * float(np.sqrt(max(2.0 * eta * Pc / rho, 1.0)))
+
L_vap = v_drop * tau_vap if np.isfinite(tau_vap) else float("nan")
x_max = float(max(L_ch, L_vap if np.isfinite(L_vap) else L_ch) * 1.1)
xs = np.linspace(0.0, x_max, n_pts)
- # d^2(x)/d0^2 = 1 - x/L_vap (linear in x under d^2-law at constant v_drop), clipped at 0
- d2 = np.clip(1.0 - xs / L_vap, 0.0, 1.0) if (np.isfinite(L_vap) and L_vap > 0) else np.ones_like(xs)
+ d2 = (np.clip(1.0 - xs / L_vap, 0.0, 1.0)
+ if (np.isfinite(L_vap) and L_vap > 0) else np.ones_like(xs))
return {
- "d2_profile": [[float(x), float(y)] for x, y in zip(xs, d2)],
- "L_vap_m": float(L_vap), "L_ch_m": float(L_ch),
- "tau_conv_s": float(inp["tau_conv_O"]), "tau_sens_s": float(inp["tau_sens"]),
+ "stream": key, "fluid": fluid, "phase": phase,
"smd_um": float(D32 * 1e6), "smd_band_um": [float(D32 * 0.8e6), float(D32 * 1.2e6)],
+ "tau_conv_s": float(tau_vap),
+ "L_vap_m": float(L_vap), "L_ch_m": L_ch,
"vaporized_in_chamber": bool(np.isfinite(L_vap) and L_vap <= L_ch),
+ "d2_profile": [[float(x), float(y)] for x, y in zip(xs, d2)],
}
+def _vaporization_profile(inp: Dict[str, Any], Pc: float, n_pts: int = 40) -> Dict[str, Any]:
+ """Viz #5: droplet decay along the chamber, for BOTH streams.
+
+ The top-level keys (``L_vap_m``, ``smd_um``, ``tau_conv_s``, ``vaporized_in_chamber``) describe
+ the **rate-limiting** stream — the one that paces the burn — not the oxidizer. They used to be
+ hardwired to the oxidizer, which is right only when the oxidizer happens to be the slower
+ vaporizer. On LOX/methane it is (3.7 ms vs 2.9 ms) so the card read correctly by luck; on
+ LOX/ethanol it is not (13 ms vs 25 ms), and the card reported a 211 mm vaporization length for
+ LOX while ethanol -- the stream actually setting the lag -- was far worse. The health radar
+ scores off these keys, so it was scoring the wrong stream too.
+ """
+ per_stream = [_stream_vaporization(inp, Pc, k, n_pts) for k in ("O", "F")]
+ rl = str(inp.get("rate_limiting_stream", "O"))
+ lead = next((s for s in per_stream if s["stream"] == rl), per_stream[0])
+ # A gas stream can never be the one to plot; fall back to the liquid if it somehow is.
+ if lead.get("L_vap_m") is None:
+ lead = next((s for s in per_stream if s.get("L_vap_m") is not None), lead)
+
+ out = dict(lead)
+ out.pop("note", None)
+ out["streams"] = per_stream
+ out["rate_limiting_stream"] = lead["stream"]
+ out["tau_sens_s"] = float(inp["tau_sens"])
+ if lead.get("smd_um") is None:
+ out["smd_um"] = float(inp["D32_O"] * 1e6)
+ out["smd_band_um"] = [float(inp["D32_O"] * 0.8e6), float(inp["D32_O"] * 1.2e6)]
+ return out
+
+
def _sensitivity(inp: Dict[str, Any]) -> Dict[str, Any]:
"""n / chi sensitivity bands for the acoustic limiting-mode growth rate (cheap sweep)."""
D_ch, L_ch, gas, coeffs = inp["D_ch"], inp["L_ch"], inp["gas"], inp["damping_coeffs"]
@@ -188,7 +226,7 @@ def mode_alpha(name):
def _diagnostics(state: str, chug_margin: float, acoustic_margin: float, gate_threshold: float,
limiting: Optional[str], chug_rich: Dict[str, Any], ac: Dict[str, Any],
- vap: Dict[str, Any]) -> Dict[str, Any]:
+ vap: Dict[str, Any], fallbacks: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Turn the rich quantities into a verdict, findings, and design actions tied to the
sensitivity sliders (η_inj, SMD, n, χ). Derived from the SAME numbers the cards render,
so the headline can never disagree with the charts."""
@@ -274,12 +312,25 @@ def _diagnostics(state: str, chug_margin: float, acoustic_margin: float, gate_th
headline = (f"Unstable risk — {limiting or 'a mode'} is driven. "
"Change the design before hot fire.")
- fb = _fallbacks_used()
+ fb = fallbacks
if fb:
names = ", ".join(str(f.get("name", "?")) for f in fb[:3])
more = "…" if len(fb) > 3 else ""
+ # Say where the missing values live. "Load a propellant preset" was printed for every
+ # fallback including feed-line lengths and chamber geometry, which no propellant preset
+ # supplies -- advice that cannot work reads as noise and gets ignored.
+ kinds = {("propellant" if ".fluids." in str(f.get("name", "")) else
+ "plumbing" if ".feed." in str(f.get("name", "")) else
+ "model") for f in fb}
+ hints = []
+ if "propellant" in kinds:
+ hints.append("load a propellant preset for the fluid properties")
+ if "plumbing" in kinds:
+ hints.append("set feed_system lengths/bores for the plumbing")
+ if "model" in kinds:
+ hints.append("the rest are model calibration defaults")
assumptions_note = (f"{len(fb)} physics input(s) fell back to recorded defaults "
- f"({names}{more}). Load a propellant preset for measured values.")
+ f"({names}{more}). " + "; ".join(hints).capitalize() + ".")
else:
assumptions_note = "Config fully specified the stability physics — no fallbacks used."
@@ -303,6 +354,17 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
cg: Any, *, gate_threshold: float = 1.05,
overrides: Optional[Dict[str, float]] = None) -> Dict[str, Any]:
"""Assemble the full rich stability payload (plan §A5 schema). <=5 s."""
+ from engine.pipeline import assumptions as _assumptions
+ with _assumptions.scope() as _used_here:
+ return _build_rich_report(config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg,
+ gate_threshold=gate_threshold, overrides=overrides,
+ used_here=_used_here)
+
+
+def _build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: float,
+ gamma: float, R: float, Tc: float, diagnostics: Dict[str, Any],
+ cg: Any, *, gate_threshold: float, overrides: Optional[Dict[str, float]],
+ used_here: Dict[str, Any]) -> Dict[str, Any]:
inp = analysis.build_stability_inputs(
config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg, overrides=overrides,
)
@@ -356,6 +418,7 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
vap = _vaporization_profile(inp, Pc)
sens = _sensitivity(inp)
+ fallbacks = _fallbacks_used(used_here)
radar = _radar(chug_margin, ac, vap, gate_threshold, inp["acoustic_gate_alpha_offset"])
min_margin = float(min(chug_margin, acoustic_margin))
@@ -364,7 +427,7 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
else "marginal" if min_margin >= 0.95 else "unstable")
limiting = "chug" if chug_margin <= acoustic_margin else ac.get("limiting_mode")
diag = _diagnostics(state, chug_margin, acoustic_margin, gate_threshold, limiting,
- chug_rich, ac, vap)
+ chug_rich, ac, vap, fallbacks)
return {
"summary": {"state": state, "min_margin": min_margin,
@@ -395,6 +458,8 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
"dP_reg_max_psi": float(streams[0].regulator.max_excursion_pa / _PA_PER_PSI),
"eta_inj_O": inp["eta_inj_O"], "eta_inj_F": inp["eta_inj_F"],
"smd_O_um": float(inp["D32_O"] * 1e6),
+ "smd_F_um": float(inp["D32_F"] * 1e6),
+ "rate_limiting_stream": inp.get("rate_limiting_stream"),
"mach_nozzle_entrance": float(inp["mach_nozzle_entrance"]),
"contraction_ratio": float(inp["contraction_ratio"]),
"feed_length_O_m": float(inp["feed_length_O"]), "feed_length_F_m": float(inp["feed_length_F"]),
@@ -411,15 +476,25 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
"lag_breakdown": lag_break,
# Every recorded silent-default substitution this process has made (P2c registry).
# Empty list = config fully specified the physics. The hardcoded-Cd bug class, surfaced.
- "fallbacks_used": _fallbacks_used(),
+ "fallbacks_used": fallbacks,
},
"sensitivity": sens,
}
-def _fallbacks_used():
+def _fallbacks_used(used_here: Optional[Dict[str, Any]] = None):
+ """Substitutions made by THIS evaluation.
+
+ ``used_here`` is the collection from the ``assumptions.scope()`` wrapped around the report. The
+ old form read the process-global registry, so a report inherited every fallback the process had
+ ever recorded -- after a methalox run, an ethalox run with a complete preset still announced the
+ previous propellant's missing fields. Falls back to the global registry only when called without
+ a scope (kept so an external caller does not break).
+ """
try:
- from engine.pipeline.assumptions import fallbacks_used
- return fallbacks_used()
+ from engine.pipeline import assumptions
except ImportError:
return []
+ if used_here is not None:
+ return assumptions.as_list(used_here)
+ return assumptions.fallbacks_used()
diff --git a/EngineDesign/engine/pipeline/time_varying_solver.py b/EngineDesign/engine/pipeline/time_varying_solver.py
index fd367a861..cf116e9b1 100644
--- a/EngineDesign/engine/pipeline/time_varying_solver.py
+++ b/EngineDesign/engine/pipeline/time_varying_solver.py
@@ -141,9 +141,18 @@ def __init__(
self,
config: PintleEngineConfig,
cea_cache: Any,
+ P_ambient: Optional[float] = None,
):
"""
Initialize the coupled time-varying solver.
+
+ ``P_ambient`` is the back pressure the nozzle fires into, in Pa. Explicit wins;
+ otherwise it comes from ``environment.elevation`` through the same standard
+ atmosphere the steady solve uses; only with neither is it sea level. This used to be
+ hardcoded to 101325 Pa inside ``solve_time_step`` while ``PintleEngineRunner.evaluate``
+ derived it from the site, so the two paths disagreed about the same engine by exactly
+ ``(101325 - P_a) * A_exit`` -- 61.35 N on the 6.5 kN ethalox at 626.67 m -- and every
+ time-series thrust, impulse and burn time was low by the pad's altitude.
Parameters:
-----------
@@ -154,6 +163,15 @@ def __init__(
"""
self.config = config
self.cea_cache = cea_cache
+ if P_ambient is not None:
+ self.P_ambient = float(P_ambient)
+ else:
+ self.P_ambient = 101325.0
+ env = getattr(config, "environment", None)
+ elevation = getattr(env, "elevation", None) if env is not None else None
+ if elevation is not None and elevation >= 0:
+ from engine.core.runner import compute_ambient_pressure_from_elevation
+ self.P_ambient = float(compute_ambient_pressure_from_elevation(elevation))
# Ensure chamber_geometry exists
cg = ensure_chamber_geometry(config)
@@ -287,13 +305,8 @@ def solve_time_step(
# as geometry evolves. This was missing before!
from engine.core.chamber_profiles import calculate_chamber_intrinsics
# Get ambient pressure from config if available, otherwise use fallback (0.9 * 1 atm)
- P_back = None
- if hasattr(self.config, 'environment') and self.config.environment is not None:
- elevation = getattr(self.config.environment, 'elevation', None)
- if elevation is not None:
- # Use standard atmosphere model
- from engine.core.runner import compute_ambient_pressure_from_elevation
- P_back = compute_ambient_pressure_from_elevation(elevation)
+ # One ambient for the whole solver -- the intrinsics and the thrust must see the same sky.
+ P_back = self.P_ambient
# If still None, fallback will be used (0.9 * 1 atm)
chamber_intrinsics = calculate_chamber_intrinsics(
Pc=Pc,
@@ -616,7 +629,7 @@ def solve_time_step(
# Calculate thrust with shifting equilibrium
# CRITICAL: Pass reaction progress so shifting equilibrium accounts for time-varying chemistry
- Pa = 101325.0 # Ambient
+ Pa = self.P_ambient # site ambient, same source as the steady solve (see __init__)
thrust_results = calculate_thrust(
Pc,
diff --git a/EngineDesign/frontend/src/components/ForwardMode.tsx b/EngineDesign/frontend/src/components/ForwardMode.tsx
index 1c2c6b8ab..e6b5e74e0 100644
--- a/EngineDesign/frontend/src/components/ForwardMode.tsx
+++ b/EngineDesign/frontend/src/components/ForwardMode.tsx
@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
+import { engineIdentity } from '../lib/engineIdentity';
import { evaluate } from '../api/client';
import type { RunnerResults, EngineConfig } from '../api/client';
import { ResultsDisplay } from './ResultsDisplay';
@@ -37,6 +38,11 @@ export function ForwardMode({ config }: ForwardModeProps) {
stabilityOverrides: [stabilityOverrides, setStabilityOverrides],
});
+ // What makes a displayed result belong to a DIFFERENT engine (see lib/engineIdentity).
+ // Seeded from the current config, so the first render adopts rather than clearing.
+ const identityKey = engineIdentity(config);
+ const [lastIdentity, setLastIdentity] = useState(identityKey);
+
// Update defaults when config changes
useEffect(() => {
if (config) {
@@ -51,6 +57,28 @@ export function ForwardMode({ config }: ForwardModeProps) {
}
}, [config]);
+ // Drop a result that describes the previous propellant or injector.
+ //
+ // The tabs stay mounted (hidden, not unmounted), so nothing cleared `results` on a switch: after
+ // methalox -> ethalox the Combustion stability panel kept showing methane's frequencies, lags,
+ // radar and verdict, while the tank pressures beside it had already moved to the new config. It
+ // read as a report about the engine now on screen. The sensitivity overrides survived too, so a
+ // methane-tuned SMD was silently applied to ethanol on the next evaluation.
+ //
+ // Done during render rather than in an effect: this is React's "adjusting state when a prop
+ // changes" pattern, which re-renders before committing instead of painting the stale panel once
+ // and then clearing it. An effect would show the previous propellant's numbers for a frame.
+ if (config && identityKey !== lastIdentity) {
+ setLastIdentity(identityKey);
+ setResults(null);
+ setAmbientPressure(null);
+ setError(null);
+ setStabilityOverrides({});
+ setDesignWarning(
+ 'Propellant or injector changed — previous results cleared. Run Evaluate to analyse the new engine.',
+ );
+ }
+
const handleEvaluate = useCallback(async (overridePatch?: StabilityOverrides) => {
const lox = parseFloat(loxPressure);
const fuel = parseFloat(fuelPressure);
@@ -203,7 +231,7 @@ export function ForwardMode({ config }: ForwardModeProps) {
{/* Results section */}
-
+
)}
+ {/* This number is NOT a residual. Once every requirement is met it is
+ the shaping terms that remain -- chamber mass (W_MASS*(m/m_ref)^2),
+ SMD, tank match -- and those never reach zero, so a fully converged
+ design floors in the 1e3 range. Colouring it green<=1 / red>10 painted
+ every real design red and read as "unconverged". Colour and verdict
+ come from the physics residual instead; the objective is reported
+ with the term that dominates it named. */}
{
const v = results.convergence_info.best_objective;
return typeof v === 'number' && Number.isFinite(v) ? formatLayer1ResidualScalar(v) : '-';
})()}
isText
- color={
- (results.convergence_info.best_objective ?? 0) <= 1
- ? 'green'
- : (results.convergence_info.best_objective ?? 0) <= 10
- ? 'yellow'
- : 'red'
- }
+ color={(() => {
+ const v = results.convergence_info.best_objective;
+ if (typeof v !== 'number' || !Number.isFinite(v) || v >= 1e6) return 'red';
+ const rms = results.convergence_info.primary_relative_residual?.rms_primary;
+ if (typeof rms === 'number' && Number.isFinite(rms)) {
+ return rms <= 0.01 ? 'green' : rms <= 0.05 ? 'yellow' : 'red';
+ }
+ return 'green';
+ })()}
footnote={(() => {
const v = results.convergence_info.best_objective;
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {
return 'Weighted sum of squared penalties; infeasible runs floor at ~1e6.';
}
- return `Weighted penalty sum (W×term²); log10 ≈ ${Math.log10(v).toFixed(3)}. Sum of breakdown terms ≈ objective when feasible.`;
+ if (v >= 1e6) return 'Infeasible: no candidate cleared every hard constraint.';
+ const bd = results.convergence_info.best_objective_breakdown ?? {};
+ let topKey = '';
+ let topVal = 0;
+ for (const [k, raw] of Object.entries(bd)) {
+ if (!k.endsWith('_penalty') || k === 'infeasibility_penalty') continue;
+ const x = typeof raw === 'number' ? raw : NaN;
+ if (Number.isFinite(x) && x > topVal) {
+ topVal = x;
+ topKey = k;
+ }
+ }
+ const share =
+ topKey && topVal > 0
+ ? ` ${((100 * topVal) / v).toFixed(1)}% of it is ${topKey.replace(/_penalty$/, '').replace(/_/g, ' ')}.`
+ : '';
+ return `Not a residual: shaping terms (chamber mass, SMD, tank match) never reach 0, so a converged design floors near 1e3.${share} Convergence is the physics residual below.`;
})()}
/>
@@ -610,6 +614,121 @@ export function ResultsDisplay({ results, isLoading, targetExitPressure }: Resul
)}
+ {/* ---------------------------------------------------------------------------------
+ INJECTOR AND SPRAY.
+
+ /api/evaluate already returns 61 diagnostic keys; this view rendered a handful of
+ them, so Forward Mode showed a strictly smaller picture of the same engine than
+ Layer 1 did -- no SMD, no Weber numbers, no discharge coefficients, no momentum
+ ratio. Nothing here is recomputed: every number is read straight off the solver,
+ and the effective SMD uses the same mass-flux blend Layer 1 uses
+ (MR/(1+MR)*D32_O + 1/(1+MR)*D32_F, _impinging_smd_penalty_with_angle).
+ --------------------------------------------------------------------------------- */}
+ {(() => {
+ // Values here are mixed number/string/boolean, so keep it unknown and narrow at use.
+ const d = (results as unknown as Record).diagnostics as
+ Record | undefined;
+ if (!d) return null;
+ const num = (k: string): number | undefined => {
+ const v = d[k];
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
+ };
+ const d32o = num('D32_O');
+ const d32f = num('D32_F');
+ const mr = num('MR') ?? results.MR;
+ const smdEff = (d32o !== undefined && d32f !== undefined && mr && mr > 0)
+ ? (mr / (1 + mr)) * d32o + (1 / (1 + mr)) * d32f
+ : (d32o ?? d32f);
+ const aEff = (num('A_eff_O') ?? 0) + (num('A_eff_F') ?? 0);
+ const areaRatio = results.A_throat ? aEff / results.A_throat : undefined;
+ const um = (m: number | undefined) => (m === undefined ? '—' : formatNumber(m * 1e6, 1));
+ const mm = (m: number | undefined) => (m === undefined ? '—' : formatNumber(m * 1e3, 3));
+ return (
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ })()}
+
+ {/* ---------------------------------------------------------------------------------
+ INJECTOR GEOMETRY. Pitch circles, standoff and web are pure geometry from the
+ design variables -- the solver does not return them, so they are derived here with
+ the SAME function the Chamber Geometry drawing uses, rather than a second copy.
+ --------------------------------------------------------------------------------- */}
+ {(() => {
+ const cfgInj = config?.injector as Record | undefined;
+ if (!cfgInj || String(cfgInj.type ?? '').toLowerCase() !== 'impinging') return null;
+ const geom = cfgInj.geometry as Record> | undefined;
+ const cg = config?.chamber_geometry as Record | undefined;
+ const ox = geom?.oxidizer;
+ const fu = geom?.fuel;
+ const bore = Number(cg?.chamber_diameter ?? 0);
+ if (!ox || !fu || !(bore > 0)) return null;
+ const req = (config?.design_requirements ?? {}) as Record;
+ const n = (v: unknown) => { const x = Number(v); return Number.isFinite(x) ? x : 0; };
+ const { g } = deriveInjectorLayout({
+ oxidizer: { n_elements: Number(ox.n_elements), d_jet: Number(ox.d_jet), impingement_angle: Number(ox.impingement_angle), spacing: Number(ox.spacing) },
+ fuel: { n_elements: Number(fu.n_elements), d_jet: Number(fu.d_jet), impingement_angle: Number(fu.impingement_angle), spacing: Number(fu.spacing) },
+ boreDiameter: bore,
+ centerClearDiameter: n(req.layer1_injector_center_clear_dia_m),
+ minWeb: n(req.layer1_injector_min_web_m),
+ wallClearance: n(req.layer1_injector_wall_clearance_m),
+ plateThickness: n(req.layer1_injector_plate_thickness_m) || 0.0127,
+ counterboreDiameter: n(req.layer1_injector_counterbore_dia_m),
+ });
+ const MM = 1000;
+ const f2 = (v: number) => formatNumber(v, 2);
+ return (
+ }
+ >
+
+
+ );
+ })()}
+
{/* Additional Thermodynamic Properties */}
pad.t + plotH - ((w - yMin) / (yMax - yMin)) * plotH;
const x0 = toX(0); // the stability boundary
+ const clipId = 'locus-plot-clip';
const branch = locus.map((p) => `${toX(p.real)},${toY(p.imag)}`).join(' ');
@@ -122,6 +133,11 @@ export function ChugRootLocus({ data }: Props) {
orient="auto" markerUnits="strokeWidth">
+ {/* Zooming omega means the zeta rays leave from off-frame; clip them to the axes
+ rather than letting them draw across the margins. */}
+
+
+
{/* half-plane shading: the single most important thing on the chart */}
@@ -156,22 +172,25 @@ export function ChugRootLocus({ data }: Props) {
))}
{/* constant-zeta rays from the origin */}
- {rays.map((r) => {
- const px = toX(r.x);
- const py = toY(r.y);
- if (!Number.isFinite(px) || !Number.isFinite(py)) return null;
- return (
-
-
+ {rays.map((r) => {
+ const px = toX(r.x);
+ const py = toY(r.y);
+ if (!Number.isFinite(px) || !Number.isFinite(py)) return null;
+ return (
+
-
- );
- })}
+ );
+ })}
+
+ {/* Ray labels go INSIDE the frame. They used to be placed at pad.t - 3, which is
+ above the plot entirely -- they floated in the gap under the subtitle, detached
+ from the rays they name. */}
{rays.map((r) => (
-
+ {/* At the TOP this sat on the frame line and fought the zeta labels for the same
+ few pixels. The boundary is a full-height line; label it where nothing else is. */}
+
σ = 0
@@ -203,14 +224,17 @@ export function ChugRootLocus({ data }: Props) {
<>
-
+ {/* Start label goes BELOW its point and end label above: the sweep starts at the
+ top-left where the zeta=0.5 ray label also lives, and the two overlapped by
+ 7.5 x 7.1 px. Splitting them vertically separates them for any locus shape. */}
+
η={locus[0].eta.toFixed(2)}
η={locus[locus.length - 1].eta.toFixed(2)}
@@ -238,12 +262,12 @@ export function ChugRootLocus({ data }: Props) {
← decaying · growing →
-
+
Im(s) = ω [rad/s]
-
+
f = ω/2π [Hz]
diff --git a/EngineDesign/frontend/src/components/stability/StabilityPanel.tsx b/EngineDesign/frontend/src/components/stability/StabilityPanel.tsx
index a9a0d4045..d2528f8db 100644
--- a/EngineDesign/frontend/src/components/stability/StabilityPanel.tsx
+++ b/EngineDesign/frontend/src/components/stability/StabilityPanel.tsx
@@ -69,7 +69,20 @@ export function StabilityPanel({
}
const eta = overrides.eta_inj_O ?? data.assumptions.eta_inj_O;
+ const etaF = overrides.eta_inj_F ?? data.assumptions.eta_inj_F;
const smd = overrides.smd_um ?? data.assumptions.smd_O_um;
+ const smdF = overrides.smd_F_um ?? data.assumptions.smd_F_um ?? data.assumptions.smd_O_um;
+ const nameO = data.assumptions.fluid_O ?? 'oxidizer';
+ const nameF = data.assumptions.fluid_F ?? 'fuel';
+ const rateLimiting = data.vaporization?.rate_limiting_stream ?? data.assumptions.rate_limiting_stream;
+ // Slider ranges follow the design's own spray, not a fixed 30-120 um window: an ethanol doublet
+ // atomizes near 180 um and would sit off the end of a methane-shaped slider.
+ const smdRange = (v: number): [number, number] => [
+ Math.max(5, Math.round(v * 0.35)),
+ Math.round(Math.max(v * 1.8, 60)),
+ ];
+ const [smdMin, smdMax] = smdRange(data.assumptions.smd_O_um);
+ const [smdFMin, smdFMax] = smdRange(data.assumptions.smd_F_um ?? data.assumptions.smd_O_um);
const nVal = overrides.n_interaction ?? data.assumptions.n;
const chi = overrides.chi_acoustic ?? data.assumptions.chi_acoustic;
const lagModel = overrides.time_lag_model ?? data.assumptions.time_lag_model ?? 'leonardi_dtl';
@@ -106,11 +119,30 @@ export function StabilityPanel({
{interactive && onOverridesChange && (
- setOverride({ eta_inj_O: v })} />
- setOverride({ smd_um: v })} />
- setOverride({ n_interaction: v })} />
- setOverride({ chi_acoustic: v })} />
+ setOverride({ eta_inj_O: v })} />
+ setOverride({ eta_inj_F: v })} />
+ setOverride({ smd_um: v })}
+ />
+ setOverride({ smd_F_um: v })}
+ />
+ setOverride({ n_interaction: v })} />
+ setOverride({ chi_acoustic: v })} />
+
+ ★ marks the rate-limiting stream — the one whose lag sets the chug and acoustic verdicts.
+ Atomizing the other one finer buys nothing.{' '}
+
+ n and χ are combustion-response calibration constants, not propellant data: they do not
+ change when you switch propellants, and χ is the single largest modelling uncertainty
+ here. Sweep them rather than trusting one value.
+
+