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 ( +
} + > +
+ + + 90 ? 'text-yellow-400' : 'text-[var(--color-text-primary)]'} /> + + + + + + + + + + + + + +
+
+ ); + })()} + {/* 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. + +