From 5f7f7288781b676ea9a2039955356111b6745927 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Mon, 20 Jul 2026 21:25:04 -0700 Subject: [PATCH 01/35] nozzle: drop unused PhysicsValidator import (combustion-efficiency fix superseded) The combustion-efficiency misuse this branch fixed via velocity-form eta_c* recovery (v_exit_delivered = eta_cstar * v_exit) was fixed upstream in 1e3f7426 using the RPA Cf form instead: F = zeta_n * Cf_vac * Pc * At - Pa * Ae with zeta_c already carried by Pc from the chamber solve. Both apply eta_c* to delivered thrust; upstream is kept as the single path. What remains here: PhysicsValidator became unused when upstream removed the tautological validate_thrust_equation cross-check, plus .gitignore. --- .gitignore | 3 +++ EngineDesign/engine/core/nozzle.py | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 8c0dfcd4..f1fb64d5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ docs/doxygen/ # DAQ output data scripts/postprocessing/output/ + +# Engine design review working doc (local only) +ENGINE_DESIGN_REVIEW.md diff --git a/EngineDesign/engine/core/nozzle.py b/EngineDesign/engine/core/nozzle.py index d2bb1bb7..48d2183a 100644 --- a/EngineDesign/engine/core/nozzle.py +++ b/EngineDesign/engine/core/nozzle.py @@ -26,7 +26,6 @@ from engine.pipeline.numerical_robustness import ( PhysicalConstraints, NumericalStability, - PhysicsValidator, ) from engine.core.mach_solver import solve_exit_mach_robust @@ -584,7 +583,7 @@ def calculate_thrust( "Cf_theoretical": float(Cf), # Theoretical (efficiency-adjusted ideal) "P_exit": float(P_exit), "P_throat": float(P_throat), - "v_exit": float(v_exit), + "v_exit": float(v_exit), # ideal isentropic exit velocity (display/parity; delivered thrust uses Cf_vac_delivered) "T_exit": float(T_exit), "T_throat": float(T_throat), "temperature_profile": temperature_profile, # Full profile along chamber From 3c454356ea57067809b73fa63a1f309ce4ecc2d4 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Wed, 15 Jul 2026 01:49:40 -0700 Subject: [PATCH 02/35] Added Python linting for errors to CI/CD and fixes failures --- .github/workflows/lint.yml | 42 ++++++++++ .../engine/control/robust_ddp/ddp_solver.py | 1 + .../engine/control/robust_ddp/policy_lut.py | 5 +- EngineDesign/engine/core/chamber_solver.py | 1 - .../engine/optimizer/display_results.py | 1 - .../layers/layer1_static_optimization.py | 5 +- .../engine/optimizer/main_optimizer.py | 15 ++-- .../engine/optimizer/views/helpers.py | 4 +- EngineDesign/engine/optimizer/views/tabs.py | 3 +- .../pipeline/comprehensive_geometry_sizing.py | 3 +- .../pipeline/comprehensive_optimizer.py | 2 + .../engine/pipeline/reaction_chemistry.py | 1 + EngineDesign/engine/pipeline/spalding.py | 3 +- .../engine/pipeline/stability/report.py | 2 +- .../engine/pipeline/time_varying_solver.py | 12 ++- .../test_robust_ddp_safety_filter.py | 1 + EngineDesign/tests/test_orchestrator_smoke.py | 81 +++++++++++++++++++ EngineDesign/ui/design_optimization_view.py | 10 +-- EngineDesign/ui/flight_sim.py | 1 - .../calibration/smart_calibration_gui.py | 2 +- .../test_guis/combined_gui/combined_gui.py | 1 + ruff.toml | 37 +++++++++ 22 files changed, 200 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 EngineDesign/tests/test_orchestrator_smoke.py create mode 100644 ruff.toml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..3d4137d2 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,42 @@ +name: Lint (ruff bug-gate) + +# Repo-wide static bug-gate. Runs the bug-detecting ruff ruleset (see /ruff.toml) +# over every subproject — EngineDesign, daq-server, firmware, pid-designer — to catch +# undefined-name / use-before-assignment / redefinition defects that unit tests miss +# when the crashing path isn't exercised. Fast (~seconds): no project deps installed, +# just ruff itself. + +# Quoted `on` — YAML 1.1 treats bare `on` as boolean true (matches the other workflows). +'on': + push: + pull_request: + workflow_dispatch: + +# Cancel superseded runs on the same ref to save CI minutes. +concurrency: + group: lint-${{ github.ref }} + cancel-in-progress: true + +jobs: + ruff: + name: Ruff bug-gate (whole repo) + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + # Pinned so a future ruff release adding new rules to the selected families + # can't turn this gate red without an intentional bump. + - name: Install ruff + run: pip install ruff==0.15.21 + + # Config (rule selection) lives in /ruff.toml so local `ruff check` matches CI. + - name: Run ruff bug-gate + run: ruff check . diff --git a/EngineDesign/engine/control/robust_ddp/ddp_solver.py b/EngineDesign/engine/control/robust_ddp/ddp_solver.py index 1166e377..50b7ba6f 100644 --- a/EngineDesign/engine/control/robust_ddp/ddp_solver.py +++ b/EngineDesign/engine/control/robust_ddp/ddp_solver.py @@ -122,6 +122,7 @@ def solve_ddp( eng_estimates_all = [] # Main DDP loop + prev_objective = float("inf") # carried between iterations; only read when iteration > 0 for iteration in range(cfg.max_iterations): # Forward rollout x_seq, eng_estimates, objective, constraint_violations = forward_rollout( diff --git a/EngineDesign/engine/control/robust_ddp/policy_lut.py b/EngineDesign/engine/control/robust_ddp/policy_lut.py index 9029c85a..90f632a7 100644 --- a/EngineDesign/engine/control/robust_ddp/policy_lut.py +++ b/EngineDesign/engine/control/robust_ddp/policy_lut.py @@ -7,11 +7,14 @@ from __future__ import annotations from pathlib import Path -from typing import Optional, Tuple, List +from typing import Optional, Tuple, List, TYPE_CHECKING import numpy as np from .dynamics import N_CONTROL, IDX_P_U_F, IDX_P_U_O +if TYPE_CHECKING: + from scipy.interpolate import RegularGridInterpolator + def _make_interpolator( axes: List[np.ndarray], diff --git a/EngineDesign/engine/core/chamber_solver.py b/EngineDesign/engine/core/chamber_solver.py index ca56c15e..e404ff22 100644 --- a/EngineDesign/engine/core/chamber_solver.py +++ b/EngineDesign/engine/core/chamber_solver.py @@ -756,7 +756,6 @@ def tracked_residual_func(Pc): "eta_cstar": eta, "cooling_efficiency": cooling_eff, "Tc": effective_Tc, # Use effective temperature after cooling (accounts for energy removal) - "Tc_ideal": cea_props["Tc"], # Store original CEA temperature for reference "gamma": gamma, "R": R, "M": cea_props.get("M"), # Molecular weight [kg/kmol] diff --git a/EngineDesign/engine/optimizer/display_results.py b/EngineDesign/engine/optimizer/display_results.py index a7594b62..876286c2 100644 --- a/EngineDesign/engine/optimizer/display_results.py +++ b/EngineDesign/engine/optimizer/display_results.py @@ -12,7 +12,6 @@ import streamlit as st from plotly.subplots import make_subplots import plotly.graph_objects as go -import time import uuid from engine.pipeline.config_schemas import PintleEngineConfig diff --git a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py index 557a0cbb..0a366c85 100644 --- a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py +++ b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py @@ -6674,8 +6674,9 @@ def run_hybrid_optimization( # Initial mean for this block z0 = current_x[block_indices] - # Objective wrapper - def block_obj_fn(z): + # Objective wrapper. Bind the loop vars as defaults so the closure captures + # THIS block's values, not the final iteration's (B023 late-binding trap). + def block_obj_fn(z, current_x=current_x, block_indices=block_indices): # Stitch full_x = current_x.copy() # Start with current baseline full_x[block_indices] = z # Overwrite block diff --git a/EngineDesign/engine/optimizer/main_optimizer.py b/EngineDesign/engine/optimizer/main_optimizer.py index e8627105..60134293 100644 --- a/EngineDesign/engine/optimizer/main_optimizer.py +++ b/EngineDesign/engine/optimizer/main_optimizer.py @@ -84,7 +84,6 @@ def run_full_engine_optimization_with_flight_sim( """ from engine.pipeline.system_diagnostics import SystemDiagnostics from scipy.optimize import minimize, differential_evolution - from pathlib import Path from datetime import datetime # Get objective tolerance for early stopping @@ -171,6 +170,11 @@ def update_progress(stage: str, progress: float, message: str): lox_P_end_ratio = pressure_config.get("lox_end_pct", 0.70) fuel_P_start = pressure_config.get("fuel_start_psi", 500) * psi_to_Pa fuel_P_end_ratio = pressure_config.get("fuel_end_pct", 0.70) + # Hard pressure ceilings, also used as the fallback tank-start pressures (0.8x) + # when Layer 1 doesn't return a pressure curve. Read here rather than in the + # Layer 2 block below because those fallbacks are consumed far earlier. + max_lox_P_psi = pressure_config.get("max_lox_pressure_psi", 500) + max_fuel_P_psi = pressure_config.get("max_fuel_pressure_psi", 500) # ------------------------------------------------------------------ # Estimate ambient pressure at launch site for exit-pressure targeting @@ -360,11 +364,10 @@ def update_progress(stage: str, progress: float, message: str): if use_time_varying and layer1_acceptable: try: from engine.optimizer.layers.layer2_pressure import run_layer2_pressure - - # Get max pressures from config (these are HARD LIMITS - never exceeded) - max_lox_P_psi = pressure_config.get("max_lox_pressure_psi", 500) - max_fuel_P_psi = pressure_config.get("max_fuel_pressure_psi", 500) - + + # max_lox_P_psi / max_fuel_P_psi (hard pressure ceilings) are read once + # near the top of the function since earlier fallbacks depend on them. + # Get rocket mass and tank capacity from config (if available) # These are needed for impulse and capacity calculations in layer2_pressure rocket_dry_mass_kg = getattr(config_obj.rocket, 'dry_mass_kg', None) if hasattr(config_obj, 'rocket') else None diff --git a/EngineDesign/engine/optimizer/views/helpers.py b/EngineDesign/engine/optimizer/views/helpers.py index 7b11f954..1280c0bd 100644 --- a/EngineDesign/engine/optimizer/views/helpers.py +++ b/EngineDesign/engine/optimizer/views/helpers.py @@ -5,11 +5,13 @@ from __future__ import annotations -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Tuple import numpy as np import pandas as pd import streamlit as st import copy +from plotly.subplots import make_subplots +from engine.core.chamber_geometry import chamber_geometry_calc from engine.pipeline.config_schemas import PintleEngineConfig from engine.core.runner import PintleEngineRunner diff --git a/EngineDesign/engine/optimizer/views/tabs.py b/EngineDesign/engine/optimizer/views/tabs.py index 5cb54366..76c05411 100644 --- a/EngineDesign/engine/optimizer/views/tabs.py +++ b/EngineDesign/engine/optimizer/views/tabs.py @@ -3591,8 +3591,7 @@ def _sanitize_for_yaml(obj): graphite_recession_rate_thermal = np.asarray(graphite_recession_rate_thermal[:n_points]) graphite_recession_rate_oxidation = np.asarray(graphite_recession_rate_oxidation[:n_points]) - # Create plots - import plotly.graph_objects as go + # Create plots (go / make_subplots are imported at module scope) from plotly.subplots import make_subplots # Convert Pa to psi (1 psi = 6894.76 Pa) diff --git a/EngineDesign/engine/pipeline/comprehensive_geometry_sizing.py b/EngineDesign/engine/pipeline/comprehensive_geometry_sizing.py index 7caf1f39..b053874e 100644 --- a/EngineDesign/engine/pipeline/comprehensive_geometry_sizing.py +++ b/EngineDesign/engine/pipeline/comprehensive_geometry_sizing.py @@ -153,7 +153,8 @@ def size_complete_geometry( L_chamber = cg.length if cg.length else (cg.volume / cg.A_throat if cg.A_throat and cg.A_throat > 0 else 0.18) # Calculate diameters - if L_chamber > 0: + if V_chamber > 0 and A_throat > 0: + if L_chamber > 0: D_chamber_initial = np.sqrt(4.0 * V_chamber / (np.pi * L_chamber)) else: D_chamber_initial = np.sqrt(4.0 * V_chamber / np.pi) # Assume cylindrical diff --git a/EngineDesign/engine/pipeline/comprehensive_optimizer.py b/EngineDesign/engine/pipeline/comprehensive_optimizer.py index cc82983e..9e24bded 100644 --- a/EngineDesign/engine/pipeline/comprehensive_optimizer.py +++ b/EngineDesign/engine/pipeline/comprehensive_optimizer.py @@ -150,6 +150,8 @@ def objective(x: np.ndarray) -> float: 2.0 * stability_penalty ) + # Overall stability margin is the worst (min) of the component margins. + stability_margin = min(chugging_margin, acoustic_margin, feed_margin) history.append({ "x": x.copy(), "F": F_actual, diff --git a/EngineDesign/engine/pipeline/reaction_chemistry.py b/EngineDesign/engine/pipeline/reaction_chemistry.py index b971de17..85418240 100644 --- a/EngineDesign/engine/pipeline/reaction_chemistry.py +++ b/EngineDesign/engine/pipeline/reaction_chemistry.py @@ -10,6 +10,7 @@ from __future__ import annotations from typing import Dict, List, Tuple, Optional, Any +import warnings import numpy as np from engine.pipeline.config_schemas import PintleEngineConfig from engine.pipeline.numerical_robustness import ( diff --git a/EngineDesign/engine/pipeline/spalding.py b/EngineDesign/engine/pipeline/spalding.py index d52bd0d9..d07a1cd1 100644 --- a/EngineDesign/engine/pipeline/spalding.py +++ b/EngineDesign/engine/pipeline/spalding.py @@ -612,7 +612,8 @@ def solve_spalding_coupled( warned_pv_stall = False T_s_clipped_count = 0 X_F_s_clipped_count = 0 - + clipping_count = 0 # total clip events across the iteration; checked against max_iter below + for iteration in range(max_iter): # Step 1: Vapor pressure at current T_s try: diff --git a/EngineDesign/engine/pipeline/stability/report.py b/EngineDesign/engine/pipeline/stability/report.py index a6f1c64b..4cc22873 100644 --- a/EngineDesign/engine/pipeline/stability/report.py +++ b/EngineDesign/engine/pipeline/stability/report.py @@ -34,7 +34,7 @@ def _chug_boundary_curve(streams, chamber, n_pts: int = 16) -> List[List[float]] curve: List[List[float]] = [] for eta in np.linspace(0.08, 0.45, n_pts): # scale all streams to this eta; bisect lag factor k in [0.1, 8] for GM(k)=1 - def gm_at(kfac: float) -> float: + def gm_at(kfac: float, eta=eta) -> float: sc = [] for s in streams: s2 = copy.copy(s) diff --git a/EngineDesign/engine/pipeline/time_varying_solver.py b/EngineDesign/engine/pipeline/time_varying_solver.py index ea11d740..3537e1d8 100644 --- a/EngineDesign/engine/pipeline/time_varying_solver.py +++ b/EngineDesign/engine/pipeline/time_varying_solver.py @@ -450,13 +450,11 @@ def solve_time_step( ) gas_viscosity = float(gas_viscosity) - # Backside temperature should come from the multi-layer thermal model; require it. - if 'T_stainless_throat' not in locals() or T_stainless_throat is None: - # In some cases T_stainless_throat might not be calculated yet or fail - # Fallback to T_backside_thermal if available, or 300K - T_backside = 300.0 - else: - T_backside = float(T_stainless_throat) + # Backside temperature should come from the multi-layer thermal model, but it + # is computed later in this step, so it may not be bound yet on this path. + # Read it defensively (missing or None -> 300 K fallback). + _T_stainless = locals().get("T_stainless_throat") + T_backside = float(_T_stainless) if _T_stainless is not None else 300.0 else: # In simplified mode, these are not used for recession but we provide placeholders gas_viscosity = 4e-5 diff --git a/EngineDesign/tests/control/robust_ddp/test_robust_ddp_safety_filter.py b/EngineDesign/tests/control/robust_ddp/test_robust_ddp_safety_filter.py index fb6e81d6..e3b2f79a 100644 --- a/EngineDesign/tests/control/robust_ddp/test_robust_ddp_safety_filter.py +++ b/EngineDesign/tests/control/robust_ddp/test_robust_ddp_safety_filter.py @@ -12,6 +12,7 @@ _generate_action_candidates, _compute_action_cost, ) +from engine.control.robust_ddp.robustness import get_w_bar_array from engine.control.robust_ddp.data_models import ControllerConfig, ControllerState from engine.control.robust_ddp.engine_wrapper import EngineEstimate, EngineWrapper from engine.control.robust_ddp.dynamics import IDX_P_U_F, IDX_P_U_O, IDX_P_COPV diff --git a/EngineDesign/tests/test_orchestrator_smoke.py b/EngineDesign/tests/test_orchestrator_smoke.py new file mode 100644 index 00000000..60d157b1 --- /dev/null +++ b/EngineDesign/tests/test_orchestrator_smoke.py @@ -0,0 +1,81 @@ +"""Smoke test for the full multi-layer optimization orchestrator. + +``run_full_engine_optimization_with_flight_sim`` (``engine/optimizer/main_optimizer.py``) +is the Streamlit/CLI "full optimize" entrypoint that chains Layers 1-4. It has no other +test coverage, and a use-before-assignment ``NameError`` (``max_lox_P_psi``) once made it +crash on *every* run right after Layer 1 — invisible to CI because nothing exercised it +(the backend/React path calls the layers directly). + +This test stubs the heavy layer entrypoints and drives the orchestrator end-to-end with +``use_time_varying=False`` so the *glue* between layers — variable plumbing, the sample-based +interpolation fallback, and the final result assembly — is exercised in well under a second +without running a real optimization or touching CEA. It guards against that whole class of +orchestration-wiring regression (NameError / TypeError / bad result shape), which static +linting alone cannot fully cover (e.g. wrong call signatures). +""" + +import pytest +from unittest.mock import patch, MagicMock + +# The orchestrator imports the Streamlit/RocketPy/plotly UI chain at import time. These are +# installed in CI (requirements-ci.txt); skip cleanly if a minimal local env lacks them. +mo = pytest.importorskip("engine.optimizer.main_optimizer") +from engine.pipeline.io import load_config + +CANONICAL_CONFIG = "configs/canonical/impinging.yaml" + + +def _fake_layer1(**kwargs): + """Stand in for run_layer1_optimization: return a real config + minimal results dict.""" + cfg = load_config(CANONICAL_CONFIG) + layer1_results = { + "performance": { + "F": 7000.0, + "initial_thrust_error": 0.05, + "initial_MR_error": 0.05, + "pressure_candidate_valid": True, + }, + "optimized_pressure_curves": {"lox_start_psi": 500.0, "fuel_start_psi": 500.0}, + "iteration_history": [], + "convergence_info": {}, + } + return cfg, layer1_results + + +def test_full_orchestrator_wiring_smoke(): + """The orchestrator runs entry -> Layer 1 -> result assembly without raising.""" + cfg = load_config(CANONICAL_CONFIG) + + # Stubbed runner: evaluate() returns a real numeric dict so the sample-based + # interpolation fallback (interp1d over sampled thrust/Isp/Pc) gets real arrays. + runner_stub = MagicMock() + runner_stub.evaluate.return_value = { + "F": 7000.0, "Isp": 250.0, "Pc": 2.0e6, + "mdot_O": 1.0, "mdot_F": 0.4, "mdot_total": 1.4, "MR": 2.5, + } + + requirements = {"target_thrust": 7000.0, "target_apogee": 3048.0, "optimal_of_ratio": 2.55} + tolerances = {"thrust": 0.10, "apogee": 0.15} + pressure_config = { + "lox_start_psi": 500.0, "fuel_start_psi": 500.0, + "max_lox_pressure_psi": 600.0, "max_fuel_pressure_psi": 600.0, + } + + with patch.object(mo, "run_layer1_optimization", side_effect=_fake_layer1), \ + patch.object(mo, "PintleEngineRunner", return_value=runner_stub): + out_cfg, results = mo.run_full_engine_optimization_with_flight_sim( + config_obj=cfg, + runner=runner_stub, + requirements=requirements, + target_burn_time=10.0, + max_iterations=1, + tolerances=tolerances, + pressure_config=pressure_config, + use_time_varying=False, + ) + + # Return contract: (config, results dict). The tail assembly — which lives past the + # line that used to NameError — must run and stamp the pressure-curve config. + assert out_cfg is not None + assert isinstance(results, dict) + assert "pressure_curve_config" in results diff --git a/EngineDesign/ui/design_optimization_view.py b/EngineDesign/ui/design_optimization_view.py index 6d9caa5c..801a8270 100644 --- a/EngineDesign/ui/design_optimization_view.py +++ b/EngineDesign/ui/design_optimization_view.py @@ -286,14 +286,13 @@ def design_optimization_view(config_obj: PintleEngineConfig, runner: Optional[Pi """) # Create tabs for different optimization stages - tab_design, tab_full_engine, tab_layer1, tab_layer2, tab_layer3, tab_layer4, tab_layer5 = st.tabs([ + tab_design, tab_full_engine, tab_layer1, tab_layer2, tab_layer3, tab_layer4 = st.tabs([ "📋 Design Requirements", "🚀 Full Engine Optimizer", "🔧 Layer 1: Static Optimization", "⏱️ Layer 2: Pressure Candidate", "🔥 Layer 3: Thermal Protection", - "✈️ Layer 4: Flight Simulation", - "📊 Layer 5: Flight Analysis" + "✈️ Layer 4: Flight Simulation" ]) with tab_design: @@ -313,9 +312,6 @@ def design_optimization_view(config_obj: PintleEngineConfig, runner: Optional[Pi with tab_layer4: config_obj = _layer4_tab(config_obj, runner) - - with tab_layer5: - config_obj = _layer5_tab(config_obj, runner) - + return config_obj diff --git a/EngineDesign/ui/flight_sim.py b/EngineDesign/ui/flight_sim.py index 519ea532..2b82c90e 100644 --- a/EngineDesign/ui/flight_sim.py +++ b/EngineDesign/ui/flight_sim.py @@ -323,7 +323,6 @@ def setup_flight(config, thrust_curve, mdot_lox, mdot_fuel, plot_results=False): # Tank volume = π * r² * h, max mass = volume * density * fill_factor # RocketPy's internal tank calculations (liquid height, center of mass) can fail # when liquid level gets too close to tank geometry bounds due to numerical precision - import math # Validate and cap propellant masses to tank capacity (fill factor from config, default 90%) from engine.pipeline.tank_capacity import resolve_fuel_tank_limits, resolve_lox_tank_limits diff --git a/daq-server/tools/calibration/smart_calibration_gui.py b/daq-server/tools/calibration/smart_calibration_gui.py index 41a98781..7a4760b3 100755 --- a/daq-server/tools/calibration/smart_calibration_gui.py +++ b/daq-server/tools/calibration/smart_calibration_gui.py @@ -17,7 +17,7 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure -# import serial +import serial import struct from collections import deque from datetime import datetime diff --git a/firmware/test_guis/combined_gui/combined_gui.py b/firmware/test_guis/combined_gui/combined_gui.py index 2d357e0e..de113b4d 100644 --- a/firmware/test_guis/combined_gui/combined_gui.py +++ b/firmware/test_guis/combined_gui/combined_gui.py @@ -10,6 +10,7 @@ import csv import json +import math import os import re import socket diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..d354a222 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,37 @@ +# Repo-wide lint gate — BUG-detecting rules only, not style. +# +# Rationale: this gate exists to catch the use-before-assignment / undefined-name / +# redefinition class of bug — e.g. the `max_lox_P_psi` NameError that made the engine +# optimizer's orchestrator crash on every run, and the missing `import serial` / `import math` +# that would NameError the moment those GUIs ran. These are runtime crashes that unit tests +# miss when the crashing path isn't exercised, but ruff flags statically in ~1s across the +# whole monorepo. +# +# Deliberately NOT enabled: the large pre-existing style backlog (unused imports/variables, +# f-strings without placeholders, etc.). Those are hygiene, not bugs; gating on them would +# require a big cleanup and add noise. Expand `select` cautiously and only with rules that +# indicate real defects. + +target-version = "py311" + +[lint] +select = [ + "F821", # undefined name (missing import, typo, use-before-any-binding) + "F823", # local variable referenced before assignment + "F811", # redefinition of an unused name (usually a shadow / dead re-import) + "F601", # `==`/`!=` against a repeated dict key literal + "F602", # `in`/`not in` with a repeated literal + "F631", # assert on a (always-truthy) tuple + "F632", # use of `==`/`is` with a str/num/bytes literal that can't match + "F701", # `break` outside a loop + "F702", # `continue` outside a loop + "F704", # `yield` outside a function + "F706", # `return` outside a function + "F707", # `except` after a bare `except` + "B006", # mutable value as an argument default + "B023", # closure does not bind the loop variable (late-binding trap) +] + +# B008 (function-call-in-default-argument) is intentionally excluded: FastAPI's +# File()/Depends()/Query()/Form() default-argument idiom is a required framework +# pattern in backend/routers/*, not a bug. From 00fa37b6c0d38751fc0b48851a622e0ac484184c Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Wed, 15 Jul 2026 13:26:33 -0700 Subject: [PATCH 03/35] Dead code cleanup, improved CI catching of bad syntax and practices --- .../dead_code_20260715}/copv/copv_solve.py | 0 .../control/robust_ddp/copv_calculator.py | 0 .../engine/optimizer/display_results.py | 0 .../engine/optimizer/main_optimizer.py | 0 .../engine/optimizer/views/__init__.py | 0 .../engine/optimizer/views/helpers.py | 0 .../engine/optimizer/views/tabs.py | 0 .../pipeline/chamber_geometry_visualizer.py | 0 .../pipeline/comprehensive_geometry_sizing.py | 0 .../pipeline/thermal/ablative_sizing.py | 0 .../pipeline/thermal/graphite_geometry.py | 0 .../thermal/graphite_variable_thickness.py | 0 .../engine/pipeline/validation.py | 0 .../tests/test_orchestrator_smoke.py | 0 .../ui/design_optimization_view.py | 0 EngineDesign/engine/optimizer/__init__.py | 30 +------------------ .../layers/layer3_thermal_protection.py | 7 +++++ ruff.toml | 5 ++++ 18 files changed, 13 insertions(+), 29 deletions(-) rename EngineDesign/{ => archive/dead_code_20260715}/copv/copv_solve.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/control/robust_ddp/copv_calculator.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/optimizer/display_results.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/optimizer/main_optimizer.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/optimizer/views/__init__.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/optimizer/views/helpers.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/optimizer/views/tabs.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/pipeline/chamber_geometry_visualizer.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/pipeline/comprehensive_geometry_sizing.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/pipeline/thermal/ablative_sizing.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/pipeline/thermal/graphite_geometry.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/pipeline/thermal/graphite_variable_thickness.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/engine/pipeline/validation.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/tests/test_orchestrator_smoke.py (100%) rename EngineDesign/{ => archive/dead_code_20260715}/ui/design_optimization_view.py (100%) diff --git a/EngineDesign/copv/copv_solve.py b/EngineDesign/archive/dead_code_20260715/copv/copv_solve.py similarity index 100% rename from EngineDesign/copv/copv_solve.py rename to EngineDesign/archive/dead_code_20260715/copv/copv_solve.py diff --git a/EngineDesign/engine/control/robust_ddp/copv_calculator.py b/EngineDesign/archive/dead_code_20260715/engine/control/robust_ddp/copv_calculator.py similarity index 100% rename from EngineDesign/engine/control/robust_ddp/copv_calculator.py rename to EngineDesign/archive/dead_code_20260715/engine/control/robust_ddp/copv_calculator.py diff --git a/EngineDesign/engine/optimizer/display_results.py b/EngineDesign/archive/dead_code_20260715/engine/optimizer/display_results.py similarity index 100% rename from EngineDesign/engine/optimizer/display_results.py rename to EngineDesign/archive/dead_code_20260715/engine/optimizer/display_results.py diff --git a/EngineDesign/engine/optimizer/main_optimizer.py b/EngineDesign/archive/dead_code_20260715/engine/optimizer/main_optimizer.py similarity index 100% rename from EngineDesign/engine/optimizer/main_optimizer.py rename to EngineDesign/archive/dead_code_20260715/engine/optimizer/main_optimizer.py diff --git a/EngineDesign/engine/optimizer/views/__init__.py b/EngineDesign/archive/dead_code_20260715/engine/optimizer/views/__init__.py similarity index 100% rename from EngineDesign/engine/optimizer/views/__init__.py rename to EngineDesign/archive/dead_code_20260715/engine/optimizer/views/__init__.py diff --git a/EngineDesign/engine/optimizer/views/helpers.py b/EngineDesign/archive/dead_code_20260715/engine/optimizer/views/helpers.py similarity index 100% rename from EngineDesign/engine/optimizer/views/helpers.py rename to EngineDesign/archive/dead_code_20260715/engine/optimizer/views/helpers.py diff --git a/EngineDesign/engine/optimizer/views/tabs.py b/EngineDesign/archive/dead_code_20260715/engine/optimizer/views/tabs.py similarity index 100% rename from EngineDesign/engine/optimizer/views/tabs.py rename to EngineDesign/archive/dead_code_20260715/engine/optimizer/views/tabs.py diff --git a/EngineDesign/engine/pipeline/chamber_geometry_visualizer.py b/EngineDesign/archive/dead_code_20260715/engine/pipeline/chamber_geometry_visualizer.py similarity index 100% rename from EngineDesign/engine/pipeline/chamber_geometry_visualizer.py rename to EngineDesign/archive/dead_code_20260715/engine/pipeline/chamber_geometry_visualizer.py diff --git a/EngineDesign/engine/pipeline/comprehensive_geometry_sizing.py b/EngineDesign/archive/dead_code_20260715/engine/pipeline/comprehensive_geometry_sizing.py similarity index 100% rename from EngineDesign/engine/pipeline/comprehensive_geometry_sizing.py rename to EngineDesign/archive/dead_code_20260715/engine/pipeline/comprehensive_geometry_sizing.py diff --git a/EngineDesign/engine/pipeline/thermal/ablative_sizing.py b/EngineDesign/archive/dead_code_20260715/engine/pipeline/thermal/ablative_sizing.py similarity index 100% rename from EngineDesign/engine/pipeline/thermal/ablative_sizing.py rename to EngineDesign/archive/dead_code_20260715/engine/pipeline/thermal/ablative_sizing.py diff --git a/EngineDesign/engine/pipeline/thermal/graphite_geometry.py b/EngineDesign/archive/dead_code_20260715/engine/pipeline/thermal/graphite_geometry.py similarity index 100% rename from EngineDesign/engine/pipeline/thermal/graphite_geometry.py rename to EngineDesign/archive/dead_code_20260715/engine/pipeline/thermal/graphite_geometry.py diff --git a/EngineDesign/engine/pipeline/thermal/graphite_variable_thickness.py b/EngineDesign/archive/dead_code_20260715/engine/pipeline/thermal/graphite_variable_thickness.py similarity index 100% rename from EngineDesign/engine/pipeline/thermal/graphite_variable_thickness.py rename to EngineDesign/archive/dead_code_20260715/engine/pipeline/thermal/graphite_variable_thickness.py diff --git a/EngineDesign/engine/pipeline/validation.py b/EngineDesign/archive/dead_code_20260715/engine/pipeline/validation.py similarity index 100% rename from EngineDesign/engine/pipeline/validation.py rename to EngineDesign/archive/dead_code_20260715/engine/pipeline/validation.py diff --git a/EngineDesign/tests/test_orchestrator_smoke.py b/EngineDesign/archive/dead_code_20260715/tests/test_orchestrator_smoke.py similarity index 100% rename from EngineDesign/tests/test_orchestrator_smoke.py rename to EngineDesign/archive/dead_code_20260715/tests/test_orchestrator_smoke.py diff --git a/EngineDesign/ui/design_optimization_view.py b/EngineDesign/archive/dead_code_20260715/ui/design_optimization_view.py similarity index 100% rename from EngineDesign/ui/design_optimization_view.py rename to EngineDesign/archive/dead_code_20260715/ui/design_optimization_view.py diff --git a/EngineDesign/engine/optimizer/__init__.py b/EngineDesign/engine/optimizer/__init__.py index 51a8b9ac..76b6677f 100644 --- a/EngineDesign/engine/optimizer/__init__.py +++ b/EngineDesign/engine/optimizer/__init__.py @@ -5,10 +5,9 @@ Modules: - helpers.py: Pressure curve generation and variable conversion - layers/layer1_static_optimization.py: Layer 1 geometry optimization -- layers/layer2_pressure.py: Layer 2 pressure curve optimization +- layers/layer2_pressure.py: Layer 2 pressure curve optimization - layers/layer3_thermal_protection.py: Layer 3 thermal protection sizing - layers/layer4_flight_simulation.py: Layer 4 flight simulation -- display_results.py: Plotting and visualization functions - copv_flight_helpers.py: COPV and flight simulation utilities - utils.py: Parameter extraction and misc utilities @@ -23,9 +22,6 @@ run_layer2_pressure, run_layer3_thermal_protection, run_layer4_flight_simulation, - # Display - plot_pressure_curves, - plot_optimization_convergence, # Utilities extract_all_parameters, calculate_copv_pressure_curve, @@ -61,16 +57,6 @@ run_layer4_flight_simulation, ) -# Display functions -from engine.optimizer.display_results import ( - plot_pressure_curves, - plot_copv_pressure, - plot_flight_trajectory, - plot_optimization_convergence, - plot_time_varying_results, - plot_layer1_parameterization_history, -) - # COPV and flight helpers from engine.optimizer.copv_flight_helpers import ( calculate_copv_pressure_curve, @@ -82,11 +68,6 @@ extract_all_parameters, ) -# Main optimizer -from engine.optimizer.main_optimizer import ( - run_full_engine_optimization_with_flight_sim, -) - __all__ = [ # Pressure curve helpers 'generate_segmented_pressure_curve', @@ -101,19 +82,10 @@ 'run_layer3_thermal_protection', # Layer 4 'run_layer4_flight_simulation', - # Display functions - 'plot_pressure_curves', - 'plot_copv_pressure', - 'plot_flight_trajectory', - 'plot_optimization_convergence', - 'plot_time_varying_results', - 'plot_layer1_parameterization_history', # COPV and flight 'calculate_copv_pressure_curve', 'run_flight_simulation', # Utilities 'extract_all_parameters', - # Main optimizer - 'run_full_engine_optimization_with_flight_sim', ] diff --git a/EngineDesign/engine/optimizer/layers/layer3_thermal_protection.py b/EngineDesign/engine/optimizer/layers/layer3_thermal_protection.py index e8292959..b5889a9e 100644 --- a/EngineDesign/engine/optimizer/layers/layer3_thermal_protection.py +++ b/EngineDesign/engine/optimizer/layers/layer3_thermal_protection.py @@ -1018,6 +1018,13 @@ def __init__(self, x, fun): 0.74, f"⚠️ Re-evaluation failed: {e}, using original results", ) + # Fail closed: the safety check above never completed, so we cannot + # claim the thermal protection is adequate. Leaving the optimistic + # default (True from initialization) would let a failed analysis + # report VALID — mark it invalid instead. + thermal_results["ablative_adequate"] = False + thermal_results["graphite_adequate"] = False + thermal_results["thermal_protection_valid"] = False status_msg = ( "Completed | Ablative {:.2f} mm, Graphite {:.2f} mm, " diff --git a/ruff.toml b/ruff.toml index d354a222..d045388c 100644 --- a/ruff.toml +++ b/ruff.toml @@ -14,6 +14,11 @@ target-version = "py311" +# Deprecated / quarantined code lives under any `archive/` directory (see the +# EngineDesign dead-code cleanup). It is intentionally out of the active tree and +# may retain dangling imports to since-removed modules, so the bug-gate skips it. +extend-exclude = ["**/archive/**"] + [lint] select = [ "F821", # undefined name (missing import, typo, use-before-any-binding) From 71c7e10863f203215b29acb0d5b28450ad6c0924 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 01:43:38 -0700 Subject: [PATCH 04/35] thermal (layer 3): fix 386x gas viscosity unit error, add sanity and struct parity tests --- .../engine/native/include/ed_phys_const.h | 7 +- EngineDesign/engine/native/src/ed_cooling.c | 4 +- .../native/tests/golden/residual_samples.json | 180 ++++++++--------- EngineDesign/engine/pipeline/constants.py | 17 +- .../pipeline/thermal/ablative_cooling.py | 13 +- .../engine/pipeline/thermal/regen_cooling.py | 26 ++- .../tests/test_gas_side_heat_transfer.py | 191 ++++++++++++++++++ EngineDesign/tests/test_native_ab_parity.py | 138 +++++++++++++ 8 files changed, 463 insertions(+), 113 deletions(-) create mode 100644 EngineDesign/tests/test_gas_side_heat_transfer.py diff --git a/EngineDesign/engine/native/include/ed_phys_const.h b/EngineDesign/engine/native/include/ed_phys_const.h index df37404d..10a0bdc2 100644 --- a/EngineDesign/engine/native/include/ed_phys_const.h +++ b/EngineDesign/engine/native/include/ed_phys_const.h @@ -36,7 +36,12 @@ #define ED_EPS_SMALL 1e-6 #define ED_EPS_TINY 1e-8 #define ED_RANKINE_PER_KELVIN 1.8 -#define ED_LB_S_PER_IN2_TO_PA_S 6894.76 +/* Huzel eq. (4-16) returns viscosity in lb/(in*s) -- pound-MASS per inch-sec; + * see the nomenclature under eq. (4-12). NOT lbf*s/in^2 (= psi*s), whose + * factor is 6894.76. The two differ by exactly g_c = 386.088 lbm*in/(lbf*s^2). + * Must stay numerically identical to LB_PER_IN_S_TO_PA_S in + * engine/pipeline/constants.py or the A/B parity test will diverge. */ +#define ED_LB_PER_IN_S_TO_PA_S 17.8579673 #define ED_HUZEL_COEFF 46.6e-10 #endif /* ED_PHYS_CONST_H */ diff --git a/EngineDesign/engine/native/src/ed_cooling.c b/EngineDesign/engine/native/src/ed_cooling.c index 1d6a5309..d5ab4e06 100644 --- a/EngineDesign/engine/native/src/ed_cooling.c +++ b/EngineDesign/engine/native/src/ed_cooling.c @@ -12,8 +12,8 @@ double ed_gas_viscosity_huzel(double T_K, double M) { const double T_rankine = T_K * ED_RANKINE_PER_KELVIN; - const double mu_lb_s_in2 = ED_HUZEL_COEFF * sqrt(M) * pow(T_rankine, 0.6); - return mu_lb_s_in2 * ED_LB_S_PER_IN2_TO_PA_S; + const double mu_lb_per_in_s = ED_HUZEL_COEFF * sqrt(M) * pow(T_rankine, 0.6); + return mu_lb_per_in_s * ED_LB_PER_IN_S_TO_PA_S; } double ed_chamber_wetted_area(const EdGeometry *g) { diff --git a/EngineDesign/engine/native/tests/golden/residual_samples.json b/EngineDesign/engine/native/tests/golden/residual_samples.json index 472d9001..79e0f656 100644 --- a/EngineDesign/engine/native/tests/golden/residual_samples.json +++ b/EngineDesign/engine/native/tests/golden/residual_samples.json @@ -92,11 +92,11 @@ "eta_kinetics": 0.9527915089975729, "eta_mixing": 0.9599983613985529, "eta_total": 0.9146782873406365, - "cooling_eff": 0.9924138640833312, - "heat_removed": 195674.5204793161, - "eta_final": 0.9077394135328446, - "cstar_actual": 1664.9848912439616, - "mdot_demand": 1.913958916113782 + "cooling_eff": 0.9805496873112249, + "heat_removed": 495856.9057713169, + "eta_final": 0.8968875086422279, + "cstar_actual": 1645.0802166041626, + "mdot_demand": 1.9371168929192095 }, { "P_tank_O": 3447378.646584, @@ -127,11 +127,11 @@ "eta_kinetics": 0.9814985320893502, "eta_mixing": 0.9599809002344764, "eta_total": 0.9422198444139422, - "cooling_eff": 0.9911020349252543, - "heat_removed": 198647.07378400167, - "eta_final": 0.9338360051456146, - "cstar_actual": 1713.9174771531486, - "mdot_demand": 2.272496256414308 + "cooling_eff": 0.9787600831181865, + "heat_removed": 468450.5493231528, + "eta_final": 0.9222071732341949, + "cstar_actual": 1692.574481014602, + "mdot_demand": 2.3011519400309237 }, { "P_tank_O": 3447378.646584, @@ -162,11 +162,11 @@ "eta_kinetics": 0.9942887407517236, "eta_mixing": 0.959775783140413, "eta_total": 0.9542942548226806, - "cooling_eff": 0.9890730880488525, - "heat_removed": 201012.04362185596, - "eta_final": 0.9438667655247472, - "cstar_actual": 1733.151683567272, - "mdot_demand": 2.655872228102873 + "cooling_eff": 0.9761408397408002, + "heat_removed": 433370.482493808, + "eta_final": 0.9315255952624326, + "cstar_actual": 1710.4905190909176, + "mdot_demand": 2.69105813338413 }, { "P_tank_O": 3447378.646584, @@ -197,11 +197,11 @@ "eta_kinetics": 0.9508402841428883, "eta_mixing": 0.9589832728080518, "eta_total": 0.9118399275793578, - "cooling_eff": 0.9926152486828815, - "heat_removed": 195675.00744282446, - "eta_final": 0.9051062164731649, - "cstar_actual": 1660.1550543384872, - "mdot_demand": 1.9195271366149036 + "cooling_eff": 0.9808134571524906, + "heat_removed": 502502.1264852846, + "eta_final": 0.8943448717387866, + "cstar_actual": 1640.4164860609742, + "mdot_demand": 1.942624147507297 }, { "P_tank_O": 3447378.646584, @@ -232,11 +232,11 @@ "eta_kinetics": 0.9802029443850289, "eta_mixing": 0.9578587038956297, "eta_total": 0.9388959218633209, - "cooling_eff": 0.9914038988174715, - "heat_removed": 198647.79747104202, - "eta_final": 0.9308250775191205, - "cstar_actual": 1708.3913660875478, - "mdot_demand": 2.2798470701437563 + "cooling_eff": 0.9791451117631393, + "heat_removed": 476149.54582314176, + "eta_final": 0.9193153523468172, + "cstar_actual": 1687.2669727022617, + "mdot_demand": 2.3083904999312055 }, { "P_tank_O": 3447378.646584, @@ -267,11 +267,11 @@ "eta_kinetics": 0.9864612631741922, "eta_mixing": 0.9548344359289281, "eta_total": 0.9419071837886678, - "cooling_eff": 0.9895868524791969, - "heat_removed": 201013.26123423516, - "eta_final": 0.9320989653329721, - "cstar_actual": 1711.5433555074112, - "mdot_demand": 2.689402759599638 + "cooling_eff": 0.976775970123939, + "heat_removed": 442698.986788475, + "eta_final": 0.9200323032118833, + "cstar_actual": 1689.3862497229147, + "mdot_demand": 2.724675558494112 }, { "P_tank_O": 3723168.93831072, @@ -302,11 +302,11 @@ "eta_kinetics": 0.9490989224369234, "eta_mixing": 0.9586299749811161, "eta_total": 0.9098346762045865, - "cooling_eff": 0.9927861234903853, - "heat_removed": 195675.42103317552, - "eta_final": 0.9032712412062813, - "cstar_actual": 1656.7893239872215, - "mdot_demand": 1.9234266129395305 + "cooling_eff": 0.9810383595722456, + "heat_removed": 508402.1114868174, + "eta_final": 0.8925827182256928, + "cstar_actual": 1637.1843261132917, + "mdot_demand": 1.9464593124687648 }, { "P_tank_O": 3723168.93831072, @@ -337,11 +337,11 @@ "eta_kinetics": 0.9790036667278557, "eta_mixing": 0.9582577151791158, "eta_total": 0.938137816830597, - "cooling_eff": 0.9916618740806882, - "heat_removed": 198648.41683707622, - "eta_final": 0.9303155055841953, - "cstar_actual": 1707.4561223827404, - "mdot_demand": 2.281095835832271 + "cooling_eff": 0.9794760611601041, + "heat_removed": 483125.9469518781, + "eta_final": 0.9188835336545724, + "cstar_actual": 1686.4744335417222, + "mdot_demand": 2.309475301356372 }, { "P_tank_O": 3723168.93831072, @@ -372,11 +372,11 @@ "eta_kinetics": 0.992816956321436, "eta_mixing": 0.9574937342240339, "eta_total": 0.9506160149091514, - "cooling_eff": 0.9900309712209368, - "heat_removed": 201014.31637930468, - "eta_final": 0.9411392964986837, - "cstar_actual": 1728.143436951266, - "mdot_demand": 2.6635690794258196 + "cooling_eff": 0.9773289810744866, + "heat_removed": 451457.93038776447, + "eta_final": 0.92906458124425, + "cstar_actual": 1705.971543802573, + "mdot_demand": 2.6981865202839224 }, { "P_tank_O": 3723168.93831072, @@ -407,11 +407,11 @@ "eta_kinetics": 0.9471478994181045, "eta_mixing": 0.9599851592954596, "eta_total": 0.9092479270653505, - "cooling_eff": 0.9929684662340222, - "heat_removed": 195675.86278835073, - "eta_final": 0.9028545195645452, - "cstar_actual": 1656.0249689013883, - "mdot_demand": 1.9243143899606718 + "cooling_eff": 0.9812795151992314, + "heat_removed": 514982.3124817475, + "eta_final": 0.8922263650665933, + "cstar_actual": 1636.530699514069, + "mdot_demand": 1.9472367238435175 }, { "P_tank_O": 3723168.93831072, @@ -442,11 +442,11 @@ "eta_kinetics": 0.9776708606224255, "eta_mixing": 0.9599999711055908, "eta_total": 0.9385639979483018, - "cooling_eff": 0.9919275147606353, - "heat_removed": 198649.05547291078, - "eta_final": 0.9309874539286651, - "cstar_actual": 1708.6893839029485, - "mdot_demand": 2.279449434944701 + "cooling_eff": 0.9798187858236823, + "heat_removed": 490728.1543565683, + "eta_final": 0.9196226368875261, + "cstar_actual": 1687.8309478990607, + "mdot_demand": 2.3076191697289077 }, { "P_tank_O": 3723168.93831072, @@ -477,11 +477,11 @@ "eta_kinetics": 0.9920100362822578, "eta_mixing": 0.959952408606402, "eta_total": 0.9522824236908776, - "cooling_eff": 0.9904603775606472, - "heat_removed": 201015.338863697, - "eta_final": 0.9431980089132348, - "cstar_actual": 1731.923696006448, - "mdot_demand": 2.657755323799738 + "cooling_eff": 0.9778674804543092, + "heat_removed": 460622.3762011718, + "eta_final": 0.9312060143355214, + "cstar_actual": 1709.9036966264098, + "mdot_demand": 2.691981678592601 }, { "P_tank_O": 3998959.2300374396, @@ -512,11 +512,11 @@ "eta_kinetics": 0.9456644746137003, "eta_mixing": 0.955390458603255, "eta_total": 0.9034788160069008, - "cooling_eff": 0.993101136189494, - "heat_removed": 195676.1844678129, - "eta_final": 0.8972458386995921, - "cstar_actual": 1645.7374692503465, - "mdot_demand": 1.9363432730511398 + "cooling_eff": 0.9814557603410546, + "heat_removed": 519966.5247533827, + "eta_final": 0.8867244883160886, + "cstar_actual": 1626.4391010591505, + "mdot_demand": 1.9593187815737474 }, { "P_tank_O": 3998959.2300374396, @@ -547,11 +547,11 @@ "eta_kinetics": 0.9766662350258609, "eta_mixing": 0.9535907961433452, "eta_total": 0.9313399326246143, - "cooling_eff": 0.9921147682628934, - "heat_removed": 198649.50618449104, - "eta_final": 0.923996101429848, - "cstar_actual": 1695.8577933767378, - "mdot_demand": 2.2966967312030584 + "cooling_eff": 0.9800616238781573, + "heat_removed": 496363.64118659304, + "eta_final": 0.9127705267506532, + "cstar_actual": 1675.2549160752144, + "mdot_demand": 2.3249423196790167 }, { "P_tank_O": 3998959.2300374396, @@ -582,11 +582,11 @@ "eta_kinetics": 0.9914092409250157, "eta_mixing": 0.9498921654489881, "eta_total": 0.9417318707084007, - "cooling_eff": 0.9907472413988414, - "heat_removed": 201016.02319183227, - "eta_final": 0.9330182530417184, - "cstar_actual": 1713.231374514215, - "mdot_demand": 2.686752934804991 + "cooling_eff": 0.9782294641944284, + "heat_removed": 467170.2009348422, + "eta_final": 0.9212298632978955, + "cstar_actual": 1691.585239405628, + "mdot_demand": 2.7211335948364157 }, { "P_tank_O": 3998959.2300374396, @@ -617,11 +617,11 @@ "eta_kinetics": 0.9437165734485586, "eta_mixing": 0.9587896798054453, "eta_total": 0.9048257112418707, - "cooling_eff": 0.9932680859541632, - "heat_removed": 195676.5895814214, - "eta_final": 0.8987345023273272, - "cstar_actual": 1648.4679912607073, - "mdot_demand": 1.9331359144886988 + "cooling_eff": 0.9816785224517259, + "heat_removed": 526489.5258903311, + "eta_final": 0.8882479672882517, + "cstar_actual": 1629.2334817293777, + "mdot_demand": 1.9559582549264365 }, { "P_tank_O": 3998959.2300374396, @@ -652,11 +652,11 @@ "eta_kinetics": 0.9753047498596494, "eta_mixing": 0.9585371363069007, "eta_total": 0.9348658219569798, - "cooling_eff": 0.9923527473076638, - "heat_removed": 198650.0796228072, - "eta_final": 0.9277166667830461, - "cstar_actual": 1702.6863392333973, - "mdot_demand": 2.2874859337787243 + "cooling_eff": 0.980371812701662, + "heat_removed": 503883.55897902785, + "eta_final": 0.9165161005047934, + "cstar_actual": 1682.1293611424571, + "mdot_demand": 2.3154408576450347 }, { "P_tank_O": 3998959.2300374396, @@ -687,11 +687,11 @@ "eta_kinetics": 0.9905482168900243, "eta_mixing": 0.9580826177887473, "eta_total": 0.9490270286839703, - "cooling_eff": 0.9911183074944285, - "heat_removed": 201016.9098899447, - "eta_final": 0.9405980624357231, - "cstar_actual": 1727.1496094729753, - "mdot_demand": 2.6651017365430376 + "cooling_eff": 0.9787005544448864, + "heat_removed": 476203.1927454524, + "eta_final": 0.9288132791561847, + "cstar_actual": 1705.5100966440093, + "mdot_demand": 2.6989165485056903 } ] } \ No newline at end of file diff --git a/EngineDesign/engine/pipeline/constants.py b/EngineDesign/engine/pipeline/constants.py index 9b494e94..993c5b1c 100644 --- a/EngineDesign/engine/pipeline/constants.py +++ b/EngineDesign/engine/pipeline/constants.py @@ -357,10 +357,19 @@ # T_Rankine = T_Kelvin × RANKINE_PER_KELVIN RANKINE_PER_KELVIN = 1.8 -# Viscosity unit conversion: lb·s/in² to Pa·s -# Formula from Huzel uses lb·s/in², need to convert to Pa·s -# 1 lb·s/in² = 6894.76 Pa·s -LB_S_PER_IN2_TO_PA_S = 6894.76 +# Viscosity unit conversion: lb/(in·s) to Pa·s +# +# Huzel & Huang (NASA SP-125) eq. (4-16) returns viscosity in lb/(in·s) -- +# pound-MASS per inch-second. See the nomenclature under eq. (4-12): +# "mu = Viscosity, lb/in sec". +# +# 1 lb/(in·s) = 0.45359237 kg / (0.0254 m · s) = 17.8579673 Pa·s +# +# NOTE: do not confuse this with lbf·s/in² (= psi·s), whose factor is 6894.76. +# The two differ by exactly g_c = 386.088 lbm·in/(lbf·s²), and using the psi +# factor here inflates viscosity by that 386x. Huzel's eq. (4-13) carries an +# explicit `g` for precisely this lbm/lbf reconciliation. +LB_PER_IN_S_TO_PA_S = 17.8579673 # ============================================================================ # MIXING AND TURBULENCE CONSTANTS diff --git a/EngineDesign/engine/pipeline/thermal/ablative_cooling.py b/EngineDesign/engine/pipeline/thermal/ablative_cooling.py index 7ae18278..9746583c 100644 --- a/EngineDesign/engine/pipeline/thermal/ablative_cooling.py +++ b/EngineDesign/engine/pipeline/thermal/ablative_cooling.py @@ -8,6 +8,7 @@ from engine.pipeline.config_schemas import AblativeCoolingConfig from engine.pipeline.constants import STEFAN_BOLTZMANN_W_M2_K4, EPSILON_SMALL +from engine.pipeline.thermal.regen_cooling import calculate_gas_viscosity_huzel def compute_ablative_heat_flux_profile( @@ -115,13 +116,11 @@ def compute_ablative_heat_flux_profile( A_chamber = np.pi * (D_chamber / 2) ** 2 A_exit = np.pi * (D_exit / 2) ** 2 if has_nozzle else A_throat - # Gas viscosity using Huzel formula: μ = 46.6e-10 × M^0.5 × T^0.6 [lb·s/in²] - # Convert to Pa·s: multiply by 6894.76 - def calc_viscosity(T_K, M_kg_kmol): - T_R = T_K * 1.8 # Kelvin to Rankine - mu_imperial = 46.6e-10 * (M_kg_kmol ** 0.5) * (T_R ** 0.6) - return mu_imperial * 6894.76 # Convert to Pa·s - + # Gas viscosity: Huzel eq. (4-16), shared with the regen path so the + # unit conversion lives in exactly one place. + calc_viscosity = calculate_gas_viscosity_huzel + + # Supersonic Mach number from area ratio using Newton-Raphson def mach_from_area_ratio_supersonic(area_ratio, gamma, tol=1e-6, max_iter=50): """Solve for supersonic M given A/A* using Newton-Raphson.""" diff --git a/EngineDesign/engine/pipeline/thermal/regen_cooling.py b/EngineDesign/engine/pipeline/thermal/regen_cooling.py index 3e01d373..cbc1756f 100644 --- a/EngineDesign/engine/pipeline/thermal/regen_cooling.py +++ b/EngineDesign/engine/pipeline/thermal/regen_cooling.py @@ -30,9 +30,9 @@ ) # Unit conversion constants for viscosity formula -# Formula uses Rankine and lb/lb-mol, result in lb·s/in² +# Formula uses Rankine and lb/lb-mol, result in lb/(in·s) # Import from constants to avoid duplication -from engine.pipeline.constants import RANKINE_PER_KELVIN, LB_S_PER_IN2_TO_PA_S +from engine.pipeline.constants import RANKINE_PER_KELVIN, LB_PER_IN_S_TO_PA_S def calculate_gas_viscosity_huzel( @@ -47,7 +47,15 @@ def calculate_gas_viscosity_huzel( Where: - M = molecular weight [lb/lb-mol] (same as kg/kmol numerically) - T = temperature [°R] (Rankine) - - Result: μ [lb·s/in²], converted to [Pa·s] + - Result: μ [lb/(in·s)], converted to [Pa·s] + + The correlation originates with Bartz (1957), "A Simple Equation for Rapid + Estimation of Rocket Nozzle Convective Heat Transfer Coefficients", + J. Jet Propulsion 27(1):49-51; Huzel & Huang reproduce it as eq. (4-16). + It is fitted to NBS air data and is stated to be reasonably accurate for + mixtures consisting principally of DIATOMIC gases -- combustion products + (CO2, H2O) are triatomic, so treat this as an approximation. Prefer CEA + transport properties where available. This correlation is specifically for combustion gases and accounts for: - Temperature dependence (T^0.6) @@ -76,13 +84,13 @@ def calculate_gas_viscosity_huzel( # Molecular weight: lb/lb-mol = kg/kmol (same numerically) M_lb_lbmol = molecular_weight - # Calculate viscosity using Huzel's formula + # Calculate viscosity using Huzel eq. (4-16) # μ = (46.6 × 10^-10) × M^0.5 × T^0.6 - # Result is in lb·s/in² - mu_lb_s_in2 = 46.6e-10 * (M_lb_lbmol ** 0.5) * (T_rankine ** 0.6) - - # Convert from lb·s/in² to Pa·s - viscosity_pa_s = mu_lb_s_in2 * LB_S_PER_IN2_TO_PA_S + # Result is in lb/(in·s) [pound-MASS per inch-second] + mu_lb_per_in_s = 46.6e-10 * (M_lb_lbmol ** 0.5) * (T_rankine ** 0.6) + + # Convert from lb/(in·s) to Pa·s + viscosity_pa_s = mu_lb_per_in_s * LB_PER_IN_S_TO_PA_S return float(viscosity_pa_s) diff --git a/EngineDesign/tests/test_gas_side_heat_transfer.py b/EngineDesign/tests/test_gas_side_heat_transfer.py new file mode 100644 index 00000000..6afe8ef1 --- /dev/null +++ b/EngineDesign/tests/test_gas_side_heat_transfer.py @@ -0,0 +1,191 @@ +"""Physical-sanity guards for the gas-side heat transfer chain. + +This chain had no test coverage at all, which is how a 386x unit error in the +gas viscosity survived: nothing ever asserted that any intermediate value was +physically possible, and the one number surfaced in the UI happened to look +right because two large errors cancelled. + +The assertions here are deliberately *loose*. They are not accuracy checks -- +they are "is this physically possible" checks, with bounds wide enough that +only a genuine modelling error trips them. A correct model should clear them +by a wide margin. + +Reference for the correlations under test: + Huzel & Huang, "Design of Liquid Propellant Rocket Engines" (NASA SP-125), + ch. 4 -- eq. (4-12) Nusselt form and nomenclature, (4-13) Bartz h_g, + (4-15) Prandtl estimate, (4-16) gas viscosity. + +Note the nomenclature under eq. (4-12): "mu = Viscosity, lb/in sec". That unit +string is what fixes the conversion factor used by eq. (4-16). + +Tests marked ``xfail(strict=True)`` describe defects that are known-open and +scheduled. Strict mode means that when the underlying fix lands, the XPASS is +reported as a failure -- that is the intended signal to delete the marker. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from engine.pipeline.thermal.regen_cooling import ( + calculate_gas_viscosity_huzel, + estimate_hot_wall_heat_flux, +) + + +# A representative LOX/CH4 operating point: ~20 bar chamber, 100 mm bore. +# Values are order-of-magnitude realistic; nothing here depends on them being +# a specific engine. +TC_K = 3400.0 +PC_PA = 2.0e6 +GAMMA = 1.14 +R_GAS = 378.0 +M_MOL = 22.0 +D_CHAMBER_M = 0.10 +L_CHAMBER_M = 0.25 +MDOT_KG_S = 2.0 +T_WALL_K = 1200.0 + + +def _gas_props(): + return { + "Tc": TC_K, + "Pc": PC_PA, + "gamma": GAMMA, + "R": R_GAS, + "M": M_MOL, + "chamber_length": L_CHAMBER_M, + "chamber_area": np.pi * (D_CHAMBER_M / 2.0) ** 2, + } + + +def _hot_wall(): + # config=None is the path taken by time_varying_solver.py, which passes no + # regen config. + return estimate_hot_wall_heat_flux( + _gas_props(), + None, + wall_temperature=T_WALL_K, + mdot_total=MDOT_KG_S, + ) + + +class TestGasViscosity: + """Huzel eq. (4-16). The anchor test -- everything downstream depends on it.""" + + def test_magnitude_is_gas_like(self): + """Combustion gas at 3400 K is ~1e-4 Pa.s, not ~1e-2 (that is light oil). + + Applying the psi->Pa factor (6894.76) instead of the lb/(in.s)->Pa.s + factor (17.858) inflates this by exactly g_c = 386.088 in/s^2. + """ + mu = calculate_gas_viscosity_huzel(TC_K, M_MOL) + assert 5.0e-5 < mu < 1.2e-4, ( + f"gas viscosity {mu:.3e} Pa.s is not physically possible for a " + f"combustion gas at {TC_K} K (expected ~7e-5). A value near 2.8e-2 " + f"means the lb/(in.s) -> Pa.s conversion used the psi -> Pa factor." + ) + + def test_scales_with_temperature_and_mass(self): + """Sanity on the functional form: mu ~ M^0.5 T^0.6, both increasing.""" + assert calculate_gas_viscosity_huzel(3400.0, 22.0) > calculate_gas_viscosity_huzel(2000.0, 22.0) + assert calculate_gas_viscosity_huzel(3400.0, 30.0) > calculate_gas_viscosity_huzel(3400.0, 22.0) + + +class TestGasSideCoefficient: + """The convective film coefficient and the flow regime that sets it.""" + + def test_flow_is_turbulent(self): + """A rocket chamber is never laminar. + + This is the assertion that would have caught the viscosity bug directly: + an inflated mu drove Re below the 2000 threshold in + estimate_hot_wall_heat_flux, silently selecting the laminar Nu = 4.36 + branch and collapsing h_g by ~200x. + """ + mu = calculate_gas_viscosity_huzel(TC_K, M_MOL) + rho = PC_PA / (R_GAS * TC_K) + area = np.pi * (D_CHAMBER_M / 2.0) ** 2 + velocity = MDOT_KG_S / (rho * area) + reynolds = rho * velocity * D_CHAMBER_M / mu + + assert reynolds > 1.0e4, ( + f"chamber Reynolds number {reynolds:.3e} implies laminar or " + f"transitional flow, which no liquid rocket chamber exhibits" + ) + + def test_h_gas_magnitude(self): + """h_g for a chamber of this class is O(10^3) W/(m^2.K). + + The lower bound is set well below the physically expected 2000-5000 on + purpose: the default hot-gas thermal conductivity is itself suspect + (0.1 W/m.K, vs ~0.3-0.5 for combustion products at 3400 K), which drags + the computed value down. Tighten this bound once that is addressed. + """ + h_g = _hot_wall()["h_hot"] + assert 300.0 < h_g < 2.0e4, ( + f"h_hot = {h_g:.1f} W/(m^2.K) is outside any plausible range for a " + f"rocket chamber; single digits indicate the laminar Nu branch" + ) + + +class TestHeatFlux: + """Total wall heat flux and its convective/radiative split.""" + + def test_total_flux_magnitude(self): + """1-10 MW/m^2 is the accepted band for a chamber at this pressure.""" + q_total = _hot_wall()["heat_flux_total"] + assert 0.5e6 < q_total < 1.5e7, ( + f"chamber heat flux {q_total/1e6:.2f} MW/m^2 is outside the " + f"plausible 0.5-15 MW/m^2 band" + ) + + @pytest.mark.xfail( + strict=True, + reason=( + "Gas emissivity is hardcoded to the wall's surface emissivity " + "(0.8-0.85). That value is correct for charred phenolic but wrong " + "for the gas: LOX/CH4 products are non-luminous CO2/H2O band " + "radiators, not a grey body, so radiation is overstated by roughly " + "an order of magnitude. Needs a separate gas_emissivity, ideally " + "from mean beam length + Hottel/WSGG. Delete this marker when fixed." + ), + ) + def test_convection_dominates_over_radiation(self): + """Convection must carry the bulk of the load. + + A near-total collapse of the convective term is invisible in the + headline number, because the total is the only value surfaced to the + UI -- so this asserts the *split*, not just the sum. Huzel/Sutton + design guidance puts radiation at ~5% of the total for non-luminous + propellants, and up to ~40% only for metallised or heavily sooting ones. + """ + result = _hot_wall() + q_conv = result["heat_flux_conv"] + q_total = result["heat_flux_total"] + assert q_conv / q_total > 0.5, ( + f"convection is only {100*q_conv/q_total:.1f}% of the total heat " + f"flux; for a rocket chamber it should dominate" + ) + + +class TestNativeParity: + """The C port reimplements the same viscosity correlation.""" + + def test_python_and_c_viscosity_agree(self): + ed_native = pytest.importorskip( + "engine.native.python.ed_native", + reason="native extension not built", + ) + fn = getattr(ed_native, "ed_gas_viscosity_huzel", None) + if fn is None: + pytest.skip("native build does not expose ed_gas_viscosity_huzel") + + py_mu = calculate_gas_viscosity_huzel(TC_K, M_MOL) + c_mu = fn(TC_K, M_MOL) + assert py_mu == pytest.approx(c_mu, rel=1e-9), ( + "Python and C viscosity disagree -- the shared conversion constant " + "is defined in two places (constants.py and ed_phys_const.h) and " + "they have drifted" + ) diff --git a/EngineDesign/tests/test_native_ab_parity.py b/EngineDesign/tests/test_native_ab_parity.py index ebb0c4fe..ea858858 100644 --- a/EngineDesign/tests/test_native_ab_parity.py +++ b/EngineDesign/tests/test_native_ab_parity.py @@ -128,12 +128,64 @@ def rig(native_injector_mod): "F", "Isp", "Pc", "MR", "mdot_total", "mdot_O", "mdot_F", "Cf_actual", "cstar_actual", "cstar_ideal", "eta_cstar", "P_exit", "T_exit", "v_exit", + # Tc here is the cooling-REDUCED effective chamber temperature (Tc_ideal + # minus the ablative temperature reduction), so it is the wrapper-visible + # end of the thermal chain. Anything that moves gas-side heat transfer + # moves this. + "Tc", ] WRAPPER_DIAG_FIELDS = [ "D32_O", "D32_F", "Cd_O", "Cd_F", "momentum_ratio_R", "delta_p_feed_O", "delta_p_feed_F", ] +# --- EdEvaluateResult -> live-Python mapping ------------------------------- +# +# Every double on the struct is resolved against the live Python result. The +# default rule is "same name, top level first, then diagnostics"; the map below +# only lists fields whose Python name differs. TestKernelStructParity's coverage +# test asserts that every struct field is either resolvable or explicitly +# excluded, so adding a field to ed_state.h without a parity story fails here. +# +# The Tc pair is the trap: the struct's `Tc` is the IDEAL flame temperature, +# while Python's top-level `Tc` is the cooling-REDUCED one (the struct calls +# that `Tc_effective`). Mapping them by name would silently compare the wrong +# pair and mask a real cooling divergence. +# A value may be a dotted path, or a callable(ref) for derived quantities. +STRUCT_FIELD_OVERRIDES = { + "Tc": "diagnostics.Tc_ideal", + "Tc_effective": "Tc", + "delta_P_injector_O": "diagnostics.delta_p_injector_O", + "delta_P_injector_F": "diagnostics.delta_p_injector_F", + "cooling_efficiency": "diagnostics.cooling_efficiency", + # Derived, not a Python key: ed_chamber.c:98 stores the coarser of the two + # stream SMDs (`d->SMD = ed_max(inj.D32_O, inj.D32_F)`), while Python only + # reports D32_O / D32_F separately. Reproduce the C definition here rather + # than pinning to whichever stream happens to be larger in one config. + "SMD": lambda ref: max((ref.get("diagnostics") or {})["D32_O"], + (ref.get("diagnostics") or {})["D32_F"]), +} + +# Struct fields with no live-Python counterpart, with the reason. +STRUCT_FIELDS_UNMAPPED = { + "converged": "C-side solver status flag; asserted separately in struct_results", +} + + +def _resolve_ref(ref: dict, struct_name: str): + """Live-Python value for a struct field, or None if absent.""" + path = STRUCT_FIELD_OVERRIDES.get(struct_name, struct_name) + if callable(path): + try: + return path(ref) + except (KeyError, TypeError): + return None + if path.startswith("diagnostics."): + return (ref.get("diagnostics") or {}).get(path.split(".", 1)[1]) + if ref.get(path) is not None: + return ref[path] + return (ref.get("diagnostics") or {}).get(path) + class TestWrapperParity: """native_injector.evaluate() must match runner.evaluate() live.""" @@ -237,6 +289,92 @@ def test_struct_thrust_matches_python(self, rig, struct_results): _assert_close(f"{label}.Isp", res.Isp, ref["Isp"]) _assert_close(f"{label}.Cf_actual", res.Cf_actual, ref["Cf_actual"]) + def test_struct_thermal_matches_python(self, rig, struct_results): + """The C kernel's gas-side thermal chain must match live Python. + + Previously NOTHING here compared a thermal quantity: the wrapper and + struct field lists covered flow, thrust and injector terms only. The + only C-vs-Python check on the cooling path was + tests/golden/residual_samples.json via test_residual_golden.c — a frozen + snapshot, which by construction cannot see a Python-side change until a + human regenerates it. + + These two fields are the end of that chain: + * cooling_efficiency -- ablative heat removal as a fraction, the + output of ed_cooling_evaluate / compute_ablative_response; + * Tc_effective -- Tc_ideal reduced by that heat removal, which + then propagates into c* and thrust. + + Both depend on the gas-side film coefficient, so a divergence in + viscosity, conductivity, Prandtl, emissivity or the Nusselt branch + between the two implementations surfaces here immediately, with no + regeneration step. + + Requires a config with ablative cooling enabled (canonical/impinging.yaml + has it on); with all cooling disabled these collapse to 1.0 / Tc_ideal + and the check becomes vacuous rather than wrong. + """ + assert rig["config"].ablative_cooling.enabled, ( + "canonical impinging config no longer enables ablative cooling — " + "this test would be vacuous; point it at a config that does" + ) + for p_o, p_f in rig["points"]: + ref = rig["reference"][(p_o, p_f)] + res = struct_results[(p_o, p_f)] + label = f"({p_o / PSI_TO_PA:.0f},{p_f / PSI_TO_PA:.0f})psi struct" + ref_diag = ref.get("diagnostics") or {} + _assert_close(f"{label}.cooling_efficiency", res.cooling_efficiency, + ref_diag.get("cooling_efficiency")) + _assert_close(f"{label}.Tc_effective", res.Tc_effective, ref["Tc"]) + + def test_struct_all_fields_match_python(self, rig, struct_results): + """Every EdEvaluateResult double vs its live-Python counterpart. + + The focused tests above localize the common failure modes; this one is + the backstop that leaves no field unchecked. It is what makes the + component golden-vector suites (nozzle/injector) redundant: the fields + they pin against a frozen snapshot -- throat/exit state, discharge + coefficients, jet areas, SMD, momentum ratio -- are all compared here + against Python re-derived at test time, so a change on either side is + caught with no regeneration step. + """ + from engine.native.python.ed_native import EdEvaluateResult + + checked = 0 + for p_o, p_f in rig["points"]: + ref = rig["reference"][(p_o, p_f)] + res = struct_results[(p_o, p_f)] + label = f"({p_o / PSI_TO_PA:.0f},{p_f / PSI_TO_PA:.0f})psi struct" + for name in EdEvaluateResult._doubles: + if name in STRUCT_FIELDS_UNMAPPED: + continue + want = _resolve_ref(ref, name) + assert want is not None, ( + f"{label}.{name}: no live-Python counterpart resolved. Add a " + f"STRUCT_FIELD_OVERRIDES entry or justify it in " + f"STRUCT_FIELDS_UNMAPPED." + ) + _assert_close(f"{label}.{name}", getattr(res, name), want) + checked += 1 + assert checked > 0, "no struct fields were compared — fixture produced no points" + + def test_struct_field_coverage_is_complete(self): + """No EdEvaluateResult field may be silently uncompared. + + Without this, adding a field to ed_state.h + the ctypes mirror would + create untested C surface that looks covered. + """ + from engine.native.python.ed_native import EdEvaluateResult + + all_fields = set(EdEvaluateResult._doubles) | {"converged"} + accounted = set(EdEvaluateResult._doubles) | set(STRUCT_FIELDS_UNMAPPED) + missing = all_fields - accounted + assert not missing, ( + f"EdEvaluateResult fields with no parity story: {sorted(missing)}. " + f"Either they have a live-Python counterpart (add to the comparison) " + f"or they do not (add to STRUCT_FIELDS_UNMAPPED with a reason)." + ) + # --------------------------------------------------------------------------- # Physics invariant — the ceiling check that would have caught the thrust bug From f4d1f581290bad4d5ad82daf21636a68794e22ea Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 01:52:22 -0700 Subject: [PATCH 05/35] nozzle golden tests: drop retired momentum-method thrust from exit-state kernel --- .../engine/native/include/ed_nozzle.h | 25 ++++++++----------- EngineDesign/engine/native/src/ed_nozzle.c | 20 +++------------ .../engine/native/tests/test_nozzle_golden.c | 10 ++------ .../native/tools/export_nozzle_golden.py | 12 ++++----- 4 files changed, 21 insertions(+), 46 deletions(-) diff --git a/EngineDesign/engine/native/include/ed_nozzle.h b/EngineDesign/engine/native/include/ed_nozzle.h index c64cfcc7..b9befc57 100644 --- a/EngineDesign/engine/native/include/ed_nozzle.h +++ b/EngineDesign/engine/native/include/ed_nozzle.h @@ -4,14 +4,9 @@ * mach_solver.py + frozen exit state from chamber gamma/R). Python computes the * same frozen exit state — both sides report it as display-only. * - * NOTE (2026-07): the F/Isp/Cf fields in EdNozzleResult are the RETIRED - * momentum-method reconstruction and are NOT consumed anywhere — delivered - * thrust is computed in ed_evaluate.c as zeta_n*Cf_vac*Pc*At - Pa*Ae (RPA - * basis, matching nozzle.py; see docs/thrust_efficiency_bug_analysis.md). - * ed_evaluate reads only the exit/throat state from this kernel. The legacy - * fields remain so the golden vectors (nozzle_golden.json) still pin the - * arithmetic; drop them together with a golden re-export if EdNozzleResult - * ever changes shape. + * Scope: exit/throat state only. Delivered thrust is computed in ed_evaluate.c + * on the RPA basis (zeta_n*Cf_vac*Pc*At - Pa*Ae, matching nozzle.py); this + * kernel does not compute thrust. */ #ifndef ED_NOZZLE_H #define ED_NOZZLE_H @@ -31,7 +26,10 @@ typedef struct EdNozzleInputs { double A_throat; /* [m^2] */ double A_exit; /* [m^2] */ double eps; /* expansion ratio A_exit/A_throat (>1) */ - double Pa; /* ambient pressure [Pa] */ + double Pa; /* ambient pressure [Pa]; unused by the exit-state + * solve (it only fed the retired momentum-method + * thrust) — retained so callers keep a single + * nozzle-input struct. */ double nozzle_efficiency; /* scales Cf_theoretical only (frozen F is independent) */ double Cf_ideal; /* CEA ideal thrust coefficient */ double gamma; /* chamber gamma (>1) */ @@ -39,12 +37,10 @@ typedef struct EdNozzleInputs { double Tc; /* chamber temperature [K] */ } EdNozzleInputs; -/* Flat result. Mirrors the nozzle keys runner.evaluate() forwards to Layer 1. */ +/* Flat result: the frozen exit/throat state ed_evaluate.c consumes, plus the + * echoed/theoretical Cf. Not a mirror of runner.evaluate()'s nozzle keys — + * thrust and Isp are ed_evaluate.c's job. */ typedef struct EdNozzleResult { - double F; /* total thrust [N] = momentum + pressure */ - double F_momentum; /* [N] */ - double F_pressure; /* [N] */ - double Cf_actual; /* F / (Pc * A_throat) */ double Cf_ideal; /* echoed from input */ double Cf_theoretical; /* nozzle_efficiency * Cf_ideal */ double P_exit; /* [Pa] */ @@ -53,7 +49,6 @@ typedef struct EdNozzleResult { double M_exit; /* exit Mach (>1) */ double P_throat; /* [Pa] */ double T_throat; /* [K] */ - double Isp; /* [s] */ int converged; } EdNozzleResult; diff --git a/EngineDesign/engine/native/src/ed_nozzle.c b/EngineDesign/engine/native/src/ed_nozzle.c index f313d39e..f38034aa 100644 --- a/EngineDesign/engine/native/src/ed_nozzle.c +++ b/EngineDesign/engine/native/src/ed_nozzle.c @@ -3,10 +3,9 @@ * (estimate_initial_mach / solve_mach_from_area_ratio, supersonic branch) with * the same Newton tolerance (1e-10) => golden agreement near machine precision. * - * The momentum/pressure thrust computed at the bottom is the RETIRED pre-RPA - * reconstruction, kept only to satisfy the historical golden vectors — nothing - * consumes it. Delivered thrust lives in ed_evaluate.c (RPA Cf_vac basis). - * See ed_nozzle.h for the full scope note. + * Exit/throat state only. Delivered thrust lives in ed_evaluate.c (RPA Cf_vac + * basis); the retired momentum-method reconstruction that used to be computed + * here was removed once nothing consumed it. */ #include "ed_nozzle.h" @@ -82,7 +81,6 @@ ed_status_t ed_nozzle_solve(const EdNozzleInputs *in, EdNozzleResult *out) { const double R = in->R; const double Tc = in->Tc; const double eps = in->eps; - const double Pa = in->Pa; /* Input guards mirror the ValueError conditions in calculate_thrust. */ if (!(in->A_throat > 0.0) || !(in->A_exit > 0.0) || !(eps > 1.0) || @@ -107,22 +105,11 @@ ed_status_t ed_nozzle_solve(const EdNozzleInputs *in, EdNozzleResult *out) { double v_exit = M_exit * a_exit; if (!ed_isfinite(v_exit) || v_exit <= 0.0) return ED_ERR_NONFINITE; - /* Thrust: momentum + pressure (the primary path; frozen F is independent of - * nozzle_efficiency, which only scales the theoretical Cf cross-check). */ - double F_momentum = mdot * v_exit; - double F_pressure = (P_exit - Pa) * in->A_exit; - double F = F_momentum + F_pressure; - if (!ed_isfinite(F)) return ED_ERR_NONFINITE; - /* Throat (choked, M=1) isentropic conditions. */ double throat_temp_ratio = 2.0 / (gamma + 1.0); double T_throat = Tc * throat_temp_ratio; double P_throat = Pc * pow(throat_temp_ratio, gamma / (gamma - 1.0)); - out->F = F; - out->F_momentum = F_momentum; - out->F_pressure = F_pressure; - out->Cf_actual = F / (Pc * in->A_throat); out->Cf_ideal = in->Cf_ideal; out->Cf_theoretical = in->nozzle_efficiency * in->Cf_ideal; out->P_exit = P_exit; @@ -131,7 +118,6 @@ ed_status_t ed_nozzle_solve(const EdNozzleInputs *in, EdNozzleResult *out) { out->M_exit = M_exit; out->P_throat = P_throat; out->T_throat = T_throat; - out->Isp = F / (mdot * ED_G0); out->converged = 1; return ED_OK; } diff --git a/EngineDesign/engine/native/tests/test_nozzle_golden.c b/EngineDesign/engine/native/tests/test_nozzle_golden.c index 08f30aef..e505a9d6 100644 --- a/EngineDesign/engine/native/tests/test_nozzle_golden.c +++ b/EngineDesign/engine/native/tests/test_nozzle_golden.c @@ -37,7 +37,7 @@ int main(void) { const char *p = js, *end; while ((p = edt_next_object(p, &end)) != NULL) { EdNozzleInputs in; - double F, Isp, Cf_actual, P_exit, T_exit, v_exit, M_exit, P_throat, T_throat; + double P_exit, T_exit, v_exit, M_exit, P_throat, T_throat; int ok = edt_find_double(p, end, "Pc", &in.Pc) && edt_find_double(p, end, "mdot_total", &in.mdot_total) && @@ -50,15 +50,12 @@ int main(void) { edt_find_double(p, end, "gamma", &in.gamma) && edt_find_double(p, end, "R", &in.R) && edt_find_double(p, end, "Tc", &in.Tc) && - edt_find_double(p, end, "F", &F) && - edt_find_double(p, end, "Cf_actual", &Cf_actual) && edt_find_double(p, end, "P_exit", &P_exit) && edt_find_double(p, end, "T_exit", &T_exit) && edt_find_double(p, end, "v_exit", &v_exit) && edt_find_double(p, end, "M_exit", &M_exit) && edt_find_double(p, end, "P_throat", &P_throat) && - edt_find_double(p, end, "T_throat", &T_throat) && - edt_find_double(p, end, "Isp", &Isp); + edt_find_double(p, end, "T_throat", &T_throat); if (ok) { EdNozzleResult r; if (ed_nozzle_solve(&in, &r) != ED_OK) { @@ -71,9 +68,6 @@ int main(void) { check("v_exit", r.v_exit, v_exit, n); check("P_throat", r.P_throat, P_throat, n); check("T_throat", r.T_throat, T_throat, n); - check("F", r.F, F, n); - check("Cf_actual", r.Cf_actual, Cf_actual, n); - check("Isp", r.Isp, Isp, n); } n++; } diff --git a/EngineDesign/engine/native/tools/export_nozzle_golden.py b/EngineDesign/engine/native/tools/export_nozzle_golden.py index 69d2e802..d3e21284 100644 --- a/EngineDesign/engine/native/tools/export_nozzle_golden.py +++ b/EngineDesign/engine/native/tools/export_nozzle_golden.py @@ -75,19 +75,19 @@ def main() -> None: "gamma": float(cea["gamma"]), "R": float(cea["R"]), "Tc": float(cea["Tc"]), - # --- expected frozen outputs --- - "F": float(res["F"]), - "Cf_actual": float(res["Cf_actual"]), + # --- expected frozen outputs (exit/throat state only) --- + # No F/Isp/Cf_actual: EdNozzleResult no longer computes thrust + # (delivered thrust is ed_evaluate.c on the RPA basis), so there + # is nothing on the C side for those keys to pin. "P_exit": float(res["P_exit"]), "T_exit": float(res["T_exit"]), "v_exit": float(res["v_exit"]), "M_exit": float(res["M_exit"]), "P_throat": float(res["P_throat"]), "T_throat": float(res["T_throat"]), - "Isp": float(res["Isp"]), }) - print(f"{label:>10} Pc={Pc/1e5:6.2f} bar F={res['F']:8.1f} N " - f"Isp={res['Isp']:6.2f} s M_exit={res['M_exit']:.4f}") + print(f"{label:>10} Pc={Pc/1e5:6.2f} bar M_exit={res['M_exit']:.4f} " + f"P_exit={res['P_exit']/1e3:8.2f} kPa T_exit={res['T_exit']:7.1f} K") OUT.parent.mkdir(parents=True, exist_ok=True) OUT.write_text(json.dumps(samples, indent=1)) From 607f8822a0ee52ba60a51527cc78b1c106a1460e Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 01:55:23 -0700 Subject: [PATCH 06/35] stability (layer1/2): fail-safe chug fallback, kill dead evil scoring, add gate tests --- .../heuristic_chug_scoring.py | 56 ++++++++ EngineDesign/engine/core/runner.py | 7 +- .../engine/pipeline/stability/analysis.py | 132 +++++++++++------- .../tests/test_chug_gate_guardrail.py | 97 +++++++++++++ .../tests/test_layer2_of_pointwise.py | 12 ++ .../tests/test_layer2_pc_constraint.py | 16 +++ 6 files changed, 270 insertions(+), 50 deletions(-) create mode 100644 EngineDesign/archive/dead_code_20260721/heuristic_chug_scoring.py create mode 100644 EngineDesign/tests/test_chug_gate_guardrail.py diff --git a/EngineDesign/archive/dead_code_20260721/heuristic_chug_scoring.py b/EngineDesign/archive/dead_code_20260721/heuristic_chug_scoring.py new file mode 100644 index 00000000..9bde2922 --- /dev/null +++ b/EngineDesign/archive/dead_code_20260721/heuristic_chug_scoring.py @@ -0,0 +1,56 @@ +"""ARCHIVED 2026-07-21 — heuristic chug "stability index/margin" removed from +``engine/pipeline/stability/analysis.py::calculate_chugging_frequency``. + +Why it was removed +------------------ +1. It measured the WRONG variables. The index was a product of a chamber-pressure factor, an + L* factor and a frequency-band factor. It never looked at **injector stiffness + (dP_inj / Pc)**, feed inertance/resistance, or the combustion time lag — i.e. none of the + physics that actually drives chug. It therefore could not distinguish a stable design from + an unstable one, and was structurally blind to the blowdown failure mode (stiffness + degrading as tank pressure decays). + +2. It was NON-BINDING. With ``stability_index`` floored at 0.4 and the mapping + ``margin = index * 1.5 + 0.4``, the minimum achievable margin was exactly 1.0, and a typical + design landed ~1.42 — comfortably above every configured threshold. It could not fail a real + design. The floors and the "more generous mapping" were added incrementally to stop it + rejecting good designs (see the commit comments: "More lenient factors...", "Minimum 0.4 for + any reasonable design", "More generous mapping...") rather than fixing the model. + +3. It was already DEAD. ``comprehensive_stability_analysis`` overwrote + ``chugging["stability_margin"]`` with the physical ``chug_gate_margin`` from + ``compute_physical_stability``, so this value was computed and then discarded on every + evaluation. It survived only as the silent neutral-pass fallback, which is now fail-safe. + +Replacement: the rigorous lumped model in ``engine/pipeline/stability/chug.py`` (impedance +characteristic equation -> Nyquist gain margin / growth rate), reached via +``compute_physical_stability``. + +Kept for reference only. Not imported anywhere. +""" + + +def _archived_chug_stability_index(Pc: float, Lstar: float, freq: float) -> "tuple[float, float]": + """The removed heuristic. Returns (stability_index, stability_margin).""" + Pc_ref = 1.0e6 + Lstar_ref = 1.0 + Pc_factor = min(1.0, (Pc / Pc_ref) ** 0.3) if Pc > 0 else 0.0 + Lstar_factor = min(1.0, (Lstar / Lstar_ref) ** 0.2) if Lstar > 0 else 0.0 + Pc_factor = max(Pc_factor, 0.6) if Pc > 0.3e6 else Pc_factor + Lstar_factor = max(Lstar_factor, 0.7) if Lstar > 0.6 else Lstar_factor + + if freq < 5.0: + f_factor = 0.6 + elif freq < 10.0: + f_factor = 0.75 + elif freq > 600.0: + f_factor = 0.9 + elif freq > 400.0: + f_factor = 0.95 + else: + f_factor = 1.0 + + stability_index = Pc_factor * Lstar_factor * f_factor + stability_index = max(stability_index, 0.4) + stability_margin = stability_index * 1.5 + 0.4 + return float(stability_index), float(stability_margin) diff --git a/EngineDesign/engine/core/runner.py b/EngineDesign/engine/core/runner.py index 6eddb34c..e6d4b54b 100644 --- a/EngineDesign/engine/core/runner.py +++ b/EngineDesign/engine/core/runner.py @@ -467,7 +467,7 @@ def log_info(msg): "stability_state": "unstable", "stability_score": 0.0, "is_stable": False, - "chugging": {"frequency": 0.0, "stability_margin": 0.0, "stability_index": 0.0, "period": 0.0, "tau_residence": 0.0, "Lstar": 0.0}, + "chugging": {"frequency": 0.0, "stability_margin": 0.0, "period": 0.0, "tau_residence": 0.0, "Lstar": 0.0}, "acoustic": {"stability_margin": 0.0, "modes": {}, "longitudinal_modes": [], "transverse_modes": [], "sound_speed": 0.0}, "feed_system": {"pogo_frequency": 0.0, "surge_frequency": 0.0, "water_hammer_margin": 0.0, "stability_margin": 0.0, "sound_speed": 0.0}, "mode_coupling": [], @@ -817,6 +817,10 @@ def evaluate_arrays_with_time( "diagnostics": [], # Stability arrays (comprehensive analysis for all 3 types) "chugging_stability_margin": np.full(n, np.nan), + # Raw Nyquist gain margin (GM > 1 == stable). Unlike ``chugging_stability_margin`` + # — which is a tanh-squashed gate value that saturates ~1.3 — this is the physical, + # unbounded number. NaN here means the physical stability model fell back. + "chug_gain_margin": np.full(n, np.nan), "stability_score": np.full(n, np.nan), "stability_state": np.full(n, "unstable", dtype=object), } @@ -952,6 +956,7 @@ def evaluate_arrays_with_time( # Extract stability metrics results["chugging_stability_margin"][i] = stability_results.get("chugging", {}).get("stability_margin", np.nan) + results["chug_gain_margin"][i] = stability_results.get("chugging", {}).get("chug_gain_margin", np.nan) results["stability_score"][i] = stability_results.get("stability_score", np.nan) results["stability_state"][i] = stability_results.get("stability_state", "unstable") except Exception as e: diff --git a/EngineDesign/engine/pipeline/stability/analysis.py b/EngineDesign/engine/pipeline/stability/analysis.py index 34f5a180..e91fda89 100644 --- a/EngineDesign/engine/pipeline/stability/analysis.py +++ b/EngineDesign/engine/pipeline/stability/analysis.py @@ -66,8 +66,9 @@ def calculate_chugging_frequency( - frequency_residence: frequency from 1 / (2 pi tau_res) [Hz] - frequency_helmholtz: Helmholtz estimate if possible [Hz or np.nan] - period: oscillation period [s] - - stability_index: heuristic index (higher is better) - - stability_margin: backward compatibility field (maps from stability_index) + (No stability score is returned: the heuristic index/margin was removed 2026-07-21 — + see archive/dead_code_20260721/heuristic_chug_scoring.py. The chug margin comes from + compute_physical_stability.) - tau_residence: residence time L* / c* [s] - Lstar: characteristic length [m] """ @@ -108,52 +109,20 @@ def calculate_chugging_frequency( period = 1.0 / freq if freq > 0.0 else float("inf") - # Simple stability index: - # - Better if Pc is higher - # - Better if L* is reasonably large (say ≥ 0.8 m) - # - Penalize if chugging frequency is very low (hard to damp) or in a problematic band - # FIXED: More lenient factors to allow reasonable designs to achieve stable margins - Pc_ref = 1.0e6 - Lstar_ref = 1.0 - # More lenient Pc factor - even 0.5 MPa can be acceptable - Pc_factor = min(1.0, (Pc / Pc_ref) ** 0.3) if Pc > 0 else 0.0 - # More lenient Lstar factor - even 0.6 m can be acceptable - Lstar_factor = min(1.0, (Lstar / Lstar_ref) ** 0.2) if Lstar > 0 else 0.0 - # Ensure minimum factors for reasonable designs - Pc_factor = max(Pc_factor, 0.6) if Pc > 0.3e6 else Pc_factor # At least 0.6 for Pc > 0.3 MPa - Lstar_factor = max(Lstar_factor, 0.7) if Lstar > 0.6 else Lstar_factor # At least 0.7 for L* > 0.6 m - - # Frequency health factor: prefer 20 to 400 Hz for chugging - # Much more lenient penalties to allow optimizer to find feasible solutions - if freq < 5.0: - f_factor = 0.6 # Very low frequencies - still penalized but not as harsh - elif freq < 10.0: - f_factor = 0.75 # Low frequencies - moderate penalty - elif freq > 600.0: - f_factor = 0.9 # High frequencies - minimal penalty - elif freq > 400.0: - f_factor = 0.95 # Moderate-high frequencies - very small penalty - else: - f_factor = 1.0 # Ideal range - - stability_index = Pc_factor * Lstar_factor * f_factor - # Ensure minimum index for reasonable designs - stability_index = max(stability_index, 0.4) # Minimum 0.4 for any reasonable design - - # Backward compatibility: map stability_index to stability_margin - # FIXED: More generous mapping to ensure reasonable designs can meet requirements - # For a reasonable design (index ~ 0.6-0.8), we want margin ~ 1.2-1.5 - # New mapping: margin = stability_index * 1.5 + 0.4 (gives 1.3 for index=0.6, 1.6 for index=0.8, 1.9 for index=1.0) - # This ensures reasonable designs can achieve required margins - stability_margin = stability_index * 1.5 + 0.4 # More generous mapping + # NOTE (2026-07-21): the heuristic "stability index / margin" that used to be computed here has + # been REMOVED. It scored Pc x L* x frequency-band only — it never looked at injector stiffness + # or the feed/combustion coupling that actually drives chug — it was floored so it could not + # fail a real design, and ``comprehensive_stability_analysis`` overwrote its margin with the + # physical ``chug_gate_margin`` on every evaluation anyway. The archived implementation and the + # full rationale live in ``archive/dead_code_20260721/heuristic_chug_scoring.py``. + # This function now reports chug FREQUENCY only (still used for mode-coupling checks and as a + # seed when the physical model yields no valid frequency). return { "frequency": float(freq), "frequency_residence": freq_res, "frequency_helmholtz": freq_helm, "period": float(period), - "stability_index": float(stability_index), - "stability_margin": float(stability_margin), # Backward compatibility "tau_residence": float(tau_residence), "Lstar": float(Lstar), } @@ -329,6 +298,40 @@ def analyze_feed_system_stability( _CHUG_GATE_CENTER = 0.80 # chug gain margin -> "neutral" (gate margin 1.0) _CHUG_GATE_SCALE = 0.20 _GATE_SPAN = 0.30 # gate margin ranges ~[0.7, 1.3] +# IMPORTANT — the gate margin is a SQUASHED transform, not a gain margin. Do not read a gate +# threshold as "N% margin". Inverting ``_chug_gate_margin`` (raw Nyquist gain margin GM -> gate): +# GM 0.83 -> gate 1.05 (canonical ``min_stability_margin``) +# GM 0.96 -> gate 1.20 (Layer-1 default ``min_stability_margin``) +# GM 1.00 -> gate 1.229 <-- the TRUE stability boundary (GM > 1 == stable) +# GM 1.20 -> gate 1.289 (a genuine 20% gain margin) +# GM >1.3 -> gate ~1.2999 (saturated; cannot distinguish good from great) +# Because the centre sits at GM = 0.80, every threshold currently in use (0.7 / 1.05 / 1.2) admits +# designs that are NOT Nyquist-stable. That is deliberate-but-interim (see the note above); reason +# about the raw ``chug_gain_margin`` instead, and re-centre only once T5/T6/T7/H3 calibrate the +# feed/regulator inputs. Tightening onto the un-measured absolute GM would be confidently wrong. + +# Fail-safe margin used when the physical stability model errors: unknown stability is treated as +# UNSAFE (fails every configured gate) rather than silently passing. Pairs with +# ``stability_source == "fallback_failsafe"`` so the cause is visible in the result. +_STABILITY_FAILSAFE_MARGIN = 0.0 + +_stability_fallback_warned = False + + +def _warn_stability_fallback_once(exc: Exception) -> None: + """Warn on the first physical-stability failure per process (avoids per-eval log spam).""" + global _stability_fallback_warned + if _stability_fallback_warned: + return + _stability_fallback_warned = True + import warnings + warnings.warn( + f"compute_physical_stability failed ({exc!r}); stability margins fail-safe to " + f"{_STABILITY_FAILSAFE_MARGIN} and the design is reported UNSTABLE. " + "Check stability_source == 'fallback_failsafe' in the results.", + RuntimeWarning, + stacklevel=2, + ) _ACOUSTIC_GATE_OFFSET = 350.0 # [1/s] alpha offset so marginal acoustic still passes _ACOUSTIC_GATE_SCALE = 1000.0 # [1/s] # LOX property fallbacks (default.yaml leaves LOX latent_heat / boiling_point null). @@ -485,31 +488,44 @@ def compute_physical_stability(config, Pc: float, MR: float, mdot_total: float, # enabled; fall back to Python on any issue. from engine.native.python import native_injector _native = native_injector.native_enabled() + # ``*_source`` breadcrumbs record which implementation actually ran. The native->Python + # fallback is silent by design, so without these there is no way to tell from a result + # whether the C kernel or the Python twin produced the margin. chug_fast = None + chug_source = "python" if _native: try: chug_fast = native_injector.chug_margin_fast(inp["streams"], inp["chamber"]) + chug_source = "native" if chug_fast is not None else "python" except Exception: chug_fast = None + chug_source = "python" if chug_fast is None: chug_fast = chug.chug_margin_fast(inp["streams"], inp["chamber"]) + chug_source = "python" ac_fast = None + acoustic_source = "python" if _native: try: ac_fast = native_injector.fast_acoustic(inp["D_ch"], inp["L_ch"], inp["gas"], n=inp["n_interaction"], tau_sens=inp["tau_sens"]) + acoustic_source = "native" if ac_fast is not None else "python" except Exception: ac_fast = None + acoustic_source = "python" if ac_fast is None: ac_fast = acoustic.fast_acoustic(inp["D_ch"], inp["L_ch"], inp["gas"], n=inp["n_interaction"], tau_sens=inp["tau_sens"]) + acoustic_source = "python" return { "chug": chug_fast, "acoustic": ac_fast, "chug_gate_margin": _chug_gate_margin(chug_fast.get("gain_margin", float("nan"))), "acoustic_gate_margin": _acoustic_gate_margin(ac_fast.get("alpha_max", float("nan"))), "f_chug_hz": chug_fast.get("f_chug_hz"), + "chug_source": chug_source, + "acoustic_source": acoustic_source, "tau_conv_O": inp["tau_conv_O"], "tau_conv_F": inp["tau_conv_F"], "tau_sens": inp["tau_sens"], "eta_inj_O": inp["eta_inj_O"], "eta_inj_F": inp["eta_inj_F"], "D_ch": inp["D_ch"], "L_ch": inp["L_ch"], } @@ -704,8 +720,13 @@ def comprehensive_stability_analysis( phys = None try: phys = compute_physical_stability(config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg) - except Exception: # defensive: never fail the eval on a stability-model error + except Exception as exc: # never abort the eval on a stability-model error — fail SAFE below phys = None + _warn_stability_fallback_once(exc) + + # Which implementation actually produced the margins: "native" / "python" (physical model ran) + # or "fallback_failsafe" (physical model threw; margins fail closed below). + stability_source = phys.get("chug_source", "python") if phys is not None else "fallback_failsafe" if phys is not None: chug_margin = float(phys["chug_gate_margin"]) @@ -721,10 +742,12 @@ def comprehensive_stability_analysis( if not phys["acoustic"].get("stable", True): issues.append(f"Acoustic mode {phys['acoustic'].get('limiting_mode')} driven (alpha>0)") else: - # fallback: do NOT regress if extraction fails — neutral-pass margins - chug_margin = float(chugging.get("stability_margin", 1.10)) - acoustic_margin = 1.10 + # FAIL-SAFE. Previously this coasted on neutral ~1.10 margins, so a stability-model + # failure silently PASSED the design. Unknown stability is now treated as unsafe. + chug_margin = _STABILITY_FAILSAFE_MARGIN + acoustic_margin = _STABILITY_FAILSAFE_MARGIN feed_stability["stability_margin"] = chug_margin + chugging["stability_margin"] = chug_margin # Numeric score in [0,1] monotone in the limiting gate margin (1.05 ~ gate threshold). min_margin = min(chug_margin, acoustic_margin) @@ -733,8 +756,9 @@ def comprehensive_stability_analysis( has_severe_mode_coupling = any(p.get("relative_difference", 1.0) < 0.05 for p in mode_coupling) # State from physical margins (growth-rate based, not heuristic). - chug_ok = (phys is None) or phys["chug"].get("stable", True) - ac_ok = (phys is None) or phys["acoustic"].get("stable", True) + # Fail closed: no physical model result means stability is UNKNOWN, not OK. + chug_ok = phys is not None and phys["chug"].get("stable", True) + ac_ok = phys is not None and phys["acoustic"].get("stable", True) if chug_margin >= 1.05 and acoustic_margin >= 1.05 and chug_ok and ac_ok: stability_state = "stable" elif chug_margin >= 0.95 and acoustic_margin >= 0.95: @@ -766,6 +790,7 @@ def comprehensive_stability_analysis( return { "stability_state": stability_state, "stability_score": score, + "stability_source": stability_source, "is_stable": is_stable, # Backward compatibility "chugging": chugging, "acoustic": acoustic, @@ -796,7 +821,16 @@ def _generate_stability_recommendations( recs.append("System appears reasonably stable for this point. Still monitor during hot fire.") # Chugging related - if chugging["stability_index"] < 0.5: + # Physical chug health. (The heuristic ``stability_index`` this used to read was removed + # 2026-07-21 — it ignored injector stiffness, the actual chug driver.) Prefer the raw Nyquist + # gain margin (GM > 1 == stable); fall back to the squashed gate margin (1.229 == GM 1.0). + _gm = chugging.get("chug_gain_margin") + if _gm is not None and np.isfinite(_gm): + _chug_unhealthy = float(_gm) < 1.0 + else: + _chug_unhealthy = float(chugging.get("stability_margin", 1.0)) < 1.229 + if _chug_unhealthy: + recs.append("Low chug margin: stiffen the injector (raise dP_inj/Pc) — the dominant lever.") recs.append("Increase chamber pressure or L* to improve low frequency combustion stability.") recs.append("Consider injector or chamber damping features such as baffles or acoustic liners.") diff --git a/EngineDesign/tests/test_chug_gate_guardrail.py b/EngineDesign/tests/test_chug_gate_guardrail.py new file mode 100644 index 00000000..546d2c21 --- /dev/null +++ b/EngineDesign/tests/test_chug_gate_guardrail.py @@ -0,0 +1,97 @@ +"""Guardrail: the chug gate must come from the PHYSICAL stability model and must respond to +injector stiffness. + +Regression cover for two defects found 2026-07-21: + + 1. A heuristic chug "stability index" (Pc x L* x frequency-band, floored at 0.4 and mapped + ``margin = index*1.5 + 0.4`` so the minimum achievable margin was 1.0) used to supply the + chug margin. It was structurally blind to injector stiffness -- the dominant chug driver -- + and could not fail a real design. + + 2. ``compute_physical_stability`` was wrapped in a bare ``try/except`` that fell back to + neutral ~1.10 margins, so a stability-model failure silently PASSED the design. + +These tests run the real canonical config (no mocks) so they exercise the actual live path. +""" + +import os +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from engine.core.runner import PintleEngineRunner # noqa: E402 +from engine.pipeline.io import load_config # noqa: E402 + +PSI = 6894.757 +CONFIG = ROOT / "configs" / "canonical" / "impinging.yaml" + +# Two operating points on the canonical engine. Lower tank pressure -> lower mdot -> lower +# dP_inj/Pc -> the injector stiffness that drives chug degrades. +P_HIGH_PSI = 500.0 +P_LOW_PSI = 450.0 + + +@pytest.fixture(scope="module") +def stability_by_psi(): + """Evaluate once per operating point and share across tests (each eval is a few seconds).""" + cwd = os.getcwd() + os.chdir(ROOT) + try: + runner = PintleEngineRunner(load_config(str(CONFIG))) + out = {} + for psi in (P_HIGH_PSI, P_LOW_PSI): + res = runner.evaluate(psi * PSI, psi * PSI, silent=True) + out[psi] = res.get("stability") or res.get("stability_results") or {} + return out + finally: + os.chdir(cwd) + + +def test_chug_margin_comes_from_physical_model(stability_by_psi): + """The margin must be physically derived, not the fail-safe fallback.""" + st = stability_by_psi[P_HIGH_PSI] + source = st.get("stability_source") + assert source in ("native", "python"), ( + "The chug margin must be produced by the physical stability model. " + f"stability_source={source!r}. 'fallback_failsafe' means compute_physical_stability " + "threw, so the margin is not physically derived." + ) + + +def test_raw_gain_margin_is_reported(stability_by_psi): + """The raw Nyquist gain margin must be surfaced, not only the squashed gate margin. + + The gate margin is ``1 + 0.30*tanh((GM-0.80)/0.20)``: it saturates at ~1.2999 for any + GM >~ 1.3, so it cannot be reasoned about quantitatively. GM is the physical number. + """ + gm = stability_by_psi[P_HIGH_PSI].get("chugging", {}).get("chug_gain_margin") + assert gm is not None and np.isfinite(gm), ( + "chug_gain_margin (raw Nyquist gain margin, >1 == stable) must be reported alongside " + f"the squashed gate margin. Got {gm!r}." + ) + + +def test_chug_margin_responds_to_injector_stiffness(stability_by_psi): + """Degrading injector stiffness must degrade the chug margin. + + This is precisely the property the removed heuristic lacked. If this assertion fails, the + chug gate has become blind to its dominant driver again -- which is how a blowdown design + could sail through the gate while chugging at end of burn. + """ + gm_high = stability_by_psi[P_HIGH_PSI]["chugging"]["chug_gain_margin"] + gm_low = stability_by_psi[P_LOW_PSI]["chugging"]["chug_gain_margin"] + + assert np.isfinite(gm_high) and np.isfinite(gm_low), ( + f"Both gain margins must be finite. high={gm_high!r} low={gm_low!r}" + ) + assert gm_low < gm_high, ( + "Chug gain margin must DEGRADE as injector stiffness degrades (lower tank pressure -> " + f"lower mdot -> lower dP_inj/Pc). GM({P_HIGH_PSI:.0f} psi)={gm_high:.4f}, " + f"GM({P_LOW_PSI:.0f} psi)={gm_low:.4f}. No response means the gate is blind to the " + "dominant chug driver." + ) diff --git a/EngineDesign/tests/test_layer2_of_pointwise.py b/EngineDesign/tests/test_layer2_of_pointwise.py index edcbde4d..2ff20619 100644 --- a/EngineDesign/tests/test_layer2_of_pointwise.py +++ b/EngineDesign/tests/test_layer2_of_pointwise.py @@ -58,6 +58,12 @@ def test_layer2a_pointwise_mr(): else: print("FAIL: run_layer2a_minimum_pressures accepted spiked MR despite 30% spike.") + assert not success, ( + "run_layer2a_minimum_pressures should REJECT a mixture ratio with a 30% pointwise " + "spike (limit 20%) even though the mean error is only ~1.5%. It returned success=True, " + f"so the pointwise MR check is not binding. summary={summary}" + ) + def test_layer2_objective_weighted_penalty(): print("\n=== Testing Layer 2 Objective Weighted MR Penalty ===") @@ -146,6 +152,12 @@ def capture_obj(it, obj, best): print("PASS: Objective correctly penalizes larger spikes more heavily.") else: print("FAIL: Objective did not penalize larger spike correctly.") + + assert obj_very_spiked > obj_spiked, ( + "The Layer-2 objective must penalise a larger pointwise MR spike more heavily: " + f"40%-spike objective ({obj_very_spiked:.4f}) should exceed the 20%-spike objective " + f"({obj_spiked:.4f}). Equal values mean the max-error weighting is not active." + ) # We can't easily compare to obj_flat without knowing exact base penalties, # but the jump from spiked to very spiked confirms the max-weight logic. diff --git a/EngineDesign/tests/test_layer2_pc_constraint.py b/EngineDesign/tests/test_layer2_pc_constraint.py index 04d30d36..b8a8137a 100644 --- a/EngineDesign/tests/test_layer2_pc_constraint.py +++ b/EngineDesign/tests/test_layer2_pc_constraint.py @@ -113,6 +113,11 @@ def capture_case1(eval_num, obj, best): else: print(f"FAIL: Did not find zero Pc penalty (Objs: {objs_case1}).") + assert found_safe, ( + "Case 1: expected a zero chamber-pressure-stability penalty (Pc_stab=0.00) for a " + f"stable pressure curve, but no such log line was emitted. objs={objs_case1}" + ) + # --- Test Case 2: Extreme Drift in Output (Objective Penalty) --- print("\n--- Test Case 2: Extreme Drift (>25%) ---") mock_logger.reset_mock() @@ -164,6 +169,11 @@ def capture_case2(eval_num, obj, best): else: print(f"FAIL: Did not find high Pc penalty (Objs: {objs_case2}).") + assert found_penalty, ( + "Case 2: expected an elevated Pc-stability penalty for a >25% chamber-pressure " + f"drift, but neither the objective nor the logs showed one. objs={objs_case2}" + ) + # --- Test Case 3: Severe Pressure Drop (Pre-Solver Pruning) --- print("\n--- Test Case 3: Severe Pressure Drop (<75% of Initial) ---") mock_logger.reset_mock() @@ -211,5 +221,11 @@ def capture_case3(eval_num, obj, best): else: print(f"FAIL: Did not find high penalty for low pressure (Objs: {objs_case3}).") + assert found_pruning, ( + "Case 3: expected a high objective penalty when the generated tank pressure is " + "forced below 75% of the initial chamber pressure, but the objective stayed low. " + f"objs={objs_case3}" + ) + if __name__ == "__main__": test_layer2_pc_constraint() From 8361291a71f9c1ac750846b715f241da9c0d9559 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 02:55:49 -0700 Subject: [PATCH 07/35] thermal (layer 3): take cp/k/Pr from CEA --- EngineDesign/configs/canonical/impinging.yaml | 6 +- EngineDesign/configs/canonical/pintle.yaml | 6 +- EngineDesign/configs/default.yaml | 6 +- EngineDesign/configs/impinging_lox_ch4.yaml | 6 +- .../configs/impinging_lox_ch4_8000N.yaml | 6 +- .../impinging_lox_ch4_8000N_optimal.yaml | 6 +- EngineDesign/configs/impinging_smoke.yaml | 6 +- EngineDesign/configs/test.yaml | 6 +- EngineDesign/engine/core/chamber_solver.py | 26 ++--- EngineDesign/engine/pipeline/constants.py | 16 +++- .../pipeline/thermal/ablative_cooling.py | 21 ++-- .../engine/pipeline/thermal/gas_transport.py | 96 +++++++++++++++++++ .../engine/pipeline/thermal/regen_cooling.py | 31 +++--- .../tests/test_gas_side_heat_transfer.py | 51 ++++++++-- 14 files changed, 216 insertions(+), 73 deletions(-) create mode 100644 EngineDesign/engine/pipeline/thermal/gas_transport.py diff --git a/EngineDesign/configs/canonical/impinging.yaml b/EngineDesign/configs/canonical/impinging.yaml index 4142a466..627f90fa 100644 --- a/EngineDesign/configs/canonical/impinging.yaml +++ b/EngineDesign/configs/canonical/impinging.yaml @@ -48,15 +48,15 @@ regen_cooling: wall_thickness: 0.002 wall_thermal_conductivity: 320.0 chamber_inner_diameter: 0.08491 - hot_gas_prandtl: 0.7 + hot_gas_prandtl: 0.644 hot_gas_viscosity: 4.0e-05 - hot_gas_thermal_conductivity: 0.12 + hot_gas_thermal_conductivity: 0.394 radiation_emissivity_hot: 0.85 radiation_view_factor: 1.0 n_segments: 20 gas_turbulence_intensity: 0.1 coolant_turbulence_intensity: 0.05 - hot_gas_cp: 2200.0 + hot_gas_cp: 2463.0 recovery_factor: null film_cooling: enabled: false diff --git a/EngineDesign/configs/canonical/pintle.yaml b/EngineDesign/configs/canonical/pintle.yaml index 9813dead..810c85c9 100644 --- a/EngineDesign/configs/canonical/pintle.yaml +++ b/EngineDesign/configs/canonical/pintle.yaml @@ -418,9 +418,9 @@ regen_cooling: d_outlet: null enabled: false gas_turbulence_intensity: 0.1 - hot_gas_cp: 2200.0 - hot_gas_prandtl: 0.7 - hot_gas_thermal_conductivity: 0.12 + hot_gas_cp: 2201.0 + hot_gas_prandtl: 0.670 + hot_gas_thermal_conductivity: 0.346 hot_gas_viscosity: 4.0e-05 n_channels: 100 n_segments: 20 diff --git a/EngineDesign/configs/default.yaml b/EngineDesign/configs/default.yaml index 9e9cfc94..ec7f7823 100644 --- a/EngineDesign/configs/default.yaml +++ b/EngineDesign/configs/default.yaml @@ -73,15 +73,15 @@ regen_cooling: wall_thickness: 0.002 wall_thermal_conductivity: 320.0 chamber_inner_diameter: 0.08491 - hot_gas_prandtl: 0.7 + hot_gas_prandtl: 0.644 hot_gas_viscosity: 4.0e-05 - hot_gas_thermal_conductivity: 0.12 + hot_gas_thermal_conductivity: 0.394 radiation_emissivity_hot: 0.85 radiation_view_factor: 1.0 n_segments: 20 gas_turbulence_intensity: 0.1 coolant_turbulence_intensity: 0.05 - hot_gas_cp: 2200.0 + hot_gas_cp: 2463.0 recovery_factor: null film_cooling: enabled: false diff --git a/EngineDesign/configs/impinging_lox_ch4.yaml b/EngineDesign/configs/impinging_lox_ch4.yaml index 7fc8bc47..c84475e1 100644 --- a/EngineDesign/configs/impinging_lox_ch4.yaml +++ b/EngineDesign/configs/impinging_lox_ch4.yaml @@ -400,9 +400,9 @@ regen_cooling: d_outlet: null enabled: false gas_turbulence_intensity: 0.1 - hot_gas_cp: 2200.0 - hot_gas_prandtl: 0.7 - hot_gas_thermal_conductivity: 0.12 + hot_gas_cp: 2463.0 + hot_gas_prandtl: 0.644 + hot_gas_thermal_conductivity: 0.394 hot_gas_viscosity: 4.0e-05 n_channels: 100 n_segments: 20 diff --git a/EngineDesign/configs/impinging_lox_ch4_8000N.yaml b/EngineDesign/configs/impinging_lox_ch4_8000N.yaml index 37970ae5..a4a4b37f 100644 --- a/EngineDesign/configs/impinging_lox_ch4_8000N.yaml +++ b/EngineDesign/configs/impinging_lox_ch4_8000N.yaml @@ -443,9 +443,9 @@ regen_cooling: d_outlet: null enabled: false gas_turbulence_intensity: 0.1 - hot_gas_cp: 2200.0 - hot_gas_prandtl: 0.7 - hot_gas_thermal_conductivity: 0.12 + hot_gas_cp: 2463.0 + hot_gas_prandtl: 0.644 + hot_gas_thermal_conductivity: 0.394 hot_gas_viscosity: 4.0e-05 n_channels: 100 n_segments: 20 diff --git a/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml b/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml index 17d65aa1..91a2dd50 100644 --- a/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml +++ b/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml @@ -74,15 +74,15 @@ regen_cooling: wall_thickness: 0.002 wall_thermal_conductivity: 320.0 chamber_inner_diameter: 0.1516 - hot_gas_prandtl: 0.7 + hot_gas_prandtl: 0.644 hot_gas_viscosity: 4.0e-05 - hot_gas_thermal_conductivity: 0.12 + hot_gas_thermal_conductivity: 0.394 radiation_emissivity_hot: 0.85 radiation_view_factor: 1.0 n_segments: 20 gas_turbulence_intensity: 0.1 coolant_turbulence_intensity: 0.05 - hot_gas_cp: 2200.0 + hot_gas_cp: 2463.0 recovery_factor: null film_cooling: enabled: false diff --git a/EngineDesign/configs/impinging_smoke.yaml b/EngineDesign/configs/impinging_smoke.yaml index 4919ae37..605cfc93 100644 --- a/EngineDesign/configs/impinging_smoke.yaml +++ b/EngineDesign/configs/impinging_smoke.yaml @@ -48,15 +48,15 @@ regen_cooling: wall_thickness: 0.002 wall_thermal_conductivity: 320.0 chamber_inner_diameter: 0.08491 - hot_gas_prandtl: 0.7 + hot_gas_prandtl: 0.644 hot_gas_viscosity: 4.0e-05 - hot_gas_thermal_conductivity: 0.12 + hot_gas_thermal_conductivity: 0.394 radiation_emissivity_hot: 0.85 radiation_view_factor: 1.0 n_segments: 20 gas_turbulence_intensity: 0.1 coolant_turbulence_intensity: 0.05 - hot_gas_cp: 2200.0 + hot_gas_cp: 2463.0 recovery_factor: null film_cooling: enabled: false diff --git a/EngineDesign/configs/test.yaml b/EngineDesign/configs/test.yaml index a8cdd9ae..2b632c30 100644 --- a/EngineDesign/configs/test.yaml +++ b/EngineDesign/configs/test.yaml @@ -68,15 +68,15 @@ regen_cooling: wall_thickness: 0.002 wall_thermal_conductivity: 320.0 chamber_inner_diameter: 0.08491 - hot_gas_prandtl: 0.7 + hot_gas_prandtl: 0.623 hot_gas_viscosity: 4.0e-05 - hot_gas_thermal_conductivity: 0.12 + hot_gas_thermal_conductivity: 0.351 radiation_emissivity_hot: 0.85 radiation_view_factor: 1.0 n_segments: 20 gas_turbulence_intensity: 0.1 coolant_turbulence_intensity: 0.05 - hot_gas_cp: 2200.0 + hot_gas_cp: 2079.0 recovery_factor: null film_cooling: enabled: false diff --git a/EngineDesign/engine/core/chamber_solver.py b/EngineDesign/engine/core/chamber_solver.py index e404ff22..bb713320 100644 --- a/EngineDesign/engine/core/chamber_solver.py +++ b/EngineDesign/engine/core/chamber_solver.py @@ -1009,23 +1009,17 @@ def _evaluate_cooling_models( from engine.pipeline.constants import DEFAULT_HOT_GAS_VISC_PA_S mu_g_config = regen_cfg.hot_gas_viscosity if regen_cfg is not None else DEFAULT_HOT_GAS_VISC_PA_S - # Calculate viscosity using Huzel's formula if molecular weight is available + # Transport properties from the shared provider. This block used to be a + # near-verbatim copy of regen_cooling.estimate_hot_wall_heat_flux, down + # to the comments, and had the same defect: cp derived from gamma and + # Pr derived from it in turn. + from engine.pipeline.thermal.gas_transport import hot_gas_transport M = cea_props.get("M") # Molecular weight [kg/kmol] - if M is not None and M > 0 and Tc > 0: - from engine.pipeline.thermal.regen_cooling import calculate_gas_viscosity_huzel - mu_g_calculated = calculate_gas_viscosity_huzel(Tc, M) - else: - mu_g_calculated = mu_g_config # Fallback to config if M not available - - # Use calculated viscosity for calculations (more accurate) - mu_g = mu_g_calculated - - k_g = regen_cfg.hot_gas_thermal_conductivity if regen_cfg is not None else 0.1 - Pr_g = ( - regen_cfg.hot_gas_prandtl - if (regen_cfg is not None and regen_cfg.hot_gas_prandtl > 0) - else mu_g * gamma * R / max(k_g * (gamma - 1.0), 1e-6) - ) + _tr = hot_gas_transport(Tc, M, regen_cfg) + mu_g = _tr["mu"] + k_g = _tr["k"] + Pr_g = _tr["Pr"] + mu_g_calculated = mu_g Re_g = rho_g * velocity_g * geometry["diameter"] / max(mu_g, 1e-8) if Re_g < 2000: diff --git a/EngineDesign/engine/pipeline/constants.py b/EngineDesign/engine/pipeline/constants.py index 993c5b1c..8e63afd3 100644 --- a/EngineDesign/engine/pipeline/constants.py +++ b/EngineDesign/engine/pipeline/constants.py @@ -103,10 +103,20 @@ # Default oxidizer properties (LOX) DEFAULT_LOX_DENS_KG_M3 = 1140.0 # kg/m³ -# Default hot gas properties +# Default hot gas properties. +# +# Anchored on NASA CEA transport properties for LOX/CH4 products at Pc = 20 bar, +# MR 2.8, FROZEN reactions (Tc = 3261 K, M = 19.50): mu 1.030e-4 Pa·s, +# cp 2463 J/(kg·K), k 0.394 W/(m·K), Pr 0.644. See thermal/gas_transport.py for +# why frozen rather than equilibrium, and for the known mu inconsistency. +# +# The previous conductivity (0.1) was ~4x low, which suppressed h_g by the same +# factor — it was the largest single error in the gas-side chain after the +# viscosity unit fix. DEFAULT_HOT_GAS_VISC_PA_S = 4.0e-5 # Pa·s -DEFAULT_HOT_GAS_THERMAL_COND_W_M_K = 0.1 # W/(m·K) -DEFAULT_HOT_GAS_PRANDTL_ND = 0.7 # Dimensionless +DEFAULT_HOT_GAS_THERMAL_COND_W_M_K = 0.39 # W/(m·K) +DEFAULT_HOT_GAS_PRANDTL_ND = 0.64 # Dimensionless +DEFAULT_HOT_GAS_CP_J_KG_K = 2460.0 # J/(kg·K) # ============================================================================ # HEAT TRANSFER CONSTANTS diff --git a/EngineDesign/engine/pipeline/thermal/ablative_cooling.py b/EngineDesign/engine/pipeline/thermal/ablative_cooling.py index 9746583c..c5a26dcd 100644 --- a/EngineDesign/engine/pipeline/thermal/ablative_cooling.py +++ b/EngineDesign/engine/pipeline/thermal/ablative_cooling.py @@ -8,6 +8,7 @@ from engine.pipeline.config_schemas import AblativeCoolingConfig from engine.pipeline.constants import STEFAN_BOLTZMANN_W_M2_K4, EPSILON_SMALL +from engine.pipeline.thermal.gas_transport import hot_gas_transport from engine.pipeline.thermal.regen_cooling import calculate_gas_viscosity_huzel @@ -151,11 +152,15 @@ def mach_from_area_ratio_supersonic(area_ratio, gamma, tol=1e-6, max_iter=50): return M - # Gas thermal conductivity estimate: k ≈ μ × cp / Pr - # Typical Pr for combustion gases: 0.7-0.8 - Pr_gas = 0.75 - cp_gas = gamma * R_gas / (gamma - 1.0) - + # Transport properties from the shared provider, so this axial profile and + # the lumped estimate_hot_wall_heat_flux() agree. They previously differed + # by 3x in both k and Pr, in opposite directions: this function pinned + # Pr = 0.75 and derived k, while the other pinned k and derived Pr. + _tr = hot_gas_transport(Tc, M_mol, None) + cp_gas = _tr["cp"] + Pr_gas = _tr["Pr"] + k_gas = _tr["k"] + # Recovery factor for adiabatic wall temperature # Typical value for turbulent flow: r ≈ Pr^(1/3) ≈ 0.9 recovery_factor = 0.9 @@ -219,7 +224,11 @@ def mach_from_area_ratio_supersonic(area_ratio, gamma, tol=1e-6, max_iter=50): # Gas properties at local temperature mu_local = calc_viscosity(T_local, M_mol) - k_local = mu_local * cp_gas / Pr_gas + # Conductivity is taken as constant rather than re-derived from the + # local viscosity: that derivation implied k varies axially, but it + # inherited the Huzel correlation's ~35% offset, so the variation was + # false precision. Local viscosity still sets the local Reynolds number. + k_local = k_gas # Reynolds number based on local diameter Re_local = rho_local * V_local * D_local / max(mu_local, 1e-8) diff --git a/EngineDesign/engine/pipeline/thermal/gas_transport.py b/EngineDesign/engine/pipeline/thermal/gas_transport.py new file mode 100644 index 00000000..c9f31f4f --- /dev/null +++ b/EngineDesign/engine/pipeline/thermal/gas_transport.py @@ -0,0 +1,96 @@ +"""Single source of hot-gas transport properties for the gas-side heat transfer. + +Bartz (Huzel & Huang eq. 4-13) needs four properties of the combustion +products: viscosity, specific heat, thermal conductivity and Prandtl number. +They were previously computed in three separate places, which had drifted into +contradicting each other: + + regen_cooling.estimate_hot_wall_heat_flux k from config, Pr DERIVED as mu*cp/k + ablative_cooling.compute_..._profile Pr hardcoded 0.75, k DERIVED as mu*cp/Pr + chamber_solver (ablative block) a near-verbatim copy of the first + +For the same gas at the same operating point those disagreed by 3x in both k +and Pr, in opposite directions, and the first produced Pr = 2.25 -- impossible +for a gas. All three also derived cp as gamma*R/(gamma-1). That identity holds +for a calorically perfect gas, but CEA's GAMMAs is the isentropic exponent of a +dissociating mixture, not cp/cv; the derived value overshoots by ~40%. + +Reference values, NASA CEA for LOX/CH4 at Pc = 20 bar, MR 2.8, FROZEN +reactions (Tc = 3261 K, M = 19.50, gamma = 1.1397): + + mu = 1.030e-4 Pa.s cp = 2463 J/(kg.K) + k = 0.394 W/(m.K) Pr = 0.644 + +Frozen rather than equilibrium is deliberate. Equilibrium transport bundles the +energy of radical recombination into an inflated effective conductivity (k is +3.3x higher, cp 2.4x), which would raise the Bartz transport group by ~2.95x. +Bartz's correlation was calibrated against frozen-type properties -- eq. (4-16) +is literally a diatomic-air fit with no reaction contribution -- so pairing it +with equilibrium properties double-counts rather than improving accuracy. +Note that frozen is NOT the conservative choice: it predicts a lower heat load. +Add margin explicitly rather than by selecting equilibrium properties. + +KNOWN LIMITATION: the four values are not mutually consistent, because the +viscosity comes from the Huzel correlation (~35% below CEA for these products, +being an air fit) while cp/k/Pr come from config. mu*cp/k therefore does not +reproduce Pr exactly. The fix is to source all four from CEA's TRANSPORT +PROPERTIES block, which is self-consistent by construction and is already +present in the text cea_cache.py fetches -- it is simply never parsed. +""" + +from __future__ import annotations + +from typing import Dict, Optional + +from engine.pipeline.constants import ( + DEFAULT_HOT_GAS_CP_J_KG_K, + DEFAULT_HOT_GAS_PRANDTL_ND, + DEFAULT_HOT_GAS_THERMAL_COND_W_M_K, + DEFAULT_HOT_GAS_VISC_PA_S, +) +from engine.pipeline.thermal.regen_cooling import calculate_gas_viscosity_huzel + + +def hot_gas_transport( + temperature: float, + molecular_weight: Optional[float], + config=None, +) -> Dict[str, float]: + """Transport properties of the combustion products at the gas-side wall. + + Parameters + ---------- + temperature : float + Gas temperature [K] at which viscosity is evaluated. + molecular_weight : float or None + Product molecular weight [kg/kmol] from CEA. When absent, viscosity + falls back to the configured constant. + config : RegenCoolingConfig or None + Supplies measured/assumed properties. None is a valid caller state + (time_varying_solver passes no regen config), in which case the + CEA-anchored defaults are used. + + Returns + ------- + dict with 'mu' [Pa.s], 'cp' [J/(kg.K)], 'k' [W/(m.K)], 'Pr' [-] + + None of the four is derived from the others -- deriving is what allowed the + three former copies to disagree by 3x while each looking locally sensible. + """ + def _cfg(name: str, default: float) -> float: + if config is None: + return default + value = getattr(config, name, None) + return float(value) if value is not None and value > 0 else default + + if molecular_weight is not None and molecular_weight > 0 and temperature > 0: + mu = calculate_gas_viscosity_huzel(temperature, molecular_weight) + else: + mu = _cfg("hot_gas_viscosity", DEFAULT_HOT_GAS_VISC_PA_S) + + return { + "mu": float(mu), + "cp": _cfg("hot_gas_cp", DEFAULT_HOT_GAS_CP_J_KG_K), + "k": _cfg("hot_gas_thermal_conductivity", DEFAULT_HOT_GAS_THERMAL_COND_W_M_K), + "Pr": _cfg("hot_gas_prandtl", DEFAULT_HOT_GAS_PRANDTL_ND), + } diff --git a/EngineDesign/engine/pipeline/thermal/regen_cooling.py b/EngineDesign/engine/pipeline/thermal/regen_cooling.py index cbc1756f..ac7673e0 100644 --- a/EngineDesign/engine/pipeline/thermal/regen_cooling.py +++ b/EngineDesign/engine/pipeline/thermal/regen_cooling.py @@ -579,23 +579,18 @@ def estimate_hot_wall_heat_flux( rho_g = max(Pc / (R_g * max(Tc, 1.0)), MIN_DENS_KG_M3) V_g = mdot_total / (rho_g * A_cross) - # Get viscosity from config (for reference) + # Transport properties from the single provider. Local import: gas_transport + # imports the viscosity correlation from this module. + from engine.pipeline.thermal.gas_transport import hot_gas_transport + mu_g_config = config.hot_gas_viscosity if config is not None else DEFAULT_HOT_GAS_VISC_PA_S - - # Calculate viscosity using Huzel's formula if molecular weight is available M = gas_props.get("M") # Molecular weight [kg/kmol] - if M is not None and M > 0 and Tc > 0: - mu_g_calculated = calculate_gas_viscosity_huzel(Tc, M) - else: - mu_g_calculated = mu_g_config # Fallback to config if M not available - - # Use calculated viscosity for calculations (more accurate) - mu_g = mu_g_calculated - - k_g = config.hot_gas_thermal_conductivity if config is not None else DEFAULT_HOT_GAS_THERMAL_COND_W_M_K - cp_g = gamma * R_g / max(gamma - 1.0, EPSILON_SMALL) - Pr_g_source = config.hot_gas_prandtl if (config is not None and config.hot_gas_prandtl > 0) else None - Pr_g = Pr_g_source if Pr_g_source is not None else (mu_g * cp_g / max(k_g, EPSILON_SMALL)) + _tr = hot_gas_transport(Tc, M, config) + mu_g = _tr["mu"] + cp_g = _tr["cp"] + k_g = _tr["k"] + Pr_g = _tr["Pr"] + mu_g_calculated = mu_g Re_g = rho_g * V_g * chamber_d_inner / max(mu_g, EPSILON_TINY) if Re_g < 2000: @@ -630,4 +625,10 @@ def estimate_hot_wall_heat_flux( "gas_viscosity": float(mu_g), # Viscosity used in calculations (calculated from Huzel if available) "gas_viscosity_config": float(mu_g_config), # Viscosity from config (for reference) "gas_viscosity_calculated": float(mu_g_calculated), # Viscosity from Huzel formula (for reference) + # Transport properties actually used, surfaced so they can be asserted + # on: an unphysical Prandtl was the tell that cp/k were being derived + # from each other rather than supplied. + "gas_cp": float(cp_g), + "gas_thermal_conductivity": float(k_g), + "gas_prandtl": float(Pr_g), } diff --git a/EngineDesign/tests/test_gas_side_heat_transfer.py b/EngineDesign/tests/test_gas_side_heat_transfer.py index 6afe8ef1..bf750534 100644 --- a/EngineDesign/tests/test_gas_side_heat_transfer.py +++ b/EngineDesign/tests/test_gas_side_heat_transfer.py @@ -116,17 +116,46 @@ def test_flow_is_turbulent(self): ) def test_h_gas_magnitude(self): - """h_g for a chamber of this class is O(10^3) W/(m^2.K). + """h_g for a chamber of this class is ~2000-5000 W/(m^2.K). - The lower bound is set well below the physically expected 2000-5000 on - purpose: the default hot-gas thermal conductivity is itself suspect - (0.1 W/m.K, vs ~0.3-0.5 for combustion products at 3400 K), which drags - the computed value down. Tighten this bound once that is addressed. + Anchored on NASA CEA transport properties for these products + (LOX/CH4, Pc = 20 bar, MR 2.8, FROZEN reactions): + + mu = 1.03e-4 Pa.s cp = 2463 J/(kg.K) + k = 0.394 W/(m.K) Pr = 0.644 + + Those give h_g ~ 2060 here. The dominant sensitivity is k: the + conductivity used by the code has the largest error of the four, and it + alone moves h_g by a factor of ~3. """ h_g = _hot_wall()["h_hot"] - assert 300.0 < h_g < 2.0e4, ( - f"h_hot = {h_g:.1f} W/(m^2.K) is outside any plausible range for a " - f"rocket chamber; single digits indicate the laminar Nu branch" + assert 1.5e3 < h_g < 6.0e3, ( + f"h_hot = {h_g:.1f} W/(m^2.K) is outside the 1500-6000 band Bartz " + f"gives for a chamber of this class; check cp/k/Pr, which must come " + f"from the gas mixture rather than being derived from gamma" + ) + + def test_prandtl_is_physical(self): + """A combustion gas has Pr ~ 0.5-1.0; nothing else is possible. + + Deriving Pr as mu*cp/k from three independently-wrong values produced + 2.25 here, which no gas exhibits. cp in particular must not come from + gamma*R/(gamma-1): that identity holds for a calorically perfect gas, + but CEA's GAMMAs is the isentropic exponent of a DISSOCIATING mixture, + not cp/cv, and the derived cp overshoots by ~40%. + """ + Pr = _hot_wall()["gas_prandtl"] + assert 0.3 < Pr < 1.2, ( + f"hot-gas Prandtl = {Pr:.3f} is not physical for a combustion gas " + f"(CEA frozen gives 0.644 for these products)" + ) + + def test_convective_flux_magnitude(self): + """Convective flux at an ablative wall: a few MW/m^2 at this Pc.""" + q_conv = _hot_wall()["heat_flux_conv"] + assert 2.0e6 < q_conv < 8.0e6, ( + f"convective flux {q_conv/1e6:.2f} MW/m^2 is outside the plausible " + f"2-8 MW/m^2 band for a 20 bar chamber with a 1200 K wall" ) @@ -164,7 +193,11 @@ def test_convection_dominates_over_radiation(self): result = _hot_wall() q_conv = result["heat_flux_conv"] q_total = result["heat_flux_total"] - assert q_conv / q_total > 0.5, ( + # 0.75 (i.e. radiation under 25%) rather than a bare majority: with the + # transport properties corrected, convection alone already carries ~40%, + # so a 0.5 threshold would flip to XPASS on an unrelated nudge while + # radiation is still ~10x too high. + assert q_conv / q_total > 0.75, ( f"convection is only {100*q_conv/q_total:.1f}% of the total heat " f"flux; for a rocket chamber it should dominate" ) From eaa0cf7d19a868d2537845af94b89d0792f6cdd2 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 02:58:38 -0700 Subject: [PATCH 08/35] thermal (layer 3): solve for the wall temp properly, fix old version returning initial guess due to series of bugs --- .../engine/pipeline/thermal_analysis.py | 76 +++++++++++++------ .../tests/test_gas_side_heat_transfer.py | 56 ++++++++++++++ 2 files changed, 110 insertions(+), 22 deletions(-) diff --git a/EngineDesign/engine/pipeline/thermal_analysis.py b/EngineDesign/engine/pipeline/thermal_analysis.py index d729704c..6315032f 100644 --- a/EngineDesign/engine/pipeline/thermal_analysis.py +++ b/EngineDesign/engine/pipeline/thermal_analysis.py @@ -16,6 +16,7 @@ import numpy as np from .config_schemas import AblativeCoolingConfig, GraphiteInsertConfig +from .constants import STEFAN_BOLTZMANN_W_M2_K4 as STEFAN_BOLTZMANN SIGMA = 5.670374419e-8 # Stefan-Boltzmann constant @@ -93,28 +94,59 @@ def calculate_steady_state_temperature_profile( R_conv_cold = 1.0 / max(bc.h_ambient, 1e-6) R_total = R_conv_hot + R_cond_total + R_conv_cold - - # Calculate total heat flux (convective + radiative) - # Iterative: need to know surface temp for radiation - # Start with convective only, then iterate - T_surface_hot_guess = bc.T_hot_gas * 0.8 # Initial guess - - for _ in range(10): # Iterate for radiation coupling - q_conv = bc.h_hot_gas * (bc.T_hot_gas - T_surface_hot_guess) - q_rad = bc.q_rad_hot # Assume constant for now - q_total = q_conv + q_rad - - # Temperature drop across each resistance - delta_T_hot = q_total * R_conv_hot - T_surface_hot = bc.T_hot_gas - delta_T_hot - - if abs(T_surface_hot - T_surface_hot_guess) < 1.0: # Converged - break - T_surface_hot_guess = T_surface_hot - - # Cold surface temperature - delta_T_cold = q_total * R_conv_cold - T_surface_cold = bc.T_ambient + delta_T_cold + + # Resistance from the hot surface out to ambient (through the wall stack). + R_wall = max(R_cond_total + R_conv_cold, 1e-9) + + # Hot-surface energy balance: + # + # h(Tg - Ts) + q_rad_in = eps*sigma*(Ts^4 - Tamb^4) + (Ts - Tamb)/R_wall + # \------ in from gas ------/ \--- re-radiated ---/ \-- conducted --/ + # + # The previous implementation iterated + # Ts_new = Tg - (h*(Tg - Ts_guess) + q_rad) * (1/h) + # which simplifies to Ts_new = Ts_guess - q_rad/h: an identity in Ts that + # never references the wall stack at all. With q_rad = 0 it "converged" + # instantly on its own initial guess (Ts = 0.8*Tg for ANY h, conductivity or + # thickness); with q_rad > 0 it marched down by q_rad/h per iteration for a + # fixed 10 iterations, reaching -16 650 K for realistic boundary conditions. + # + # Surface re-radiation is included because without it the balance has no sink + # other than a poorly-conducting wall, and the correct root then lands ABOVE + # the gas temperature (4905 K for the canonical case) -- algebraically valid, + # physically useless. + emissivity = layers[0].emissivity if layers else 0.8 + T_amb = bc.T_ambient + + def _residual(T_s: float) -> float: + q_in = bc.h_hot_gas * (bc.T_hot_gas - T_s) + bc.q_rad_hot + q_out = (emissivity * STEFAN_BOLTZMANN * (T_s ** 4 - T_amb ** 4) + + (T_s - T_amb) / R_wall) + return q_in - q_out + + # _residual is strictly decreasing in T_s, so bisection is unconditionally + # safe -- no initial guess to get wrong, and no iteration-count artefacts. + lo, hi = T_amb, 10000.0 + if _residual(lo) <= 0.0: + T_surface_hot = lo + else: + while _residual(hi) > 0.0 and hi < 1.0e6: + hi *= 2.0 + for _ in range(200): + mid = 0.5 * (lo + hi) + if _residual(mid) > 0.0: + lo = mid + else: + hi = mid + if hi - lo < 1e-6: + break + T_surface_hot = 0.5 * (lo + hi) + + # Flux actually conducted through the wall: what the gas delivers minus what + # the surface re-radiates. The layer profile below is built on this, not on + # the incident flux. + q_total = (T_surface_hot - T_amb) / R_wall + T_surface_cold = T_amb + q_total * R_conv_cold # Build temperature profile through layers positions = [] diff --git a/EngineDesign/tests/test_gas_side_heat_transfer.py b/EngineDesign/tests/test_gas_side_heat_transfer.py index bf750534..a96f8261 100644 --- a/EngineDesign/tests/test_gas_side_heat_transfer.py +++ b/EngineDesign/tests/test_gas_side_heat_transfer.py @@ -203,6 +203,62 @@ def test_convection_dominates_over_radiation(self): ) +class TestWallTemperatureSolver: + """calculate_steady_state_temperature_profile must actually solve. + + Its previous iteration reduced algebraically to Ts_new = Ts_guess - q_rad/h, + an identity that never referenced the wall stack. With q_rad = 0 it returned + its own initial guess (0.8 * T_hot_gas) for every input; with q_rad > 0 it + drifted by a fixed 10 iterations, reaching -16 650 K on realistic boundary + conditions. Both failure modes are invisible unless something asserts that + the output responds to the inputs, which is what these do. + """ + + @staticmethod + def _profile(h_gas=2038.0, q_rad=3.5e5, k_abl=0.35): + from engine.pipeline.thermal_analysis import ( + MaterialLayer, + ThermalBoundaryConditions, + calculate_steady_state_temperature_profile, + ) + layers = [ + MaterialLayer("ablator", 0.010, k_abl, 1600.0, 1500.0, emissivity=0.85), + MaterialLayer("steel", 0.003, 16.0, 7900.0, 500.0), + ] + return calculate_steady_state_temperature_profile( + layers, + ThermalBoundaryConditions(T_hot_gas=3016.0, h_hot_gas=h_gas, q_rad_hot=q_rad), + ) + + def test_temperatures_are_physical(self): + r = self._profile() + assert 300.0 < r["T_surface_hot"] < 4000.0, ( + f"hot-surface temperature {r['T_surface_hot']:.1f} K is not physical" + ) + assert r["T_surface_cold"] <= r["T_surface_hot"], ( + "back face is hotter than the hot surface — heat is flowing uphill" + ) + + def test_responds_to_gas_side_coefficient(self): + """More convective coupling must raise the surface temperature.""" + cold = self._profile(h_gas=500.0)["T_surface_hot"] + hot = self._profile(h_gas=20000.0)["T_surface_hot"] + assert hot > cold + 50.0, ( + f"surface temperature barely moved with h_g ({cold:.1f} -> {hot:.1f} K); " + f"the solve is ignoring the gas-side boundary condition" + ) + + def test_responds_to_wall_conductivity(self): + """A better-conducting liner must run a hotter back face.""" + insulating = self._profile(k_abl=0.1)["T_surface_cold"] + conducting = self._profile(k_abl=16.0)["T_surface_cold"] + assert conducting > insulating + 50.0, ( + f"back-face temperature barely moved with liner conductivity " + f"({insulating:.1f} -> {conducting:.1f} K); the conduction " + f"resistances are computed but not used" + ) + + class TestNativeParity: """The C port reimplements the same viscosity correlation.""" From 21e86ee2c4ea61e495aa22893f5f1038948e9fa5 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 03:01:24 -0700 Subject: [PATCH 09/35] thermal (layer 3): fix silent dict key typos --- EngineDesign/engine/pipeline/time_varying_solver.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/EngineDesign/engine/pipeline/time_varying_solver.py b/EngineDesign/engine/pipeline/time_varying_solver.py index 3537e1d8..a931fbba 100644 --- a/EngineDesign/engine/pipeline/time_varying_solver.py +++ b/EngineDesign/engine/pipeline/time_varying_solver.py @@ -356,7 +356,11 @@ def solve_time_step( mdot_total=mdot_total, ) heat_flux_chamber = heat_flux_chamber_dict["heat_flux_total"] - h_hot_chamber = heat_flux_chamber_dict.get("h_g", 50000.0) # Convective coefficient + # Indexed, not .get(): this read used the key "h_g" while the producer + # returns "h_hot", so it silently resolved to a hardcoded 50 000 W/(m^2.K) + # fallback -- 25-100x the real coefficient -- on every call. A missing key + # must raise here rather than substitute a plausible-looking constant. + h_hot_chamber = heat_flux_chamber_dict["h_hot"] # Throat heat flux using physics-based Bartz correlation from engine.pipeline.physics_based_replacements import calculate_throat_heat_flux_physics @@ -910,7 +914,9 @@ def solve_time_step( bc_chamber = ThermalBoundaryConditions( T_hot_gas=Tc, h_hot_gas=h_hot_chamber, - q_rad_hot=heat_flux_chamber_dict.get("heat_flux_radiative", 0.0), + # Producer key is "heat_flux_rad"; "heat_flux_radiative" never + # matched, so the radiative boundary condition was always 0. + q_rad_hot=heat_flux_chamber_dict["heat_flux_rad"], T_ambient=300.0, h_ambient=10.0, # Natural convection ) From d306feb925ae5b5c4ca1cbb3d39f726fddc2646a Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 04:06:14 -0700 Subject: [PATCH 10/35] Seemingly working blowdowm, will continue to work on it --- EngineDesign/copv/blowdown_solver.py | 140 +++++- .../engine/optimizer/feed_pressure_model.py | 27 +- .../optimizer/layers/layer2_pressure.py | 450 +++++++++++++++++- .../engine/pipeline/config_schemas.py | 45 +- .../tests/test_blowdown_solver_integration.py | 212 +++++++++ .../tests/test_feed_pressure_regime.py | 88 ++++ .../tests/test_layer2_blowdown_branch.py | 174 +++++++ 7 files changed, 1115 insertions(+), 21 deletions(-) create mode 100644 EngineDesign/tests/test_blowdown_solver_integration.py create mode 100644 EngineDesign/tests/test_feed_pressure_regime.py create mode 100644 EngineDesign/tests/test_layer2_blowdown_branch.py diff --git a/EngineDesign/copv/blowdown_solver.py b/EngineDesign/copv/blowdown_solver.py index d814a009..a15cb318 100644 --- a/EngineDesign/copv/blowdown_solver.py +++ b/EngineDesign/copv/blowdown_solver.py @@ -11,6 +11,8 @@ - Coupled simulation: Pressure -> mdot -> Mass Depletion -> Volume Expansion -> Pressure """ +from functools import lru_cache + import numpy as np import pandas as pd from typing import Dict, Optional, Tuple, Callable, Any @@ -136,6 +138,34 @@ def _config_tank_volume(config: Any, tank_attr: str, h_attr: str, r_attr: str) - raise ValueError(f"{tank_attr}: tank volume not specified in config or request") +@lru_cache(maxsize=4) +def load_Z_lookup_table_cached(csv_path: str) -> Tuple[RegularGridInterpolator, np.ndarray, np.ndarray]: + """Cached :func:`load_Z_lookup_table`. The table is static, but a Layer-2 blowdown search calls + ``simulate_coupled_blowdown`` ~100 times; re-reading the CSV and rebuilding the interpolator + each time dominated the runtime.""" + return load_Z_lookup_table(csv_path) + + +def resolve_Z_table_path(csv_path: str) -> str: + """Resolve the N2 compressibility table path. + + The default is a bare filename, which previously only resolved when the caller's cwd happened + to be this package — anywhere else every blowdown candidate died with FileNotFoundError. Fall + back to the copy that ships next to this module. + """ + import os + + if os.path.isfile(csv_path): + return csv_path + local = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.path.basename(csv_path)) + if os.path.isfile(local): + return local + raise FileNotFoundError( + f"N2 compressibility table not found: {csv_path!r} (also tried {local!r}). " + "Pass n2_Z_csv=, or use_real_gas=False to skip the real-gas correction." + ) + + def load_Z_lookup_table(csv_path: str) -> Tuple[RegularGridInterpolator, np.ndarray, np.ndarray]: """Load compressibility factor Z(P,T) lookup table from CSV.""" df_z = pd.read_csv(csv_path) @@ -179,6 +209,25 @@ def Z_lookup( return Z_vals +_NO_SOLUTION_MARKERS = ("no solution", "supply < demand", "insufficient mass flow") + + +def is_no_solution_error(exc: BaseException) -> bool: + """True when the chamber solver reported that no steady operating point exists. + + As the tanks decay there comes a point where injector supply is below combustion demand at + every chamber pressure, so the root-find has no solution and the solver raises. That is a + physical FLAMEOUT — the normal end of a blowdown burn — not a defect. + + Deliberately narrow: a ``TypeError``/``KeyError``/``AttributeError`` from an actual bug must + NOT be silently reclassified as end-of-burn. Anything unrecognised propagates. + """ + if not isinstance(exc, (ValueError, ArithmeticError)): + return False + msg = str(exc).lower() + return any(marker in msg for marker in _NO_SOLUTION_MARKERS) + + class TankState: """Helper class to track individual tank state during blowdown.""" def __init__( @@ -220,6 +269,15 @@ def get_Z(self, T, P): return Z_lookup(T, P, self.Z_interp) return 1.0 + def snapshot(self) -> Tuple[float, float, float, float, float]: + """Capture the mutable state so a predictor step can be rolled back (Heun corrector).""" + return (self.m_prop, self.V_ullage, self.m_gas, self.T_gas, self.P_gas) + + def restore(self, snap: Tuple[float, float, float, float, float]) -> None: + """Restore state captured by :meth:`snapshot`. ``m_gas_0`` is intentionally left intact — + it is the *initial* gas mass and must survive a rollback.""" + self.m_prop, self.V_ullage, self.m_gas, self.T_gas, self.P_gas = snap + def step(self, mdot: float, dt: float, mdot_gas: float = 0.0) -> float: """Advance tank state by dt. @@ -352,6 +410,8 @@ def simulate_coupled_blowdown( T_lox_gas_K: float = 250.0, T_fuel_gas_K: float = 293.0, n_polytropic: float = 1.2, + n_polytropic_lox: Optional[float] = None, + n_polytropic_fuel: Optional[float] = None, use_real_gas: bool = True, n2_Z_csv: str = "n2_Z_lookup.csv", total_propellant_kg: Optional[float] = None, @@ -384,7 +444,7 @@ def simulate_coupled_blowdown( # Load Z lookup Z_interp = None if use_real_gas: - Z_interp, _, _ = load_Z_lookup_table(n2_Z_csv) + Z_interp, _, _ = load_Z_lookup_table_cached(resolve_Z_table_path(n2_Z_csv)) # Helpers to extract config (legacy fallback when no overrides supplied) def get_tank_params(fluid_key, tank_attr, h_attr, r_attr): @@ -475,9 +535,17 @@ def get_injector_area(fluid_type: str, for_gas_venting: bool = False) -> float: A_inj_lox = get_injector_area('oxidizer') A_inj_fuel = get_injector_area('fuel') + # Per-tank polytropic exponent. The LOX ullage sits on cryogenic liquid — a heat SINK — so it + # cools faster than the fuel ullage (whose near-ambient walls act as a heat SOURCE), and its + # effective n runs higher, potentially >= gamma. Sharing one n under-predicts LOX decay, which + # over-predicts end-of-burn tank pressure: the dangerous direction for the chug margin. + # ``n_polytropic`` remains the shared fallback so existing callers are unaffected. + n_lox = float(n_polytropic if n_polytropic_lox is None else n_polytropic_lox) + n_fuel = float(n_polytropic if n_polytropic_fuel is None else n_polytropic_fuel) + # Initialize Tank States - lox_tank = TankState(V_lox, m_lox, rho_lox, P_lox_initial_Pa, T_lox_gas_K, R_pressurant, Z_interp, n_polytropic) - fuel_tank = TankState(V_fuel, m_fuel, rho_fuel, P_fuel_initial_Pa, T_fuel_gas_K, R_pressurant, Z_interp, n_polytropic) + lox_tank = TankState(V_lox, m_lox, rho_lox, P_lox_initial_Pa, T_lox_gas_K, R_pressurant, Z_interp, n_lox) + fuel_tank = TankState(V_fuel, m_fuel, rho_fuel, P_fuel_initial_Pa, T_fuel_gas_K, R_pressurant, Z_interp, n_fuel) # Arrays to store history N = len(times) @@ -488,10 +556,21 @@ def get_injector_area(fluid_type: str, for_gas_venting: bool = False) -> float: # Add depletion flags for flameout masking history['lox']['is_depleted'] = np.zeros(N, dtype=bool) history['fuel']['is_depleted'] = np.zeros(N, dtype=bool) - + # True once the chamber solver can no longer close (it raises "supply < demand at all Pc" as + # the tanks decay). That is a physical flameout, distinct from propellant depletion. + history['engine_out'] = np.zeros(N, dtype=bool) + # Initial Conditions (t=0) # We need initial mdot based on initial Pressure - mdot_lox_0, mdot_fuel_0 = evaluate_engine_fn(P_lox_initial_Pa, P_fuel_initial_Pa) + try: + mdot_lox_0, mdot_fuel_0 = evaluate_engine_fn(P_lox_initial_Pa, P_fuel_initial_Pa) + engine_out = False + except Exception as exc: + if not is_no_solution_error(exc): + raise + # The engine cannot even close at the start pressure — nothing to simulate. + mdot_lox_0, mdot_fuel_0 = 0.0, 0.0 + engine_out = True # Store t=0 history['lox']['P_Pa'][0] = lox_tank.P_gas @@ -545,18 +624,52 @@ def get_injector_area(fluid_type: str, for_gas_venting: bool = False) -> float: mdot_fuel_liquid = mdot_fuel_liquid_prev mdot_fuel_gas = 0.0 - # Step Tanks with appropriate flows - P_lox_new = lox_tank.step(mdot_lox_liquid, dt, mdot_lox_gas) - P_fuel_new = fuel_tank.step(mdot_fuel_liquid, dt, mdot_fuel_gas) + # --- Heun (predictor-corrector) on the LIQUID mass update ------------------------- + # Forward Euler drains the whole interval at the start-of-step flow. Blowdown flow + # decreases monotonically, so that ALWAYS over-drains — a one-signed bias that + # accumulates and pushes end-of-burn pressure low, the dangerous direction for the chug + # check. Predict with the start flow, evaluate the engine at the predicted pressure, then + # redo the step from the original state with the average of the two. O(dt) -> O(dt^2). + snap_lox = lox_tank.snapshot() + snap_fuel = fuel_tank.snapshot() + + P_lox_pred = lox_tank.step(mdot_lox_liquid, dt, mdot_lox_gas) + P_fuel_pred = fuel_tank.step(mdot_fuel_liquid, dt, mdot_fuel_gas) + + if engine_out or lox_depleted or fuel_depleted: + mdot_lox_pred, mdot_fuel_pred = 0.0, 0.0 + else: + try: + mdot_lox_pred, mdot_fuel_pred = evaluate_engine_fn(P_lox_pred, P_fuel_pred) + except Exception as exc: + if not is_no_solution_error(exc): + raise + # No chamber solution at the predicted pressure. Degrade to plain Euler for this + # interval; the corrector evaluation below decides whether we have flamed out. + mdot_lox_pred, mdot_fuel_pred = mdot_lox_liquid, mdot_fuel_liquid + + lox_tank.restore(snap_lox) + fuel_tank.restore(snap_fuel) + + P_lox_new = lox_tank.step(0.5 * (mdot_lox_liquid + mdot_lox_pred), dt, mdot_lox_gas) + P_fuel_new = fuel_tank.step(0.5 * (mdot_fuel_liquid + mdot_fuel_pred), dt, mdot_fuel_gas) - # Evaluate Engine with NEW pressures to get NEW LIQUID mdot - # CRITICAL: If EITHER tank is depleted, engine flame-out occurs -> zero flow - if lox_depleted or fuel_depleted: - # Flameout condition: no combustion without both propellants + # Evaluate Engine with NEW pressures to get NEW LIQUID mdot. + # Flameout has two causes: (a) either tank empty, or (b) the chamber solver can no longer + # close as the tanks decay (it RAISES "No solution: Supply < Demand at all Pc"). Both end + # the burn. Tank pressure only falls further, so once out we stay out. + if engine_out or lox_depleted or fuel_depleted: mdot_lox_new = 0.0 mdot_fuel_new = 0.0 elif lox_tank.m_prop > 0 and fuel_tank.m_prop > 0: - mdot_lox_new, mdot_fuel_new = evaluate_engine_fn(P_lox_new, P_fuel_new) + try: + mdot_lox_new, mdot_fuel_new = evaluate_engine_fn(P_lox_new, P_fuel_new) + except Exception as exc: + if not is_no_solution_error(exc): + raise + engine_out = True + mdot_lox_new = 0.0 + mdot_fuel_new = 0.0 else: # Safety fallback mdot_lox_new = 0.0 @@ -576,5 +689,6 @@ def get_injector_area(fluid_type: str, for_gas_venting: bool = False) -> float: history['fuel']['mdot_kg_s'][i] = mdot_fuel_new history['fuel']['m_prop_kg'][i] = fuel_tank.m_prop history['fuel']['is_depleted'][i] = fuel_depleted + history['engine_out'][i] = engine_out return history diff --git a/EngineDesign/engine/optimizer/feed_pressure_model.py b/EngineDesign/engine/optimizer/feed_pressure_model.py index 5c3107a8..4d4b5a56 100644 --- a/EngineDesign/engine/optimizer/feed_pressure_model.py +++ b/EngineDesign/engine/optimizer/feed_pressure_model.py @@ -1,4 +1,8 @@ -"""Feed tank/manifold pressure vs time — blowdown segments vs dome-regulated (Phys §6.2).""" +"""Feed tank/manifold pressure vs time — regime selection and the dome-regulated curve (Phys §6.2). + +Regimes: ``blowdown`` (passive ullage expansion), ``dome_regulated`` (regulated setpoint + EOB +droop), ``active`` (legacy free-form segmented curve — needs closed-loop pressure control). +""" from __future__ import annotations @@ -9,13 +13,26 @@ _DRIFT_PSI_PER_1000_PSI_INLET = 0.010 # Aqua 1092 supply-pressure effect [Phys §6.1] +VALID_FEED_PRESSURE_MODELS = ("blowdown", "dome_regulated", "active") + + def get_feed_pressure_model(config: Any) -> str: - """Return ``blowdown`` or ``dome_regulated`` from design requirements.""" + """Return the Layer-2 feed-pressure regime: ``blowdown``, ``dome_regulated`` or ``active``. + + Missing or unrecognised values resolve to ``active`` — the legacy free-form segment search — + so configs that never declared a regime keep the behaviour they have today. This is + deliberately NOT the schema default: ``DesignRequirementsConfig.feed_pressure_model`` defaults + to ``dome_regulated``; this fallback only applies when design requirements are absent entirely + (or carry a value the schema never validated, e.g. a mock in tests). + """ dr = getattr(config, "design_requirements", None) if dr is None: - return "blowdown" - model = getattr(dr, "feed_pressure_model", None) or "blowdown" - return str(model).strip().lower() + return "active" + model = getattr(dr, "feed_pressure_model", None) + if model is None: + return "active" + normalized = str(model).strip().lower() + return normalized if normalized in VALID_FEED_PRESSURE_MODELS else "active" def generate_dome_regulated_pressure_curve( diff --git a/EngineDesign/engine/optimizer/layers/layer2_pressure.py b/EngineDesign/engine/optimizer/layers/layer2_pressure.py index c8e92324..a9d81abd 100644 --- a/EngineDesign/engine/optimizer/layers/layer2_pressure.py +++ b/EngineDesign/engine/optimizer/layers/layer2_pressure.py @@ -536,6 +536,426 @@ def _evaluate_scale(scale: float) -> Tuple[bool, Dict[str, Any]]: return min_lox, min_fuel, summary, True +def _blowdown_tank_volume_m3(config: Any, tank_attr: str, h_attr: str, r_attr: str, label: str) -> float: + """Total tank volume [m^3] for the blowdown regime. + + Prefers the explicit ``tank_volume_m3`` (real hardware), falling back to the cylindrical + approximation. Blowdown is driven by ULLAGE VOLUME, so a volume is required — a kg + ``*_tank_capacity_kg`` requirement is density-dependent and cannot substitute. + """ + tank = getattr(config, tank_attr, None) + if tank is not None: + vol = getattr(tank, "tank_volume_m3", None) + if vol: + return float(vol) + h = getattr(tank, h_attr, None) + r = getattr(tank, r_attr, None) + if h and r: + return float(np.pi * float(r) ** 2 * float(h)) + raise ValueError( + f"feed_pressure_model='blowdown' requires a {label} tank volume: set " + f"config.{tank_attr}.tank_volume_m3 (or {h_attr}/{r_attr}). Blowdown pressure is set by " + f"ullage expansion, so tank VOLUME is required; {label}_tank_capacity_kg is a " + f"density-dependent mission requirement and cannot substitute." + ) + + +def _blowdown_density(config: Any, key: str) -> float: + fluids = getattr(config, "fluids", None) + fluid = fluids.get(key) if isinstance(fluids, dict) else getattr(fluids, key, None) + rho = getattr(fluid, "density", None) if fluid is not None else None + if not rho: + raise ValueError(f"feed_pressure_model='blowdown' requires config.fluids['{key}'].density") + return float(rho) + + +def _run_layer2_blowdown( + optimized_config: PintleEngineConfig, + initial_lox_pressure_pa: float, + initial_fuel_pressure_pa: float, + peak_thrust: float, + target_apogee_m: float, + rocket_dry_mass_kg: float, + max_lox_tank_capacity_kg: float, + max_fuel_tank_capacity_kg: float, + target_burn_time: float, + n_time_points: int = 200, + update_progress: Optional[Callable] = None, + log_status: Optional[Callable] = None, + optimal_of_ratio: Optional[float] = None, + min_stability_margin: Optional[float] = None, + objective_callback: Optional[Callable] = None, + stop_event: Optional[Any] = None, + de_n_time_points: int = 25, + layer2_logger: Optional[Any] = None, +) -> Tuple[PintleEngineConfig, np.ndarray, np.ndarray, np.ndarray, Dict[str, Any], bool]: + """Layer 2 — PASSIVE BLOWDOWN regime. + + Tank pressure is NOT a decision variable here. In a passive blowdown system P(t) is a + *consequence* of the pressurant expanding as propellant leaves, so the only things the + designer actually controls are the ullage split and the start pressure. Start pressure is + fixed at Layer 1's anchor (it sized the geometry at that operating point), which leaves a + 2-D search: + + ullage_frac_lox, ullage_frac_fuel (fraction of each tank that is gas at t=0) + + For a fixed tank volume, ullage fraction *is* the propellant load: + V_ullage = frac * V_tank ; m_loaded = (1 - frac) * V_tank * rho + so more ullage buys a flatter pressure curve at the cost of carrying less propellant. That + trade is what makes the optimum interior. + + Each candidate is forward-modelled with ``simulate_coupled_blowdown`` and then scored through + the SAME time-series evaluator the other regimes use, so scores stay comparable. + + Returns the standard 6-tuple ``(config, t, P_O, P_F, summary, success)``. + """ + from copv.blowdown_solver import simulate_coupled_blowdown + + log = layer2_logger if layer2_logger is not None else logging.getLogger("layer2_blowdown") + + dr = getattr(optimized_config, "design_requirements", None) + + def _req(name: str, default: float) -> float: + val = getattr(dr, name, None) if dr is not None else None + return float(default if val is None else val) + + # Config knobs (all optional; defaults chosen conservatively). + u_lo = _req("blowdown_ullage_frac_min", 0.05) + u_hi = _req("blowdown_ullage_frac_max", 0.60) + # LOX ullage sits on cryogenic liquid (a heat SINK) so it decays faster than the fuel side. + n_lox = _req("blowdown_n_polytropic_lox", 1.40) + n_fuel = _req("blowdown_n_polytropic_fuel", 1.20) + # Worst-case exponents for the SAFETY re-run only (higher n == faster decay == lower tail). + n_lox_hi = _req("blowdown_n_polytropic_lox_hi", 1.50) + n_fuel_hi = _req("blowdown_n_polytropic_fuel_hi", 1.30) + + V_lox = _blowdown_tank_volume_m3(optimized_config, "lox_tank", "lox_h", "lox_radius", "LOX") + V_fuel = _blowdown_tank_volume_m3(optimized_config, "fuel_tank", "rp1_h", "rp1_radius", "fuel") + rho_lox = _blowdown_density(optimized_config, "oxidizer") + rho_fuel = _blowdown_density(optimized_config, "fuel") + + log.info("=" * 70) + log.info("Layer 2: BLOWDOWN regime (2-D ullage search)") + log.info("=" * 70) + log.info(f"LOX tank volume : {V_lox*1e3:.3f} L (rho={rho_lox:.1f} kg/m3)") + log.info(f"Fuel tank volume: {V_fuel*1e3:.3f} L (rho={rho_fuel:.1f} kg/m3)") + log.info(f"Start pressures (Layer 1 anchor): LOX {initial_lox_pressure_pa/6894.76:.1f} psi, " + f"Fuel {initial_fuel_pressure_pa/6894.76:.1f} psi") + log.info(f"Ullage bounds: [{u_lo:.2f}, {u_hi:.2f}] n_lox={n_lox} n_fuel={n_fuel} " + f"(safety n_hi: {n_lox_hi}/{n_fuel_hi})") + + config_bd = copy.deepcopy(optimized_config) + if getattr(config_bd, "ablative_cooling", None): + config_bd.ablative_cooling.enabled = False + if getattr(config_bd, "graphite_insert", None): + config_bd.graphite_insert.enabled = False + runner = PintleEngineRunner(config_bd) + + def _engine(P_lox: float, P_fuel: float) -> Tuple[float, float]: + """Single operating point for the blowdown solver. + + Deliberately does NOT swallow exceptions: the chamber solver raises once it can no longer + close (supply < demand at all Pc) and ``simulate_coupled_blowdown`` interprets that as + flameout, which is the physically correct end of a blowdown burn. + """ + res = runner.evaluate(P_lox, P_fuel, silent=True) + return float(res["mdot_O"]), float(res["mdot_F"]) + + PENALTY = 1e6 + eval_count = {"n": 0} + # Why candidates were rejected. A search that finds nothing must EXPLAIN itself rather than + # just reporting "no feasible candidate". + reject_reasons: Dict[str, int] = {} + + def _tally(reason: str) -> None: + key = reason.split(" (")[0] + reject_reasons[key] = reject_reasons.get(key, 0) + 1 + + def _simulate(u_lox: float, u_fuel: float, npts: int, n_o: float, n_f: float): + """Forward-model one candidate. Returns (time, P_lox, P_fuel, m_loaded_lox, m_loaded_fuel).""" + m_lox = (1.0 - u_lox) * V_lox * rho_lox + m_fuel = (1.0 - u_fuel) * V_fuel * rho_fuel + t = np.linspace(0.0, target_burn_time, npts) + hist = simulate_coupled_blowdown( + t, + _engine, + initial_lox_pressure_pa, + initial_fuel_pressure_pa, + config_bd, + n_polytropic_lox=n_o, + n_polytropic_fuel=n_f, + lox_ullage_volume_m3=u_lox * V_lox, + fuel_ullage_volume_m3=u_fuel * V_fuel, + lox_propellant_mass_kg=m_lox, + fuel_propellant_mass_kg=m_fuel, + ) + return t, hist["lox"]["P_Pa"], hist["fuel"]["P_Pa"], m_lox, m_fuel + + def _score(u_lox: float, u_fuel: float, npts: int) -> Tuple[float, Dict[str, Any]]: + """Objective (lower is better) + metrics for one candidate.""" + eval_count["n"] += 1 + try: + t, P_lox, P_fuel, m_load_lox, m_load_fuel = _simulate(u_lox, u_fuel, npts, n_lox, n_fuel) + results = runner.evaluate_arrays_with_time( + t, P_lox, P_fuel, track_ablative_geometry=False, use_coupled_solver=False + ) + except Exception as exc: # noqa: BLE001 - a failed candidate must not kill the search + log.warning(f"blowdown candidate u=({u_lox:.3f},{u_fuel:.3f}) failed: {exc!r}") + return PENALTY, {"feasible": False, + "reason": f"solver error: {type(exc).__name__}: {exc}"} + + F = np.atleast_1d(results.get("F", np.zeros(npts)))[:npts] + mdot_O = np.atleast_1d(results.get("mdot_O", np.zeros(npts)))[:npts] + mdot_F = np.atleast_1d(results.get("mdot_F", np.zeros(npts)))[:npts] + th = t[: F.shape[0]] + if F.shape[0] < 2: + return PENALTY, {"feasible": False, "reason": "too few time points"} + + # The array evaluator returns NaN rows for operating points it cannot solve — the same + # end-of-burn condition the single-point path signals by RAISING. Treat NaN as "engine + # out" (no thrust, no flow) instead of letting it poison the integrals, and score only + # the burning portion. Without this every blowdown candidate scored NaN, and because + # `NaN < best` is False the search silently found nothing. + burning = np.isfinite(F) + n_burn = int(burning.sum()) + if n_burn < 2: + return PENALTY, {"feasible": False, + "reason": "engine never produced solvable thrust at this ullage split"} + burn_end_s = float(th[burning][-1]) + + F = np.nan_to_num(F, nan=0.0, posinf=0.0, neginf=0.0) + mdot_O = np.nan_to_num(mdot_O, nan=0.0, posinf=0.0, neginf=0.0) + mdot_F = np.nan_to_num(mdot_F, nan=0.0, posinf=0.0, neginf=0.0) + + consumed_lox = float(np.trapezoid(mdot_O, th)) + consumed_fuel = float(np.trapezoid(mdot_F, th)) + total_impulse = float(np.trapezoid(F, th)) + + # MASS BUDGET USES *LOADED* PROPELLANT, NOT CONSUMED. What you lift is what you loaded; + # propellant stranded by flameout is dead weight. Scoring on consumed would make stranding + # free and actively reward steep, chug-unsafe decay. + m_loaded_total = m_load_lox + m_load_fuel + required_impulse = calculate_required_impulse_from_mass( + target_apogee_m, rocket_dry_mass_kg, m_loaded_total, target_burn_time + ) + impulse_ratio = total_impulse / max(required_impulse, 1e-9) + + # O/F: pointwise deviation (same convention as the other regimes). + # O/F and chug are only meaningful while the engine is actually burning. + max_mr_err = 0.0 + if optimal_of_ratio: + MR = np.atleast_1d(results.get("MR", np.full_like(th, optimal_of_ratio)))[: th.shape[0]] + mr_err = np.abs(MR - optimal_of_ratio) / max(optimal_of_ratio, 1e-9) + max_mr_err = float(np.nanmax(mr_err[burning])) if n_burn else 0.0 + + # Chug: min over the WHOLE burn (not just the endpoint — cheap, and it does not rely on + # the margin degrading monotonically). Threshold is whatever the caller configured; we do + # NOT invent one, because the absolute gate calibration is still un-measured. + def _worst(key): + arr = results.get(key, None) + if arr is None: + return float("nan") + arr = np.atleast_1d(arr)[: th.shape[0]] + sel = arr[burning[: arr.shape[0]]] + return float(np.nanmin(sel)) if sel.size and np.isfinite(sel).any() else float("nan") + + min_chug = _worst("chugging_stability_margin") + min_gm = _worst("chug_gain_margin") + chug_threshold = min_stability_margin if min_stability_margin is not None else 0.7 + + obj = -impulse_ratio + if max_mr_err > 0.20: + obj += 50.0 * (max_mr_err - 0.20) + over_lox = max(0.0, consumed_lox - max_lox_tank_capacity_kg) + over_fuel = max(0.0, consumed_fuel - max_fuel_tank_capacity_kg) + if over_lox or over_fuel: + obj += 300.0 * (over_lox / max(max_lox_tank_capacity_kg, 1e-9) + + over_fuel / max(max_fuel_tank_capacity_kg, 1e-9)) + + metrics = { + "feasible": True, + "ullage_frac_lox": u_lox, + "ullage_frac_fuel": u_fuel, + "m_loaded_lox_kg": m_load_lox, + "m_loaded_fuel_kg": m_load_fuel, + "consumed_lox_kg": consumed_lox, + "consumed_fuel_kg": consumed_fuel, + "stranded_lox_kg": max(0.0, m_load_lox - consumed_lox), + "stranded_fuel_kg": max(0.0, m_load_fuel - consumed_fuel), + "total_impulse": total_impulse, + "required_impulse": required_impulse, + "impulse_ratio": impulse_ratio, + "max_mr_error": max_mr_err, + "burn_end_s": burn_end_s, + "n_burning_points": n_burn, + "min_chug_margin": min_chug, + "min_chug_gain_margin": min_gm, + "chug_threshold": chug_threshold, + } + + if not np.isfinite(obj): + # Reject NON-FINITE objectives EXPLICITLY. `obj < best` is False for NaN, so an + # unguarded NaN would silently never win and the search would report "no feasible + # candidate" with no explanation of why. NaN here means the time-series solver + # returned NaN thrust/impulse for this operating point. + metrics["feasible"] = False + metrics["reason"] = "non-finite objective (NaN thrust/impulse from time-series solver)" + return PENALTY, metrics + + if objective_callback: + try: + objective_callback(eval_count["n"], float(obj), float(obj)) + except Exception: # noqa: BLE001 - a reporting hook must never break the search + pass + return float(obj), metrics + + def _safety_ok(u_lox: float, u_fuel: float, npts: int) -> Tuple[bool, float]: + """Re-run at the WORST-CASE polytropic exponents and re-check the chug margin. + + Higher n == faster decay == lower end-of-burn pressure == tighter stiffness, so n_hi is + the binding case. Performance is scored at nominal n; safety must hold at n_hi. + """ + try: + t, P_lox, P_fuel, _, _ = _simulate(u_lox, u_fuel, npts, n_lox_hi, n_fuel_hi) + res = runner.evaluate_arrays_with_time( + t, P_lox, P_fuel, track_ablative_geometry=False, use_coupled_solver=False + ) + except Exception: # noqa: BLE001 - cannot verify safety => treat as unsafe + return False, float("nan") + margins = res.get("chugging_stability_margin", None) + if margins is None: + return True, float("nan") + worst = float(np.nanmin(np.atleast_1d(margins))) + threshold = min_stability_margin if min_stability_margin is not None else 0.7 + return bool(worst >= threshold), worst + + def _stopped() -> bool: + return stop_event is not None and getattr(stop_event, "is_set", lambda: False)() + + # ---- 2-D search: coarse grid, then a refined grid around the winner ------------------- + best = (PENALTY, None, None, None) # (obj, u_lox, u_fuel, metrics) + + def _sweep(lo_o, hi_o, lo_f, hi_f, n, stage): + nonlocal best + for u_o in np.linspace(lo_o, hi_o, n): + for u_f in np.linspace(lo_f, hi_f, n): + if _stopped(): + return + obj, m = _score(float(u_o), float(u_f), de_n_time_points) + if not m.get("feasible"): + _tally(m.get("reason", "infeasible")) + continue + # HARD impulse gate, matching run_layer2a_minimum_pressures. Without it the + # branch will happily return a design that cannot reach the target apogee -- + # and worse, `-impulse_ratio` is gameable: loading LESS propellant lightens the + # rocket, cutting required impulse faster than delivered impulse falls, so the + # search drifts to maximum ullage and strands most of the propellant. + if not (m.get("impulse_ratio", 0.0) >= 1.0): + _tally("impulse below required (cannot reach target apogee)") + continue + ok, worst = _safety_ok(float(u_o), float(u_f), de_n_time_points) + m["safety_min_chug_at_n_hi"] = worst + m["safety_ok"] = ok + if not ok: + _tally("chug gate failed at n_hi") # hard gate at the worst-case exponent + continue + if obj < best[0]: + best = (obj, float(u_o), float(u_f), m) + if update_progress: + try: + update_progress(stage, f"blowdown {stage}: best obj={best[0]:.4f}") + except Exception: # noqa: BLE001 + pass + + _sweep(u_lo, u_hi, u_lo, u_hi, 5, "coarse grid") + if best[1] is not None and not _stopped(): + span_o = (u_hi - u_lo) / 4.0 + span_f = (u_hi - u_lo) / 4.0 + _sweep( + max(u_lo, best[1] - span_o), min(u_hi, best[1] + span_o), + max(u_lo, best[2] - span_f), min(u_hi, best[2] + span_f), + 5, "refine", + ) + + success = best[1] is not None + if not success: + log.error("Blowdown search found no feasible, chug-safe candidate. Rejections by cause:") + for reason, count in sorted(reject_reasons.items(), key=lambda kv: -kv[1]): + log.error(f" {count:3d} x {reason}") + t = np.linspace(0.0, target_burn_time, n_time_points) + flat_o = np.full(n_time_points, initial_lox_pressure_pa) + flat_f = np.full(n_time_points, initial_fuel_pressure_pa) + return optimized_config, t, flat_o, flat_f, {"is_success": False, "regime": "blowdown"}, False + + # ---- Final run at FULL time resolution ------------------------------------------------ + u_lox_best, u_fuel_best = best[1], best[2] + t, P_lox, P_fuel, m_load_lox, m_load_fuel = _simulate( + u_lox_best, u_fuel_best, n_time_points, n_lox, n_fuel + ) + final_obj, m = _score(u_lox_best, u_fuel_best, n_time_points) + ok_final, worst_final = _safety_ok(u_lox_best, u_fuel_best, n_time_points) + + log.info("-" * 70) + log.info(f"Best ullage: LOX {u_lox_best:.3f}, Fuel {u_fuel_best:.3f} ({eval_count['n']} evals)") + log.info(f"Loaded : LOX {m_load_lox:.3f} kg, Fuel {m_load_fuel:.3f} kg") + log.info(f"Consumed: LOX {m.get('consumed_lox_kg', 0):.3f} kg, " + f"Fuel {m.get('consumed_fuel_kg', 0):.3f} kg " + f"(stranded {m.get('stranded_lox_kg', 0):.3f}/{m.get('stranded_fuel_kg', 0):.3f} kg)") + log.info(f"Impulse ratio: {m.get('impulse_ratio', 0):.3f} " + f"min chug {m.get('min_chug_margin', float('nan')):.3f} " + f"(n_hi {worst_final:.3f}, raw GM {m.get('min_chug_gain_margin', float('nan')):.3f})") + log.info(f"Pressure: LOX {P_lox[0]/6894.76:.1f} -> {P_lox[-1]/6894.76:.1f} psi, " + f"Fuel {P_fuel[0]/6894.76:.1f} -> {P_fuel[-1]/6894.76:.1f} psi") + + summary = { + "regime": "blowdown", + "ullage_frac_lox": u_lox_best, + "ullage_frac_fuel": u_fuel_best, + "lox_tank_volume_m3": V_lox, + "fuel_tank_volume_m3": V_fuel, + "n_polytropic_lox": n_lox, + "n_polytropic_fuel": n_fuel, + "n_polytropic_lox_hi": n_lox_hi, + "n_polytropic_fuel_hi": n_fuel_hi, + "initial_lox_pressure_pa": initial_lox_pressure_pa, + "initial_fuel_pressure_pa": initial_fuel_pressure_pa, + "lox_start_pressure_pa": float(P_lox[0]), + "lox_end_pressure_pa": float(P_lox[-1]), + "fuel_start_pressure_pa": float(P_fuel[0]), + "fuel_end_pressure_pa": float(P_fuel[-1]), + "target_burn_time": target_burn_time, + "burn_time_s": target_burn_time, + "n_time_points": n_time_points, + "peak_thrust": peak_thrust, + "n_evaluations": eval_count["n"], + # Loaded vs consumed: the gap is propellant stranded by flameout (dead weight). + "loaded_lox_mass_kg": m_load_lox, + "loaded_fuel_mass_kg": m_load_fuel, + "lox_mass_kg": m.get("consumed_lox_kg"), + "fuel_mass_kg": m.get("consumed_fuel_kg"), + "total_lox_mass_kg": m.get("consumed_lox_kg"), + "total_fuel_mass_kg": m.get("consumed_fuel_kg"), + "total_propellant_mass_kg": m_load_lox + m_load_fuel, + "stranded_lox_kg": m.get("stranded_lox_kg"), + "stranded_fuel_kg": m.get("stranded_fuel_kg"), + "total_impulse_Ns": m.get("total_impulse"), + "required_impulse_Ns": m.get("required_impulse"), + "total_impulse_actual": m.get("total_impulse"), + "required_impulse": m.get("required_impulse"), + "impulse_ratio": m.get("impulse_ratio"), + "max_lox_tank_capacity_kg": max_lox_tank_capacity_kg, + "max_fuel_tank_capacity_kg": max_fuel_tank_capacity_kg, + "min_stability_margin": m.get("min_chug_margin"), + "min_chug_gain_margin": m.get("min_chug_gain_margin"), + "safety_min_chug_at_n_hi": worst_final, + "safety_ok_at_n_hi": ok_final, + "max_mr_error": m.get("max_mr_error"), + "objective": final_obj, + "is_success": True, + } + return optimized_config, t, P_lox, P_fuel, summary, True + + def run_layer2_pressure( optimized_config: PintleEngineConfig, initial_lox_pressure_pa: float, @@ -732,7 +1152,35 @@ def save_evaluation_plot( layer2_logger.info(f" -> Adjusted Fuel floor to {local_min_fuel_floor/1e6:.2f} MPa") from engine.optimizer.feed_pressure_model import get_feed_pressure_model, dome_regulated_tank_pair - skip_de_optimization = get_feed_pressure_model(optimized_config) == "dome_regulated" + feed_pressure_regime = get_feed_pressure_model(optimized_config) + layer2_logger.info(f"Feed pressure regime: {feed_pressure_regime}") + + if feed_pressure_regime == "blowdown": + # Passive blowdown: P(t) is a CONSEQUENCE of ullage expansion, not a free curve, so the + # segment search below does not apply. Hand off to the physical branch, which runs its own + # 2-D ullage search and returns the same 6-tuple. + return _run_layer2_blowdown( + optimized_config=optimized_config, + initial_lox_pressure_pa=initial_lox_pressure_pa, + initial_fuel_pressure_pa=initial_fuel_pressure_pa, + peak_thrust=peak_thrust, + target_apogee_m=target_apogee_m, + rocket_dry_mass_kg=rocket_dry_mass_kg, + max_lox_tank_capacity_kg=max_lox_tank_capacity_kg, + max_fuel_tank_capacity_kg=max_fuel_tank_capacity_kg, + target_burn_time=target_burn_time, + n_time_points=n_time_points, + update_progress=update_progress, + log_status=log_status, + optimal_of_ratio=optimal_of_ratio, + min_stability_margin=min_stability_margin, + objective_callback=objective_callback, + stop_event=stop_event, + de_n_time_points=de_n_time_points, + layer2_logger=layer2_logger, + ) + + skip_de_optimization = feed_pressure_regime == "dome_regulated" # Optimization variables: # Shared-segment parameterization with fixed N_SEGMENTS segments: diff --git a/EngineDesign/engine/pipeline/config_schemas.py b/EngineDesign/engine/pipeline/config_schemas.py index 44a4c900..8de95d90 100644 --- a/EngineDesign/engine/pipeline/config_schemas.py +++ b/EngineDesign/engine/pipeline/config_schemas.py @@ -970,9 +970,50 @@ class DesignRequirementsConfig(BaseModel): gt=0.0, description="Preferred upper edge for fuel ΔP_inj/Pc (soft hinge)", ) - feed_pressure_model: str = Field( + feed_pressure_model: Literal["blowdown", "dome_regulated", "active"] = Field( default="dome_regulated", - description="Tank pressure time model: blowdown (decaying segments) or dome_regulated (eq. 6.2)", + description=( + "Layer-2 feed-pressure regime. " + "'blowdown': passive ullage expansion — tank P(t) is a physical CONSEQUENCE of ullage " + "fraction + start pressure, so Layer 2 searches the ullage split, not the curve. " + "'dome_regulated': regulator holds a ~constant setpoint (Layer 1's pressure) with " + "end-of-burn droop at lockup (eq. 6.2). " + "'active': legacy free-form segmented pressure curve optimised directly — only " + "physically realisable with closed-loop active pressure control." + ), + ) + # --- Blowdown regime knobs (used only when feed_pressure_model == "blowdown") --- + blowdown_ullage_frac_min: float = Field( + default=0.05, gt=0.0, lt=1.0, + description="Lower bound on the Layer-2 blowdown ullage-fraction search (gas fraction of tank volume at t=0)", + ) + blowdown_ullage_frac_max: float = Field( + default=0.60, gt=0.0, lt=1.0, + description="Upper bound on the Layer-2 blowdown ullage-fraction search", + ) + blowdown_n_polytropic_lox: float = Field( + default=1.40, gt=1.0, + description=( + "Nominal polytropic exponent for the LOX ullage. Higher than the fuel side because the " + "LOX ullage sits on cryogenic liquid — a heat SINK — so it cools (and so decays) faster. " + "Fit this to a static-fire P-vs-ullage-volume log when available." + ), + ) + blowdown_n_polytropic_fuel: float = Field( + default=1.20, gt=1.0, + description="Nominal polytropic exponent for the fuel ullage (near-ambient walls act as a heat source)", + ) + blowdown_n_polytropic_lox_hi: float = Field( + default=1.50, gt=1.0, + description=( + "Worst-case LOX polytropic exponent used ONLY for the chug safety re-check. Higher n = " + "faster decay = lower end-of-burn pressure, so this is the binding case. Performance is " + "scored at the nominal n; safety must hold here." + ), + ) + blowdown_n_polytropic_fuel_hi: float = Field( + default=1.30, gt=1.0, + description="Worst-case fuel polytropic exponent for the chug safety re-check", ) W_geom_ao_af_momentum: float = Field( default=0.0, diff --git a/EngineDesign/tests/test_blowdown_solver_integration.py b/EngineDesign/tests/test_blowdown_solver_integration.py new file mode 100644 index 00000000..0f92294e --- /dev/null +++ b/EngineDesign/tests/test_blowdown_solver_integration.py @@ -0,0 +1,212 @@ +"""Blowdown solver: per-tank polytropic n, Heun integration, and flameout handling (Phase 3.1/3.2). + +Uses a synthetic ``evaluate_engine_fn`` so these are fast and deterministic — no CEA, no runner. +The synthetic engine reproduces the two behaviours that matter: + * mdot falls as tank pressure falls (so the Euler over-drain bias is exercised), and + * it RAISES below a floor pressure, exactly as the real chamber solver does + ("No solution: Supply < Demand at all Pc"), which the solver must treat as flameout. +""" + +import math +import sys +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from copv.blowdown_solver import simulate_coupled_blowdown # noqa: E402 + +P_INIT = 3.0e6 +P_FLAMEOUT = 1.0e6 + + +def _config(): + return SimpleNamespace( + fluids={ + "oxidizer": SimpleNamespace(density=1140.0), + "fuel": SimpleNamespace(density=810.0), + }, + injector=None, # get_injector_area() falls back internally + ) + + +def _engine(mdot_ref=2.0, p_min=P_FLAMEOUT, coupled=True): + """mdot ~ sqrt(dP); raises below p_min like the real chamber solver. + + ``coupled=True`` mimics reality: both streams key off the limiting tank, because chamber + pressure is set by the combined flow. ``coupled=False`` gives each stream its own pressure, + which is unphysical but lets a test isolate per-tank plumbing from genuine coupling. + """ + + def evaluate(P_lox, P_fuel): + if min(P_lox, P_fuel) <= p_min: + raise ValueError("No solution: Supply < Demand at all Pc") + if coupled: + P = min(P_lox, P_fuel) + s_o = s_f = math.sqrt((P - p_min) / (P_INIT - p_min)) + else: + s_o = math.sqrt((P_lox - p_min) / (P_INIT - p_min)) + s_f = math.sqrt((P_fuel - p_min) / (P_INIT - p_min)) + return mdot_ref * s_o, 0.5 * mdot_ref * s_f + + return evaluate + + +def _run(n_points=200, burn_s=5.0, n_lox=1.2, n_fuel=1.2, mdot_ref=2.0, vol=0.05, coupled=True): + return simulate_coupled_blowdown( + np.linspace(0.0, burn_s, n_points), + _engine(mdot_ref=mdot_ref, coupled=coupled), + P_INIT, + P_INIT, + _config(), + n_polytropic_lox=n_lox, + n_polytropic_fuel=n_fuel, + use_real_gas=False, # keep hermetic: no n2_Z_lookup.csv dependency + lox_tank_volume_m3=vol, + fuel_tank_volume_m3=vol, + lox_propellant_mass_kg=30.0, + fuel_propellant_mass_kg=15.0, + ) + + +def test_pressure_decays_monotonically_while_burning(): + h = _run() + P = h["lox"]["P_Pa"] + assert P[0] == pytest.approx(P_INIT), "curve must start at the Layer-1 anchor pressure" + burning = ~h["engine_out"] + dP = np.diff(P[burning]) + assert np.all(dP <= 1e-6), "tank pressure must decrease monotonically during the burn" + assert np.all(np.diff(h["lox"]["V_ullage_m3"][burning]) >= -1e-12), "ullage must grow" + + +def test_higher_polytropic_n_decays_faster(): + """LOX sits on cryogenic liquid (heat sink) -> higher effective n -> faster decay. + + A shared n would make both tanks decay identically; this is the property that lets us model + the LOX side conservatively. + """ + soft = _run(n_lox=1.1)["lox"]["P_Pa"][-1] + hard = _run(n_lox=1.4)["lox"]["P_Pa"][-1] + assert hard < soft, ( + f"Higher polytropic n must decay faster: P_end(n=1.4)={hard:.0f} Pa should be below " + f"P_end(n=1.1)={soft:.0f} Pa." + ) + + +def test_per_tank_n_is_wired_to_the_right_tank(): + """``n_polytropic_lox`` must drive the LOX tank only — a plumbing check. + + Uses the DECOUPLED engine deliberately. With the physical (coupled) engine, changing the LOX + exponent legitimately moves the fuel tank too: LOX pressure feeds chamber pressure, which sets + both streams' mdot, which changes how fast the fuel drains. That real coupling would mask a + genuine plumbing bug, so we remove it here. + """ + base = _run(n_lox=1.2, n_fuel=1.2, coupled=False) + lox_only = _run(n_lox=1.45, n_fuel=1.2, coupled=False) + assert lox_only["lox"]["P_Pa"][-1] < base["lox"]["P_Pa"][-1], "LOX exponent had no effect" + assert lox_only["fuel"]["P_Pa"][-1] == pytest.approx( + base["fuel"]["P_Pa"][-1], rel=1e-9 + ), "changing n_polytropic_lox must NOT reach the fuel tank" + + +def test_tanks_are_coupled_through_the_engine(): + """Sanity on the above: with the physical engine the tanks DO interact, as they must.""" + base = _run(n_lox=1.2, coupled=True) + stiffer = _run(n_lox=1.45, coupled=True) + assert stiffer["fuel"]["P_Pa"][-1] != pytest.approx(base["fuel"]["P_Pa"][-1], rel=1e-9), ( + "With a coupled engine, a faster-decaying LOX tank must change the fuel drain rate too." + ) + + +def test_heun_is_resolution_insensitive(): + """Second-order integration => a coarse grid must already agree with a fine one. + + The DE search runs at ~25 points; with forward Euler that carried a ~1% one-signed + over-drain bias versus a fine grid. Heun should cut that to well under 0.5%. + """ + coarse = _run(n_points=25)["lox"]["P_Pa"][-1] + fine = _run(n_points=800)["lox"]["P_Pa"][-1] + rel = abs(coarse - fine) / fine + assert rel < 5e-3, ( + f"Coarse-grid (25 pt) end pressure {coarse:.0f} Pa differs from fine-grid (800 pt) " + f"{fine:.0f} Pa by {rel:.3%}; expected <0.5% with predictor-corrector integration." + ) + + +def test_flameout_when_chamber_solver_cannot_close(): + """The real chamber solver raises once tank pressure is too low. That is a flameout, not a + crash: the sim must survive, latch engine_out, and stop consuming propellant.""" + # Small ullage + high flow -> pressure craters through P_FLAMEOUT during the burn. + h = _run(burn_s=30.0, mdot_ref=6.0, vol=0.033) + + assert h["engine_out"].any(), ( + "Expected the synthetic engine to stop closing as pressure decayed below " + f"{P_FLAMEOUT:.0f} Pa, latching engine_out." + ) + # Once out, stays out (pressure only falls further). + first_out = int(np.argmax(h["engine_out"])) + assert h["engine_out"][first_out:].all(), "engine_out must latch, not flicker" + # And no propellant is consumed after flameout. + m_after = h["lox"]["m_prop_kg"][first_out:] + assert np.all(np.diff(m_after) >= -1e-9), "propellant must not drain after flameout" + + +def test_real_bugs_are_not_misread_as_flameout(): + """A genuine defect must PROPAGATE, not be silently reclassified as end-of-burn. + + The flameout catch keys on the chamber solver's "no solution / supply < demand" message. A + TypeError from an actual bug shares nothing with that and must escape — otherwise the search + would quietly treat broken candidates as "burn ended early", which is the silent-failure + pattern this codebase has been bitten by repeatedly. + """ + + def broken(P_lox, P_fuel): + raise TypeError("simulated bug in the engine model") + + with pytest.raises(TypeError, match="simulated bug"): + simulate_coupled_blowdown( + np.linspace(0.0, 5.0, 20), + broken, + P_INIT, + P_INIT, + _config(), + use_real_gas=False, + lox_tank_volume_m3=0.05, + fuel_tank_volume_m3=0.05, + lox_propellant_mass_kg=30.0, + fuel_propellant_mass_kg=15.0, + ) + + +def test_no_solution_classifier_matches_the_real_message(): + from copv.blowdown_solver import is_no_solution_error + + real = ValueError( + "No solution: Supply < Demand at all Pc. Residual at bounds: [-0.03, -1.10] kg/s. " + "Insufficient mass flow. Check tank pressures and injector geometry." + ) + assert is_no_solution_error(real) is True + assert is_no_solution_error(TypeError("bad operand")) is False + assert is_no_solution_error(ValueError("some unrelated validation failure")) is False + + +def test_start_pressure_that_cannot_light_is_handled(): + """If the engine cannot close even at the start pressure, return cleanly rather than raise.""" + h = simulate_coupled_blowdown( + np.linspace(0.0, 5.0, 50), + _engine(p_min=P_INIT * 1.5), # floor above the start pressure + P_INIT, + P_INIT, + _config(), + use_real_gas=False, + lox_tank_volume_m3=0.05, + fuel_tank_volume_m3=0.05, + lox_propellant_mass_kg=30.0, + fuel_propellant_mass_kg=15.0, + ) + assert h["engine_out"].all() or h["lox"]["mdot_kg_s"][0] == 0.0 + assert np.all(np.isfinite(h["lox"]["P_Pa"])), "pressure history must stay finite" diff --git a/EngineDesign/tests/test_feed_pressure_regime.py b/EngineDesign/tests/test_feed_pressure_regime.py new file mode 100644 index 00000000..11a098fc --- /dev/null +++ b/EngineDesign/tests/test_feed_pressure_regime.py @@ -0,0 +1,88 @@ +"""Layer-2 feed-pressure regime selection (Phase 2 dispatch skeleton). + +The regime decides how Layer 2 treats tank pressure: + blowdown -- passive ullage expansion; P(t) is a consequence, not a free variable + dome_regulated -- regulator holds Layer 1's pressure as a setpoint, with EOB droop + active -- legacy free-form segmented curve (needs closed-loop pressure control) + +Critical behaviour under test: an undeclared/unrecognised regime must resolve to ``active`` so +configs that never declared one keep the free-curve search they have today, rather than silently +being routed into a different physical model. +""" + +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from engine.optimizer.feed_pressure_model import ( # noqa: E402 + VALID_FEED_PRESSURE_MODELS, + get_feed_pressure_model, +) + + +def _cfg(model): + return SimpleNamespace(design_requirements=SimpleNamespace(feed_pressure_model=model)) + + +@pytest.mark.parametrize("regime", VALID_FEED_PRESSURE_MODELS) +def test_valid_regimes_round_trip(regime): + assert get_feed_pressure_model(_cfg(regime)) == regime + + +@pytest.mark.parametrize( + "raw,expected", + [(" BLOWDOWN ", "blowdown"), ("Dome_Regulated", "dome_regulated"), ("ACTIVE", "active")], +) +def test_regime_is_normalised(raw, expected): + assert get_feed_pressure_model(_cfg(raw)) == expected + + +@pytest.mark.parametrize( + "cfg", + [ + SimpleNamespace(design_requirements=None), + SimpleNamespace(), + SimpleNamespace(design_requirements=SimpleNamespace(feed_pressure_model=None)), + SimpleNamespace(design_requirements=SimpleNamespace(feed_pressure_model="turbopump")), + ], + ids=["dr_is_none", "no_dr_attr", "field_is_none", "unrecognised_value"], +) +def test_unknown_or_missing_falls_back_to_active(cfg): + """Behaviour preservation: an undeclared regime keeps the legacy free-curve search.""" + assert get_feed_pressure_model(cfg) == "active", ( + "An undeclared or unrecognised feed_pressure_model must resolve to 'active' (the legacy " + "segment search). Resolving to 'blowdown' would silently route such configs into the " + "physical blowdown model and change their results." + ) + + +def test_schema_rejects_unknown_regime(): + """The field is a validated 3-value enum, so the GUI can render it and typos fail loudly.""" + from pydantic import ValidationError + + from engine.pipeline.config_schemas import DesignRequirementsConfig + + DesignRequirementsConfig(feed_pressure_model="active") # valid value constructs fine + + with pytest.raises(ValidationError) as exc: + DesignRequirementsConfig(feed_pressure_model="turbopump") + assert "feed_pressure_model" in str(exc.value) + + +def test_canonical_config_resolves_to_dome_regulated(): + import os + + from engine.pipeline.io import load_config + + cwd = os.getcwd() + os.chdir(ROOT) + try: + cfg = load_config(str(ROOT / "configs" / "canonical" / "impinging.yaml")) + finally: + os.chdir(cwd) + assert get_feed_pressure_model(cfg) == "dome_regulated" diff --git a/EngineDesign/tests/test_layer2_blowdown_branch.py b/EngineDesign/tests/test_layer2_blowdown_branch.py new file mode 100644 index 00000000..ae6f190d --- /dev/null +++ b/EngineDesign/tests/test_layer2_blowdown_branch.py @@ -0,0 +1,174 @@ +"""Layer 2 blowdown branch (Phase 3.3-3.5). + +The point of this branch: in a PASSIVE blowdown system tank pressure is a *consequence* of ullage +expansion, not a free curve. So the produced P(t) must be physically realisable — monotonically +decaying and continuous — unlike the legacy segment search, which optimised an arbitrary curve. + +The engine is mocked (same pattern as the other Layer-2 tests) so these run in ~1 s: the real +runner costs ~1 s per operating point, which is fine for a long-running optimisation but not for +a test suite. +""" + +import math +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from engine.optimizer.layers.layer2_pressure import _run_layer2_blowdown # noqa: E402 + +P_START = 3.0e6 +P_FLOOR = 1.0e6 + + +def _config(lox_vol=0.017474, fuel_vol=0.011109, **dr_kwargs): + dr = SimpleNamespace(feed_pressure_model="blowdown", **dr_kwargs) + return SimpleNamespace( + fluids={ + "oxidizer": SimpleNamespace(density=1140.0), + "fuel": SimpleNamespace(density=810.0), + }, + lox_tank=SimpleNamespace(tank_volume_m3=lox_vol) if lox_vol else None, + fuel_tank=SimpleNamespace(tank_volume_m3=fuel_vol) if fuel_vol else None, + design_requirements=dr, + ablative_cooling=None, + graphite_insert=None, + injector=None, + ) + + +def _scale(P): + return np.sqrt(np.clip((P - P_FLOOR) / (P_START - P_FLOOR), 0.0, None)) + + +def _runner(chug_margin=1.30): + r = MagicMock() + + def ev(P_lox, P_fuel, silent=True): + P = min(P_lox, P_fuel) + if P <= P_FLOOR: + raise ValueError("No solution: Supply < Demand at all Pc") + s = math.sqrt((P - P_FLOOR) / (P_START - P_FLOOR)) + return {"Pc": 0.6 * P, "mdot_O": 2.0 * s, "mdot_F": 1.0 * s} + + def ev_arr(t, P_lox, P_fuel, **kw): + n = len(t) + s = _scale(np.minimum(P_lox, P_fuel)) + return { + "F": 8000.0 * s, + "mdot_O": 2.0 * s, + "mdot_F": 1.0 * s, + "MR": np.full(n, 2.0), + "Pc": 0.6 * np.minimum(P_lox, P_fuel), + "chugging_stability_margin": np.full(n, chug_margin), + "chug_gain_margin": np.full(n, 2.0), + } + + r.evaluate.side_effect = ev + r.evaluate_arrays_with_time.side_effect = ev_arr + return r + + +def _run(cfg=None, chug_margin=1.30, min_stability_margin=1.05): + cfg = cfg if cfg is not None else _config() + with patch( + "engine.optimizer.layers.layer2_pressure.PintleEngineRunner", + return_value=_runner(chug_margin), + ): + return _run_layer2_blowdown( + optimized_config=cfg, + initial_lox_pressure_pa=P_START, + initial_fuel_pressure_pa=P_START, + peak_thrust=8000.0, + target_apogee_m=3000.0, + rocket_dry_mass_kg=60.0, + max_lox_tank_capacity_kg=100.0, + max_fuel_tank_capacity_kg=100.0, + target_burn_time=8.0, + n_time_points=30, + optimal_of_ratio=2.0, + min_stability_margin=min_stability_margin, + de_n_time_points=12, + ) + + +def test_returns_standard_six_tuple_and_succeeds(): + cfg, t, P_o, P_f, summary, success = _run() + assert success is True + assert summary["regime"] == "blowdown" + assert len(t) == len(P_o) == len(P_f) == 30 + + +def test_curve_is_physically_realisable(): + """The whole reason this branch exists: a passive tank cannot follow an arbitrary curve. + + Must start at the Layer-1 anchor, decay monotonically, and be continuous (the legacy segment + renderer produced vertical cliffs at segment boundaries). + """ + _, _, P_o, P_f, _, _ = _run() + + assert P_o[0] == pytest.approx(P_START), "curve must start at the Layer-1 anchor" + assert P_f[0] == pytest.approx(P_START) + + for name, P in (("LOX", P_o), ("fuel", P_f)): + assert np.all(np.diff(P) <= 1e-6), f"{name} pressure must decay monotonically" + steps = np.abs(np.diff(P)) + assert steps.max() < 0.25 * P_START, ( + f"{name} curve has a discontinuity of {steps.max()/1e6:.2f} MPa — a passive blowdown " + "tank cannot step; this is the segment-cliff failure mode." + ) + + +def test_ullage_fractions_respect_configured_bounds(): + cfg = _config(blowdown_ullage_frac_min=0.10, blowdown_ullage_frac_max=0.40) + _, _, _, _, summary, _ = _run(cfg) + for key in ("ullage_frac_lox", "ullage_frac_fuel"): + assert 0.10 - 1e-9 <= summary[key] <= 0.40 + 1e-9, f"{key}={summary[key]} out of bounds" + + +def test_mass_budget_uses_loaded_not_consumed(): + """Loaded propellant is what you lift; propellant stranded by flameout is dead weight. + + Scoring on consumed would make stranding free and reward steep, chug-unsafe decay. + """ + _, _, _, _, s, _ = _run() + loaded = s["loaded_lox_mass_kg"] + s["loaded_fuel_mass_kg"] + assert s["total_propellant_mass_kg"] == pytest.approx(loaded), ( + "total_propellant_mass_kg (which feeds the required-impulse calc) must be the LOADED mass" + ) + assert s["loaded_lox_mass_kg"] >= s["lox_mass_kg"] - 1e-9, "cannot consume more than loaded" + assert s["stranded_lox_kg"] >= -1e-9 and s["stranded_fuel_kg"] >= -1e-9 + + +def test_loaded_mass_follows_ullage_and_tank_volume(): + _, _, _, _, s, _ = _run() + expected_lox = (1.0 - s["ullage_frac_lox"]) * s["lox_tank_volume_m3"] * 1140.0 + assert s["loaded_lox_mass_kg"] == pytest.approx(expected_lox, rel=1e-9) + + +def test_chug_gate_rejects_every_candidate_when_margin_is_low(): + """A design that cannot clear the stability gate must FAIL, not be returned anyway.""" + _, _, _, _, summary, success = _run(chug_margin=0.50, min_stability_margin=1.05) + assert success is False, "the branch must not return a chug-unsafe design as a success" + assert summary["is_success"] is False + + +def test_safety_recheck_runs_at_worst_case_exponent(): + _, _, _, _, s, _ = _run() + assert s["n_polytropic_lox_hi"] > s["n_polytropic_lox"], ( + "the safety re-check exponent must be higher (faster decay) than the nominal one" + ) + assert s["safety_ok_at_n_hi"] is True + assert "safety_min_chug_at_n_hi" in s + + +def test_missing_tank_volume_raises_a_clear_error(): + """Blowdown needs a VOLUME; the kg capacity requirement is density-dependent and won't do.""" + with pytest.raises(ValueError, match="tank volume"): + _run(_config(lox_vol=None)) From 2ee6b55a1b1b6b6f554659e8e341a83e1a22818f Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 11:08:18 -0700 Subject: [PATCH 11/35] blowdown: share solver helpers across consumers, stop timeseries killing engine errors --- EngineDesign/backend/routers/timeseries.py | 35 +++++---- EngineDesign/copv/blowdown_solver.py | 75 ++++++++++++++++--- .../optimizer/layers/layer2_pressure.py | 53 ++++--------- 3 files changed, 96 insertions(+), 67 deletions(-) diff --git a/EngineDesign/backend/routers/timeseries.py b/EngineDesign/backend/routers/timeseries.py index c0bab34b..8026e2db 100644 --- a/EngineDesign/backend/routers/timeseries.py +++ b/EngineDesign/backend/routers/timeseries.py @@ -868,7 +868,12 @@ async def generate_from_segments(request: SegmentsRequest): if request.blowdown_mode: # ===== BLOWDOWN MODE ===== - from copv.blowdown_solver import simulate_coupled_blowdown, resolve_blowdown_tank_state + from copv.blowdown_solver import ( + make_engine_evaluator, + polytropic_exponents_from_config, + resolve_blowdown_tank_state, + simulate_coupled_blowdown, + ) try: V_lox, m_lox_init, _, V_fuel, m_fuel_init, _ = resolve_blowdown_tank_state( @@ -883,22 +888,15 @@ async def generate_from_segments(request: SegmentsRequest): except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - # Import coupled blowdown solver - - # Define engine callback for coupled solver - def engine_evaluator(P_lox_Pa: float, P_fuel_Pa: float): - # Run single-point evaluation - # Note: evaluate returns dict with mdot_O and mdot_F (kg/s) - try: - res = app_state.runner.evaluate( - P_tank_O=P_lox_Pa, - P_tank_F=P_fuel_Pa, - silent=True - ) - return res["mdot_O"], res["mdot_F"] - except Exception: - # If evaluation fails (e.g. pressure too low for CEA), return 0 flow - return 0.0, 0.0 + # Shared engine callback. This previously swallowed EVERY exception and returned + # (0, 0), silently reporting "engine off" for genuine defects and producing a + # plausible-but-wrong curve. simulate_coupled_blowdown now classifies the chamber + # solver's no-solution error as flameout itself, so real bugs surface instead. + engine_evaluator = make_engine_evaluator(app_state.runner) + + # Per-tank polytropic exponents, shared with the Layer-2 blowdown branch so the same + # tank cannot decay at two different rates depending on which entry point is called. + _n_lox, _n_fuel = polytropic_exponents_from_config(app_state.runner.config) # Run coupled simulation blowdown_results = simulate_coupled_blowdown( @@ -910,7 +908,8 @@ def engine_evaluator(P_lox_Pa: float, P_fuel_Pa: float): R_pressurant=296.803, # N2 T_lox_gas_K=250.0, T_fuel_gas_K=293.0, - n_polytropic=1.2, + n_polytropic_lox=_n_lox, + n_polytropic_fuel=_n_fuel, use_real_gas=True, n2_Z_csv=N2_Z_LOOKUP_CSV, total_propellant_kg=request.total_propellant_kg, diff --git a/EngineDesign/copv/blowdown_solver.py b/EngineDesign/copv/blowdown_solver.py index a15cb318..b1ab1496 100644 --- a/EngineDesign/copv/blowdown_solver.py +++ b/EngineDesign/copv/blowdown_solver.py @@ -111,7 +111,7 @@ def resolve_blowdown_tank_state( tank_name="LOX tank", ) else: - V_lox = _config_tank_volume(config, "lox_tank", "lox_h", "lox_radius") + V_lox = config_tank_volume_m3(config, "lox_tank", "lox_h", "lox_radius", "LOX tank") if fuel_tank_volume_m3 is not None or fuel_ullage_volume_m3 is not None: V_fuel = resolve_tank_volume_m3( @@ -122,20 +122,75 @@ def resolve_blowdown_tank_state( tank_name="Fuel tank", ) else: - V_fuel = _config_tank_volume(config, "fuel_tank", "rp1_h", "rp1_radius") + V_fuel = config_tank_volume_m3(config, "fuel_tank", "rp1_h", "rp1_radius", "Fuel tank") return V_lox, m_lox, rho_lox, V_fuel, m_fuel, rho_fuel -def _config_tank_volume(config: Any, tank_attr: str, h_attr: str, r_attr: str) -> float: +def config_tank_volume_m3(config: Any, tank_attr: str, h_attr: str, r_attr: str, + label: Optional[str] = None) -> float: + """Total tank volume [m^3] from config. + + Prefers the explicit ``tank_volume_m3`` (real hardware), then the cylindrical approximation, + then the legacy ``config.propellant`` fallback. + + Single source of truth for every blowdown consumer (the Layer-2 branch and the + ``/timeseries`` blowdown endpoint both need it). Blowdown pressure is driven by ULLAGE + VOLUME, so a volume is genuinely required — a kg ``*_tank_capacity_kg`` mission requirement + is density-dependent and cannot substitute for it. + """ + label = label or tank_attr tank = getattr(config, tank_attr, None) - if tank and hasattr(tank, "tank_volume_m3") and tank.tank_volume_m3 is not None: - return float(tank.tank_volume_m3) - if tank and hasattr(tank, h_attr) and hasattr(tank, r_attr): - return float(np.pi * getattr(tank, r_attr) ** 2 * getattr(tank, h_attr)) - if hasattr(config, "propellant") and hasattr(config.propellant, "tank_volume_m3"): - return float(config.propellant.tank_volume_m3) - raise ValueError(f"{tank_attr}: tank volume not specified in config or request") + if tank is not None: + vol = getattr(tank, "tank_volume_m3", None) + if vol: + return float(vol) + h = getattr(tank, h_attr, None) + r = getattr(tank, r_attr, None) + if h and r: + return float(np.pi * float(r) ** 2 * float(h)) + prop = getattr(config, "propellant", None) + if prop is not None and getattr(prop, "tank_volume_m3", None): + return float(prop.tank_volume_m3) + raise ValueError( + f"{label}: tank volume not specified. Set config.{tank_attr}.tank_volume_m3 (or " + f"{h_attr}/{r_attr}). Blowdown pressure is set by ullage expansion, so tank VOLUME is " + f"required; a kg tank-capacity requirement is density-dependent and cannot substitute." + ) + + +def make_engine_evaluator(runner: Any) -> Callable[[float, float], Tuple[float, float]]: + """Build the ``(P_lox_Pa, P_fuel_Pa) -> (mdot_O, mdot_F)`` callback this solver expects. + + Shared by every blowdown consumer so they cannot drift apart. + + Deliberately does NOT swallow exceptions. The chamber solver raises once it can no longer + close, and :func:`simulate_coupled_blowdown` interprets that as flameout — the physically + correct end of a blowdown burn. Returning ``(0, 0)`` on *any* exception (as the timeseries + endpoint used to) also silently reports "engine off" for genuine defects, producing a + plausible-looking but wrong curve. + """ + + def evaluate(P_lox_Pa: float, P_fuel_Pa: float) -> Tuple[float, float]: + res = runner.evaluate(P_lox_Pa, P_fuel_Pa, silent=True) + return float(res["mdot_O"]), float(res["mdot_F"]) + + return evaluate + + +def polytropic_exponents_from_config(config: Any, default_lox: float = 1.40, + default_fuel: float = 1.20) -> Tuple[float, float]: + """Nominal per-tank polytropic exponents from ``design_requirements``. + + LOX runs higher than fuel by default: the LOX ullage sits on cryogenic liquid — a heat SINK — + so it cools, and therefore decays, faster than a fuel ullage warmed by near-ambient walls. + Shared so the Layer-2 branch and the timeseries endpoint cannot model the same tank + differently. + """ + dr = getattr(config, "design_requirements", None) + n_lox = getattr(dr, "blowdown_n_polytropic_lox", None) if dr is not None else None + n_fuel = getattr(dr, "blowdown_n_polytropic_fuel", None) if dr is not None else None + return float(n_lox or default_lox), float(n_fuel or default_fuel) @lru_cache(maxsize=4) diff --git a/EngineDesign/engine/optimizer/layers/layer2_pressure.py b/EngineDesign/engine/optimizer/layers/layer2_pressure.py index a9d81abd..15033adf 100644 --- a/EngineDesign/engine/optimizer/layers/layer2_pressure.py +++ b/EngineDesign/engine/optimizer/layers/layer2_pressure.py @@ -536,30 +536,6 @@ def _evaluate_scale(scale: float) -> Tuple[bool, Dict[str, Any]]: return min_lox, min_fuel, summary, True -def _blowdown_tank_volume_m3(config: Any, tank_attr: str, h_attr: str, r_attr: str, label: str) -> float: - """Total tank volume [m^3] for the blowdown regime. - - Prefers the explicit ``tank_volume_m3`` (real hardware), falling back to the cylindrical - approximation. Blowdown is driven by ULLAGE VOLUME, so a volume is required — a kg - ``*_tank_capacity_kg`` requirement is density-dependent and cannot substitute. - """ - tank = getattr(config, tank_attr, None) - if tank is not None: - vol = getattr(tank, "tank_volume_m3", None) - if vol: - return float(vol) - h = getattr(tank, h_attr, None) - r = getattr(tank, r_attr, None) - if h and r: - return float(np.pi * float(r) ** 2 * float(h)) - raise ValueError( - f"feed_pressure_model='blowdown' requires a {label} tank volume: set " - f"config.{tank_attr}.tank_volume_m3 (or {h_attr}/{r_attr}). Blowdown pressure is set by " - f"ullage expansion, so tank VOLUME is required; {label}_tank_capacity_kg is a " - f"density-dependent mission requirement and cannot substitute." - ) - - def _blowdown_density(config: Any, key: str) -> float: fluids = getattr(config, "fluids", None) fluid = fluids.get(key) if isinstance(fluids, dict) else getattr(fluids, key, None) @@ -609,7 +585,12 @@ def _run_layer2_blowdown( Returns the standard 6-tuple ``(config, t, P_O, P_F, summary, success)``. """ - from copv.blowdown_solver import simulate_coupled_blowdown + from copv.blowdown_solver import ( + config_tank_volume_m3, + make_engine_evaluator, + polytropic_exponents_from_config, + simulate_coupled_blowdown, + ) log = layer2_logger if layer2_logger is not None else logging.getLogger("layer2_blowdown") @@ -622,15 +603,15 @@ def _req(name: str, default: float) -> float: # Config knobs (all optional; defaults chosen conservatively). u_lo = _req("blowdown_ullage_frac_min", 0.05) u_hi = _req("blowdown_ullage_frac_max", 0.60) - # LOX ullage sits on cryogenic liquid (a heat SINK) so it decays faster than the fuel side. - n_lox = _req("blowdown_n_polytropic_lox", 1.40) - n_fuel = _req("blowdown_n_polytropic_fuel", 1.20) + # Nominal per-tank exponents come from the shared helper so this branch and the + # /timeseries blowdown endpoint cannot model the same tank differently. + n_lox, n_fuel = polytropic_exponents_from_config(optimized_config) # Worst-case exponents for the SAFETY re-run only (higher n == faster decay == lower tail). n_lox_hi = _req("blowdown_n_polytropic_lox_hi", 1.50) n_fuel_hi = _req("blowdown_n_polytropic_fuel_hi", 1.30) - V_lox = _blowdown_tank_volume_m3(optimized_config, "lox_tank", "lox_h", "lox_radius", "LOX") - V_fuel = _blowdown_tank_volume_m3(optimized_config, "fuel_tank", "rp1_h", "rp1_radius", "fuel") + V_lox = config_tank_volume_m3(optimized_config, "lox_tank", "lox_h", "lox_radius", "LOX tank") + V_fuel = config_tank_volume_m3(optimized_config, "fuel_tank", "rp1_h", "rp1_radius", "Fuel tank") rho_lox = _blowdown_density(optimized_config, "oxidizer") rho_fuel = _blowdown_density(optimized_config, "fuel") @@ -651,15 +632,9 @@ def _req(name: str, default: float) -> float: config_bd.graphite_insert.enabled = False runner = PintleEngineRunner(config_bd) - def _engine(P_lox: float, P_fuel: float) -> Tuple[float, float]: - """Single operating point for the blowdown solver. - - Deliberately does NOT swallow exceptions: the chamber solver raises once it can no longer - close (supply < demand at all Pc) and ``simulate_coupled_blowdown`` interprets that as - flameout, which is the physically correct end of a blowdown burn. - """ - res = runner.evaluate(P_lox, P_fuel, silent=True) - return float(res["mdot_O"]), float(res["mdot_F"]) + # Shared callback: lets the chamber solver's no-solution error propagate so + # simulate_coupled_blowdown can classify it as flameout, while genuine defects still surface. + _engine = make_engine_evaluator(runner) PENALTY = 1e6 eval_count = {"n": 0} From 6998525b79e27f69191c0d0c3cc0c26518401aa9 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 12:55:06 -0700 Subject: [PATCH 12/35] thermal (layer 3): use gas emissivity approx for gas radiation, not the charred wall's surface value which is physically wrong --- EngineDesign/configs/canonical/impinging.yaml | 2 +- EngineDesign/configs/canonical/pintle.yaml | 2 +- EngineDesign/configs/default.yaml | 2 +- EngineDesign/configs/impinging_lox_ch4.yaml | 2 +- .../configs/impinging_lox_ch4_8000N.yaml | 2 +- .../impinging_lox_ch4_8000N_optimal.yaml | 2 +- EngineDesign/configs/impinging_smoke.yaml | 2 +- EngineDesign/configs/test.yaml | 2 +- .../engine/pipeline/config_schemas.py | 2 +- EngineDesign/engine/pipeline/constants.py | 43 ++++++++++- .../pipeline/thermal/ablative_cooling.py | 12 ++- .../engine/pipeline/thermal/gas_transport.py | 73 +++++++++++++++++++ .../engine/pipeline/thermal/regen_cooling.py | 5 +- 13 files changed, 136 insertions(+), 15 deletions(-) diff --git a/EngineDesign/configs/canonical/impinging.yaml b/EngineDesign/configs/canonical/impinging.yaml index 627f90fa..7d1c0a32 100644 --- a/EngineDesign/configs/canonical/impinging.yaml +++ b/EngineDesign/configs/canonical/impinging.yaml @@ -51,7 +51,7 @@ regen_cooling: hot_gas_prandtl: 0.644 hot_gas_viscosity: 4.0e-05 hot_gas_thermal_conductivity: 0.394 - radiation_emissivity_hot: 0.85 + radiation_emissivity_hot: 0.10 radiation_view_factor: 1.0 n_segments: 20 gas_turbulence_intensity: 0.1 diff --git a/EngineDesign/configs/canonical/pintle.yaml b/EngineDesign/configs/canonical/pintle.yaml index 810c85c9..32ddb57d 100644 --- a/EngineDesign/configs/canonical/pintle.yaml +++ b/EngineDesign/configs/canonical/pintle.yaml @@ -424,7 +424,7 @@ regen_cooling: hot_gas_viscosity: 4.0e-05 n_channels: 100 n_segments: 20 - radiation_emissivity_hot: 0.85 + radiation_emissivity_hot: 0.10 radiation_view_factor: 1.0 recovery_factor: null roughness: 0.0 diff --git a/EngineDesign/configs/default.yaml b/EngineDesign/configs/default.yaml index ec7f7823..947f274c 100644 --- a/EngineDesign/configs/default.yaml +++ b/EngineDesign/configs/default.yaml @@ -76,7 +76,7 @@ regen_cooling: hot_gas_prandtl: 0.644 hot_gas_viscosity: 4.0e-05 hot_gas_thermal_conductivity: 0.394 - radiation_emissivity_hot: 0.85 + radiation_emissivity_hot: 0.10 radiation_view_factor: 1.0 n_segments: 20 gas_turbulence_intensity: 0.1 diff --git a/EngineDesign/configs/impinging_lox_ch4.yaml b/EngineDesign/configs/impinging_lox_ch4.yaml index c84475e1..70e19aa0 100644 --- a/EngineDesign/configs/impinging_lox_ch4.yaml +++ b/EngineDesign/configs/impinging_lox_ch4.yaml @@ -406,7 +406,7 @@ regen_cooling: hot_gas_viscosity: 4.0e-05 n_channels: 100 n_segments: 20 - radiation_emissivity_hot: 0.85 + radiation_emissivity_hot: 0.10 radiation_view_factor: 1.0 recovery_factor: null roughness: 0.0 diff --git a/EngineDesign/configs/impinging_lox_ch4_8000N.yaml b/EngineDesign/configs/impinging_lox_ch4_8000N.yaml index a4a4b37f..9904df04 100644 --- a/EngineDesign/configs/impinging_lox_ch4_8000N.yaml +++ b/EngineDesign/configs/impinging_lox_ch4_8000N.yaml @@ -449,7 +449,7 @@ regen_cooling: hot_gas_viscosity: 4.0e-05 n_channels: 100 n_segments: 20 - radiation_emissivity_hot: 0.85 + radiation_emissivity_hot: 0.10 radiation_view_factor: 1.0 recovery_factor: null roughness: 0.0 diff --git a/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml b/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml index 91a2dd50..fecc6168 100644 --- a/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml +++ b/EngineDesign/configs/impinging_lox_ch4_8000N_optimal.yaml @@ -77,7 +77,7 @@ regen_cooling: hot_gas_prandtl: 0.644 hot_gas_viscosity: 4.0e-05 hot_gas_thermal_conductivity: 0.394 - radiation_emissivity_hot: 0.85 + radiation_emissivity_hot: 0.10 radiation_view_factor: 1.0 n_segments: 20 gas_turbulence_intensity: 0.1 diff --git a/EngineDesign/configs/impinging_smoke.yaml b/EngineDesign/configs/impinging_smoke.yaml index 605cfc93..f5043a38 100644 --- a/EngineDesign/configs/impinging_smoke.yaml +++ b/EngineDesign/configs/impinging_smoke.yaml @@ -51,7 +51,7 @@ regen_cooling: hot_gas_prandtl: 0.644 hot_gas_viscosity: 4.0e-05 hot_gas_thermal_conductivity: 0.394 - radiation_emissivity_hot: 0.85 + radiation_emissivity_hot: 0.10 radiation_view_factor: 1.0 n_segments: 20 gas_turbulence_intensity: 0.1 diff --git a/EngineDesign/configs/test.yaml b/EngineDesign/configs/test.yaml index 2b632c30..03fa0d17 100644 --- a/EngineDesign/configs/test.yaml +++ b/EngineDesign/configs/test.yaml @@ -71,7 +71,7 @@ regen_cooling: hot_gas_prandtl: 0.623 hot_gas_viscosity: 4.0e-05 hot_gas_thermal_conductivity: 0.351 - radiation_emissivity_hot: 0.85 + radiation_emissivity_hot: 0.10 radiation_view_factor: 1.0 n_segments: 20 gas_turbulence_intensity: 0.1 diff --git a/EngineDesign/engine/pipeline/config_schemas.py b/EngineDesign/engine/pipeline/config_schemas.py index 8de95d90..6778fb91 100644 --- a/EngineDesign/engine/pipeline/config_schemas.py +++ b/EngineDesign/engine/pipeline/config_schemas.py @@ -146,7 +146,7 @@ class RegenCoolingConfig(BaseModel): hot_gas_prandtl: float = Field(default=0.7, gt=0, description="Assumed hot-gas Prandtl number") hot_gas_viscosity: float = Field(default=4.0e-5, gt=0, description="Effective hot-gas viscosity [Pa·s]") hot_gas_thermal_conductivity: float = Field(default=0.1, gt=0, description="Effective hot-gas thermal conductivity [W/(m·K)]") - radiation_emissivity_hot: float = Field(default=0.8, ge=0, le=1, description="Effective hot-side emissivity for radiation") + radiation_emissivity_hot: float = Field(default=0.10, ge=0, le=1, description="Emissivity of the combustion GAS for gas->wall radiation (NOT a wall surface property; CO2/H2O band radiators are ~0.1, uncertain to ~2x). Raise only for sooting/metallised propellants.") radiation_view_factor: float = Field(default=1.0, ge=0, le=1, description="Radiation view factor to coolant surface") n_segments: int = Field(default=20, gt=0, description="Number of axial segments for heat-transfer integration") gas_turbulence_intensity: float = Field(default=0.1, ge=0, description="Estimated turbulence intensity of hot gas (0-1)") diff --git a/EngineDesign/engine/pipeline/constants.py b/EngineDesign/engine/pipeline/constants.py index 8e63afd3..23d0f99c 100644 --- a/EngineDesign/engine/pipeline/constants.py +++ b/EngineDesign/engine/pipeline/constants.py @@ -123,7 +123,48 @@ # ============================================================================ # Default radiation properties -DEFAULT_EMISSIVITY_ND = 0.8 # Typical for hot gas/combustion products +# GAS emissivity for the gas -> wall radiation term. This is NOT a surface +# property and must not be confused with the wall's own emissivity (~0.85 for +# charred phenolic, which is correct where the wall re-radiates to ambient). +# +# LOX/CH4 and LOX/Ethanol products are CO2 and H2O: non-luminous, selectively- +# radiating band emitters, not a grey body. At Tc ~3300 K the Planck peak sits +# near 0.9 um, far from the 2.7/4.3/6.3 um bands, so most of the available +# energy lies in spectral windows where the gas is transparent. +# +# 0.10 is an ENGINEERING ESTIMATE, not a measured or computed value, and is +# uncertain to roughly a factor of 2. It is calibrated against the outcome rather +# than derived from first principles: +# +# Goebel, Kniesner, Frey, Knab & Mundt, "Radiative Heat Transfer Analysis in +# Modern Rocket Combustion Chambers", EUCASS 2013 / CEAS Space Journal 6:79 +# (2014). CFD (NSMB, P1 radiation model, Smith and Denison WSGG models) on a +# subscale CH4/O2 chamber gives a radiative/total wall heat flux ratio of +# 8% peak (at the injector inlet) and 2.5% integrated over the chamber. +# H2/O2 peaks at 9-10%. +# +# With eps = 0.10 this model produces ~6.8% for the canonical methalox case -- +# inside that band, at the high end (our single lumped value is closer to their +# local peak than their integrated figure, so if anything it is generous). +# +# Do not treat 0.10 as physically derived. Computing it properly means mean beam +# length + CO2/H2O partial pressures from CEA + a HITEMP-based correlation +# (Alberti et al., valid 400-3000 K to 40-50 atm; classical Hottel charts are up +# to 80% off at these pressures). That was judged not worth the cache rebuild: +# once convection is correct, radiation carries <10% of the load, so even a 2x +# error here moves total flux by ~7%. See ENGINE_DESIGN_REVIEW.md. +# +# Valid for CH4 and Ethanol, which are both practically soot-free: CH4 has no +# C-C bond so soot kinetics are slow, and Ethanol is oxygenated (CEA puts its +# equilibrium carbon at zero by MR 0.8, where CH4 and RP-1 still produce it). +# +# NOT valid for KEROSENE-CLASS fuels (RP-1, Jet-A, JP-x). Huzel & Huang eq. +# (4-17)/(4-18) treat that carbon as an insulating deposit resistance R_d in +# series with h_g, which REDUCES flux rather than acting as enhanced radiation. +# R_d is unmodelled here, so kerolox heat load is over-predicted by roughly 5x -- +# conservative in direction, but far too large to call a margin. See +# gas_transport.warn_if_carbon_depositing, which flags this at runtime. +DEFAULT_GAS_EMISSIVITY_ND = 0.10 DEFAULT_VIEW_FACTOR_ND = 1.0 # Full view (no obstruction) # Default turbulence intensity diff --git a/EngineDesign/engine/pipeline/thermal/ablative_cooling.py b/EngineDesign/engine/pipeline/thermal/ablative_cooling.py index c5a26dcd..381e08ab 100644 --- a/EngineDesign/engine/pipeline/thermal/ablative_cooling.py +++ b/EngineDesign/engine/pipeline/thermal/ablative_cooling.py @@ -160,6 +160,7 @@ def mach_from_area_ratio_supersonic(area_ratio, gamma, tol=1e-6, max_iter=50): cp_gas = _tr["cp"] Pr_gas = _tr["Pr"] k_gas = _tr["k"] + eps_gas = _tr["eps_gas"] # Recovery factor for adiabatic wall temperature # Typical value for turbulent flow: r ≈ Pr^(1/3) ≈ 0.9 @@ -252,9 +253,14 @@ def mach_from_area_ratio_supersonic(area_ratio, gamma, tol=1e-6, max_iter=50): # Convective heat flux: q_conv = h × (Taw - Tw) q_conv = h_local * throat_factor * max(Taw - T_wall, 0.0) - # Radiative heat flux: q_rad = ε × σ × (Tg⁴ - Tw⁴) - emissivity = ablative_config.surface_emissivity - q_rad = emissivity * STEFAN_BOLTZMANN_W_M2_K4 * (T_local ** 4 - T_wall ** 4) + # Radiative heat flux: q_rad = ε_gas × σ × (Tg⁴ - Tw⁴) + # + # ε here must be the GAS emissivity. This previously used + # ablative_config.surface_emissivity -- the CHARRED WALL's emissivity + # (~0.85, correct for the wall's own re-radiation below) -- which + # overstated gas radiation by roughly an order of magnitude for + # non-luminous CO2/H2O products. + q_rad = eps_gas * STEFAN_BOLTZMANN_W_M2_K4 * (T_local ** 4 - T_wall ** 4) q_rad = max(q_rad, 0.0) # Only positive (gas → wall) # Incident heat flux (total from gas to wall) diff --git a/EngineDesign/engine/pipeline/thermal/gas_transport.py b/EngineDesign/engine/pipeline/thermal/gas_transport.py index c9f31f4f..8dc693d0 100644 --- a/EngineDesign/engine/pipeline/thermal/gas_transport.py +++ b/EngineDesign/engine/pipeline/thermal/gas_transport.py @@ -40,9 +40,11 @@ from __future__ import annotations +import warnings from typing import Dict, Optional from engine.pipeline.constants import ( + DEFAULT_GAS_EMISSIVITY_ND, DEFAULT_HOT_GAS_CP_J_KG_K, DEFAULT_HOT_GAS_PRANDTL_ND, DEFAULT_HOT_GAS_THERMAL_COND_W_M_K, @@ -93,4 +95,75 @@ def _cfg(name: str, default: float) -> float: "cp": _cfg("hot_gas_cp", DEFAULT_HOT_GAS_CP_J_KG_K), "k": _cfg("hot_gas_thermal_conductivity", DEFAULT_HOT_GAS_THERMAL_COND_W_M_K), "Pr": _cfg("hot_gas_prandtl", DEFAULT_HOT_GAS_PRANDTL_ND), + # Emissivity of the GAS for the gas -> wall radiation term. Carried here + # so the lumped and axial paths cannot diverge, and so it is impossible + # to reach for a wall surface emissivity by mistake -- which is exactly + # what compute_ablative_heat_flux_profile used to do. + "eps_gas": _cfg("radiation_emissivity_hot", DEFAULT_GAS_EMISSIVITY_ND), } + + +# Fuels whose combustion products lay down a tenacious carbon deposit on the +# chamber wall. Kerosene-class only: the deposit is driven by soot kinetics +# (aromatics and long-chain alkanes), not by equilibrium carbon. +# +# Deliberately NOT listed: +# * CH4 -- no C-C bond, so the acetylene -> PAH -> soot chain is kinetically +# slow. Practically soot-free. +# * Ethanol -- oxygenated (the OH group partly self-oxidises), and CEA puts +# its equilibrium carbon at zero by MR 0.8, where CH4 and RP-1 are +# both still producing it. Historically clean (V-2, Redstone). +_CARBON_DEPOSITING_FUELS = { + "RP-1", "RP1", "RP_1", "KEROSENE", "JETA", "JET-A", "JP-4", "JP-5", "JP-8", +} + +_warned_fuels: set[str] = set() + + +def warn_if_carbon_depositing(config) -> Optional[str]: + """Warn once per fuel that the gas-side model is wrong for kerosene-class fuels. + + Huzel & Huang eqs. (4-17)/(4-18) model the LO2/RP-1 carbon deposit as a + thermal resistance R_d in SERIES with the gas film, and fig. 4-25 puts + R_d at 1100-2100 in^2.sec.degF/Btu against a film resistance 1/h_g of + roughly 370 -- so the deposit dominates and cuts gas-side conductance by + about 5x. We do not model it, because the published data is a single curve + at one propellant, one chamber pressure and one mixture ratio; extrapolating + it would be inventing precision we do not have. + + The consequence is one-directional and worth stating loudly: heat load is + OVER-predicted, so liners come out oversized rather than undersized. Safe, + but badly so -- a 5x error is not a margin. + + Returns the message when one is emitted, else None (so tests can assert on + it without capturing warnings). + """ + if config is None: + return None + cea = getattr(getattr(config, "combustion", None), "cea", None) + fuel = str(getattr(cea, "fuel_name", "") or "").strip() + if fuel.upper().replace(" ", "") not in _CARBON_DEPOSITING_FUELS: + return None + + # Only relevant if a wall heat-transfer model will actually run. + def _on(name: str) -> bool: + return bool(getattr(getattr(config, name, None), "enabled", False)) + + if not (_on("ablative_cooling") or _on("regen_cooling") or _on("graphite_insert")): + return None + + if fuel in _warned_fuels: + return None + _warned_fuels.add(fuel) + + msg = ( + f"Propellant fuel '{fuel}' is kerosene-class and deposits carbon on the " + f"chamber wall. That deposit is an insulating thermal resistance in series " + f"with the gas film (Huzel & Huang eq. 4-17/4-18) and is NOT modelled here. " + f"Gas-side heat load is therefore over-predicted -- by roughly 5x at " + f"Huzel's fig. 4-25 conditions. Liner thicknesses will be oversized; treat " + f"absolute heat flux, wall temperature and recession as unreliable for this " + f"propellant." + ) + warnings.warn(msg, stacklevel=2) + return msg diff --git a/EngineDesign/engine/pipeline/thermal/regen_cooling.py b/EngineDesign/engine/pipeline/thermal/regen_cooling.py index ac7673e0..c282c84a 100644 --- a/EngineDesign/engine/pipeline/thermal/regen_cooling.py +++ b/EngineDesign/engine/pipeline/thermal/regen_cooling.py @@ -15,7 +15,6 @@ DEFAULT_COOLANT_TEMP_K, DEFAULT_HOT_GAS_VISC_PA_S, DEFAULT_HOT_GAS_THERMAL_COND_W_M_K, - DEFAULT_EMISSIVITY_ND, DEFAULT_VIEW_FACTOR_ND, NU_LAMINAR_ND, NU_TURBULENT_COEFFICIENT_ND, @@ -606,7 +605,9 @@ def estimate_hot_wall_heat_flux( delta_T = max(Taw - wall_temperature, 0.0) heat_flux_conv = h_g * delta_T - emissivity = config.radiation_emissivity_hot if config is not None else DEFAULT_EMISSIVITY_ND + # Gas emissivity via the shared provider, so this and the axial profile in + # ablative_cooling cannot drift apart. + emissivity = _tr["eps_gas"] view_factor = config.radiation_view_factor if config is not None else DEFAULT_VIEW_FACTOR_ND heat_flux_rad = emissivity * view_factor * STEFAN_BOLTZMANN_W_M2_K4 * ( Tc ** 4 - wall_temperature ** 4 From 61c28bf6faf00c8b18c9177cd1428b690bf017d1 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 12:59:11 -0700 Subject: [PATCH 13/35] thermal (layer 3): warn on kero mixes that temperature modeling is shit, validate wall solver against Huzel sample calc 4-7 --- EngineDesign/engine/core/runner.py | 6 + .../tests/test_gas_side_heat_transfer.py | 134 ++++++++++++++++-- 2 files changed, 126 insertions(+), 14 deletions(-) diff --git a/EngineDesign/engine/core/runner.py b/EngineDesign/engine/core/runner.py index e6d4b54b..b9894a2d 100644 --- a/EngineDesign/engine/core/runner.py +++ b/EngineDesign/engine/core/runner.py @@ -73,6 +73,12 @@ def __init__(self, config: PintleEngineConfig): # (deep copy is done in Layer 3 before passing config to runner) self.config = config cg = ensure_chamber_geometry(self.config) + + # Kerosene-class fuels lay down an insulating carbon deposit we do not + # model; the gas-side heat load is over-predicted for them. Warn once + # rather than let the number look trustworthy. + from engine.pipeline.thermal.gas_transport import warn_if_carbon_depositing + warn_if_carbon_depositing(self.config) # Initialize CEA cache self.cea_cache = CEACache(config.combustion.cea) diff --git a/EngineDesign/tests/test_gas_side_heat_transfer.py b/EngineDesign/tests/test_gas_side_heat_transfer.py index a96f8261..61cf26b5 100644 --- a/EngineDesign/tests/test_gas_side_heat_transfer.py +++ b/EngineDesign/tests/test_gas_side_heat_transfer.py @@ -170,25 +170,20 @@ def test_total_flux_magnitude(self): f"plausible 0.5-15 MW/m^2 band" ) - @pytest.mark.xfail( - strict=True, - reason=( - "Gas emissivity is hardcoded to the wall's surface emissivity " - "(0.8-0.85). That value is correct for charred phenolic but wrong " - "for the gas: LOX/CH4 products are non-luminous CO2/H2O band " - "radiators, not a grey body, so radiation is overstated by roughly " - "an order of magnitude. Needs a separate gas_emissivity, ideally " - "from mean beam length + Hottel/WSGG. Delete this marker when fixed." - ), - ) def test_convection_dominates_over_radiation(self): """Convection must carry the bulk of the load. A near-total collapse of the convective term is invisible in the headline number, because the total is the only value surfaced to the - UI -- so this asserts the *split*, not just the sum. Huzel/Sutton - design guidance puts radiation at ~5% of the total for non-luminous - propellants, and up to ~40% only for metallised or heavily sooting ones. + UI -- so this asserts the *split*, not just the sum. + + Reference for the expected split (measured, not folklore): Goebel, + Kniesner, Frey, Knab & Mundt, "Radiative Heat Transfer Analysis in + Modern Rocket Combustion Chambers", EUCASS 2013 / CEAS Space Journal + 6:79 (2014). For a subscale CH4/O2 chamber, radiative/total wall heat + flux is 8% at its local peak (injector inlet) and 2.5% integrated over + the chamber; H2/O2 peaks at 9-10%. Convection therefore carries + >90% of the load, which is what the 0.75 threshold below guards. """ result = _hot_wall() q_conv = result["heat_flux_conv"] @@ -203,6 +198,61 @@ def test_convection_dominates_over_radiation(self): ) +class TestCarbonDepositWarning: + """Kerosene-class fuels must be flagged; clean fuels must not be. + + The gas-side model omits the carbon-deposit resistance (Huzel 4-17/4-18), + which over-predicts kerolox heat load by ~5x. A silent 5x is exactly the + failure mode this whole exercise has been about, so it warns. Equally, a + warning that cried wolf on methalox would be worse than none -- hence the + negative cases carry as much weight as the positive one. + """ + + @staticmethod + def _cfg(fuel: str, ablative: bool = True): + import types + + return types.SimpleNamespace( + combustion=types.SimpleNamespace(cea=types.SimpleNamespace(fuel_name=fuel)), + ablative_cooling=types.SimpleNamespace(enabled=ablative), + regen_cooling=types.SimpleNamespace(enabled=False), + graphite_insert=types.SimpleNamespace(enabled=False), + ) + + def _warn(self, fuel: str, ablative: bool = True): + from engine.pipeline.thermal import gas_transport + + gas_transport._warned_fuels.clear() # warns once per fuel by design + return gas_transport.warn_if_carbon_depositing(self._cfg(fuel, ablative)) + + @pytest.mark.parametrize("fuel", ["RP-1", "RP1", "Kerosene", "JP-8", "Jet-A"]) + def test_warns_for_kerosene_class(self, fuel): + msg = self._warn(fuel) + assert msg is not None, f"{fuel} deposits carbon but was not flagged" + assert "over-predicted" in msg + + @pytest.mark.parametrize("fuel", ["CH4", "Ethanol", "Methanol", "LH2"]) + def test_silent_for_clean_fuels(self, fuel): + assert self._warn(fuel) is None, ( + f"{fuel} does not lay down a carbon deposit; warning here would train " + f"users to ignore the message" + ) + + def test_silent_when_no_wall_model_runs(self): + """No heat-transfer model enabled -> the limitation is irrelevant.""" + assert self._warn("RP-1", ablative=False) is None + + def test_warns_only_once_per_fuel(self): + from engine.pipeline.thermal import gas_transport + + gas_transport._warned_fuels.clear() + cfg = self._cfg("RP-1") + assert gas_transport.warn_if_carbon_depositing(cfg) is not None + assert gas_transport.warn_if_carbon_depositing(cfg) is None, ( + "repeat calls must stay quiet; a per-timestep warning would be noise" + ) + + class TestWallTemperatureSolver: """calculate_steady_state_temperature_profile must actually solve. @@ -248,6 +298,62 @@ def test_responds_to_gas_side_coefficient(self): f"the solve is ignoring the gas-side boundary condition" ) + def test_matches_huzel_sample_calculation_4_7(self): + """Reproduce a published worked example: Huzel & Huang Sample Calc 4-7. + + This is the strongest check in the file -- every other assertion here is + a plausibility bound we chose ourselves, whereas this one has a printed + answer we must land on. + + Huzel eq. (4-38), radiation-cooled nozzle extension: + + h_gc * (T_aw - T_wg) = eps * sigma * T_wg^4 + + which is exactly the surface balance this solver implements, in the + limit q_rad_in = 0 (no gas radiation term), T_ambient -> 0 (radiating to + space) and R_wall -> infinity (negligible drop through the metal, which + is the stated assumption). That the general form collapses onto Huzel's + is the justification for including the re-radiation term at all. + + Given (A-4 stage nozzle extension at area ratio 8): + h_gc = 7.1e-5 Btu/(in^2.sec.degR), T_aw = 4900 degR, eps = 0.95 + Printed answer: + T_wg = 2660 degR, q = 0.159 Btu/(in^2.sec) + """ + from engine.pipeline.thermal_analysis import ( + MaterialLayer, + ThermalBoundaryConditions, + calculate_steady_state_temperature_profile, + ) + + BTU_J = 1055.05585 + IN2_M2 = 6.4516e-4 + h_si = 7.1e-5 * BTU_J / IN2_M2 * 1.8 # Btu/(in^2.s.degR) -> W/(m^2.K) + Taw_si = 4900.0 / 1.8 # degR -> K + + layers = [MaterialLayer("refractory", 0.002, 100.0, 10000.0, 300.0, + emissivity=0.95)] + r = calculate_steady_state_temperature_profile( + layers, + ThermalBoundaryConditions( + T_hot_gas=Taw_si, h_hot_gas=h_si, q_rad_hot=0.0, + T_ambient=1e-6, # radiating to space + h_ambient=1e-9, # adiabatic back face + ), + ) + T_wg_degR = r["T_surface_hot"] * 1.8 + q_si = h_si * (Taw_si - r["T_surface_hot"]) + q_huzel = 0.159 * BTU_J / IN2_M2 + + assert T_wg_degR == pytest.approx(2660.0, rel=0.01), ( + f"wall temperature {T_wg_degR:.0f} degR does not reproduce Huzel " + f"Sample Calc 4-7 (2660 degR)" + ) + assert q_si == pytest.approx(q_huzel, rel=0.02), ( + f"heat flux {q_si/1e3:.1f} kW/m^2 does not reproduce Huzel's " + f"{q_huzel/1e3:.1f} kW/m^2" + ) + def test_responds_to_wall_conductivity(self): """A better-conducting liner must run a hotter back face.""" insulating = self._profile(k_abl=0.1)["T_surface_cold"] From c31dbf2f86f5f239c841e23b9656654783bc4f0c Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 13:54:35 -0700 Subject: [PATCH 14/35] chug: forward real injector pressure drop to the stability model, stop assuming a stiff injector --- EngineDesign/engine/core/runner.py | 16 +++++++ .../engine/pipeline/stability/analysis.py | 44 ++++++++++++++----- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/EngineDesign/engine/core/runner.py b/EngineDesign/engine/core/runner.py index b9894a2d..f687c660 100644 --- a/EngineDesign/engine/core/runner.py +++ b/EngineDesign/engine/core/runner.py @@ -827,6 +827,11 @@ def evaluate_arrays_with_time( # — which is a tanh-squashed gate value that saturates ~1.3 — this is the physical, # unbounded number. NaN here means the physical stability model fell back. "chug_gain_margin": np.full(n, np.nan), + # Injector stiffness eta = dP_inj/Pc per side. The calibration-free chug criterion + # (conventional range 0.15-0.25). eta ~ mdot, so in blowdown it falls monotonically and + # its minimum lands at depletion — the design-relevant worst point. + "eta_inj_O": np.full(n, np.nan), + "eta_inj_F": np.full(n, np.nan), "stability_score": np.full(n, np.nan), "stability_state": np.full(n, "unstable", dtype=object), } @@ -946,6 +951,14 @@ def evaluate_arrays_with_time( "mdot_F": diagnostics["mdot_F"], "P_tank_O": float(P_tank_O[i]), "P_tank_F": float(P_tank_F[i]), + # Injector/feed dP drive the chug model's stiffness term (eta = dP_inj/Pc). + # These were NOT forwarded, so the stability model fell back to an assumed + # 30%-stiff injector on every timestep of every array eval — i.e. the chug + # gate never saw a real injector pressure drop in the time-series path. + "delta_p_injector_O": diagnostics.get("delta_p_injector_O"), + "delta_p_injector_F": diagnostics.get("delta_p_injector_F"), + "delta_p_feed_O": diagnostics.get("delta_p_feed_O"), + "delta_p_feed_F": diagnostics.get("delta_p_feed_F"), } stability_results = comprehensive_stability_analysis( @@ -963,6 +976,9 @@ def evaluate_arrays_with_time( # Extract stability metrics results["chugging_stability_margin"][i] = stability_results.get("chugging", {}).get("stability_margin", np.nan) results["chug_gain_margin"][i] = stability_results.get("chugging", {}).get("chug_gain_margin", np.nan) + _chg = stability_results.get("chugging", {}) + results["eta_inj_O"][i] = _chg.get("eta_inj_O") if _chg.get("eta_inj_O") is not None else np.nan + results["eta_inj_F"][i] = _chg.get("eta_inj_F") if _chg.get("eta_inj_F") is not None else np.nan results["stability_score"][i] = stability_results.get("stability_score", np.nan) results["stability_state"][i] = stability_results.get("stability_state", "unstable") except Exception as e: diff --git a/EngineDesign/engine/pipeline/stability/analysis.py b/EngineDesign/engine/pipeline/stability/analysis.py index e91fda89..8328ba2e 100644 --- a/EngineDesign/engine/pipeline/stability/analysis.py +++ b/EngineDesign/engine/pipeline/stability/analysis.py @@ -340,14 +340,16 @@ def _warn_stability_fallback_once(exc: Exception) -> None: def _chug_gate_margin(gain_margin: float) -> float: + # Fail CLOSED on a non-finite margin. This used to return a neutral-pass 1.10, which meant a + # NaN gain margin (missing injector dP, un-converged root find) silently passed every gate. if not np.isfinite(gain_margin): - return 1.10 # neutral-pass when unknown (do not spuriously fail) + return _STABILITY_FAILSAFE_MARGIN return float(1.0 + _GATE_SPAN * np.tanh((gain_margin - _CHUG_GATE_CENTER) / _CHUG_GATE_SCALE)) def _acoustic_gate_margin(alpha_max: float) -> float: if not np.isfinite(alpha_max): - return 1.10 + return _STABILITY_FAILSAFE_MARGIN return float(1.0 + _GATE_SPAN * np.tanh((_ACOUSTIC_GATE_OFFSET - alpha_max) / _ACOUSTIC_GATE_SCALE)) @@ -403,12 +405,30 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta nu_g = mu_g / rho_g if rho_g > 0 else 2.0e-5 a_snd = core.sound_speed(gamma, R, Tc) + from engine.pipeline.assumptions import assume + mdot_O = float(diagnostics.get("mdot_O") or mdot_total * MR / (1.0 + MR)) mdot_F = float(diagnostics.get("mdot_F") or mdot_total / (1.0 + MR)) - dpiO = float(diagnostics.get("delta_p_injector_O") or 0.30 * Pc) - dpiF = float(diagnostics.get("delta_p_injector_F") or 0.30 * Pc) - dpfO = float(diagnostics.get("delta_p_feed_O") or 0.10 * Pc) - dpfF = float(diagnostics.get("delta_p_feed_F") or 0.10 * Pc) + + def _dp_injector(side: str) -> float: + """Injector-face dP. NO fallback: this is the chug stiffness driver (eta = dP_inj/Pc), so a + missing value must poison the margin (-> NaN -> fail closed), never coast on an assumed + healthy injector. The old ``or 0.30 * Pc`` silently declared every such design 30% stiff — + and because the array path never forwarded these keys, that was EVERY time-series eval.""" + v = diagnostics.get(f"delta_p_injector_{side}") + return float(v) if v is not None and np.isfinite(v) else float("nan") + + def _dp_feed(side: str) -> float: + """Feed-line dP. Secondary (sets feed resistance, not stiffness), so a missing value is a + DECLARED assumption rather than a hard fail — but it is recorded, not hidden in a literal.""" + v = diagnostics.get(f"delta_p_feed_{side}") + if v is not None and np.isfinite(v): + return float(v) + return float(assume(f"stability.delta_p_feed_{side}", 0.10 * Pc, unit="Pa", + reason=f"delta_p_feed_{side} missing from eval diagnostics")) + + dpiO, dpiF = _dp_injector("O"), _dp_injector("F") + dpfO, dpfF = _dp_feed("O"), _dp_feed("F") D32_O = float(diagnostics.get("D32_O") or 80e-6) D32_F = float(diagnostics.get("D32_F") or 60e-6) ov = overrides or {} @@ -418,10 +438,8 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta 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 - - from engine.pipeline.assumptions import assume + eta_O = dpiO / Pc if Pc > 0 else float("nan") + eta_F = dpiF / Pc if Pc > 0 else float("nan") def _fluid(key, attr, fb_value, fb_unit): v = _fluid_attr(config.fluids, key, attr, None) @@ -736,6 +754,12 @@ def comprehensive_stability_analysis( chugging["frequency"] = float(_fch) # physical chug freq, not L*/c* placeholder chugging["stability_margin"] = chug_margin chugging["chug_gain_margin"] = phys["chug"].get("gain_margin") + # Injector stiffness eta = dP_inj/Pc, per side. This is the calibration-free chug criterion + # (conventional design range 0.15-0.25); unlike the gate margin it is a real ratio and does + # not saturate. Surfaced per-timestep so the burn-long minimum can be reported/gated. + chugging["eta_inj_O"] = phys.get("eta_inj_O") + chugging["eta_inj_F"] = phys.get("eta_inj_F") + chugging["dominant_driver"] = phys["chug"].get("dominant_driver") feed_stability["stability_margin"] = chug_margin # feed-coupled instability IS chug (un-rig) if not phys["chug"].get("stable", True): issues.append("Chug (feed-coupled LF) margin low: stiffen injector or improve atomization") From 2dd8cfbee8f891d8dd74b699c7f329e54f9e6993 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 14:33:39 -0700 Subject: [PATCH 15/35] combustion: apply Huzel Tc x eta^2, so combustion efficiency is actually piped into temperature and convert cooling's energy deficit to c* via sqrt which is correct due to units issues and recovers some performance --- EngineDesign/engine/core/chamber_solver.py | 58 ++++- EngineDesign/engine/native/src/ed_chamber.c | 9 +- .../engine/pipeline/combustion_eff.py | 157 +++++++++--- ...test_chamber_temp_combustion_correction.py | 232 ++++++++++++++++++ 4 files changed, 412 insertions(+), 44 deletions(-) create mode 100644 EngineDesign/tests/test_chamber_temp_combustion_correction.py diff --git a/EngineDesign/engine/core/chamber_solver.py b/EngineDesign/engine/core/chamber_solver.py index bb713320..09f372aa 100644 --- a/EngineDesign/engine/core/chamber_solver.py +++ b/EngineDesign/engine/core/chamber_solver.py @@ -15,7 +15,11 @@ from typing import Tuple, Dict, Any, List, Optional from engine.pipeline.config_schemas import PintleEngineConfig, ensure_chamber_geometry -from engine.pipeline.combustion_eff import eta_cstar, calculate_Lstar +from engine.pipeline.combustion_eff import ( + eta_cstar, + calculate_Lstar, + calculate_actual_chamber_temp, +) from engine.pipeline.cea_cache import CEACache from engine.pipeline.thermal.film_cooling import compute_film_cooling from engine.pipeline.thermal.regen_cooling import ( @@ -312,14 +316,9 @@ def solve( if Pc_guess is None: Pc_guess = (Pc_min + Pc_max) / 2 - # Create residual function with tank pressures bound - # Create residual function with tank pressures bound - # Also pass debug to residual if needed in future (currently residual uses instance state, but we can't easily pass debug to it without changing signature broadly or storing state) - # However, residual calls eta_cstar which needs debug... - # Wait, residual() calls eta_cstar() inside. - # I need to update residual() to use debug flag. - # I'll store `self._debug = debug` temporarily or modify residual validation. - # Storing on self is easiest for this scope. + # Create residual function with tank pressures bound. residual() calls + # eta_cstar() internally and that needs the debug flag, but residual's + # signature is fixed by the root finder -- so debug is passed on self. self._debug = debug self._silent = silent # gates the display-only cooling profile in post-processing @@ -702,15 +701,18 @@ def tracked_residual_func(Pc): "fuel_props": self._get_fuel_props(), } - eta = eta_cstar( + eta, eta_components = eta_cstar( current_Lstar, self.config.combustion.efficiency, cooling_eff, advanced_params, debug=debug, + return_components=True, ) - + # Comprehensive validation of final solution + # eta here is the c* efficiency (combustion x cooling). c* is the only + # quantity it may be applied to -- see combustion_eff's module docstring. cstar_actual = eta * cea_props["cstar_ideal"] gamma = cea_props["gamma"] R = cea_props["R"] @@ -752,8 +754,25 @@ def tracked_residual_func(Pc): "MR": MR, "cstar_ideal": cea_props["cstar_ideal"], "Tc_ideal": cea_props["Tc"], # Store original ideal temperature + # Chamber temperature corrected for INCOMPLETE COMBUSTION, per Huzel + # & Huang Sample Calc 4-3 (Tc x eta_c*^2). Reported only -- the + # gas-side heat transfer is still evaluated at the uncorrected Tc, + # because _evaluate_cooling_models runs before eta is known. Closing + # that is tracked as the next thermal work item. Measured on + # configs/canonical/impinging.yaml: Tc_ideal 3013 K vs 2677 K here, + # a 336 K (11.2%) overstatement, worth roughly -19% on convective + # heat flux and -38% on gas radiation once it is wired through. + "Tc_combustion": calculate_actual_chamber_temp( + cea_props["Tc"], eta_components["eta_combustion"] + ), "cstar_actual": cstar_actual, "eta_cstar": eta, + # The two factors behind eta_cstar, unblended. eta_cstar is their + # product; anything correcting a TEMPERATURE needs eta_combustion + # alone, since cooling already reaches temperature via Tc below. + "eta_combustion": eta_components["eta_combustion"], + "eta_cooling_energy": eta_components["eta_cooling_energy"], + "eta_cooling_cstar": eta_components["eta_cooling_cstar"], "cooling_efficiency": cooling_eff, "Tc": effective_Tc, # Use effective temperature after cooling (accounts for energy removal) "gamma": gamma, @@ -957,6 +976,23 @@ def _compute_cooling_efficiency( gamma: float, R: float, ) -> float: + """Fraction of the gas enthalpy that survives film/ablative/regen cooling. + + Returned as an ENERGY fraction, 1 - Q_removed / (mdot*cp*Tc). The caller + multiplies it into the c* efficiency (combustion_eff.eta_cstar). + + KNOWN CURRENCY MISMATCH -- deliberately left alone, not overlooked. + c* ~ sqrt(Tc), so an energy deficit of f should cost c* only sqrt(1-f), + not (1-f). Applying the energy fraction linearly to c* therefore roughly + doubles the intended penalty: at cooling_eff = 0.96 the c* hit is 4% + where sqrt gives 2%. Fixing it changes every solved Pc and needs the C + mirror in ed_cooling.c updated in lockstep, so it is called out here + rather than patched silently. + + Note also that `Tc` arrives as effective_Tc -- already reduced by the + same cooling this function is measuring -- which shrinks the denominator + and slightly deepens the deficit. Second-order next to the above. + """ eff_cfg = self.config.combustion.efficiency if not eff_cfg.use_cooling_coupling: return 1.0 diff --git a/EngineDesign/engine/native/src/ed_chamber.c b/EngineDesign/engine/native/src/ed_chamber.c index 98b052e7..e881b19a 100644 --- a/EngineDesign/engine/native/src/ed_chamber.c +++ b/EngineDesign/engine/native/src/ed_chamber.c @@ -77,7 +77,14 @@ static double chamber_residual(double Pc, void *vctx) { st->fluid_F.latent_heat, st->comb.T_star_fuel_cap_K, &eta) != ED_OK) return NAN; - const double eta_final = eta.eta_total * cool.cooling_eff; + /* eta.eta_total is combustion-only (mixing x kinetics x L*) despite the + * name; eta_final is the c* efficiency. Mirrors combustion_eff.eta_cstar -- + * see that module's docstring before applying either to a temperature. + * + * cooling_eff is an ENERGY fraction and c* goes as sqrt(Tc), so the + * conversion into a c* factor is a square root. Multiplying by cooling_eff + * directly (as this did) roughly doubles cooling's penalty on c*. */ + const double eta_final = eta.eta_total * sqrt(cool.cooling_eff); if (!(isfinite(eta_final) && eta_final > 0.0 && eta_final <= 1.0)) return NAN; const double cstar_actual = eta_final * cea.cstar_ideal; diff --git a/EngineDesign/engine/pipeline/combustion_eff.py b/EngineDesign/engine/pipeline/combustion_eff.py index e2a5b6d8..849c1976 100644 --- a/EngineDesign/engine/pipeline/combustion_eff.py +++ b/EngineDesign/engine/pipeline/combustion_eff.py @@ -3,11 +3,37 @@ This module provides both: 1. Simple efficiency model (eta_cstar) - backward compatible 2. Advanced physics-based model (via combustion_physics module) + +THREE SIMILAR NAMES, THREE DIFFERENT QUANTITIES. They have been confused +before, so the distinction is spelled out here: + + eta_combustion incomplete burning alone: mixing x kinetics x L*. + combustion_physics returns this under the key + "eta_total" -- "total" there means the total of the + combustion sub-efficiencies, NOT the total efficiency. + eta_cooling energy carried off by film/ablative/regen cooling alone. + Computed by chamber_solver._compute_cooling_efficiency as + an ENERGY fraction, which is also the factor on chamber + temperature. Its effect on c* is the SQUARE ROOT of that, + because c* goes as sqrt(Tc). + eta_cstar eta_combustion x sqrt(eta_cooling), and the return value of + eta_cstar() below. + +eta_cstar is a correct name for the product: c* is reduced by both effects, +and c*_actual = eta_cstar x c*_ideal is the only place it is ever applied. +What it is NOT is "the combustion efficiency" -- this module's docstrings +used to say that, which is how a cooling term ended up hiding inside a +number that reads as combustion-only. + +The distinction bites whenever something OTHER than c* is being corrected. +Chamber temperature is the live example: cooling already reaches temperature +through chamber_solver's effective_Tc, so a temperature correction must use +eta_combustion alone. See calculate_actual_chamber_temp. """ import numpy as np import logging -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, Tuple, Union from .config_schemas import CombustionEfficiencyConfig from .constants import ( DEFAULT_CHAMBER_PRESS_PA, @@ -59,18 +85,23 @@ def eta_cstar( cooling_efficiency: float, advanced_params: Dict[str, Any], debug: bool = False, -) -> float: + return_components: bool = False, +) -> Union[float, Tuple[float, Dict[str, float]]]: """ - Calculate combustion efficiency using advanced physics-based model. - + Calculate the c* efficiency: combustion efficiency x cooling efficiency. + + Despite living in a module named for combustion, the return value is NOT + the combustion efficiency -- the cooling efficiency is multiplied in before + returning. Use return_components to get the two factors separately. + This corrects CEA's infinite-area equilibrium assumption for finite chambers. - + NOTE: CEA uses EQUILIBRIUM flow (not frozen). The correction accounts for: - Finite residence time (L*) - Incomplete mixing - Finite-rate chemistry effects - Heat losses (applied externally via cooling_efficiency) - + Parameters: ----------- Lstar : float @@ -100,11 +131,32 @@ def eta_cstar( - fuel_props: Fuel properties dict (optional) debug : bool Enable debug logging - + return_components : bool + When True, also return the unblended factors. See Returns. + Returns: -------- eta : float - Combustion efficiency (0-1) + Combustion efficiency x cooling efficiency (0-1), applied to c*_ideal. + (eta, components) : tuple, when return_components is True + components holds the factors BEFORE they are multiplied together: + eta_combustion -- incomplete burning alone (mixing x kinetics x L*) + eta_cooling_energy -- cooling as an ENERGY/temperature fraction + eta_cooling_cstar -- the same cooling as a c* factor, sqrt of the above + eta_total -- eta_combustion x eta_cooling_cstar, i.e. `eta` + + WHY THE SPLIT MATTERS: the two factors describe different physics and are + not interchangeable. Incomplete combustion means less chemical energy was + released, so it lowers BOTH c* and the chamber temperature. Cooling removes + energy downstream of that, and its effect on temperature is already carried + separately by chamber_solver's `effective_Tc`. + + So a caller correcting chamber temperature for incomplete combustion (Huzel + & Huang, Sample Calculation 4-3: design (Tc)ns = theoretical x eta_c*^2) + must use eta_combustion, NOT the blended `eta`. Using the blended value + would subtract the cooling loss from temperature a second time. + scripts/validate_chamber.py works around this by passing + cooling_efficiency=1.0; with return_components a caller no longer has to. """ from .combustion_physics import calculate_combustion_efficiency_advanced @@ -165,8 +217,11 @@ def eta_cstar( debug=debug ) - eta = results["eta_total"] - + # Combustion-only efficiency, before cooling is blended in below. Kept in + # its own name so the two factors stay separable -- see Returns. + eta_combustion = float(results["eta_total"]) + eta = eta_combustion + if debug: logging.getLogger("evaluate").info( f"[ADV_EFF_DEBUG] eta_total: {eta:.4f} " @@ -188,8 +243,22 @@ def eta_cstar( f"Check heat transfer calculations and cooling model configuration." ) + # cooling_efficiency arrives as an ENERGY fraction: 1 - Q_removed/(mdot*cp*Tc), + # i.e. the fraction of gas enthalpy surviving cooling, which is also the + # factor on chamber TEMPERATURE. c* is not linear in temperature -- + # c* = sqrt(gamma*R*Tc)/Gamma, so c* goes as sqrt(Tc). Converting currencies + # here is therefore a square root, not a pass-through. + # + # Verified against the CEA cache: reconstructing cstar_ideal from CEA's own + # Tc/gamma/R reproduces it to 0.07% mean (0.17% worst) over MR 2.4-4.0, and + # scaling Tc by f scales c* by sqrt(f) to five decimals. + # + # This previously multiplied c* by the energy fraction directly, which + # roughly doubled cooling's penalty on c*: at cooling_eff = 0.9607 it + # applied 0.9607 where 0.9801 is correct, understating c* by 2.0%. cooling_eff = float(cooling_efficiency) - eta *= cooling_eff + eta_cooling_on_cstar = float(np.sqrt(cooling_eff)) + eta *= eta_cooling_on_cstar # Validate final efficiency - no clipping, raise error if invalid if not np.isfinite(eta): @@ -203,41 +272,65 @@ def eta_cstar( f"and cooling efficiency ({cooling_eff:.4f})." ) + if return_components: + return float(eta), { + "eta_combustion": eta_combustion, + # Both currencies, because callers need different ones: anything + # correcting a TEMPERATURE wants eta_cooling_energy, anything + # correcting c* wants eta_cooling_cstar (its square root). + "eta_cooling_energy": cooling_eff, + "eta_cooling_cstar": eta_cooling_on_cstar, + "eta_total": float(eta), + } return float(eta) def calculate_actual_chamber_temp( Tc_ideal: float, - eta: float, - gamma: float + eta_combustion: float, + gamma: Optional[float] = None, ) -> float: """ - Calculate actual chamber temperature accounting for combustion efficiency. - - T_c,actual = T_c,ideal × [η / (1 - (1-η) × (γ-1)/γ)] - + Chamber temperature corrected for incomplete combustion. + + (Tc)ns,design = (Tc)ns,theoretical x eta_c*^2 + + Huzel & Huang, "Design of Liquid Propellant Rocket Engines" (NASA SP-125), + Sample Calculation 4-3, which carries 6460 degR x 0.975^2 = 6140 degR. + + The exponent is not a fitted constant. c* = sqrt(gamma*R*Tc)/Gamma, so at + fixed gamma and R, c* is proportional to sqrt(Tc); an efficiency measured + on c* therefore lands on temperature squared. + + THE ETA MUST BE COMBUSTION-ONLY. Pass eta_cstar(...)'s `eta_combustion` + component, not its blended return value. The blended value carries the + cooling efficiency, and chamber_solver already applies cooling to + temperature separately via `effective_Tc` -- squaring the blend in here + would deduct the cooling loss from temperature twice. + Parameters: ----------- Tc_ideal : float - Ideal chamber temperature from CEA [K] - eta : float - Combustion efficiency - gamma : float - Specific heat ratio - + Theoretical (CEA) chamber temperature [K] + eta_combustion : float + Combustion efficiency alone, in (0, 1] + gamma : float, optional + Unused. Retained so existing callers keep working; the previous + implementation here took a gamma because it used an unsourced + formula, eta / (1 - (1-eta)(gamma-1)/gamma), which disagreed with + Huzel by ~9% on the multiplier (0.934 vs 0.858 at eta = 0.926) and + could not be traced to any reference. + Returns: -------- Tc_actual : float [K] """ - if gamma <= 1: - return Tc_ideal - - denominator = 1.0 - (1.0 - eta) * (gamma - 1.0) / gamma - if denominator <= 0: - return Tc_ideal - - Tc_actual = Tc_ideal * (eta / denominator) - return float(Tc_actual) + del gamma # deliberately unused; see docstring + + if not np.isfinite(eta_combustion) or eta_combustion <= 0: + return float(Tc_ideal) + + return float(Tc_ideal * eta_combustion ** 2) def calculate_frozen_flow_correction( diff --git a/EngineDesign/tests/test_chamber_temp_combustion_correction.py b/EngineDesign/tests/test_chamber_temp_combustion_correction.py new file mode 100644 index 00000000..30593b8b --- /dev/null +++ b/EngineDesign/tests/test_chamber_temp_combustion_correction.py @@ -0,0 +1,232 @@ +"""Chamber temperature corrected for incomplete combustion (Huzel Sample Calc 4-3). + +CEA reports the chamber temperature an infinite-area chamber with complete +combustion would reach. A real chamber releases less energy, so it runs cooler. +Huzel & Huang apply that as + + (Tc)ns,design = (Tc)ns,theoretical x eta_c*^2 + +and Sample Calculation 4-3 carries 6460 degR x 0.975^2 = 6140 degR. + +The exponent is 2 because c* ~ sqrt(Tc), so an efficiency measured on c* lands +on temperature squared. + +These tests also guard the reason this was hard to fix rather than the fix +itself: eta_cstar() returns combustion efficiency and cooling efficiency +multiplied together, and chamber_solver already applies cooling to temperature +via effective_Tc. Squaring the blended value onto Tc would deduct the cooling +loss from temperature twice. The correction must therefore consume the +combustion-only component -- which the module used to compute and discard. +""" + +import numpy as np +import pytest + +from engine.pipeline.combustion_eff import calculate_actual_chamber_temp + + +class TestHuzelSampleCalculation4_3: + """The printed worked example is the oracle.""" + + # Huzel & Huang, NASA SP-125, Sample Calculation 4-3 (A-1 Stage Engine). + HUZEL_TC_THEORETICAL_DEGR = 6460.0 + HUZEL_ETA_CSTAR = 0.975 + HUZEL_TC_DESIGN_DEGR = 6140.0 + + def test_matches_printed_design_temperature(self): + """6460 degR x 0.975^2 should reproduce Huzel's printed 6140 degR.""" + result = calculate_actual_chamber_temp( + self.HUZEL_TC_THEORETICAL_DEGR, self.HUZEL_ETA_CSTAR + ) + # Huzel rounds to 4 significant figures; the exact product is 6141.3. + assert result == pytest.approx(self.HUZEL_TC_DESIGN_DEGR, rel=1e-3) + + def test_is_unit_agnostic(self): + """A pure ratio, so Kelvin must give the same relative answer as degR.""" + Tc_k = self.HUZEL_TC_THEORETICAL_DEGR * 5.0 / 9.0 + result = calculate_actual_chamber_temp(Tc_k, self.HUZEL_ETA_CSTAR) + assert result / Tc_k == pytest.approx(self.HUZEL_ETA_CSTAR ** 2) + + def test_exponent_is_two_not_one(self): + """Guards against someone 'simplifying' Tc*eta^2 to Tc*eta. + + c* ~ sqrt(Tc) is the entire justification for the square; a linear + version looks equally plausible and is wrong by 2.5 percent here. + """ + squared = calculate_actual_chamber_temp(3261.0, 0.926) + linear = 3261.0 * 0.926 + assert squared < linear + assert squared == pytest.approx(3261.0 * 0.926 ** 2) + + +class TestCorrectionMagnitude: + """The correction is large enough that omitting it is not a rounding error.""" + + def test_realistic_efficiency_moves_tc_hundreds_of_kelvin(self): + """At eta = 0.926 the drop is ~450 K on a LOX/CH4 chamber.""" + Tc_ideal = 3261.0 # CEA, LOX/CH4, Pc = 20 bar, MR 2.8 + Tc_corrected = calculate_actual_chamber_temp(Tc_ideal, 0.926) + drop = Tc_ideal - Tc_corrected + assert 400.0 < drop < 500.0 + + def test_perfect_combustion_is_a_no_op(self): + assert calculate_actual_chamber_temp(3261.0, 1.0) == pytest.approx(3261.0) + + def test_correction_is_monotonic_in_efficiency(self): + temps = [calculate_actual_chamber_temp(3261.0, e) + for e in (0.80, 0.85, 0.90, 0.95, 1.00)] + assert temps == sorted(temps) + + +class TestRejectsTheUnsourcedFormula: + """The previous implementation used an untraceable formula. Keep it gone. + + It was Tc * eta / (1 - (1-eta)(gamma-1)/gamma), carried no citation, and + could not be derived. At eta = 0.926, gamma = 1.1397 it gives a multiplier + of 0.934 where Huzel gives 0.858 -- a ~250 K disagreement on Tc. + """ + + def test_does_not_reproduce_the_old_multiplier(self): + eta, gamma = 0.926, 1.1397 + old = eta / (1.0 - (1.0 - eta) * (gamma - 1.0) / gamma) + new = calculate_actual_chamber_temp(1.0, eta) + assert old == pytest.approx(0.9345, abs=1e-3) + assert new == pytest.approx(0.8575, abs=1e-3) + assert abs(old - new) > 0.05 + + def test_gamma_argument_is_ignored(self): + """gamma is vestigial; passing wildly different values must not matter.""" + a = calculate_actual_chamber_temp(3261.0, 0.926, gamma=1.14) + b = calculate_actual_chamber_temp(3261.0, 0.926, gamma=1.67) + c = calculate_actual_chamber_temp(3261.0, 0.926) + assert a == b == c + + +class TestDegenerateInputs: + @pytest.mark.parametrize("bad", [0.0, -0.1, np.nan, np.inf]) + def test_non_physical_efficiency_returns_ideal_unchanged(self, bad): + """Fail open rather than emitting a zero or NaN chamber temperature.""" + assert calculate_actual_chamber_temp(3261.0, bad) == pytest.approx(3261.0) + + +class TestComponentsAreSeparable: + """eta_cstar must expose the combustion and cooling factors unblended. + + This is the property the fix depends on. If eta_cstar ever goes back to + returning only the product, the temperature correction silently starts + deducting the cooling loss twice -- once here and once via effective_Tc. + """ + + def test_return_components_splits_the_product(self): + from engine.pipeline import combustion_eff + + captured = {} + + def fake_advanced(*args, **kwargs): + captured["called"] = True + return { + "eta_total": 0.90, + "eta_Lstar": 1.0, + "eta_kinetics": 1.0, + "eta_mixing": 0.90, + } + + import engine.pipeline.combustion_physics as cp_mod + original = cp_mod.calculate_combustion_efficiency_advanced + cp_mod.calculate_combustion_efficiency_advanced = fake_advanced + try: + eta, comps = combustion_eff.eta_cstar( + 1.0, + _DummyEfficiencyConfig(), + cooling_efficiency=0.80, + advanced_params={"Ac": 1.0, "At": 0.1, "m_dot_total": 1.0, + "chamber_length": 0.2}, + return_components=True, + ) + finally: + cp_mod.calculate_combustion_efficiency_advanced = original + + assert captured["called"] + assert comps["eta_combustion"] == pytest.approx(0.90) + # Cooling is exposed in both currencies. + assert comps["eta_cooling_energy"] == pytest.approx(0.80) + assert comps["eta_cooling_cstar"] == pytest.approx(np.sqrt(0.80)) + # c* takes the square-rooted one, NOT the energy fraction. + assert eta == pytest.approx(0.90 * np.sqrt(0.80)) + assert eta != pytest.approx(0.90 * 0.80) + # The whole point: the combustion factor is recoverable, and is NOT + # the value a temperature correction would have gotten by default. + assert comps["eta_combustion"] != pytest.approx(eta) + + def test_default_return_is_still_a_bare_float(self): + """Existing callers must be unaffected.""" + import inspect + + from engine.pipeline.combustion_eff import eta_cstar + + sig = inspect.signature(eta_cstar) + assert sig.parameters["return_components"].default is False + + +class _DummyEfficiencyConfig: + """Minimal stand-in for CombustionEfficiencyConfig.""" + + +class TestCoolingCurrencyConversion: + """Cooling is measured in energy; c* is not linear in energy. + + _compute_cooling_efficiency returns 1 - Q_removed/(mdot*cp*Tc): the + fraction of gas enthalpy surviving cooling, which is the factor on + TEMPERATURE. Since c* = sqrt(gamma*R*Tc)/Gamma, the factor on c* is the + square root of it. + + Verified numerically against the CEA cache: reconstructing cstar_ideal from + CEA's own Tc/gamma/R matches to 0.07% mean over MR 2.4-4.0, and scaling Tc + by f scales c* by sqrt(f) to five decimals. + + Before this fix the energy fraction was multiplied into c* directly, which + roughly doubled cooling's c* penalty. + """ + + @staticmethod + def _cstar(Tc, gamma, R): + expo = (gamma + 1.0) / (2.0 * (gamma - 1.0)) + return np.sqrt(R * Tc / gamma) / (2.0 / (gamma + 1.0)) ** expo + + @pytest.mark.parametrize("f", [0.9607, 0.90, 0.80, 0.50]) + def test_cstar_scales_as_sqrt_of_the_temperature_factor(self, f): + """The identity the fix rests on, exercised on real CEA-like state.""" + Tc, gamma, R = 3282.8, 1.1370, 426.4 # CEA, LOX/CH4, MR 2.86, 20 bar + ratio = self._cstar(Tc * f, gamma, R) / self._cstar(Tc, gamma, R) + assert ratio == pytest.approx(np.sqrt(f), rel=1e-9) + # And emphatically not the linear factor, unless f is 1. + if f < 1.0: + assert ratio > f + + def test_penalty_on_cstar_is_roughly_half_the_energy_deficit(self): + """Sanity framing: a 4% energy loss costs c* about 2%, not 4%.""" + f = 0.9607 + energy_deficit = 1.0 - f + cstar_deficit = 1.0 - np.sqrt(f) + assert cstar_deficit == pytest.approx(energy_deficit / 2.0, rel=0.02) + + def test_no_cooling_is_a_no_op_in_both_currencies(self): + assert np.sqrt(1.0) == pytest.approx(1.0) + + +def test_correction_would_double_count_if_given_the_blended_eta(): + """Demonstrates the trap in numbers, so the docstring warning has teeth. + + Cooling already reaches temperature through effective_Tc. Feeding the + blended eta here applies it a second time, costing an extra ~1000 K. + """ + Tc_ideal = 3261.0 + eta_combustion, eta_cooling = 0.926, 0.90 + eta_blended = eta_combustion * eta_cooling + + correct = calculate_actual_chamber_temp(Tc_ideal, eta_combustion) + double_counted = calculate_actual_chamber_temp(Tc_ideal, eta_blended) + + assert correct == pytest.approx(2796.0, abs=5.0) + assert double_counted == pytest.approx(2265.0, abs=5.0) + assert correct - double_counted > 500.0 From 95ec99d84c62578d38498dc0161de71a23f605e4 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 14:37:19 -0700 Subject: [PATCH 16/35] config: guard that every config meets its own gates, plus per-commit metric recording --- EngineDesign/engine/pipeline/run_record.py | 236 ++++++++++++++++++ EngineDesign/scripts/record_runs.py | 85 +++++++ .../tests/test_config_design_validity.py | 147 +++++++++++ 3 files changed, 468 insertions(+) create mode 100644 EngineDesign/engine/pipeline/run_record.py create mode 100644 EngineDesign/scripts/record_runs.py create mode 100644 EngineDesign/tests/test_config_design_validity.py diff --git a/EngineDesign/engine/pipeline/run_record.py b/EngineDesign/engine/pipeline/run_record.py new file mode 100644 index 00000000..810b8eaa --- /dev/null +++ b/EngineDesign/engine/pipeline/run_record.py @@ -0,0 +1,236 @@ +"""Append-only run records: one row per (commit, config) evaluation. + +WHY +--- +Config drift is invisible without history. On 2026-07-21 an audit found that NO committed config +satisfied its own declared gates, and reconstructing *why* required manual git archaeology across +several commits. The proximate cause (commit 1e3f7426 replaced momentum-reconstruction thrust with +RPA delivered thrust) moved every engine's operating point while the yamls stayed frozen -- and the +second-order effect on injector stiffness went unnoticed for days. + +One row per commit per config would have made that a one-line query. + +THE KEY FIELD IS ``config_sha256`` +---------------------------------- +It is the hash of the **resolved in-memory config**, not of the yaml file. Two consequences: + + * It is invariant to file layout, so splitting intent/derived across files (or extracting shared + material presets) does not break the series. + * Combined with ``git_sha`` it disambiguates *why* a number moved: + + config_sha same, git_sha changed -> the physics moved + config_sha changed, git_sha same -> someone edited the config + both changed -> ambiguous, go look + +PROVENANCE HONESTY +------------------ +``git_dirty`` records whether the tree had uncommitted changes; ``git_diff_sha`` hashes the actual +diff so two different dirty states are distinguishable. A dirty row is NOT a baseline -- it cannot +be reproduced from a commit alone. CI (clean by construction) writes the real series. + +Rows recorded before the config cleanup lands are archaeology, not baseline: at the time of writing +7 of 10 configs fail their own gates and ``default.yaml`` evaluates to negative thrust. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path +from typing import Any, Dict, Optional + +import numpy as np + +# Layer-1 final-validation defaults (layer1_static_optimization.py:2401 / :3177). +DEFAULT_THRUST_TOL = 0.10 +DEFAULT_OF_TOL = 0.15 + +PSI = 6894.757 + + +def _git(root: Path, *args: str) -> Optional[str]: + """Run a git command, returning stripped stdout or None if git/repo is unavailable.""" + try: + out = subprocess.run( + ["git", *args], cwd=str(root), capture_output=True, text=True, timeout=30, check=False + ) + except (OSError, subprocess.SubprocessError): + return None + return out.stdout.strip() if out.returncode == 0 else None + + +def git_provenance(root: Path) -> Dict[str, Any]: + """Commit identity plus an honest dirty-state fingerprint. + + ``git_diff_sha`` is the hash of the working-tree diff (tracked files only). It is what lets two + dirty runs be told apart; without it every local experiment looks identical in the record. + """ + sha = _git(root, "rev-parse", "HEAD") + porcelain = _git(root, "status", "--porcelain") + dirty = bool(porcelain) + diff_sha = None + if dirty: + diff = _git(root, "diff", "HEAD") + if diff is not None: + diff_sha = hashlib.sha256(diff.encode("utf-8", "replace")).hexdigest()[:16] + return { + "git_sha": sha, + "git_dirty": dirty, + "git_diff_sha": diff_sha, + "git_branch": _git(root, "rev-parse", "--abbrev-ref", "HEAD"), + } + + +def config_fingerprint(config: Any) -> str: + """SHA-256 of the RESOLVED config (post-preset-overlay), not of the source file. + + Hashing the file would make the series break the moment configs are split across files or start + inheriting from shared presets. Hashing what the solver actually sees does not. + """ + payload = config.model_dump(mode="json") + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _f(value: Any) -> float: + """Coerce to float, mapping None/non-numeric to NaN so a row never fails to serialize.""" + try: + out = float(value) + except (TypeError, ValueError): + return float("nan") + return out + + +def _jsonable(value: Any) -> Any: + """JSON has no NaN/Infinity — emit null so rows stay parseable by strict readers.""" + if isinstance(value, float) and not np.isfinite(value): + return None + return value + + +def evaluate_config(config_path: Path) -> Dict[str, Any]: + """Load a config and evaluate it at its OWN declared design point. + + Shared by the CI guard (``tests/test_config_design_validity.py``) and the run recorder so the + two cannot drift apart on what "evaluated at its design point" means. + + ``kind`` is one of ``not_engine`` / ``load_error`` / ``eval_error`` / ``checked``. + """ + from engine.core.runner import PintleEngineRunner + from engine.optimizer.injector_dp_penalty import ( + injector_dp_ratio_within_gate, + injector_dp_ratios_from_eval_result, + ) + from engine.pipeline.io import load_config + + import yaml + + # Classify BEFORE the schema load: non-engine yamls under configs/ (e.g. the robust-DDP + # controller config) legitimately fail PintleEngineConfig validation, and that is not staleness. + try: + with open(config_path, "r", encoding="utf-8") as fh: + raw = yaml.safe_load(fh) or {} + except Exception as exc: # noqa: BLE001 - unreadable file is a real, reportable condition + return {"kind": "load_error", "detail": f"{type(exc).__name__}: {exc}"} + if not isinstance(raw, dict) or not (raw.get("design_requirements") or {}).get("target_thrust"): + return {"kind": "not_engine", "detail": "no design_requirements.target_thrust"} + + try: + cfg = load_config(str(config_path)) + except Exception as exc: # noqa: BLE001 - surfaced as a row/verdict, not raised + return {"kind": "load_error", "detail": f"{type(exc).__name__}: {exc}"} + + req = cfg.design_requirements + target_thrust = _f(getattr(req, "target_thrust", None)) + target_of = _f(getattr(req, "optimal_of_ratio", None)) + + try: + res = PintleEngineRunner(cfg).evaluate( + _f(cfg.lox_tank.initial_pressure_psi) * PSI, + _f(cfg.fuel_tank.initial_pressure_psi) * PSI, + silent=True, + ) + except Exception as exc: # noqa: BLE001 - a config that cannot evaluate is the finding + return { + "kind": "eval_error", + "detail": f"{type(exc).__name__}: {str(exc)[:200]}", + "config_sha256": config_fingerprint(cfg), + } + + pc = _f(res.get("Pc")) + F = _f(res.get("F")) + MR = _f(res.get("MR")) + stab = res.get("stability") or res.get("stability_results") or {} + chug = stab.get("chugging", {}) or {} + ro, rf = injector_dp_ratios_from_eval_result(pc, res) + + thr_tol = _f(getattr(req, "layer1_thrust_validation_rel_tol", None) or DEFAULT_THRUST_TOL) + of_tol = _f(getattr(req, "layer1_of_validation_tol", None) or DEFAULT_OF_TOL) + + thrust_err = abs(F - target_thrust) / target_thrust if target_thrust > 0 else float("nan") + of_err = abs(MR - target_of) / target_of if target_of > 0 else float("nan") + + failures = [] + if not (thrust_err <= thr_tol): + failures.append( + f"thrust {F:.0f}N vs target {target_thrust:.0f}N ({thrust_err*100:.1f}% > {thr_tol*100:.0f}%)" + ) + if target_of > 0 and not (of_err <= of_tol): + failures.append( + f"O/F {MR:.2f} vs target {target_of:.2f} ({of_err*100:.1f}% > {of_tol*100:.0f}%)" + ) + for side, ratio in (("O", ro), ("F", rf)): + lo = getattr(req, f"injector_dp_ratio_{side}_min", None) + hi = getattr(req, f"injector_dp_ratio_{side}_max", None) + if lo is None or hi is None: + continue + if injector_dp_ratio_within_gate(ratio, _f(lo), _f(hi)) is False: + failures.append( + f"dP_inj_{side}/Pc {_f(ratio):.3f} outside band [{_f(lo):.2f}, {_f(hi):.2f}]" + ) + + return { + "kind": "checked", + "ok": not failures, + "failures": failures, + "config_sha256": config_fingerprint(cfg), + "metrics": { + "Pc": pc, + "F": F, + "Isp": _f(res.get("Isp")), + "MR": MR, + "mdot_total": _f(res.get("mdot_total")), + "eta_inj_O": _f(ro), + "eta_inj_F": _f(rf), + "chug_gain_margin": _f(chug.get("chug_gain_margin")), + "chugging_stability_margin": _f(chug.get("stability_margin")), + "stability_source": stab.get("stability_source"), + "target_thrust": target_thrust, + "target_of_ratio": target_of, + "thrust_err": thrust_err, + "of_err": of_err, + }, + } + + +def build_row(config_path: Path, root: Path, *, timestamp: str) -> Dict[str, Any]: + """One JSONL row: provenance + config identity + metrics + gate verdict. + + ``timestamp`` is passed in rather than read here so a batch of rows shares one instant and the + caller owns clock policy. + """ + verdict = evaluate_config(config_path) + row: Dict[str, Any] = { + "ts": timestamp, + "config_path": config_path.relative_to(root / "configs").as_posix(), + "config_sha256": verdict.get("config_sha256"), + "kind": verdict["kind"], + "gates_passed": verdict.get("ok"), + "failures": verdict.get("failures") or [], + "detail": verdict.get("detail"), + **git_provenance(root), + } + for key, value in (verdict.get("metrics") or {}).items(): + row[key] = _jsonable(value) + return row diff --git a/EngineDesign/scripts/record_runs.py b/EngineDesign/scripts/record_runs.py new file mode 100644 index 00000000..c1e31606 --- /dev/null +++ b/EngineDesign/scripts/record_runs.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Emit one JSONL row per config: git provenance + resolved-config hash + evaluated metrics. + +Intended primary caller is CI on push to main, appending to the ``metrics/engine-design`` orphan +branch. The name is namespaced because this is a monorepo -- an unscoped ``metrics`` branch would +claim the name for every subproject, and git refs cannot have both a file and a directory at one +path, so a plain ``metrics`` branch would permanently block ``metrics/``. + +Local use is fine for before/after comparisons, but local rows carry ``git_dirty: true`` and are +NOT a baseline -- they cannot be reproduced from a commit alone. See ``engine/pipeline/run_record.py``. + +Usage: + py -3.13 scripts/record_runs.py # rows to stdout + py -3.13 scripts/record_runs.py --out runs.jsonl # append to a file + py -3.13 scripts/record_runs.py --config canonical/impinging.yaml +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from engine.pipeline.run_record import build_row # noqa: E402 + + +def discover_configs() -> list[Path]: + cfg_root = ROOT / "configs" + return sorted(cfg_root.glob("*.yaml")) + sorted(cfg_root.glob("canonical/*.yaml")) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", type=Path, default=None, + help="append JSONL here (default: stdout)") + ap.add_argument("--config", action="append", default=None, + help="config path relative to configs/ (repeatable; default: all)") + ap.add_argument("--skip-non-engine", action="store_true", + help="omit rows for yamls without a thrust target instead of recording them") + args = ap.parse_args() + + if args.config: + paths = [ROOT / "configs" / c for c in args.config] + missing = [p for p in paths if not p.exists()] + if missing: + print(f"no such config(s): {[str(p) for p in missing]}", file=sys.stderr) + return 2 + else: + paths = discover_configs() + + # One instant for the whole batch so rows from a single invocation group cleanly. + timestamp = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds") + + cwd = os.getcwd() + os.chdir(ROOT) # config loading resolves cache/preset paths relative to the project root + try: + rows = [build_row(p, ROOT, timestamp=timestamp) for p in paths] + finally: + os.chdir(cwd) + + if args.skip_non_engine: + rows = [r for r in rows if r["kind"] != "not_engine"] + + lines = "".join(json.dumps(r, sort_keys=True) + "\n" for r in rows) + if args.out: + args.out.parent.mkdir(parents=True, exist_ok=True) + with open(args.out, "a", encoding="utf-8") as fh: # append-only: never rewrite history + fh.write(lines) + checked = sum(1 for r in rows if r["kind"] == "checked") + passed = sum(1 for r in rows if r.get("gates_passed")) + print(f"appended {len(rows)} row(s) to {args.out} ({checked} evaluated, {passed} passing gates)", + file=sys.stderr) + else: + sys.stdout.write(lines) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/EngineDesign/tests/test_config_design_validity.py b/EngineDesign/tests/test_config_design_validity.py new file mode 100644 index 00000000..f0e54c6f --- /dev/null +++ b/EngineDesign/tests/test_config_design_validity.py @@ -0,0 +1,147 @@ +"""Every committed engine config must satisfy its OWN declared gates at its OWN design point. + +WHY THIS EXISTS (found 2026-07-21) +---------------------------------- +The configs mix hand-authored *intent* (``design_requirements``) with optimizer-produced *derived* +output (``chamber_geometry``, ``injector.geometry``, tank pressures, ``pressure_curves``) in one +file, with nothing marking which is which and nothing checking they still agree. + +They had silently drifted apart, in two independent ways: + + 1. Requirements were edited without re-running Layer 1. ``configs/canonical/impinging.yaml`` + records ``chamber_geometry.design_thrust: 7000`` while ``design_requirements.target_thrust`` + says 8000 -- the geometry block literally documents that it was solved for a different spec. + + 2. The physics moved under frozen geometry. Commit 1e3f7426 replaced momentum-reconstruction + thrust with RPA delivered thrust ("fixes ~(1-eta_c*) over-prediction"), swapped k-e mixing for + Rupe, and retired shifting equilibrium -- while the checked-in yamls only ever received small + hand edits. Same geometry, different model => the operating point moved. + +The result: NO config passed its own gates, and nothing said so. Injector stiffness +(``dP_inj/Pc``) had drifted to 0.58 on canonical and 0.12 on ``_8000N_optimal`` against a declared +band of [0.2, 0.4] -- in opposite directions, which is exactly what undirected drift looks like. + +``docs/CONFIG_SYSTEM.md`` already states the rule ("the chamber is a seed, not a solved design, +until you re-run the optimizer") and describes a ``design_valid_for`` stamp. The stamp was never +written and the check never existed. This is that check. + +HOW TO USE IT +------------- +A config that fails is NOT a broken test -- it is a config that needs Layer 1 re-run against its +current requirements. Known-stale configs are listed in ``KNOWN_STALE`` with a reason. That list is +a RATCHET: a config in it that starts passing must be removed (``test_known_stale_list_is_current`` +enforces that), so the list can only shrink. + +Do NOT fix a failure by widening the bands in the yaml. That is how the bands got to where they +are; see the ``W_SMD`` comment in canonical for the same pattern applied to a weight. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from engine.pipeline.run_record import evaluate_config # noqa: E402 + +# Configs that are known stale, with WHY. Each entry is debt, not an exemption -- see module docstring. +KNOWN_STALE: dict[str, str] = { + "canonical/impinging.yaml": ( + "geometry solved for design_thrust 7000 / design_MR 2.55 vs requirements 8000 / 2.8; " + "measured F=5774N (0.72x), MR=1.81 (0.64x), eta_O=0.576 vs band [0.2,0.4]" + ), + "canonical/pintle.yaml": "not yet audited; pintle path is a previous project's engine", + "default.yaml": "template/seed config, not a solved design (currently evaluates to NEGATIVE thrust)", + "impinging_smoke.yaml": "byte-identical results to canonical/impinging -- appears to be a stale copy of it", + "impinging_lox_ch4.yaml": "does not evaluate at its own design point (Supply < Demand at all Pc)", + "impinging_lox_ch4_8000N.yaml": "eta_O=0.492 / eta_F=0.514 vs band [0.2,0.4] (drifted stiff)", + "impinging_lox_ch4_8000N_optimal.yaml": ( + "eta_O=0.121 / eta_F=0.124 -- BELOW its own 0.2 floor and below the ~0.15 conventional " + "chug limit (drifted soft); the optimizer pushes eta down and nothing caught it" + ), +} + + +def _engine_configs() -> list[str]: + """Committed engine configs, as paths relative to ``configs/``.""" + out: list[str] = [] + cfg_root = ROOT / "configs" + for p in sorted(cfg_root.glob("*.yaml")) + sorted(cfg_root.glob("canonical/*.yaml")): + out.append(p.relative_to(cfg_root).as_posix()) + return out + + +def _evaluate(rel_path: str) -> dict: + """Evaluate a config at its own declared design point. + + Delegates to ``engine.pipeline.run_record.evaluate_config`` so this guard and the run recorder + (``scripts/record_runs.py``) cannot drift apart on what "meets its own gates" means -- which is + the exact class of divergence this whole test exists to catch. + """ + cwd = os.getcwd() + os.chdir(ROOT) # config loading resolves cache/preset paths relative to the project root + try: + return evaluate_config(ROOT / "configs" / rel_path) + finally: + os.chdir(cwd) + + +@pytest.fixture(scope="module") +def verdicts() -> dict[str, dict]: + """Evaluate every config once; each evaluation is seconds, so share across tests.""" + return {rel: _evaluate(rel) for rel in _engine_configs()} + + +@pytest.mark.parametrize("rel_path", _engine_configs()) +def test_config_satisfies_its_own_gates(rel_path: str, verdicts: dict[str, dict]) -> None: + v = verdicts[rel_path] + if v["kind"] == "not_engine": + pytest.skip(v["detail"]) + + problem = None + if v["kind"] in ("load_error", "eval_error"): + problem = f"{v['kind']}: {v['detail']}" + elif not v["ok"]: + problem = "; ".join(v["failures"]) + + if problem is None: + return + + if rel_path in KNOWN_STALE: + pytest.xfail(f"known stale ({KNOWN_STALE[rel_path]}) -- current: {problem}") + + pytest.fail( + f"{rel_path} does not satisfy its own declared gates: {problem}\n" + "Re-run Layer 1 against this config's requirements to regenerate the derived blocks " + "(chamber_geometry, injector.geometry, tank pressures). Do NOT widen the bands to make " + "this pass -- see the module docstring." + ) + + +def test_known_stale_list_is_current(verdicts: dict[str, dict]) -> None: + """Ratchet: KNOWN_STALE may only shrink. + + Without this the exemption list silently becomes permanent -- a config gets fixed, nobody + removes it, and the next regression is invisible again. + """ + def _still_failing(rel: str) -> bool: + v = verdicts.get(rel, {}) + if v.get("kind") in ("load_error", "eval_error"): + return True + return v.get("kind") == "checked" and not v["ok"] + + # Covers both "it got fixed" and "it is no longer checked at all" (e.g. reclassified + # not_engine): either way the entry is dead and must go, or it shields a future regression. + stale_entries = [rel for rel in KNOWN_STALE if not _still_failing(rel)] + assert not stale_entries, ( + "These KNOWN_STALE entries no longer describe a failing config -- remove them so future " + f"regressions fail loudly: {stale_entries}" + ) + + unknown = sorted(set(KNOWN_STALE) - set(_engine_configs())) + assert not unknown, f"KNOWN_STALE names configs that no longer exist: {unknown}" From a165666fb511f2706c3b64b1c9ea7d0cb724cda7 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 14:42:01 -0700 Subject: [PATCH 17/35] parity: run A/B locally when the kernel is built, and compare against real Python not C --- .../engine/native/python/autobuild.py | 12 +++ EngineDesign/tests/test_native_ab_parity.py | 101 +++++++++++++++--- 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/EngineDesign/engine/native/python/autobuild.py b/EngineDesign/engine/native/python/autobuild.py index 583d3472..3c4cdb0f 100644 --- a/EngineDesign/engine/native/python/autobuild.py +++ b/EngineDesign/engine/native/python/autobuild.py @@ -76,6 +76,18 @@ def _configure_args(build_dir: str) -> list[str]: return args +def lib_if_built() -> str | None: + """Path to an already-built library, or None. NEVER builds or configures. + + Lets a caller ask "has this machine ever built the native kernel?" without + paying for a build. Used by the A/B parity suite to decide whether it can + run inside the general regression gate: if a library is already present the + toolchain exists, so running costs little and ensure_lib() will refresh it + if it has gone stale against the C sources. + """ + return _find_lib(_build_dir()) + + def ensure_lib(force: bool = False, verbose: bool = False) -> str: """Return a path to a current, arch-matched libed_physics, building if needed. diff --git a/EngineDesign/tests/test_native_ab_parity.py b/EngineDesign/tests/test_native_ab_parity.py index ea858858..e9b73a50 100644 --- a/EngineDesign/tests/test_native_ab_parity.py +++ b/EngineDesign/tests/test_native_ab_parity.py @@ -41,14 +41,41 @@ PSI_TO_PA = 6894.76 PA_AMBIENT = 101325.0 -# Opt-in: this suite is the native-parity CI job's payload (ED_REQUIRE_NATIVE=1). -# It self-skips in the general regression gate (`pytest tests/`) so the C-vs- -# Python comparison is reported once, in the job whose contract it enforces. -# Set ED_AB_PARITY=1 to run it locally/ad hoc. -pytestmark = pytest.mark.skipif( - os.environ.get("ED_REQUIRE_NATIVE") != "1" and os.environ.get("ED_AB_PARITY") != "1", - reason="A/B parity runs in the native-parity CI job; set ED_AB_PARITY=1 to run locally", -) +def _parity_should_run() -> tuple[bool, str]: + """Run whenever this machine has already built the native kernel. + + This suite is the native-parity CI job's payload, but it used to skip + unconditionally outside that job -- which meant `pytest tests/` could report + all-green while comparing today's Python against a stale binary. That is not + hypothetical: the native kernel is the DEFAULT execution path + (native_injector.native_enabled() is True unless ED_USE_NATIVE=0) and + chamber_solver._native_chamber_pc runs the whole Pc solve in C, so a + physics change made only in Python does not affect shipped numbers at all. + A green suite that never exercised C is actively misleading. + + So: if a library is already built, the toolchain exists and running is + cheap -- and ensure_lib() rebuilds it when it is older than the C sources, + which is exactly the stale-binary case. If nothing has ever been built, + skip rather than impose a first-time CMake build on a plain test run. + + ED_AB_PARITY=1 forces it on, ED_AB_PARITY=0 forces it off. + """ + if os.environ.get("ED_REQUIRE_NATIVE") == "1" or os.environ.get("ED_AB_PARITY") == "1": + return True, "" + if os.environ.get("ED_AB_PARITY") == "0": + return False, "ED_AB_PARITY=0 explicitly disables the A/B parity suite" + try: + from engine.native.python import autobuild + if autobuild.lib_if_built() is not None: + return True, "" + except Exception as exc: # import problems must not break collection + return False, f"native autobuild unavailable ({exc})" + return False, ("no native library has been built on this machine; build it " + "or set ED_AB_PARITY=1 to force the A/B parity suite") + + +_SHOULD_RUN, _SKIP_REASON = _parity_should_run() +pytestmark = pytest.mark.skipif(not _SHOULD_RUN, reason=_SKIP_REASON) # Tank-pressure points (psi) on the canonical impinging engine — nominal plus # off-nominal, same operating window the manual parity tools exercised. @@ -104,19 +131,67 @@ def native_injector_mod(): @pytest.fixture(scope="module") def rig(native_injector_mod): - """Config + runner + per-point live Python reference results.""" + """Config + runner + per-point PYTHON reference results. + + The reference MUST be computed with the native path disabled. The native + kernel is the default execution path -- native_injector.native_enabled() is + True unless ED_USE_NATIVE=0, and chamber_solver._native_chamber_pc then + runs the entire Pc solve in C and skips Python's Brent entirely. + + Measured, with a deliberate Python-only divergence live (the cooling->c* + conversion forced back to linear), reference taken from a default + runner.evaluate(): + + Pc, F, Isp |native - "python"| / native = 0.000e+00 + eta_cstar, cstar_actual |native - "python"| / native = 1.976e-02 + + i.e. the headline performance numbers were served straight from C and + compared against themselves -- they could not disagree no matter what + Python did. Only the fields Python recomputes on top of C's Pc (the + efficiency diagnostics) were genuine comparisons. Forcing the reference + onto the Python solver makes every field a real Python-vs-C check. + + native_enabled() re-reads the env var on every call, so toggling it around + the reference computation is sufficient; no reload is needed. + + """ from engine.core.runner import PintleEngineRunner from engine.pipeline.io import load_config config = load_config(ROOT / "configs" / "canonical" / "impinging.yaml") if not native_injector_mod._can_handle_chamber(config): pytest.fail("native kernel reports it cannot handle the canonical impinging config") - runner = PintleEngineRunner(config) points = [(po * PSI_TO_PA, pf * PSI_TO_PA) for po, pf in POINTS_PSI] - reference = {} - for p_o, p_f in points: - reference[(p_o, p_f)] = runner.evaluate(p_o, p_f, P_ambient=PA_AMBIENT, silent=True) + + # ED_REQUIRE_NATIVE must come off with it: the CI parity job sets it to 1, + # and closure._native_solve raises outright when native is required but + # disabled (closure.py:59). Leaving it set would make this fixture explode + # in CI while passing locally. + prior = {k: os.environ.get(k) for k in ("ED_USE_NATIVE", "ED_REQUIRE_NATIVE")} + os.environ["ED_USE_NATIVE"] = "0" + os.environ["ED_REQUIRE_NATIVE"] = "0" + try: + if native_injector_mod.native_enabled(): + pytest.fail( + "ED_USE_NATIVE=0 did not disable the native path; the reference " + "would be C, not Python, and this suite would compare C to itself" + ) + runner = PintleEngineRunner(config) + reference = { + (p_o, p_f): runner.evaluate(p_o, p_f, P_ambient=PA_AMBIENT, silent=True) + for p_o, p_f in points + } + finally: + for key, value in prior.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + if not native_injector_mod.native_enabled(): + pytest.fail("native path did not re-enable after building the Python reference") + return {"config": config, "runner": runner, "points": points, "reference": reference} From f65a43e9465d6c7e844a90fb7cf959ae83cc97f9 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 14:50:01 -0700 Subject: [PATCH 18/35] backend: boot on canonical/impinging, not the presetless default.yaml that evaluates to negative thrust --- EngineDesign/backend/main.py | 21 ++++++++++++++----- .../tests/test_config_design_validity.py | 7 ++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/EngineDesign/backend/main.py b/EngineDesign/backend/main.py index c2d92f73..f8b4ed0d 100644 --- a/EngineDesign/backend/main.py +++ b/EngineDesign/backend/main.py @@ -52,16 +52,27 @@ @asynccontextmanager async def lifespan(app: FastAPI): - """Application lifespan - load default config on startup.""" - # Try to load default config on startup - default_config_path = project_root / "configs" / "default.yaml" + """Application lifespan - load the canonical impinging config on startup. + + Was ``configs/default.yaml``, which sets no ``propellant_preset`` -- so the loaded config had + NO fluid identity (``fluids.oxidizer.name`` and ``fluids.fuel.name`` both None). Evaluating it + yields non-finite solver residuals, and via the runner it produced NEGATIVE thrust + (F = -334 N, Isp = -7.27 s). The app booted on that config, so a fresh session evaluated a + physically impossible engine until the user loaded something else. + + The canonical configs are already the documented source of truth -- ``POST /api/config/switch`` + loads ``configs/canonical/.yaml`` wholesale on an injector change (see + CONFIG_SYSTEM.md). Booting from one removes a third, unmaintained config from the startup path + instead of keeping it in sync. + """ + default_config_path = project_root / "configs" / "canonical" / "impinging.yaml" if default_config_path.exists(): try: config_obj = load_config(str(default_config_path)) app_state.set_config(config_obj, str(default_config_path)) - print(f"Loaded default config from {default_config_path}") + print(f"Loaded startup config from {default_config_path}") except Exception as e: - print(f"Warning: Could not load default config: {e}") + print(f"Warning: Could not load startup config: {e}") yield # App runs here diff --git a/EngineDesign/tests/test_config_design_validity.py b/EngineDesign/tests/test_config_design_validity.py index f0e54c6f..2a7fdc61 100644 --- a/EngineDesign/tests/test_config_design_validity.py +++ b/EngineDesign/tests/test_config_design_validity.py @@ -56,7 +56,12 @@ "measured F=5774N (0.72x), MR=1.81 (0.64x), eta_O=0.576 vs band [0.2,0.4]" ), "canonical/pintle.yaml": "not yet audited; pintle path is a previous project's engine", - "default.yaml": "template/seed config, not a solved design (currently evaluates to NEGATIVE thrust)", + "default.yaml": ( + "sets no propellant_preset, so fluids.oxidizer.name / fluids.fuel.name are both None and " + "the solver residual goes non-finite; via the runner it yields NEGATIVE thrust " + "(F=-334N, Isp=-7.27s). No longer the backend startup config (see backend/main.py) -- " + "still referenced by scripts/docs, so left in place rather than deleted" + ), "impinging_smoke.yaml": "byte-identical results to canonical/impinging -- appears to be a stale copy of it", "impinging_lox_ch4.yaml": "does not evaluate at its own design point (Supply < Demand at all Pc)", "impinging_lox_ch4_8000N.yaml": "eta_O=0.492 / eta_F=0.514 vs band [0.2,0.4] (drifted stiff)", From 183259642a435345615f10ad085753518f78f51a Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 14:52:22 -0700 Subject: [PATCH 19/35] thermal: evaluate heat transfer at the combustion-corrected chamber temperature, not CEA's ideal --- EngineDesign/engine/core/chamber_solver.py | 158 ++++++++++++------ EngineDesign/engine/native/src/ed_chamber.c | 20 ++- .../engine/pipeline/combustion_eff.py | 18 +- 3 files changed, 141 insertions(+), 55 deletions(-) diff --git a/EngineDesign/engine/core/chamber_solver.py b/EngineDesign/engine/core/chamber_solver.py index 09f372aa..de835c94 100644 --- a/EngineDesign/engine/core/chamber_solver.py +++ b/EngineDesign/engine/core/chamber_solver.py @@ -19,6 +19,7 @@ eta_cstar, calculate_Lstar, calculate_actual_chamber_temp, + blend_cooling_into_cstar, ) from engine.pipeline.cea_cache import CEACache from engine.pipeline.thermal.film_cooling import compute_film_cooling @@ -158,15 +159,6 @@ def residual(self, Pc: float, P_tank_O: float, P_tank_F: float) -> float: current_injector_diameter = self._infer_injector_diameter() - cooling_results, cooling_eff, _ = self._evaluate_cooling_models( - Pc_val, - mdot_O, - mdot_F, - cea_props, - diagnostics, - with_profile=False, # root-find: profile is display-only, never needed here - ) - geometry = self._get_chamber_geometry() advanced_params = { "Pc": Pc_val, @@ -196,15 +188,45 @@ def residual(self, Pc: float, P_tank_O: float, P_tank_F: float) -> float: if hasattr(self, '_debug') and self._debug: logging.getLogger("evaluate").info(f"[SOLVER_DEBUG] Pc Guess: {Pc_val/1e6:.4f} MPa | Supply mdot: {mdot_supply:.4f} kg/s | MR: {MR:.3f}") - # Calculate efficiency using advanced physics-based model - eta = eta_cstar( + # ORDER MATTERS: combustion efficiency FIRST, then cooling. + # + # The cooling models need a gas temperature, and a real chamber runs + # cooler than CEA's ideal value because combustion is incomplete. So we + # establish eta_combustion (which needs nothing from cooling), correct + # Tc by it, and only then evaluate heat transfer. Running cooling first + # -- as this did -- computes the heat load at a temperature several + # hundred K too high. + # + # This is not circular: eta_combustion depends on geometry, Pc, mixing + # and kinetics only. Its Tc_kinetics input is deliberately inert + # (combustion_physics lines 1136-1143 take ideal Tc on purpose to break + # exactly this loop), verified by sweeping it 1000-4500 K for zero + # change in eta. + _debug_flag = self._debug if hasattr(self, '_debug') else False + _, _eta_parts = eta_cstar( current_Lstar, self.config.combustion.efficiency, - cooling_eff, + 1.0, # cooling excluded here by construction; blended in below advanced_params, - debug=self._debug if hasattr(self, '_debug') else False, + debug=_debug_flag, + return_components=True, ) - + eta_combustion = _eta_parts["eta_combustion"] + + cooling_results, cooling_eff, _ = self._evaluate_cooling_models( + Pc_val, + mdot_O, + mdot_F, + cea_props, + diagnostics, + with_profile=False, # root-find: profile is display-only, never needed here + Tc_override=calculate_actual_chamber_temp( + advanced_params["Tc"], eta_combustion + ), + ) + + eta = blend_cooling_into_cstar(eta_combustion, cooling_eff) + # Validate efficiency if not np.isfinite(eta) or eta <= 0 or eta > 1.0: return np.nan @@ -614,6 +636,53 @@ def tracked_residual_func(Pc): # The display-only per-segment heat-flux profile is skipped on the silent # (optimizer) path — it's the dominant post-processing cost and doesn't # affect any returned scalar. + # Build advanced parameters for combustion efficiency calculation. + # This runs BEFORE the cooling models: they need a gas temperature, and + # the honest one is CEA's ideal Tc knocked down for incomplete + # combustion. See the ORDER MATTERS note in residual(). + geometry = self._get_chamber_geometry() + + advanced_params = { + "Pc": Pc_val, + "Tc": cea_props["Tc"], # Ideal Tc (Conservative Residence Time) + # Tc_kinetics is deliberately NOT set. It would have to come from + # the cooling models, which have not run yet -- and it is inert + # anyway: combustion_physics computes tau_res and tau_chem from + # ideal Tc on purpose (its lines 1136-1143) to break that loop, so + # T_react reaches nothing but two debug log lines. Measured: eta is + # unchanged to six decimals for Tc_kinetics from 1000 K to 4500 K. + "cstar_ideal": cea_props.get("cstar_ideal", DEFAULT_CSTAR_IDEAL_M_S), + "gamma": cea_props.get("gamma", DEFAULT_GAMMA_ND), + "R": cea_props.get("R", DEFAULT_GAS_CONST_J_KG_K), + "MR": MR, + "Ac": geometry["area_cross"], + "At": cg.A_throat, + "chamber_length": geometry["length"], + "Dinj": current_injector_diameter, + "m_dot_total": mdot_total, + "u_fuel": closure_diag.get("u_F"), + "u_lox": closure_diag.get("u_O"), + "spray_diagnostics": closure_diag, + "turbulence_intensity": closure_diag.get("turbulence_intensity_mix", DEFAULT_TURBULENCE_INTENSITY_ND), + "momentum_ratio_R": closure_diag.get("momentum_ratio_R"), + "R_opt": self._rupe_R_opt(), + "fuel_props": self._get_fuel_props(), + } + + _, _eta_parts = eta_cstar( + current_Lstar, + self.config.combustion.efficiency, + 1.0, # cooling excluded here by construction; blended in below + advanced_params, + debug=debug, + return_components=True, + ) + eta_combustion = _eta_parts["eta_combustion"] + + # Chamber temperature the heat transfer actually sees: CEA's ideal value + # corrected for incomplete combustion (Huzel Sample Calc 4-3). + Tc_combustion = calculate_actual_chamber_temp(cea_props["Tc"], eta_combustion) + cooling_results, cooling_eff, effective_Tc = self._evaluate_cooling_models( Pc_val, mdot_O, @@ -621,6 +690,7 @@ def tracked_residual_func(Pc): cea_props, closure_diag, with_profile=not getattr(self, "_silent", False), + Tc_override=Tc_combustion, ) # Calculate reaction progress through chamber (if finite-rate chemistry enabled) @@ -676,39 +746,16 @@ def tracked_residual_func(Pc): closure_diag['mixture_diagnostics'] = mixture_diag - # Build advanced parameters for combustion efficiency calculation - geometry = self._get_chamber_geometry() - - advanced_params = { - "Pc": Pc_val, - "Tc": cea_props["Tc"], # Ideal Tc (Conservative Residence Time) - "Tc_kinetics": effective_Tc, # Actual Tc (Conservative Kinetics) - "cstar_ideal": cea_props.get("cstar_ideal", DEFAULT_CSTAR_IDEAL_M_S), - "gamma": cea_props.get("gamma", DEFAULT_GAMMA_ND), - "R": cea_props.get("R", DEFAULT_GAS_CONST_J_KG_K), - "MR": MR, - "Ac": geometry["area_cross"], - "At": cg.A_throat, - "chamber_length": geometry["length"], - "Dinj": current_injector_diameter, - "m_dot_total": mdot_total, - "u_fuel": closure_diag.get("u_F"), - "u_lox": closure_diag.get("u_O"), - "spray_diagnostics": closure_diag, - "turbulence_intensity": closure_diag.get("turbulence_intensity_mix", DEFAULT_TURBULENCE_INTENSITY_ND), - "momentum_ratio_R": closure_diag.get("momentum_ratio_R"), - "R_opt": self._rupe_R_opt(), - "fuel_props": self._get_fuel_props(), + # advanced_params and eta_combustion were computed above, before the + # cooling models, so the heat transfer could run at the corrected + # temperature. All that remains is folding cooling into the c* figure. + eta = blend_cooling_into_cstar(eta_combustion, cooling_eff) + eta_components = { + "eta_combustion": eta_combustion, + "eta_cooling_energy": float(cooling_eff), + "eta_cooling_cstar": float(np.sqrt(cooling_eff)), + "eta_total": eta, } - - eta, eta_components = eta_cstar( - current_Lstar, - self.config.combustion.efficiency, - cooling_eff, - advanced_params, - debug=debug, - return_components=True, - ) # Comprehensive validation of final solution # eta here is the c* efficiency (combustion x cooling). c* is the only @@ -762,9 +809,7 @@ def tracked_residual_func(Pc): # configs/canonical/impinging.yaml: Tc_ideal 3013 K vs 2677 K here, # a 336 K (11.2%) overstatement, worth roughly -19% on convective # heat flux and -38% on gas radiation once it is wired through. - "Tc_combustion": calculate_actual_chamber_temp( - cea_props["Tc"], eta_components["eta_combustion"] - ), + "Tc_combustion": Tc_combustion, "cstar_actual": cstar_actual, "eta_cstar": eta, # The two factors behind eta_cstar, unblended. eta_cstar is their @@ -1021,11 +1066,24 @@ def _evaluate_cooling_models( cea_props: Dict[str, float], closure_diag: Dict[str, Any], with_profile: bool = True, + Tc_override: Optional[float] = None, ) -> Tuple[Dict[str, Any], float, float]: + """Run the cooling models and report how much gas enthalpy they remove. + + Tc_override : float, optional + Gas temperature the heat transfer is evaluated at. Callers pass the + COMBUSTION-CORRECTED temperature (Tc_ideal x eta_combustion^2, Huzel + Sample Calc 4-3) rather than CEA's ideal value, because a real + chamber releases less energy and therefore runs cooler. Heat flux is + steeply temperature-dependent -- convection roughly linear in the + driving delta-T, radiation as T^4 -- so using the ideal temperature + here over-predicts the heat load. Defaults to cea_props["Tc"] for + callers that have no efficiency estimate yet. + """ config = self.config mdot_total = mdot_O + mdot_F cooling_results: Dict[str, Any] = {} - Tc = float(cea_props["Tc"]) + Tc = float(cea_props["Tc"] if Tc_override is None else Tc_override) if mdot_total <= 0: closure_diag["cooling"] = cooling_results diff --git a/EngineDesign/engine/native/src/ed_chamber.c b/EngineDesign/engine/native/src/ed_chamber.c index e881b19a..0c70a839 100644 --- a/EngineDesign/engine/native/src/ed_chamber.c +++ b/EngineDesign/engine/native/src/ed_chamber.c @@ -54,10 +54,6 @@ static double chamber_residual(double Pc, void *vctx) { const double Dinj = st->injector.imp_O.d_jet; /* _infer_injector_diameter (impinging) */ const double Ac = ED_PI * (g->chamber_diameter * 0.5) * (g->chamber_diameter * 0.5); - EdCoolingResult cool; - if (ed_cooling_evaluate(st, Pc, mdot_O, mdot_F, cea.Tc, cea.gamma, cea.R, cea.M, &cool) != ED_OK) - return NAN; - /* Rupe mixing optimum: honor an explicit R_opt override, else derive from the * impinging-doublet angles (sqrt(sin(theta_F)/sin(theta_O))), 1.0 otherwise. */ double R_opt; @@ -77,6 +73,22 @@ static double chamber_residual(double Pc, void *vctx) { st->fluid_F.latent_heat, st->comb.T_star_fuel_cap_K, &eta) != ED_OK) return NAN; + /* ORDER MATTERS: combustion efficiency FIRST, then cooling -- mirrors + * chamber_solver.residual(). The cooling models need a gas temperature, and + * a real chamber runs cooler than CEA's ideal value because combustion is + * incomplete, so Tc is knocked down by eta_combustion^2 (Huzel & Huang + * Sample Calc 4-3: c* ~ sqrt(Tc), so a c*-measured efficiency lands on + * temperature squared) before the heat transfer is evaluated. Running + * cooling first computed the heat load several hundred K too hot. + * + * Not circular: ed_combustion_efficiency_advanced takes no cooling input. */ + const double Tc_combustion = cea.Tc * eta.eta_total * eta.eta_total; + + EdCoolingResult cool; + if (ed_cooling_evaluate(st, Pc, mdot_O, mdot_F, Tc_combustion, cea.gamma, cea.R, cea.M, + &cool) != ED_OK) + return NAN; + /* eta.eta_total is combustion-only (mixing x kinetics x L*) despite the * name; eta_final is the c* efficiency. Mirrors combustion_eff.eta_cstar -- * see that module's docstring before applying either to a temperature. diff --git a/EngineDesign/engine/pipeline/combustion_eff.py b/EngineDesign/engine/pipeline/combustion_eff.py index 849c1976..353d6260 100644 --- a/EngineDesign/engine/pipeline/combustion_eff.py +++ b/EngineDesign/engine/pipeline/combustion_eff.py @@ -79,6 +79,22 @@ def calculate_Lstar( return float(Lstar) +def blend_cooling_into_cstar(eta_combustion: float, cooling_efficiency: float) -> float: + """Combine the combustion and cooling factors into the c* efficiency. + + THE ONLY definition of how cooling converts into a c* factor. eta_cstar() + uses it, and so does chamber_solver, which needs eta_combustion before the + cooling models have run (it feeds the corrected chamber temperature into + them) and so cannot get the blend from eta_cstar's return value. + + cooling_efficiency is an ENERGY fraction -- the share of gas enthalpy + surviving cooling, which is also the factor on chamber temperature. c* goes + as sqrt(Tc), so the conversion is a square root. Applying the energy + fraction to c* directly roughly doubles cooling's penalty. + """ + return float(eta_combustion * np.sqrt(cooling_efficiency)) + + def eta_cstar( Lstar: float, config: CombustionEfficiencyConfig, @@ -257,8 +273,8 @@ def eta_cstar( # roughly doubled cooling's penalty on c*: at cooling_eff = 0.9607 it # applied 0.9607 where 0.9801 is correct, understating c* by 2.0%. cooling_eff = float(cooling_efficiency) + eta = blend_cooling_into_cstar(eta, cooling_eff) eta_cooling_on_cstar = float(np.sqrt(cooling_eff)) - eta *= eta_cooling_on_cstar # Validate final efficiency - no clipping, raise error if invalid if not np.isfinite(eta): From 4c980242a5c4012585de83dc0d2b373a9348992d Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 14:57:49 -0700 Subject: [PATCH 20/35] config guard: fail only on broken configs, report off-spec ones instead of gating on them --- EngineDesign/engine/pipeline/run_record.py | 17 ++- .../tests/test_config_design_validity.py | 137 +++++++++++------- 2 files changed, 94 insertions(+), 60 deletions(-) diff --git a/EngineDesign/engine/pipeline/run_record.py b/EngineDesign/engine/pipeline/run_record.py index 810b8eaa..b6a795be 100644 --- a/EngineDesign/engine/pipeline/run_record.py +++ b/EngineDesign/engine/pipeline/run_record.py @@ -171,13 +171,18 @@ def evaluate_config(config_path: Path) -> Dict[str, Any]: thrust_err = abs(F - target_thrust) / target_thrust if target_thrust > 0 else float("nan") of_err = abs(MR - target_of) / target_of if target_of > 0 else float("nan") - failures = [] + # OFF-SPEC IS NOT BROKEN. A config that misses its thrust target is an unconverged design, not + # a defect someone introduced -- blocking a commit on it means work-in-progress designs cannot + # be checked in, which is precisely the pressure that produces "just widen the band". These are + # reported and recorded, never used to fail CI. Genuine breakage (won't load, won't evaluate, + # non-physical output) is what the ``load_error``/``eval_error`` kinds cover, and those DO fail. + off_spec = [] if not (thrust_err <= thr_tol): - failures.append( + off_spec.append( f"thrust {F:.0f}N vs target {target_thrust:.0f}N ({thrust_err*100:.1f}% > {thr_tol*100:.0f}%)" ) if target_of > 0 and not (of_err <= of_tol): - failures.append( + off_spec.append( f"O/F {MR:.2f} vs target {target_of:.2f} ({of_err*100:.1f}% > {of_tol*100:.0f}%)" ) for side, ratio in (("O", ro), ("F", rf)): @@ -186,14 +191,14 @@ def evaluate_config(config_path: Path) -> Dict[str, Any]: if lo is None or hi is None: continue if injector_dp_ratio_within_gate(ratio, _f(lo), _f(hi)) is False: - failures.append( + off_spec.append( f"dP_inj_{side}/Pc {_f(ratio):.3f} outside band [{_f(lo):.2f}, {_f(hi):.2f}]" ) return { "kind": "checked", - "ok": not failures, - "failures": failures, + "ok": not off_spec, # "meets its own spec" -- reported, not enforced + "failures": off_spec, "config_sha256": config_fingerprint(cfg), "metrics": { "Pc": pc, diff --git a/EngineDesign/tests/test_config_design_validity.py b/EngineDesign/tests/test_config_design_validity.py index 2a7fdc61..761c9f21 100644 --- a/EngineDesign/tests/test_config_design_validity.py +++ b/EngineDesign/tests/test_config_design_validity.py @@ -1,4 +1,18 @@ -"""Every committed engine config must satisfy its OWN declared gates at its OWN design point. +"""Every committed engine config must LOAD and EVALUATE at its own design point, and its distance +from its own declared targets is reported. + +Two different questions, deliberately separated: + + * **Broken** -- won't load, won't evaluate, non-physical output. Always someone's bug, so it FAILS. + * **Off-spec** -- evaluates fine but misses its thrust/O-F/dP targets. That is an unconverged + DESIGN, not a defect, so it is REPORTED and never fails. Blocking commits on it would stop + work-in-progress designs being checked in, and that pressure is what produces "just widen the + band until it passes" -- the exact failure mode that put the dP bands and W_SMD where they are. + +Absolute pass/fail on design numbers is the wrong CI signal regardless. The useful signal is +*unexplained movement*, which needs history -- see ``scripts/record_runs.py``, which records the +same metrics per commit next to the resolved-config hash so a number that moves while the config +did not is attributable to the commit that moved it. WHY THIS EXISTS (found 2026-07-21) ---------------------------------- @@ -27,13 +41,15 @@ HOW TO USE IT ------------- -A config that fails is NOT a broken test -- it is a config that needs Layer 1 re-run against its -current requirements. Known-stale configs are listed in ``KNOWN_STALE`` with a reason. That list is -a RATCHET: a config in it that starts passing must be removed (``test_known_stale_list_is_current`` -enforces that), so the list can only shrink. - -Do NOT fix a failure by widening the bands in the yaml. That is how the bands got to where they -are; see the ``W_SMD`` comment in canonical for the same pattern applied to a weight. +A FAILURE means a config no longer loads or evaluates -- fix the config or the code that broke it. +Configs already in that state are listed in ``KNOWN_BROKEN`` with a reason, and that list is a +RATCHET: an entry that starts evaluating must be removed (``test_known_broken_list_is_current`` +enforces it), so the list can only shrink. + +Off-spec configs are printed by ``test_reports_off_spec_configs``; run with ``-s`` to see them. +The fix for those is to re-run Layer 1 against the config's requirements so the derived blocks +(chamber_geometry, injector.geometry, tank pressures) are regenerated -- NOT to widen the bands in +the yaml. See the ``W_SMD`` comment in canonical for where that habit leads. """ from __future__ import annotations @@ -49,25 +65,18 @@ from engine.pipeline.run_record import evaluate_config # noqa: E402 -# Configs that are known stale, with WHY. Each entry is debt, not an exemption -- see module docstring. -KNOWN_STALE: dict[str, str] = { - "canonical/impinging.yaml": ( - "geometry solved for design_thrust 7000 / design_MR 2.55 vs requirements 8000 / 2.8; " - "measured F=5774N (0.72x), MR=1.81 (0.64x), eta_O=0.576 vs band [0.2,0.4]" - ), - "canonical/pintle.yaml": "not yet audited; pintle path is a previous project's engine", +# Configs that cannot currently load or evaluate, with WHY. Debt, not exemptions -- and only +# genuine breakage belongs here. Off-spec configs are NOT listed; they are reported, not gated. +KNOWN_BROKEN: dict[str, str] = { "default.yaml": ( "sets no propellant_preset, so fluids.oxidizer.name / fluids.fuel.name are both None and " "the solver residual goes non-finite; via the runner it yields NEGATIVE thrust " "(F=-334N, Isp=-7.27s). No longer the backend startup config (see backend/main.py) -- " - "still referenced by scripts/docs, so left in place rather than deleted" + "still referenced by ~20 scripts/docs, so left in place rather than deleted" ), - "impinging_smoke.yaml": "byte-identical results to canonical/impinging -- appears to be a stale copy of it", - "impinging_lox_ch4.yaml": "does not evaluate at its own design point (Supply < Demand at all Pc)", - "impinging_lox_ch4_8000N.yaml": "eta_O=0.492 / eta_F=0.514 vs band [0.2,0.4] (drifted stiff)", - "impinging_lox_ch4_8000N_optimal.yaml": ( - "eta_O=0.121 / eta_F=0.124 -- BELOW its own 0.2 floor and below the ~0.15 conventional " - "chug limit (drifted soft); the optimizer pushes eta down and nothing caught it" + "impinging_lox_ch4.yaml": ( + "does not evaluate at its own design point (Supply < Demand at all Pc); 4000N/OF-3.2 " + "design from an older lineage (hardcodes cea fuel_name/ox_name, no geometry-Cd block)" ), } @@ -103,50 +112,70 @@ def verdicts() -> dict[str, dict]: @pytest.mark.parametrize("rel_path", _engine_configs()) -def test_config_satisfies_its_own_gates(rel_path: str, verdicts: dict[str, dict]) -> None: +def test_config_loads_and_evaluates(rel_path: str, verdicts: dict[str, dict]) -> None: + """BREAKAGE gate: every engine config must load and evaluate at its own design point. + + This is deliberately NOT a "meets its target" check. Missing a thrust target is an unconverged + design, not a defect -- see ``test_reports_off_spec_configs``. What fails here is a config that + cannot be loaded or cannot produce a physical answer at all, which is always someone's bug: + a schema change, a missing propellant preset, a physics change that made it unsolvable. + """ v = verdicts[rel_path] if v["kind"] == "not_engine": pytest.skip(v["detail"]) - - problem = None - if v["kind"] in ("load_error", "eval_error"): - problem = f"{v['kind']}: {v['detail']}" - elif not v["ok"]: - problem = "; ".join(v["failures"]) - - if problem is None: + if v["kind"] not in ("load_error", "eval_error"): return - if rel_path in KNOWN_STALE: - pytest.xfail(f"known stale ({KNOWN_STALE[rel_path]}) -- current: {problem}") - + problem = f"{v['kind']}: {v['detail']}" + if rel_path in KNOWN_BROKEN: + pytest.xfail(f"known broken ({KNOWN_BROKEN[rel_path]}) -- current: {problem}") pytest.fail( - f"{rel_path} does not satisfy its own declared gates: {problem}\n" - "Re-run Layer 1 against this config's requirements to regenerate the derived blocks " - "(chamber_geometry, injector.geometry, tank pressures). Do NOT widen the bands to make " - "this pass -- see the module docstring." + f"{rel_path} cannot be evaluated at its own design point: {problem}\n" + "This is breakage, not an unconverged design. Either the config is invalid or a code " + "change made it unsolvable." ) -def test_known_stale_list_is_current(verdicts: dict[str, dict]) -> None: - """Ratchet: KNOWN_STALE may only shrink. +def test_reports_off_spec_configs(verdicts: dict[str, dict], capsys) -> None: + """STATUS report: which configs miss their own declared targets. Never fails. + + Off-spec is design state, not breakage. Enforcing it would block work-in-progress designs from + being committed -- and that pressure is exactly what produces "just widen the band until it + passes", which is how the injector dP bands and W_SMD ended up where they are. + + Absolute pass/fail on these numbers is the wrong CI signal anyway. The useful signal is + *unexplained movement*, which needs history: ``scripts/record_runs.py`` records the same metrics + per commit alongside the resolved-config hash, so a number that moves while the config did not + is attributable to the commit that moved it. + """ + lines = [] + for rel, v in sorted(verdicts.items()): + if v["kind"] == "checked" and not v["ok"]: + lines.append(f" {rel}") + lines.extend(f" - {f}" for f in v["failures"]) + with capsys.disabled(): + if lines: + print(f"\noff-spec configs ({sum(1 for v in verdicts.values() if v.get('kind') == 'checked' and not v['ok'])} " + f"of {sum(1 for v in verdicts.values() if v.get('kind') == 'checked')} evaluated):") + print("\n".join(lines)) + else: + print("\nall evaluated configs meet their own declared targets") + + +def test_known_broken_list_is_current(verdicts: dict[str, dict]) -> None: + """Ratchet: KNOWN_BROKEN may only shrink. Without this the exemption list silently becomes permanent -- a config gets fixed, nobody removes it, and the next regression is invisible again. """ - def _still_failing(rel: str) -> bool: - v = verdicts.get(rel, {}) - if v.get("kind") in ("load_error", "eval_error"): - return True - return v.get("kind") == "checked" and not v["ok"] - - # Covers both "it got fixed" and "it is no longer checked at all" (e.g. reclassified - # not_engine): either way the entry is dead and must go, or it shields a future regression. - stale_entries = [rel for rel in KNOWN_STALE if not _still_failing(rel)] - assert not stale_entries, ( - "These KNOWN_STALE entries no longer describe a failing config -- remove them so future " - f"regressions fail loudly: {stale_entries}" + fixed = [ + rel for rel in KNOWN_BROKEN + if verdicts.get(rel, {}).get("kind") not in ("load_error", "eval_error") + ] + assert not fixed, ( + "These KNOWN_BROKEN entries now load and evaluate -- remove them so future breakage fails " + f"loudly: {fixed}" ) - unknown = sorted(set(KNOWN_STALE) - set(_engine_configs())) - assert not unknown, f"KNOWN_STALE names configs that no longer exist: {unknown}" + unknown = sorted(set(KNOWN_BROKEN) - set(_engine_configs())) + assert not unknown, f"KNOWN_BROKEN names configs that no longer exist: {unknown}" From 05546bac0a6b1c9642c2d082d5c5c637499395ee Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 15:01:28 -0700 Subject: [PATCH 21/35] thermal: add Bartz gas-side film coefficient, validated term-by-term against Huzel sample calc 4-3 --- EngineDesign/engine/pipeline/thermal/bartz.py | 190 ++++++++++++++ EngineDesign/tests/test_bartz_correlation.py | 236 ++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 EngineDesign/engine/pipeline/thermal/bartz.py create mode 100644 EngineDesign/tests/test_bartz_correlation.py diff --git a/EngineDesign/engine/pipeline/thermal/bartz.py b/EngineDesign/engine/pipeline/thermal/bartz.py new file mode 100644 index 00000000..6f2eb5ee --- /dev/null +++ b/EngineDesign/engine/pipeline/thermal/bartz.py @@ -0,0 +1,190 @@ +"""Bartz gas-side film coefficient (Huzel & Huang eq. 4-13). + + h_g = [ 0.026/D_t^0.2 * (mu^0.2 cp / Pr^0.6) * ((Pc)ns g / c*)^0.8 + * (D_t/R)^0.1 ] * (A_t/A)^0.9 * sigma + +WHY THIS AND NOT DITTUS-BOELTER. The gas side used plain Dittus-Boelter, +0.023 Re^0.8 Pr^0.4 on chamber diameter. That is a correlation for turbulent +flow in a smooth pipe at MODERATE wall-to-bulk temperature difference, with +properties at bulk temperature. A thrust chamber violates every one of those: +Twg/(Tc)ns runs about 0.35, the flow accelerates through a throat, and the +cross-section is not constant. Bartz exists precisely because of that -- it +keeps Dittus-Boelter's skeleton but adds the sigma property-variation +correction, expresses mass flux as (Pc)ns*g/c* instead of a local Reynolds +number, and carries the (A_t/A)^0.9 and (D_t/R)^0.1 geometry terms. + +Huzel presents the two as ALTERNATIVES, not as a derivation chain -- eq. 4-12 +is "credited to Colburn", then "or as Bartz has shown" introduces 4-13. That +matters for one specific trap: + + DO NOT "correct" the Prandtl exponent to eq. 4-12's Pr^0.34. + +4-12 is Colburn's form and is not the one implemented here. Writing eq. 4-13's +Nusselt group out with k = mu*cp/Pr gives h ~ mu^0.2 cp Pr^(n-1), so Bartz's +Pr^-0.6 IS n = 0.4. The 0.026 coefficient, the Pr^-0.6, and sigma are one +calibrated package: Sample Calculation 4-3 applies sigma from figure 4-24 on +top of the 0.026/Pr^-0.6 form, so there is no double-counting to undo and no +freedom to mix in another correlation's exponent. + +UNITS. Eqs. 4-12/4-13 are entirely in inch-pound with an explicit g, and the +group (Pc)ns*g/c* is a mass flux in lb/(in^2 s) only when Pc is psia, g is +32.174 lbm-ft/(lbf-s^2) and c* is ft/sec. Rather than fold conversion factors +into the correlation and hope, the core is implemented in Huzel's own units -- +so it can be checked term-by-term against his printed arithmetic -- and the SI +wrapper converts at the boundary. +""" + +from __future__ import annotations + +import math +from typing import Dict + +# --- unit conversions, all derived from exact base definitions ------------- +_M_PER_IN = 0.0254 # exact by definition +_M2_PER_IN2 = _M_PER_IN ** 2 # 6.4516e-4 +_M_PER_FT = 0.3048 # exact by definition +_PA_PER_PSI = 6894.757293168361 +_J_PER_BTU = 1055.05585262 # International Table +_K_PER_DEGF = 5.0 / 9.0 +_J_KG_K_PER_BTU_LB_F = 4186.8 # International Table + +#: Btu/(in^2 s degF) -> W/(m^2 K). About 2.9436e6. +BTU_IN2_S_F_TO_W_M2_K = _J_PER_BTU / (_M2_PER_IN2 * _K_PER_DEGF) + +#: Pa.s -> lb/(in s). Matches constants.LB_PER_IN_S_TO_PA_S (17.8579673). +_PA_S_PER_LB_IN_S = 17.857967302549516 + +#: lbm-ft/(lbf-s^2). The `g` printed in eq. 4-13; Sample Calc 4-3 uses 32.2. +G_C_LBM_FT_PER_LBF_S2 = 32.174049 + +BARTZ_COEFFICIENT = 0.026 +BARTZ_DT_EXPONENT = 0.2 +BARTZ_MU_EXPONENT = 0.2 +BARTZ_PR_EXPONENT = 0.6 # negative power; n = 1 - 0.6 = 0.4. See module docstring. +BARTZ_MASS_FLUX_EXPONENT = 0.8 +BARTZ_CURVATURE_EXPONENT = 0.1 +BARTZ_AREA_EXPONENT = 0.9 + + +def bartz_groups_imperial( + D_throat_in: float, + Pc_psia: float, + cstar_ft_s: float, + throat_curvature_radius_in: float, + mu_lb_in_s: float, + cp_btu_lb_f: float, + Pr: float, +) -> Dict[str, float]: + """The four bracketed groups of eq. 4-13, in Huzel's units. + + Returned separately rather than pre-multiplied so a failing validation + localises to one group instead of to "the correlation". Sample Calculation + 4-3 prints exactly these four numbers, which is what they are checked + against: + + 0.01366 coefficient / D_t^0.2 + 0.046 mu^0.2 cp / Pr^0.6 + 4.02 (Pc g / c*)^0.8 <- mass flux, lb/(in^2 s), to the 0.8 + 1.078 (D_t / R)^0.1 + + Excludes (A_t/A)^0.9 and sigma, which are per-station rather than + per-engine. Their product is Huzel's 0.0027 Btu/(in^2 s degF). + """ + for name, value in (("D_throat_in", D_throat_in), ("Pc_psia", Pc_psia), + ("cstar_ft_s", cstar_ft_s), + ("throat_curvature_radius_in", throat_curvature_radius_in), + ("mu_lb_in_s", mu_lb_in_s), ("cp_btu_lb_f", cp_btu_lb_f), + ("Pr", Pr)): + if not (value > 0.0 and math.isfinite(value)): + raise ValueError(f"bartz: {name} must be finite and positive, got {value!r}") + + diameter = BARTZ_COEFFICIENT / D_throat_in ** BARTZ_DT_EXPONENT + transport = (mu_lb_in_s ** BARTZ_MU_EXPONENT * cp_btu_lb_f + / Pr ** BARTZ_PR_EXPONENT) + # (Pc)ns * g / c* is a mass flux, lb/(in^2 s) -- NOT a pressure term. + mass_flux = Pc_psia * G_C_LBM_FT_PER_LBF_S2 / cstar_ft_s + mass_flux_group = mass_flux ** BARTZ_MASS_FLUX_EXPONENT + # R is the radius of curvature of the NOZZLE CONTOUR AT THE THROAT, not any + # chamber dimension. Sample Calc 4-3 takes the mean of the two contour + # radii, (18.68 + 4.75)/2 = 11.71 in, against D_t = 24.9 in. + curvature = (D_throat_in / throat_curvature_radius_in) ** BARTZ_CURVATURE_EXPONENT + + return { + "diameter": diameter, + "transport": transport, + "mass_flux": mass_flux, + "mass_flux_group": mass_flux_group, + "curvature": curvature, + "product": diameter * transport * mass_flux_group * curvature, + } + + +def bartz_h_g_imperial( + D_throat_in: float, + Pc_psia: float, + cstar_ft_s: float, + throat_curvature_radius_in: float, + mu_lb_in_s: float, + cp_btu_lb_f: float, + Pr: float, + area_ratio_At_over_A: float = 1.0, + sigma: float = 1.0, +) -> float: + """Eq. 4-13 complete, in Btu/(in^2 s degF). + + area_ratio_At_over_A : A_t/A at the station of interest -- 1.0 at the + throat, less than 1 everywhere else, so h_g peaks at the throat. + sigma : property-variation correction (eq. 4-14 / figure 4-24). Defaults to + 1.0 so the rest of the correlation can be validated on its own. + """ + if not (area_ratio_At_over_A > 0.0 and math.isfinite(area_ratio_At_over_A)): + raise ValueError(f"bartz: area ratio must be finite and positive, got " + f"{area_ratio_At_over_A!r}") + if not (sigma > 0.0 and math.isfinite(sigma)): + raise ValueError(f"bartz: sigma must be finite and positive, got {sigma!r}") + + groups = bartz_groups_imperial( + D_throat_in, Pc_psia, cstar_ft_s, throat_curvature_radius_in, + mu_lb_in_s, cp_btu_lb_f, Pr, + ) + return (groups["product"] + * area_ratio_At_over_A ** BARTZ_AREA_EXPONENT + * sigma) + + +def bartz_h_g( + D_throat_m: float, + Pc_pa: float, + cstar_m_s: float, + throat_curvature_radius_m: float, + mu_pa_s: float, + cp_j_kg_k: float, + Pr: float, + area_ratio_At_over_A: float = 1.0, + sigma: float = 1.0, +) -> float: + """Eq. 4-13 in SI: gas-side film coefficient in W/(m^2 K). + + Converts to Huzel's inch-pound units, evaluates, and converts back. The + correlation itself never sees an SI quantity, so there is exactly one place + a unit error can hide and it is this function. + + Transport properties should come from gas_transport.hot_gas_transport at + the STAGNATION temperature. Bartz evaluates properties there and lets sigma + carry the boundary-layer variation -- that division of labour is the whole + point of the correlation, so do not pre-adjust them to a film temperature. + Sample Calculation 4-3 evaluates mu at the design (Tc)ns, i.e. after the + combustion-efficiency correction, not at the theoretical value. + """ + h_imperial = bartz_h_g_imperial( + D_throat_in=D_throat_m / _M_PER_IN, + Pc_psia=Pc_pa / _PA_PER_PSI, + cstar_ft_s=cstar_m_s / _M_PER_FT, + throat_curvature_radius_in=throat_curvature_radius_m / _M_PER_IN, + mu_lb_in_s=mu_pa_s / _PA_S_PER_LB_IN_S, + cp_btu_lb_f=cp_j_kg_k / _J_KG_K_PER_BTU_LB_F, + Pr=Pr, + area_ratio_At_over_A=area_ratio_At_over_A, + sigma=sigma, + ) + return h_imperial * BTU_IN2_S_F_TO_W_M2_K diff --git a/EngineDesign/tests/test_bartz_correlation.py b/EngineDesign/tests/test_bartz_correlation.py new file mode 100644 index 00000000..e0ce952c --- /dev/null +++ b/EngineDesign/tests/test_bartz_correlation.py @@ -0,0 +1,236 @@ +"""Bartz gas-side film coefficient against Huzel & Huang Sample Calculation 4-3. + +Huzel works eq. (4-13) for the A-1 stage engine and prints every intermediate +group, which makes this a genuine oracle rather than a plausibility check -- +each of the four bracketed terms is asserted separately, so a failure says +WHICH one is wrong instead of just "the correlation". + + LO2/RP-1, (Pc)ns = 1000 psia, MR 2.35 + (Tc)ns theoretical 6460 degR -> design 6460 x 0.975^2 = 6140 degR + m = 22.5 lb/mol, gamma = 1.222 + design c* = 5660 ft/sec, D_t = 24.9 in + throat contour mean radius R = (18.68 + 4.75)/2 = 11.71 in + + cp = gamma*R/((gamma-1)*J) = 0.485 Btu/lb-degF (eq. 4-1 form) + Pr = 4*gamma/(9*gamma-5) = 0.816 (eq. 4-15) + mu = 46.6e-10 * m^0.5 * T^0.6 = 4.18e-6 lb/in-sec (eq. 4-16) + + h_g = [0.01366 x 0.046 x 4.02 x 1.078] (A_t/A)^0.9 sigma + = 0.0027 (A_t/A)^0.9 sigma Btu/(in^2 sec degF) + +Two details in that data are load-bearing elsewhere and are pinned here too: +mu is evaluated at the COMBUSTION-CORRECTED temperature (6140, not 6460), and +R is a throat contour curvature radius -- a quantity unrelated to any chamber +diameter, which is what the old (D_chamber/D_throat)^0.1 term wrongly used. +""" + +import math + +import pytest + +from engine.pipeline.thermal.bartz import ( + BTU_IN2_S_F_TO_W_M2_K, + bartz_groups_imperial, + bartz_h_g, + bartz_h_g_imperial, +) + +# --- Sample Calculation 4-3, A-1 stage ------------------------------------- +D_THROAT_IN = 24.9 +PC_PSIA = 1000.0 +CSTAR_FT_S = 5660.0 +R_CURV_IN = (18.68 + 4.75) / 2.0 # 11.71 +MU_LB_IN_S = 4.18e-6 +CP_BTU_LB_F = 0.485 +PR = 0.816 + +HUZEL = dict(diameter=0.01366, transport=0.046, mass_flux_group=4.02, + curvature=1.078, product=0.0027) + + +@pytest.fixture(scope="module") +def groups(): + return bartz_groups_imperial( + D_THROAT_IN, PC_PSIA, CSTAR_FT_S, R_CURV_IN, MU_LB_IN_S, CP_BTU_LB_F, PR) + + +class TestHuzelSampleCalculation4_3: + """Every printed intermediate, checked on its own.""" + + def test_diameter_group(self, groups): + """0.026 / 24.9^0.2 -> 0.01366.""" + assert groups["diameter"] == pytest.approx(HUZEL["diameter"], rel=1e-3) + + def test_transport_group(self, groups): + """mu^0.2 cp / Pr^0.6 -> 0.046. Huzel prints only 2 significant figures.""" + assert groups["transport"] == pytest.approx(HUZEL["transport"], rel=2e-2) + + def test_mass_flux_is_a_mass_flux_not_a_pressure(self, groups): + """(Pc)ns g / c* = 1000 x 32.2 / 5660 = 5.69 lb/(in^2 sec). + + Guards the single most likely misreading of eq. 4-13: treating this + group as a pressure term and dropping g, or using a g in the wrong + units. A wrong g here is a silent multiplier on every heat flux. + """ + assert groups["mass_flux"] == pytest.approx(5.69, rel=5e-3) + + def test_mass_flux_group(self, groups): + """(5.69)^0.8 -> 4.02.""" + assert groups["mass_flux_group"] == pytest.approx(HUZEL["mass_flux_group"], rel=2e-3) + + def test_curvature_group(self, groups): + """(24.9/11.71)^0.1 -> 1.078.""" + assert groups["curvature"] == pytest.approx(HUZEL["curvature"], rel=1e-3) + + def test_full_bracket(self, groups): + """Product of all four -> 0.0027 Btu/(in^2 sec degF).""" + assert groups["product"] == pytest.approx(HUZEL["product"], rel=1e-2) + + def test_h_g_at_the_throat(self): + """At the throat A_t/A = 1, so h_g is the bracket itself (sigma = 1).""" + h = bartz_h_g_imperial( + D_THROAT_IN, PC_PSIA, CSTAR_FT_S, R_CURV_IN, + MU_LB_IN_S, CP_BTU_LB_F, PR, area_ratio_At_over_A=1.0) + assert h == pytest.approx(HUZEL["product"], rel=1e-2) + + +class TestHuzelInputRelations: + """The property correlations feeding the sample, so the inputs are pinned too. + + Tolerances here are looser than in the group tests above because Huzel + rounds his intermediates on the page and the printed results inherit that. + Both discrepancies below were traced to his rounding, not to an error on + either side, so they are pinned at the level his arithmetic supports rather + than tightened until they pass by coincidence. + """ + + def test_prandtl_from_equation_4_15(self): + """4*1.222/(9*1.222-5) = 0.81494; Huzel prints 0.816 (0.13% high).""" + gamma = 1.222 + assert 4.0 * gamma / (9.0 * gamma - 5.0) == pytest.approx(0.816, rel=2e-3) + + def test_viscosity_from_equation_4_16_at_the_corrected_temperature(self): + """46.6e-10 * 22.5^0.5 * 6140^0.6 -> 4.18e-6 lb/in-sec. + + 6140 degR is the design (Tc)ns -- theoretical 6460 knocked down by the + c* correction squared. Using 6460 here gives 4.26e-6, ~3 percent high, + and the error propagates as mu^0.2 into h_g. This is Huzel evaluating + transport properties AFTER the combustion-efficiency correction. + + Exact evaluation gives 4.1436e-6 against his printed 4.18e-6, 0.87% + apart. That gap is his rounding, visible in the line he shows: + 22.5^0.5 = 4.7434 printed as 4.76, and 6140^0.6 = 187.4 printed as 188. + Carrying those rounded factors reproduces 4.17e-6. So the tolerance is + set to accommodate his precision, not ours. + """ + mu = 46.6e-10 * 22.5 ** 0.5 * 6140.0 ** 0.6 + assert mu == pytest.approx(4.18e-6, rel=1e-2) + + # His own printed intermediates, to show where the gap comes from. + assert 22.5 ** 0.5 == pytest.approx(4.7434, rel=1e-4) + assert 6140.0 ** 0.6 == pytest.approx(187.4, rel=1e-3) + assert 46.6e-10 * 4.76 * 188.0 == pytest.approx(4.17e-6, rel=5e-3) + + mu_uncorrected = 46.6e-10 * 22.5 ** 0.5 * 6460.0 ** 0.6 + assert mu_uncorrected > mu + assert mu_uncorrected / mu == pytest.approx((6460.0 / 6140.0) ** 0.6, rel=1e-9) + + def test_design_temperature_is_theoretical_times_eta_squared(self): + assert 6460.0 * 0.975 ** 2 == pytest.approx(6140.0, rel=1e-3) + + +class TestAreaAndSigmaScaling: + def test_h_g_peaks_at_the_throat(self): + """(A_t/A)^0.9 -- heat flux is highest where the gas is fastest.""" + at_throat = bartz_h_g_imperial( + D_THROAT_IN, PC_PSIA, CSTAR_FT_S, R_CURV_IN, + MU_LB_IN_S, CP_BTU_LB_F, PR, area_ratio_At_over_A=1.0) + in_chamber = bartz_h_g_imperial( + D_THROAT_IN, PC_PSIA, CSTAR_FT_S, R_CURV_IN, + MU_LB_IN_S, CP_BTU_LB_F, PR, area_ratio_At_over_A=1.0 / 2.0) + assert in_chamber < at_throat + assert in_chamber / at_throat == pytest.approx(0.5 ** 0.9, rel=1e-9) + + def test_sigma_scales_linearly(self): + base = bartz_h_g_imperial(D_THROAT_IN, PC_PSIA, CSTAR_FT_S, R_CURV_IN, + MU_LB_IN_S, CP_BTU_LB_F, PR) + scaled = bartz_h_g_imperial(D_THROAT_IN, PC_PSIA, CSTAR_FT_S, R_CURV_IN, + MU_LB_IN_S, CP_BTU_LB_F, PR, sigma=1.3) + assert scaled / base == pytest.approx(1.3, rel=1e-12) + + +class TestPrandtlExponentIsBartzNotColburn: + """Pr^-0.6 in eq. 4-13 means n = 0.4. Do not swap in eq. 4-12's Pr^0.34. + + Huzel offers 4-12 (Colburn) and 4-13 (Bartz) as alternatives -- "or as + Bartz has shown". Quoting 4-12's exponent at 4-13 is an easy and wrong + edit, so it is locked down here with the algebra that connects them: + substituting k = mu*cp/Pr into h = (k/D) C Re^0.8 Pr^n gives + h ~ mu^0.2 cp Pr^(n-1), and n-1 = -0.6 exactly when n = 0.4. + """ + + def test_exponent_corresponds_to_n_equals_0_4(self): + from engine.pipeline.thermal import bartz + assert bartz.BARTZ_PR_EXPONENT == pytest.approx(0.6) + implied_n = 1.0 - bartz.BARTZ_PR_EXPONENT + assert implied_n == pytest.approx(0.4) + assert implied_n != pytest.approx(0.34) + + def test_colburn_exponent_would_change_the_answer(self, groups): + """Not a large error -- which is why it could slip in unnoticed.""" + colburn = (MU_LB_IN_S ** 0.2 * CP_BTU_LB_F) / PR ** 0.66 + ratio = colburn / groups["transport"] + assert 1.0 < ratio < 1.05 + + +class TestSIWrapper: + """SI in, SI out, with exactly one conversion boundary.""" + + def test_round_trips_the_sample_calculation(self): + h_si = bartz_h_g( + D_throat_m=D_THROAT_IN * 0.0254, + Pc_pa=PC_PSIA * 6894.757293168361, + cstar_m_s=CSTAR_FT_S * 0.3048, + throat_curvature_radius_m=R_CURV_IN * 0.0254, + mu_pa_s=MU_LB_IN_S * 17.857967302549516, + cp_j_kg_k=CP_BTU_LB_F * 4186.8, + Pr=PR, + ) + expected = HUZEL["product"] * BTU_IN2_S_F_TO_W_M2_K + assert h_si == pytest.approx(expected, rel=1e-2) + + def test_conversion_constant(self): + """Btu/(in^2 s degF) -> W/(m^2 K) is about 2.9436e6.""" + assert BTU_IN2_S_F_TO_W_M2_K == pytest.approx(2.9436e6, rel=1e-4) + + def test_result_is_physically_plausible_for_a_large_kerolox_throat(self): + h_si = HUZEL["product"] * BTU_IN2_S_F_TO_W_M2_K + assert 5_000.0 < h_si < 12_000.0 + + +class TestRejectsBadInput: + @pytest.mark.parametrize("bad", [0.0, -1.0, float("nan"), float("inf")]) + def test_non_physical_inputs_raise(self, bad): + with pytest.raises(ValueError): + bartz_groups_imperial(bad, PC_PSIA, CSTAR_FT_S, R_CURV_IN, + MU_LB_IN_S, CP_BTU_LB_F, PR) + + def test_zero_area_ratio_raises(self): + with pytest.raises(ValueError): + bartz_h_g_imperial(D_THROAT_IN, PC_PSIA, CSTAR_FT_S, R_CURV_IN, + MU_LB_IN_S, CP_BTU_LB_F, PR, + area_ratio_At_over_A=0.0) + + def test_curvature_radius_must_be_separate_from_diameter(self): + """A rough guard that R is not being fed a chamber dimension. + + Not enforceable in general, but D_t/R for a real contour lands near 2, + giving a curvature factor close to 1.08. Feeding it something wildly + off produces a visibly different factor, which this documents. + """ + sane = bartz_groups_imperial(D_THROAT_IN, PC_PSIA, CSTAR_FT_S, R_CURV_IN, + MU_LB_IN_S, CP_BTU_LB_F, PR)["curvature"] + assert 1.0 < sane < 1.15 + wrong = bartz_groups_imperial(D_THROAT_IN, PC_PSIA, CSTAR_FT_S, 200.0, + MU_LB_IN_S, CP_BTU_LB_F, PR)["curvature"] + assert wrong < 0.82 From bc9b719c06e9eebd90be1c3428a23ac0384140f8 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 15:04:43 -0700 Subject: [PATCH 22/35] config: share liner/insert material constants via material_preset instead of duplicating them per config --- EngineDesign/configs/canonical/impinging.yaml | 23 +--- EngineDesign/configs/canonical/pintle.yaml | 23 +--- .../configs/materials/phenolic_graphite.yaml | 60 ++++++++++ .../engine/pipeline/config_schemas.py | 5 + EngineDesign/engine/pipeline/io.py | 39 +++++++ EngineDesign/tests/test_material_preset.py | 107 ++++++++++++++++++ 6 files changed, 213 insertions(+), 44 deletions(-) create mode 100644 EngineDesign/configs/materials/phenolic_graphite.yaml create mode 100644 EngineDesign/tests/test_material_preset.py diff --git a/EngineDesign/configs/canonical/impinging.yaml b/EngineDesign/configs/canonical/impinging.yaml index 7d1c0a32..bf081b85 100644 --- a/EngineDesign/configs/canonical/impinging.yaml +++ b/EngineDesign/configs/canonical/impinging.yaml @@ -1,4 +1,5 @@ propellant_preset: methalox +material_preset: phenolic_graphite injector: type: impinging geometry: @@ -77,14 +78,8 @@ film_cooling: 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.008 - 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 @@ -94,9 +89,7 @@ ablative_cooling: 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 @@ -104,29 +97,15 @@ ablative_cooling: 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 - 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 diff --git a/EngineDesign/configs/canonical/pintle.yaml b/EngineDesign/configs/canonical/pintle.yaml index 32ddb57d..02e05a28 100644 --- a/EngineDesign/configs/canonical/pintle.yaml +++ b/EngineDesign/configs/canonical/pintle.yaml @@ -1,24 +1,17 @@ propellant_preset: ethalox +material_preset: phenolic_graphite ablative_cooling: ambient_temperature: 300.0 blowing_coefficient: 0.5 blowing_efficiency: 0.75 blowing_min_reduction_factor: 0.1 - char_layer_conductivity: 0.2 char_layer_thickness: 0.001 coverage_fraction: 0.9 enabled: true - heat_of_ablation: 2500000.0 initial_thickness: 0.008 - material_density: 1600.0 nozzle_ablative: false - pyrolysis_temperature: 950.0 radiative_sink_fallback_temperature: 600.0 radiative_sink_minimum_threshold: 400.0 - specific_heat: 1500.0 - surface_emissivity: 0.85 - surface_temperature_limit: 1200.0 - thermal_conductivity: 0.35 throat_recession_multiplier: null track_geometry_evolution: true turbulence_exponent: 1.0 @@ -228,30 +221,19 @@ fuel_tank: rp1_radius: 0.0762 tank_volume_m3: 0.011109 graphite_insert: - ablation_surface_temperature: 3000.0 ablation_transition_width: 200.0 - activation_energy: 190000.0 ambient_temperature: 300.0 - char_layer_conductivity: 5.0 char_layer_thickness: 0.0005 coverage_fraction: 1.0 - emissivity: 0.8 enabled: true feedback_fraction_max: 0.2 feedback_fraction_min: 0.0 friction_coefficient_override: null - heat_of_ablation: 15000000.0 initial_thickness: 0.006 - material_density: 2260.0 mixture_mw: 0.024 - oxidation_enthalpy: 32800000.0 oxidation_pre_exponential: null oxidation_pressure_exponent: 0.5 - oxidation_rate: 1.0e-06 - oxidation_reference_pressure: 21000.0 - oxidation_reference_temperature: 973.0 oxidation_stoichiometry_ratio: 1.0 - oxidation_temperature: 800.0 oxygen_mass_fraction: 0.05 oxygen_mole_fraction: null recession_multiplier: null @@ -260,9 +242,6 @@ graphite_insert: reference_diffusivity_temperature: 1500.0 simplified_graphite_oxidation: false sizing_only_mode: false - specific_heat: 710.0 - surface_temperature_limit: 2500.0 - thermal_conductivity: 100.0 injector: geometry: fuel: diff --git a/EngineDesign/configs/materials/phenolic_graphite.yaml b/EngineDesign/configs/materials/phenolic_graphite.yaml new file mode 100644 index 00000000..c2f5d13f --- /dev/null +++ b/EngineDesign/configs/materials/phenolic_graphite.yaml @@ -0,0 +1,60 @@ +# Material preset: PHENOLIC ablative liner + GRAPHITE throat insert. +# +# WHY THIS FILE EXISTS +# -------------------- +# These are properties of the SUBSTANCE, not of any particular engine: one liner material has one +# heat of ablation regardless of which engine it lines. They were previously duplicated verbatim in +# every config -- 61 identical fields across configs/canonical/impinging.yaml and pintle.yaml, and +# the same values again in every impinging_* config. Nothing kept them in sync, so the first time +# anyone measured a real value and updated one file, the rest would have diverged silently. +# +# This follows the pattern the codebase already uses twice: configs/propellants/*.yaml supplies +# fluid identity, and config_switch.apply_injector_type() restamps the injector-dependent closures +# (SMD model, discharge Cd) at load. Materials were the remaining category with no mechanism. +# +# OVERRIDES +# --------- +# Anything set explicitly in a config WINS over the value here, and every such override is LOGGED +# by io._deep_merge_preset -- so a per-engine deviation is possible but never silent. +# +# WHAT IS *NOT* HERE (deliberately per-engine) +# -------------------------------------------- +# enabled, initial_thickness, coverage_fraction, char_layer_thickness, nozzle_ablative, +# track_geometry_evolution, throat_recession_multiplier -> sizing / configuration choices +# blowing_*, turbulence_* -> model closures, not material data +# ambient_temperature, radiative_sink_*, oxygen_* -> environment, not material data +# +# PROVENANCE WARNING +# ------------------ +# These values are AS-CONFIGURED, carried forward from the original configs. None is traced to a +# datasheet or a measurement in this repo. The material identity ("phenolic") is inferred from +# engine/pipeline/thermal_analysis.py and time_varying_solver.py, which name the layer "Phenolic +# Ablator" -- it is NOT declared anywhere in the configs. Treat every number here as unverified +# until someone checks it against the actual material being purchased. + +ablative_cooling: + material_density: 1600.0 # kg/m^3 + heat_of_ablation: 2500000.0 # J/kg + thermal_conductivity: 0.35 # W/m-K (virgin) + specific_heat: 1500.0 # J/kg-K + pyrolysis_temperature: 950.0 # K + surface_temperature_limit: 1200.0 # K + char_layer_conductivity: 0.2 # W/m-K (charred) + surface_emissivity: 0.85 # - + +graphite_insert: + material_density: 2260.0 # kg/m^3 + heat_of_ablation: 15000000.0 # J/kg + thermal_conductivity: 100.0 # W/m-K + specific_heat: 710.0 # J/kg-K + surface_temperature_limit: 2500.0 # K + emissivity: 0.8 # - + char_layer_conductivity: 5.0 # W/m-K + # Oxidation kinetics -- graphite recession is oxidation-driven below the ablation regime. + oxidation_temperature: 800.0 # K, onset + oxidation_rate: 1.0e-06 # kg/m^2-s at the reference state below + activation_energy: 190000.0 # J/mol + oxidation_reference_temperature: 973.0 # K + oxidation_reference_pressure: 21000.0 # Pa (O2 partial pressure) + oxidation_enthalpy: 32800000.0 # J/kg + ablation_surface_temperature: 3000.0 # K, sublimation regime diff --git a/EngineDesign/engine/pipeline/config_schemas.py b/EngineDesign/engine/pipeline/config_schemas.py index 6778fb91..d92626bf 100644 --- a/EngineDesign/engine/pipeline/config_schemas.py +++ b/EngineDesign/engine/pipeline/config_schemas.py @@ -1462,6 +1462,11 @@ class PintleEngineConfig(BaseModel): # validation: preset supplies fluids/CEA baseline, explicit YAML fields override. Plain str (not # Literal) on purpose — adding a new propellant must require zero code changes (UNIFICATION P7). propellant_preset: Optional[str] = Field(default=None, description="Propellant preset to merge (e.g. 'methalox', 'ethalox', 'kerolox'); explicit fields win over preset") + # Material preset name (configs/materials/.yaml). Same resolve-before-validation and + # explicit-wins semantics as propellant_preset. Supplies liner/insert MATERIAL constants only + # (densities, heat of ablation, oxidation kinetics) — sizing, enable flags and model closures + # stay per-engine. Plain str for the same reason: new materials must need zero code changes. + material_preset: Optional[str] = Field(default=None, description="Material preset to merge (e.g. 'phenolic_graphite'); explicit fields win over preset") fluids: Dict[str, FluidConfig] injector: InjectorConfig feed_system: Dict[str, FeedSystemConfig] # "oxidizer" and "fuel" diff --git a/EngineDesign/engine/pipeline/io.py b/EngineDesign/engine/pipeline/io.py index 4faab03c..9a79d971 100644 --- a/EngineDesign/engine/pipeline/io.py +++ b/EngineDesign/engine/pipeline/io.py @@ -90,6 +90,44 @@ def _apply_propellant_preset(data: Dict[str, Any], config_dir: Path) -> Dict[str ) +def _apply_material_preset(data: Dict[str, Any], config_dir: Path) -> Dict[str, Any]: + """If ``material_preset`` is set, merge ``configs/materials/.yaml`` under the same + explicit-YAML-wins semantics as the propellant preset. + + Material properties (heat of ablation, densities, oxidation kinetics, ...) are constants of the + SUBSTANCE, not of an engine -- but they were duplicated verbatim in every config, 61 identical + fields across the two canonical files alone, with nothing keeping them in sync. Sharing them + means one authoritative value; the deep merge still lets a config override any leaf, and every + override is logged, so a per-engine deviation is possible but never silent. + + No coherence check here (unlike propellants): a material has no "identity" field that a stale + explicit block could contradict into a plausible-but-wrong result. + """ + name = data.get("material_preset") + if not name or str(name).strip().lower() == "custom": + return data + name = str(name).strip().lower() + for d in _material_preset_search_dirs(config_dir): + candidate = d / f"{name}.yaml" + if candidate.exists(): + with open(candidate, "r", encoding="utf-8") as f: + preset = yaml.safe_load(f) or {} + _log.info("applying material preset '%s' from %s", name, candidate) + merged = _deep_merge_preset(preset, data) + merged["material_preset"] = name + return merged + raise FileNotFoundError( + f"Unknown material_preset '{name}'. Available: " + f"{sorted({p.stem for d in _material_preset_search_dirs(config_dir) if d.is_dir() for p in d.glob('*.yaml')})} " + f"(searched: {[str(d) for d in _material_preset_search_dirs(config_dir)]})" + ) + + +def _material_preset_search_dirs(config_dir: Path) -> List[Path]: + """Material lookup order: next to the config file first, then the repo's configs/materials.""" + return [config_dir / "materials", _PROJECT_ROOT / "configs" / "materials"] + + def load_config(config_path: Union[str, Path]) -> PintleEngineConfig: """ Load engine configuration from YAML file. @@ -117,6 +155,7 @@ def load_config(config_path: Union[str, Path]) -> PintleEngineConfig: data = yaml.safe_load(f) data = _apply_propellant_preset(data, path.resolve().parent) + data = _apply_material_preset(data, path.resolve().parent) # Re-stamp spray/discharge bindings for the declared injector type (fixes stale pintle-era # lefebvre SMD + fixed Cd left on impinging YAMLs — bogus ~1 µm D32 and supply-starved Pc). diff --git a/EngineDesign/tests/test_material_preset.py b/EngineDesign/tests/test_material_preset.py new file mode 100644 index 00000000..35037bd4 --- /dev/null +++ b/EngineDesign/tests/test_material_preset.py @@ -0,0 +1,107 @@ +"""``material_preset`` shares liner/insert MATERIAL constants instead of duplicating them per config. + +Material properties are constants of the substance -- one liner material has one heat of ablation +regardless of which engine it lines. They used to be copied verbatim into every config: 61 identical +fields across ``canonical/impinging.yaml`` and ``canonical/pintle.yaml``, repeated again in every +``impinging_*`` config. Nothing kept them in sync, so the first real measurement would have updated +one file and silently diverged the rest. + +This mirrors two mechanisms the codebase already had: ``propellant_preset`` (fluid identity) and +``config_switch.apply_injector_type`` (injector-dependent closures). Materials were the gap. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from engine.pipeline.io import load_config # noqa: E402 + +PRESET = ROOT / "configs" / "materials" / "phenolic_graphite.yaml" +CANONICAL = ["canonical/impinging.yaml", "canonical/pintle.yaml"] + + +@pytest.fixture(scope="module") +def preset_doc() -> dict: + with open(PRESET, "r", encoding="utf-8") as fh: + return yaml.safe_load(fh) + + +@pytest.mark.parametrize("rel", CANONICAL) +def test_canonical_resolves_material_values_from_preset(rel: str, preset_doc: dict) -> None: + """Every value in the preset must survive into the resolved config.""" + cfg = load_config(str(ROOT / "configs" / rel)) + dumped = cfg.model_dump(mode="json") + assert dumped.get("material_preset") == "phenolic_graphite" + for block, fields in preset_doc.items(): + resolved = dumped.get(block) or {} + for key, expected in fields.items(): + assert resolved.get(key) == pytest.approx(expected), ( + f"{rel}: {block}.{key} resolved to {resolved.get(key)!r}, preset says {expected!r}" + ) + + +@pytest.mark.parametrize("rel", CANONICAL) +def test_canonical_does_not_inline_preset_material_fields(rel: str, preset_doc: dict) -> None: + """Regression guard: the whole point is that these live in ONE place. + + Re-inlining a field would silently shadow the preset (explicit-wins), reintroducing exactly the + duplication this replaced. An intentional per-engine deviation is still allowed -- but it has to + be added to ALLOWED_OVERRIDES here, so it is a decision someone made rather than a copy-paste. + """ + ALLOWED_OVERRIDES: set[tuple[str, str]] = set() + + with open(ROOT / "configs" / rel, "r", encoding="utf-8") as fh: + raw = yaml.safe_load(fh) + + inlined = [ + f"{block}.{key}" + for block, fields in preset_doc.items() + for key in fields + if key in (raw.get(block) or {}) and (block, key) not in ALLOWED_OVERRIDES + ] + assert not inlined, ( + f"{rel} re-inlines preset-owned material fields: {inlined}. Remove them so the preset stays " + "authoritative, or add them to ALLOWED_OVERRIDES with a reason if the deviation is intended." + ) + + +def test_explicit_config_value_overrides_preset(tmp_path: Path) -> None: + """Explicit-wins must hold, so a genuine per-engine deviation remains possible. + + io._deep_merge_preset logs every override, so this is possible but never silent. + """ + src = ROOT / "configs" / "canonical" / "impinging.yaml" + with open(src, "r", encoding="utf-8") as fh: + doc = yaml.safe_load(fh) + doc.setdefault("ablative_cooling", {})["heat_of_ablation"] = 9.99e6 + + dst = tmp_path / "override.yaml" + with open(dst, "w", encoding="utf-8") as fh: + yaml.safe_dump(doc, fh) + + cfg = load_config(str(dst)) + assert cfg.ablative_cooling.heat_of_ablation == pytest.approx(9.99e6) + # A non-overridden sibling still comes from the preset. + assert cfg.ablative_cooling.material_density == pytest.approx(1600.0) + + +def test_unknown_material_preset_is_a_clear_error(tmp_path: Path) -> None: + """A typo must fail loudly with the available names, not silently skip the merge.""" + src = ROOT / "configs" / "canonical" / "impinging.yaml" + with open(src, "r", encoding="utf-8") as fh: + doc = yaml.safe_load(fh) + doc["material_preset"] = "no_such_material" + + dst = tmp_path / "bad.yaml" + with open(dst, "w", encoding="utf-8") as fh: + yaml.safe_dump(doc, fh) + + with pytest.raises(FileNotFoundError, match="no_such_material"): + load_config(str(dst)) From ccd84fa2f6d30b5e51225c35370669e2a1d98271 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 15:05:50 -0700 Subject: [PATCH 23/35] thermal: add Bartz sigma property-variation correction, checked against Huzel figure 4-24 --- EngineDesign/engine/pipeline/thermal/bartz.py | 48 +++++++++++ EngineDesign/tests/test_bartz_correlation.py | 85 +++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/EngineDesign/engine/pipeline/thermal/bartz.py b/EngineDesign/engine/pipeline/thermal/bartz.py index 6f2eb5ee..4f15a2a5 100644 --- a/EngineDesign/engine/pipeline/thermal/bartz.py +++ b/EngineDesign/engine/pipeline/thermal/bartz.py @@ -66,6 +66,54 @@ BARTZ_AREA_EXPONENT = 0.9 +def bartz_sigma( + wall_to_stagnation_temp_ratio: float, + gamma: float, + mach: float, +) -> float: + """Property-variation correction sigma (Huzel eq. 4-14, his figure 4-24). + + sigma = 1 / { [0.5*(Twg/(Tc)ns)*(1+((g-1)/2)M^2) + 0.5]^0.68 + * [1+((g-1)/2)M^2]^0.12 } + + This is the term that makes Bartz applicable to a rocket at all. Dittus- + Boelter assumes a near-isothermal boundary layer; a thrust chamber runs + Twg/(Tc)ns around 0.35, so gas properties vary strongly across the film. + Bartz evaluates properties at stagnation and puts the whole variation here. + + Note the direction: for a COLD wall sigma exceeds 1, i.e. it RAISES h_g. A + cold wall thins the boundary layer. It is easy to assume a "correction + factor" must be a knockdown; this one is not. + + Huzel prints only the chart, not this expression -- the closed form is from + Bartz's own paper. Checked against the three values his Sample Calculation + 4-3 reads off figure 4-24 at Twg/(Tc)ns = 0.8, gamma ~ 1.2: + + station M figure 4-24 closed form + chamber 0.40 1.05 1.067 + throat 1.0 1.0 1.031 + exit, eps=5 2.78 0.8 0.821 + + Agreement is 1.6 / 3.1 / 2.6 percent. That is the accuracy of reading a + small log-scale figure to two significant digits -- his "1.0" at the throat + is plainly a chart read, not an exact value -- so the closed form is taken + as reproducing figure 4-24 rather than contradicting it. + """ + if not (gamma > 1.0 and math.isfinite(gamma)): + raise ValueError(f"bartz_sigma: gamma must exceed 1, got {gamma!r}") + if not (wall_to_stagnation_temp_ratio > 0.0 + and math.isfinite(wall_to_stagnation_temp_ratio)): + raise ValueError(f"bartz_sigma: temperature ratio must be finite and " + f"positive, got {wall_to_stagnation_temp_ratio!r}") + if not (mach >= 0.0 and math.isfinite(mach)): + raise ValueError(f"bartz_sigma: mach must be finite and non-negative, " + f"got {mach!r}") + + stagnation_ratio = 1.0 + 0.5 * (gamma - 1.0) * mach * mach + wall_term = 0.5 * wall_to_stagnation_temp_ratio * stagnation_ratio + 0.5 + return 1.0 / (wall_term ** 0.68 * stagnation_ratio ** 0.12) + + def bartz_groups_imperial( D_throat_in: float, Pc_psia: float, diff --git a/EngineDesign/tests/test_bartz_correlation.py b/EngineDesign/tests/test_bartz_correlation.py index e0ce952c..b9790c24 100644 --- a/EngineDesign/tests/test_bartz_correlation.py +++ b/EngineDesign/tests/test_bartz_correlation.py @@ -159,6 +159,91 @@ def test_sigma_scales_linearly(self): assert scaled / base == pytest.approx(1.3, rel=1e-12) +class TestSigmaAgainstFigure4_24: + """sigma from the closed form vs the three values Huzel reads off his chart. + + Sample Calculation 4-3 states "a (Twg/(Tc)ns) value of 0.8 is used to + determine the a values from figure 4-24 (gamma ~ 1.2)" and then applies + 1.05 in the chamber, 1.0 at the throat and 0.8 at eps = 5. + + Tolerance is 5 percent because the reference is a chart read to two + significant figures, not a computed value -- the printed "1.0" at the + throat is the giveaway. Tightening this would be pinning our arithmetic to + someone's ruler. + """ + + TWG_OVER_TC = 0.8 + GAMMA = 1.2 + + @pytest.mark.parametrize("station, mach, chart", [ + ("chamber (Ac/At = 1.6)", 0.402, 1.05), + ("throat", 1.0, 1.0), + ("exit, eps = 5", 2.78, 0.8), + ]) + def test_matches_chart(self, station, mach, chart): + from engine.pipeline.thermal.bartz import bartz_sigma + sigma = bartz_sigma(self.TWG_OVER_TC, self.GAMMA, mach) + assert sigma == pytest.approx(chart, rel=5e-2), station + + def test_sigma_decreases_along_the_nozzle(self): + """Chamber > throat > exit, matching the chart's trend.""" + from engine.pipeline.thermal.bartz import bartz_sigma + chamber = bartz_sigma(self.TWG_OVER_TC, self.GAMMA, 0.402) + throat = bartz_sigma(self.TWG_OVER_TC, self.GAMMA, 1.0) + exit_ = bartz_sigma(self.TWG_OVER_TC, self.GAMMA, 2.78) + assert chamber > throat > exit_ + + def test_cold_wall_raises_h_g(self): + """sigma > 1 for a cold wall -- it is NOT a knockdown factor. + + Easy to assume any 'correction factor' reduces the answer. A cold wall + thins the boundary layer and increases heat transfer, so getting this + backwards would under-predict heat load, which is the dangerous + direction for a liner. + """ + from engine.pipeline.thermal.bartz import bartz_sigma + cold = bartz_sigma(0.35, self.GAMMA, 0.2) # realistic chamber wall + hot = bartz_sigma(0.95, self.GAMMA, 0.2) # wall near gas temperature + assert cold > 1.0 + assert cold > hot + + def test_reduces_to_unity_when_wall_is_at_stagnation_temperature(self): + """Twg/(Tc)ns = 1 at M = 0 means no property variation to correct.""" + from engine.pipeline.thermal.bartz import bartz_sigma + assert bartz_sigma(1.0, self.GAMMA, 0.0) == pytest.approx(1.0, rel=1e-12) + + @pytest.mark.parametrize("bad_gamma", [1.0, 0.9, -1.0, float("nan")]) + def test_rejects_bad_gamma(self, bad_gamma): + from engine.pipeline.thermal.bartz import bartz_sigma + with pytest.raises(ValueError): + bartz_sigma(0.8, bad_gamma, 1.0) + + +class TestSampleCalculation4_3StationValues: + """The three h_g values Huzel prints, end to end with his chart sigmas.""" + + @pytest.mark.parametrize("station, area_ratio_At_over_A, sigma, expected", [ + ("chamber", 1.0 / 1.6, 1.05, 0.00185), + ("throat", 1.0, 1.0, 0.0027), + ("exit eps=5", 1.0 / 5.0, 0.8, 0.000507), + ]) + def test_station_h_g(self, station, area_ratio_At_over_A, sigma, expected): + h = bartz_h_g_imperial( + D_THROAT_IN, PC_PSIA, CSTAR_FT_S, R_CURV_IN, + MU_LB_IN_S, CP_BTU_LB_F, PR, + area_ratio_At_over_A=area_ratio_At_over_A, sigma=sigma) + assert h == pytest.approx(expected, rel=2e-2), station + + def test_area_terms_match_huzels_printed_values(self): + """(1/1.6)^0.9 -> 0.655 and (1/5)^0.9 -> 0.235.""" + assert (1.0 / 1.6) ** 0.9 == pytest.approx(0.655, rel=5e-3) + assert (1.0 / 5.0) ** 0.9 == pytest.approx(0.235, rel=5e-3) + + def test_throat_is_the_hottest_station(self): + """Sanity on the whole assembly: 0.0027 > 0.00185 > 0.000507.""" + assert 0.0027 > 0.00185 > 0.000507 + + class TestPrandtlExponentIsBartzNotColburn: """Pr^-0.6 in eq. 4-13 means n = 0.4. Do not swap in eq. 4-12's Pr^0.34. From 78cd55dfa860bb7d54574e3abf6b15dfbeb38ba9 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 15:15:15 -0700 Subject: [PATCH 24/35] thermal: cite Huzel eq 4-14 for the Bartz sigma correction, pin the expression exactly --- EngineDesign/engine/pipeline/thermal/bartz.py | 29 +++++++------ EngineDesign/tests/test_bartz_correlation.py | 41 +++++++++++++++---- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/EngineDesign/engine/pipeline/thermal/bartz.py b/EngineDesign/engine/pipeline/thermal/bartz.py index 4f15a2a5..6ec49712 100644 --- a/EngineDesign/engine/pipeline/thermal/bartz.py +++ b/EngineDesign/engine/pipeline/thermal/bartz.py @@ -71,7 +71,7 @@ def bartz_sigma( gamma: float, mach: float, ) -> float: - """Property-variation correction sigma (Huzel eq. 4-14, his figure 4-24). + """Property-variation correction sigma -- Huzel & Huang eq. (4-14) verbatim. sigma = 1 / { [0.5*(Twg/(Tc)ns)*(1+((g-1)/2)M^2) + 0.5]^0.68 * [1+((g-1)/2)M^2]^0.12 } @@ -85,19 +85,24 @@ def bartz_sigma( cold wall thins the boundary layer. It is easy to assume a "correction factor" must be a knockdown; this one is not. - Huzel prints only the chart, not this expression -- the closed form is from - Bartz's own paper. Checked against the three values his Sample Calculation - 4-3 reads off figure 4-24 at Twg/(Tc)ns = 0.8, gamma ~ 1.2: + Huzel prints this equation directly (eq. 4-14) and ALSO tabulates it as + figure 4-24, "as computed by Bartz". The equation is the authority here; + the figure is a reading aid. - station M figure 4-24 closed form - chamber 0.40 1.05 1.067 - throat 1.0 1.0 1.031 - exit, eps=5 2.78 0.8 0.821 + Cross-checked anyway against the three sigma values Sample Calculation 4-3 + takes off figure 4-24 at Twg/(Tc)ns = 0.8, gamma ~ 1.2: - Agreement is 1.6 / 3.1 / 2.6 percent. That is the accuracy of reading a - small log-scale figure to two significant digits -- his "1.0" at the throat - is plainly a chart read, not an exact value -- so the closed form is taken - as reproducing figure 4-24 rather than contradicting it. + station M figure 4-24 eq. 4-14 + chamber 0.4046 1.05 1.067 + throat 1.0 1.0 1.031 + exit, eps=5 2.7850 0.8 0.820 + + All three sit 1.6-3.1 percent ABOVE the chart values. The consistent sign + is expected: the figure is a small log-scale plot read to two significant + digits (the printed "1.0" at the throat is plainly a chart read), so the + residual is his reading precision, not a discrepancy in the equation. The + Mach numbers are not Huzel's -- his chart is indexed by area ratio, so they + were solved from the isentropic area-Mach relation at gamma = 1.2. """ if not (gamma > 1.0 and math.isfinite(gamma)): raise ValueError(f"bartz_sigma: gamma must exceed 1, got {gamma!r}") diff --git a/EngineDesign/tests/test_bartz_correlation.py b/EngineDesign/tests/test_bartz_correlation.py index b9790c24..d062316d 100644 --- a/EngineDesign/tests/test_bartz_correlation.py +++ b/EngineDesign/tests/test_bartz_correlation.py @@ -160,25 +160,32 @@ def test_sigma_scales_linearly(self): class TestSigmaAgainstFigure4_24: - """sigma from the closed form vs the three values Huzel reads off his chart. + """eq. (4-14) as implemented vs the sigma values Huzel reads off figure 4-24. + + The implementation follows Huzel's printed eq. (4-14), so this is a + consistency check between his equation and his own chart, not a validation + of the equation itself -- the equation is the primary source. Sample Calculation 4-3 states "a (Twg/(Tc)ns) value of 0.8 is used to determine the a values from figure 4-24 (gamma ~ 1.2)" and then applies 1.05 in the chamber, 1.0 at the throat and 0.8 at eps = 5. - Tolerance is 5 percent because the reference is a chart read to two - significant figures, not a computed value -- the printed "1.0" at the - throat is the giveaway. Tightening this would be pinning our arithmetic to - someone's ruler. + Tolerance is 5 percent because the CHART is the imprecise side: it is a + small log-scale plot read to two significant figures, and the printed "1.0" + at the throat is the giveaway. All three deviations are positive, which is + what a consistently-rounded chart read looks like. + + The Mach numbers are solved from the isentropic area-Mach relation at + gamma = 1.2, since figure 4-24 is indexed by area ratio rather than Mach. """ TWG_OVER_TC = 0.8 GAMMA = 1.2 @pytest.mark.parametrize("station, mach, chart", [ - ("chamber (Ac/At = 1.6)", 0.402, 1.05), + ("chamber (Ac/At = 1.6)", 0.4046, 1.05), ("throat", 1.0, 1.0), - ("exit, eps = 5", 2.78, 0.8), + ("exit, eps = 5", 2.7850, 0.8), ]) def test_matches_chart(self, station, mach, chart): from engine.pipeline.thermal.bartz import bartz_sigma @@ -188,11 +195,27 @@ def test_matches_chart(self, station, mach, chart): def test_sigma_decreases_along_the_nozzle(self): """Chamber > throat > exit, matching the chart's trend.""" from engine.pipeline.thermal.bartz import bartz_sigma - chamber = bartz_sigma(self.TWG_OVER_TC, self.GAMMA, 0.402) + chamber = bartz_sigma(self.TWG_OVER_TC, self.GAMMA, 0.4046) throat = bartz_sigma(self.TWG_OVER_TC, self.GAMMA, 1.0) - exit_ = bartz_sigma(self.TWG_OVER_TC, self.GAMMA, 2.78) + exit_ = bartz_sigma(self.TWG_OVER_TC, self.GAMMA, 2.7850) assert chamber > throat > exit_ + def test_matches_huzel_equation_4_14_literally(self): + """Transcribe eq. (4-14) independently and require an exact match. + + Guards the implementation against a later "simplification" -- dropping + a 0.5, folding the two brackets together, or swapping the 0.68 and 0.12 + exponents, none of which would be obvious from the resulting numbers. + """ + from engine.pipeline.thermal.bartz import bartz_sigma + for tr in (0.35, 0.6, 0.8, 1.0): + for gamma in (1.14, 1.2, 1.222, 1.4): + for mach in (0.0, 0.3, 1.0, 2.5, 4.0): + k = 1.0 + (gamma - 1.0) / 2.0 * mach ** 2 + expected = 1.0 / ((0.5 * tr * k + 0.5) ** 0.68 * k ** 0.12) + assert bartz_sigma(tr, gamma, mach) == pytest.approx( + expected, rel=1e-12), (tr, gamma, mach) + def test_cold_wall_raises_h_g(self): """sigma > 1 for a cold wall -- it is NOT a knockdown factor. From 84b0b227564ec357c1f4d5a9b2a3e4bd3fb01d62 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 15:17:39 -0700 Subject: [PATCH 25/35] config: split canonical configs into hand-authored intent and optimizer-generated design sidecar --- EngineDesign/backend/routers/config.py | 10 +- .../configs/canonical/impinging.design.yaml | 131 +++++++++++++++++ EngineDesign/configs/canonical/impinging.yaml | 118 +-------------- .../configs/canonical/pintle.design.yaml | 133 +++++++++++++++++ EngineDesign/configs/canonical/pintle.yaml | 118 +-------------- EngineDesign/engine/pipeline/io.py | 134 ++++++++++++++++++ .../tests/test_config_intent_derived_split.py | 122 ++++++++++++++++ EngineDesign/tests/test_material_preset.py | 23 +-- 8 files changed, 546 insertions(+), 243 deletions(-) create mode 100644 EngineDesign/configs/canonical/impinging.design.yaml create mode 100644 EngineDesign/configs/canonical/pintle.design.yaml create mode 100644 EngineDesign/tests/test_config_intent_derived_split.py diff --git a/EngineDesign/backend/routers/config.py b/EngineDesign/backend/routers/config.py index 534f77eb..c21e19e6 100644 --- a/EngineDesign/backend/routers/config.py +++ b/EngineDesign/backend/routers/config.py @@ -169,11 +169,13 @@ def deep_merge(base: dict, updates: dict) -> dict: # Validate and create new config new_config = PintleEngineConfig(**merged) - # Save to disk if we have a path + # Save to disk if we have a path. MUST go through io.save_config: a split config keeps its + # optimizer-generated half in .design.yaml, and dumping the merged model into the + # intent file would put generated fields where the loader forbids them -- every subsequent + # load would then raise on the collision. if app_state.config_path: - with open(app_state.config_path, "w", encoding="utf-8") as f: - # Use clean dict (exclude unset/none if desired, but here we just dump the model) - yaml.dump(config_to_dict(new_config), f, default_flow_style=False, sort_keys=False) + from engine.pipeline.io import save_config as _save_config + _save_config(config_to_dict(new_config), app_state.config_path) app_state.set_config(new_config, app_state.config_path) diff --git a/EngineDesign/configs/canonical/impinging.design.yaml b/EngineDesign/configs/canonical/impinging.design.yaml new file mode 100644 index 00000000..3b5c9b6a --- /dev/null +++ b/EngineDesign/configs/canonical/impinging.design.yaml @@ -0,0 +1,131 @@ +# GENERATED -- optimizer output. Do NOT hand-edit. +# +# This is the code-written half of impinging.yaml. The split is by authorship: what a human +# types (requirements, hardware choices, model selections) stays in the intent file; what Layer 1/2 +# write back lives here. io._apply_design_sidecar overlays this at load, so the schema and every +# downstream consumer are unchanged. +# +# Editing a value here is pointless -- the next optimizer run overwrites it. Setting one of these +# fields in the intent file instead is an ERROR the loader raises, because that edit would be +# silently discarded. +# +# Regenerate by re-running Layer 1 against impinging.yaml's design_requirements. + +chamber_geometry: + A_throat: 0.0017703959321061754 + A_exit: 0.008159675026074325 + volume: 0.0019492708804651003 + Lstar: 1.1010366919145165 + chamber_diameter: 0.11504160142419745 + exit_diameter: 0.10192752776058954 + expansion_ratio: 4.608954911214158 + length: 0.2202290337591734 + length_cylindrical: 0.121 + length_contraction: 0.0451 + Cf: 1.6603014402898282 +injector: + geometry: + oxidizer: + n_elements: 20 + d_jet: 0.002 + impingement_angle: 50.0 + spacing: 0.006 + fuel: + n_elements: 20 + d_jet: 0.002 + impingement_angle: 60.0 + spacing: 0.006 +lox_tank: + initial_pressure_psi: 563.4671262691785 +fuel_tank: + initial_pressure_psi: 567.6435444099167 +pressure_curves: + n_points: 200 + target_burn_time_s: 6.0 + initial_lox_pressure_pa: 3704289.4239973365 + initial_fuel_pressure_pa: 3610619.76028896 + lox_segments: + - length_ratio: 0.2286473154180296 + type: blowdown + start_pressure_pa: 3704289.4239973365 + end_pressure_pa: 3467819.1693295944 + k: 0.24164644917319 + - length_ratio: 0.20249167104882543 + type: blowdown + start_pressure_pa: 3467819.1693295944 + end_pressure_pa: 3258399.4482475044 + k: 1.7414629743039511 + - length_ratio: 0.06342628444092534 + type: blowdown + start_pressure_pa: 3258399.4482475044 + end_pressure_pa: 3141806.013426955 + k: 1.3540898135161692 + - length_ratio: 0.14732213727033575 + type: blowdown + start_pressure_pa: 3141806.013426955 + end_pressure_pa: 2891698.2018464496 + k: 0.7144170122671702 + - length_ratio: 0.0925191694713507 + type: blowdown + start_pressure_pa: 2891698.2018464496 + end_pressure_pa: 2796013.581510806 + k: 1.0723820377201234 + - length_ratio: 0.08588604087308514 + type: blowdown + start_pressure_pa: 2796013.581510806 + end_pressure_pa: 2707189.0356352893 + k: 1.6281617755206732 + - length_ratio: 0.07014845137420163 + type: blowdown + start_pressure_pa: 2707189.0356352893 + end_pressure_pa: 2634640.5251457705 + k: 1.5557597269194856 + - length_ratio: 0.10955893010324647 + type: blowdown + start_pressure_pa: 2634640.5251457705 + end_pressure_pa: 2521333.1458079717 + k: 0.948130932276539 + fuel_segments: + - length_ratio: 0.2286473154180296 + type: blowdown + start_pressure_pa: 3610619.76028896 + end_pressure_pa: 3374149.5056212177 + k: 1.0784566785091103 + - length_ratio: 0.20249167104882543 + type: blowdown + start_pressure_pa: 3374149.5056212177 + end_pressure_pa: 3164729.7845391277 + k: 1.629427648989163 + - length_ratio: 0.06342628444092534 + type: blowdown + start_pressure_pa: 3164729.7845391277 + end_pressure_pa: 3099133.4332023407 + k: 1.1822268290660432 + - length_ratio: 0.14732213727033575 + type: blowdown + start_pressure_pa: 3099133.4332023407 + end_pressure_pa: 2759036.015920052 + k: 0.9585537699775258 + - length_ratio: 0.0925191694713507 + type: blowdown + start_pressure_pa: 2759036.015920052 + end_pressure_pa: 2554492.049726525 + k: 1.2370160062004798 + - length_ratio: 0.08588604087308514 + type: blowdown + start_pressure_pa: 2554492.049726525 + end_pressure_pa: 2465667.503851008 + k: 0.8239774831525549 + - length_ratio: 0.07014845137420163 + type: blowdown + start_pressure_pa: 2465667.503851008 + end_pressure_pa: 2393118.9933614894 + k: 0.8847832683519297 + - length_ratio: 0.10955893010324647 + type: blowdown + start_pressure_pa: 2393118.9933614894 + end_pressure_pa: 2004754.8014037265 + k: 1.1673408263818559 +combustion: + cea: + expansion_ratio: 4.608954911214158 diff --git a/EngineDesign/configs/canonical/impinging.yaml b/EngineDesign/configs/canonical/impinging.yaml index bf081b85..59926e37 100644 --- a/EngineDesign/configs/canonical/impinging.yaml +++ b/EngineDesign/configs/canonical/impinging.yaml @@ -2,17 +2,6 @@ propellant_preset: methalox material_preset: phenolic_graphite injector: type: impinging - geometry: - oxidizer: - n_elements: 20 - d_jet: 0.002 - impingement_angle: 50.0 - spacing: 0.006 - fuel: - n_elements: 20 - d_jet: 0.002 - impingement_angle: 60.0 - spacing: 0.006 feed_system: fuel: d_inlet: 0.009525 @@ -52,7 +41,7 @@ regen_cooling: hot_gas_prandtl: 0.644 hot_gas_viscosity: 4.0e-05 hot_gas_thermal_conductivity: 0.394 - radiation_emissivity_hot: 0.10 + radiation_emissivity_hot: 0.1 radiation_view_factor: 1.0 n_segments: 20 gas_turbulence_intensity: 0.1 @@ -186,7 +175,6 @@ combustion: cea: use_parallel_cea_build: false cea_parallel_workers: null - expansion_ratio: 4.608954911214158 cache_file: output/cache/cea_cache_LOX_CH4_3D.npz Pc_range: - 1000000.0 @@ -241,18 +229,7 @@ chamber_geometry: design_pressure: 2413166.0 design_thrust: 7000.0 design_MR: 2.55 - chamber_diameter: 0.11504160142419745 - Lstar: 1.1010366919145165 - exit_diameter: 0.10192752776058954 - expansion_ratio: 4.608954911214158 nozzle_efficiency: 0.95 - A_throat: 0.0017703959321061754 - A_exit: 0.008159675026074325 - volume: 0.0019492708804651003 - length: 0.2202290337591734 - length_cylindrical: 0.121 - length_contraction: 0.0451 - Cf: 1.6603014402898282 chamber: null nozzle: null solver: @@ -288,14 +265,12 @@ lox_tank: lox_radius: 0.06985 ox_tank_pos: 0.8 mass: 6.75 - initial_pressure_psi: 563.4671262691785 tank_volume_m3: 0.017474 fuel_tank: rp1_h: 0.609 rp1_radius: 0.0762 fuel_tank_pos: 3.0 mass: 7.0 - initial_pressure_psi: 567.6435444099167 tank_volume_m3: 0.011109 press_tank: press_h: 0.457 @@ -354,7 +329,7 @@ design_requirements: max_P_tank_F: null max_engine_length: 0.4 max_chamber_outer_diameter: 0.2032 - metal_wall_thickness_per_side_m: 0.00635 # 0.25 in/side metal case; total wall = this + ablative liner (derived in code) + metal_wall_thickness_per_side_m: 0.00635 max_nozzle_exit_diameter: 0.2032 min_Lstar: 0.76 max_Lstar: 1.5 @@ -384,7 +359,7 @@ design_requirements: W_IMPINGING_ANGLE: 400.0 W_IMPINGING_JET_ASYM: 180.0 layer1_impinging_jet_angle_max_asym_deg: 26.0 - W_SMD: 200.0 # lowered 2000->200 so SMD/atomization doesn't dominate & push designs to high Pc (low-pressure 400psi designs converge) + W_SMD: 200.0 target_smd_microns: 50.0 layer1_smd_rel_tol: 0.2 W_TANK_EQUAL: 0.0 @@ -423,90 +398,3 @@ design_requirements: layer1_P_F_start_psi_min: null layer1_P_F_start_psi_max: null frozen_parameters: null -pressure_curves: - n_points: 200 - target_burn_time_s: 6.0 - initial_lox_pressure_pa: 3704289.4239973365 - initial_fuel_pressure_pa: 3610619.76028896 - lox_segments: - - length_ratio: 0.2286473154180296 - type: blowdown - start_pressure_pa: 3704289.4239973365 - end_pressure_pa: 3467819.1693295944 - k: 0.24164644917319 - - length_ratio: 0.20249167104882543 - type: blowdown - start_pressure_pa: 3467819.1693295944 - end_pressure_pa: 3258399.4482475044 - k: 1.7414629743039511 - - length_ratio: 0.06342628444092534 - type: blowdown - start_pressure_pa: 3258399.4482475044 - end_pressure_pa: 3141806.013426955 - k: 1.3540898135161692 - - length_ratio: 0.14732213727033575 - type: blowdown - start_pressure_pa: 3141806.013426955 - end_pressure_pa: 2891698.2018464496 - k: 0.7144170122671702 - - length_ratio: 0.0925191694713507 - type: blowdown - start_pressure_pa: 2891698.2018464496 - end_pressure_pa: 2796013.581510806 - k: 1.0723820377201234 - - length_ratio: 0.08588604087308514 - type: blowdown - start_pressure_pa: 2796013.581510806 - end_pressure_pa: 2707189.0356352893 - k: 1.6281617755206732 - - length_ratio: 0.07014845137420163 - type: blowdown - start_pressure_pa: 2707189.0356352893 - end_pressure_pa: 2634640.5251457705 - k: 1.5557597269194856 - - length_ratio: 0.10955893010324647 - type: blowdown - start_pressure_pa: 2634640.5251457705 - end_pressure_pa: 2521333.1458079717 - k: 0.948130932276539 - fuel_segments: - - length_ratio: 0.2286473154180296 - type: blowdown - start_pressure_pa: 3610619.76028896 - end_pressure_pa: 3374149.5056212177 - k: 1.0784566785091103 - - length_ratio: 0.20249167104882543 - type: blowdown - start_pressure_pa: 3374149.5056212177 - end_pressure_pa: 3164729.7845391277 - k: 1.629427648989163 - - length_ratio: 0.06342628444092534 - type: blowdown - start_pressure_pa: 3164729.7845391277 - end_pressure_pa: 3099133.4332023407 - k: 1.1822268290660432 - - length_ratio: 0.14732213727033575 - type: blowdown - start_pressure_pa: 3099133.4332023407 - end_pressure_pa: 2759036.015920052 - k: 0.9585537699775258 - - length_ratio: 0.0925191694713507 - type: blowdown - start_pressure_pa: 2759036.015920052 - end_pressure_pa: 2554492.049726525 - k: 1.2370160062004798 - - length_ratio: 0.08588604087308514 - type: blowdown - start_pressure_pa: 2554492.049726525 - end_pressure_pa: 2465667.503851008 - k: 0.8239774831525549 - - length_ratio: 0.07014845137420163 - type: blowdown - start_pressure_pa: 2465667.503851008 - end_pressure_pa: 2393118.9933614894 - k: 0.8847832683519297 - - length_ratio: 0.10955893010324647 - type: blowdown - start_pressure_pa: 2393118.9933614894 - end_pressure_pa: 2004754.8014037265 - k: 1.1673408263818559 diff --git a/EngineDesign/configs/canonical/pintle.design.yaml b/EngineDesign/configs/canonical/pintle.design.yaml new file mode 100644 index 00000000..511f6961 --- /dev/null +++ b/EngineDesign/configs/canonical/pintle.design.yaml @@ -0,0 +1,133 @@ +# GENERATED -- optimizer output. Do NOT hand-edit. +# +# This is the code-written half of pintle.yaml. The split is by authorship: what a human +# types (requirements, hardware choices, model selections) stays in the intent file; what Layer 1/2 +# write back lives here. io._apply_design_sidecar overlays this at load, so the schema and every +# downstream consumer are unchanged. +# +# Editing a value here is pointless -- the next optimizer run overwrites it. Setting one of these +# fields in the intent file instead is an ERROR the loader raises, because that edit would be +# silently discarded. +# +# Regenerate by re-running Layer 1 against pintle.yaml's design_requirements. + +chamber_geometry: + A_throat: 0.0017651044259737317 + A_exit: 0.00801184666481737 + volume: 0.0021885625035635956 + Lstar: 1.239905396733828 + chamber_diameter: 0.11344154349849075 + exit_diameter: 0.101 + expansion_ratio: 4.539021344528996 + length: 0.24846200737808025 + length_cylindrical: 0.121 + length_contraction: 0.0451 + Cf: 1.5422822280584674 +injector: + geometry: + fuel: + A_entry: 0.0006243142849960269 + d_hydraulic: 0.001092199999999998 + d_pintle_tip: 0.028194 + d_reservoir_inner: 0.0292862 + h_gap: 0.000546099999999999 + lox: + A_entry: 5.8108495056721366e-06 + d_hydraulic: 0.0027200373856119215 + d_orifice: 0.0027200373856119215 + n_orifices: 14 + theta_orifice: 90.0 +lox_tank: + initial_pressure_psi: 537.261547029532 +fuel_tank: + initial_pressure_psi: 523.6759162449396 +pressure_curves: + fuel_segments: + - end_pressure_pa: 3374149.5056212177 + k: 1.0784566785091103 + length_ratio: 0.2286473154180296 + start_pressure_pa: 3610619.76028896 + type: blowdown + - end_pressure_pa: 3164729.7845391277 + k: 1.629427648989163 + length_ratio: 0.20249167104882543 + start_pressure_pa: 3374149.5056212177 + type: blowdown + - end_pressure_pa: 3099133.4332023407 + k: 1.1822268290660432 + length_ratio: 0.06342628444092534 + start_pressure_pa: 3164729.7845391277 + type: blowdown + - end_pressure_pa: 2759036.015920052 + k: 0.9585537699775258 + length_ratio: 0.14732213727033575 + start_pressure_pa: 3099133.4332023407 + type: blowdown + - end_pressure_pa: 2554492.049726525 + k: 1.2370160062004798 + length_ratio: 0.0925191694713507 + start_pressure_pa: 2759036.015920052 + type: blowdown + - end_pressure_pa: 2465667.503851008 + k: 0.8239774831525549 + length_ratio: 0.08588604087308514 + start_pressure_pa: 2554492.049726525 + type: blowdown + - end_pressure_pa: 2393118.9933614894 + k: 0.8847832683519297 + length_ratio: 0.07014845137420163 + start_pressure_pa: 2465667.503851008 + type: blowdown + - end_pressure_pa: 2004754.8014037265 + k: 1.1673408263818559 + length_ratio: 0.10955893010324647 + start_pressure_pa: 2393118.9933614894 + type: blowdown + initial_fuel_pressure_pa: 3610619.76028896 + initial_lox_pressure_pa: 3704289.4239973365 + lox_segments: + - end_pressure_pa: 3467819.1693295944 + k: 0.24164644917319 + length_ratio: 0.2286473154180296 + start_pressure_pa: 3704289.4239973365 + type: blowdown + - end_pressure_pa: 3258399.4482475044 + k: 1.7414629743039511 + length_ratio: 0.20249167104882543 + start_pressure_pa: 3467819.1693295944 + type: blowdown + - end_pressure_pa: 3141806.013426955 + k: 1.3540898135161692 + length_ratio: 0.06342628444092534 + start_pressure_pa: 3258399.4482475044 + type: blowdown + - end_pressure_pa: 2891698.2018464496 + k: 0.7144170122671702 + length_ratio: 0.14732213727033575 + start_pressure_pa: 3141806.013426955 + type: blowdown + - end_pressure_pa: 2796013.581510806 + k: 1.0723820377201234 + length_ratio: 0.0925191694713507 + start_pressure_pa: 2891698.2018464496 + type: blowdown + - end_pressure_pa: 2707189.0356352893 + k: 1.6281617755206732 + length_ratio: 0.08588604087308514 + start_pressure_pa: 2796013.581510806 + type: blowdown + - end_pressure_pa: 2634640.5251457705 + k: 1.5557597269194856 + length_ratio: 0.07014845137420163 + start_pressure_pa: 2707189.0356352893 + type: blowdown + - end_pressure_pa: 2521333.1458079717 + k: 0.948130932276539 + length_ratio: 0.10955893010324647 + start_pressure_pa: 2634640.5251457705 + type: blowdown + n_points: 200 + target_burn_time_s: 6.0 +combustion: + cea: + expansion_ratio: 4.539021344528996 diff --git a/EngineDesign/configs/canonical/pintle.yaml b/EngineDesign/configs/canonical/pintle.yaml index 02e05a28..237cb62e 100644 --- a/EngineDesign/configs/canonical/pintle.yaml +++ b/EngineDesign/configs/canonical/pintle.yaml @@ -21,21 +21,10 @@ ablative_cooling: use_physics_based_blowing: true chamber: null chamber_geometry: - A_exit: 0.00801184666481737 - A_throat: 0.0017651044259737317 - Cf: 1.5422822280584674 - Lstar: 1.239905396733828 - chamber_diameter: 0.11344154349849075 design_MR: 2.55 design_pressure: 2413166.0 design_thrust: 7000.0 - exit_diameter: 0.101 - expansion_ratio: 4.539021344528996 - length: 0.24846200737808025 - length_contraction: 0.0451 - length_cylindrical: 0.121 nozzle_efficiency: 0.95 - volume: 0.0021885625035635956 combustion: cea: MR_range: @@ -49,7 +38,6 @@ combustion: eps_range: - 4.0 - 15.0 - expansion_ratio: 4.539021344528996 fuel_name: Ethanol n_points: 34 ox_name: LOX @@ -215,7 +203,6 @@ fluids: viscosity: 0.00018 fuel_tank: fuel_tank_pos: 3.0 - initial_pressure_psi: 523.6759162449396 mass: 7.0 rp1_h: 0.609 rp1_radius: 0.0762 @@ -243,22 +230,8 @@ graphite_insert: simplified_graphite_oxidation: false sizing_only_mode: false injector: - geometry: - fuel: - A_entry: 0.0006243142849960269 - d_hydraulic: 0.001092199999999998 - d_pintle_tip: 0.028194 - d_reservoir_inner: 0.0292862 - h_gap: 0.000546099999999999 - lox: - A_entry: 5.8108495056721366e-06 - d_hydraulic: 0.0027200373856119215 - d_orifice: 0.0027200373856119215 - n_orifices: 14 - theta_orifice: 90.0 type: pintle lox_tank: - initial_pressure_psi: 537.261547029532 lox_h: 1.14 lox_radius: 0.06985 mass: 6.75 @@ -290,93 +263,6 @@ press_tank: pres_tank_pos: 3.6 press_h: 0.457 press_radius: 0.0762 -pressure_curves: - fuel_segments: - - end_pressure_pa: 3374149.5056212177 - k: 1.0784566785091103 - length_ratio: 0.2286473154180296 - start_pressure_pa: 3610619.76028896 - type: blowdown - - end_pressure_pa: 3164729.7845391277 - k: 1.629427648989163 - length_ratio: 0.20249167104882543 - start_pressure_pa: 3374149.5056212177 - type: blowdown - - end_pressure_pa: 3099133.4332023407 - k: 1.1822268290660432 - length_ratio: 0.06342628444092534 - start_pressure_pa: 3164729.7845391277 - type: blowdown - - end_pressure_pa: 2759036.015920052 - k: 0.9585537699775258 - length_ratio: 0.14732213727033575 - start_pressure_pa: 3099133.4332023407 - type: blowdown - - end_pressure_pa: 2554492.049726525 - k: 1.2370160062004798 - length_ratio: 0.0925191694713507 - start_pressure_pa: 2759036.015920052 - type: blowdown - - end_pressure_pa: 2465667.503851008 - k: 0.8239774831525549 - length_ratio: 0.08588604087308514 - start_pressure_pa: 2554492.049726525 - type: blowdown - - end_pressure_pa: 2393118.9933614894 - k: 0.8847832683519297 - length_ratio: 0.07014845137420163 - start_pressure_pa: 2465667.503851008 - type: blowdown - - end_pressure_pa: 2004754.8014037265 - k: 1.1673408263818559 - length_ratio: 0.10955893010324647 - start_pressure_pa: 2393118.9933614894 - type: blowdown - initial_fuel_pressure_pa: 3610619.76028896 - initial_lox_pressure_pa: 3704289.4239973365 - lox_segments: - - end_pressure_pa: 3467819.1693295944 - k: 0.24164644917319 - length_ratio: 0.2286473154180296 - start_pressure_pa: 3704289.4239973365 - type: blowdown - - end_pressure_pa: 3258399.4482475044 - k: 1.7414629743039511 - length_ratio: 0.20249167104882543 - start_pressure_pa: 3467819.1693295944 - type: blowdown - - end_pressure_pa: 3141806.013426955 - k: 1.3540898135161692 - length_ratio: 0.06342628444092534 - start_pressure_pa: 3258399.4482475044 - type: blowdown - - end_pressure_pa: 2891698.2018464496 - k: 0.7144170122671702 - length_ratio: 0.14732213727033575 - start_pressure_pa: 3141806.013426955 - type: blowdown - - end_pressure_pa: 2796013.581510806 - k: 1.0723820377201234 - length_ratio: 0.0925191694713507 - start_pressure_pa: 2891698.2018464496 - type: blowdown - - end_pressure_pa: 2707189.0356352893 - k: 1.6281617755206732 - length_ratio: 0.08588604087308514 - start_pressure_pa: 2796013.581510806 - type: blowdown - - end_pressure_pa: 2634640.5251457705 - k: 1.5557597269194856 - length_ratio: 0.07014845137420163 - start_pressure_pa: 2707189.0356352893 - type: blowdown - - end_pressure_pa: 2521333.1458079717 - k: 0.948130932276539 - length_ratio: 0.10955893010324647 - start_pressure_pa: 2634640.5251457705 - type: blowdown - n_points: 200 - target_burn_time_s: 6.0 regen_cooling: Cd_entrance_inf: 0.8 Cd_entrance_min: 0.6 @@ -398,12 +284,12 @@ regen_cooling: enabled: false gas_turbulence_intensity: 0.1 hot_gas_cp: 2201.0 - hot_gas_prandtl: 0.670 + hot_gas_prandtl: 0.67 hot_gas_thermal_conductivity: 0.346 hot_gas_viscosity: 4.0e-05 n_channels: 100 n_segments: 20 - radiation_emissivity_hot: 0.10 + radiation_emissivity_hot: 0.1 radiation_view_factor: 1.0 recovery_factor: null roughness: 0.0 diff --git a/EngineDesign/engine/pipeline/io.py b/EngineDesign/engine/pipeline/io.py index 9a79d971..676b34e2 100644 --- a/EngineDesign/engine/pipeline/io.py +++ b/EngineDesign/engine/pipeline/io.py @@ -2,7 +2,9 @@ from __future__ import annotations +import copy import logging + import yaml from pathlib import Path from typing import Any, Dict, List, Union @@ -128,6 +130,137 @@ def _material_preset_search_dirs(config_dir: Path) -> List[Path]: return [config_dir / "materials", _PROJECT_ROOT / "configs" / "materials"] +# Fields the OPTIMIZER writes back, verified by grepping for assignments (not schema declarations): +# chamber_geometry A_throat/A_exit/... come from the chamber+nozzle sizing, Cf from +# layer1_static_optimization:5442, injector.geometry jet sizing from Layer 1, tank start pressures +# from Layer 1, pressure_curves from Layer 2. ``chamber_geometry.design_*`` and ``nozzle_efficiency`` +# are NOT here: nothing assigns them, so they are hand-typed intent. +_GENERATED_FIELDS: Dict[str, Any] = { + "chamber_geometry": ["A_throat", "A_exit", "volume", "Lstar", "chamber_diameter", + "exit_diameter", "expansion_ratio", "length", "length_cylindrical", + "length_contraction", "Cf"], + "injector": ["geometry"], + "lox_tank": ["initial_pressure_psi"], + "fuel_tank": ["initial_pressure_psi"], + "combustion": {"cea": ["expansion_ratio"]}, + "pressure_curves": None, # whole block +} + + +def split_generated(data: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, Any]]: + """Partition a resolved config into (hand-authored intent, optimizer-generated) halves. + + The split is by AUTHORSHIP -- what a human types vs what the code writes back -- which is + checkable against the source rather than a matter of taste. Used when saving so a round trip + through the API cannot collapse a split config back into one file. + """ + intent = copy.deepcopy(data) + generated: Dict[str, Any] = {} + + def take(src: Dict[str, Any], dst: Dict[str, Any], spec: Any) -> None: + if spec is None: + return + if isinstance(spec, list): + for key in spec: + if key in src: + dst[key] = src.pop(key) + return + for key, sub in spec.items(): + if isinstance(src.get(key), dict): + bucket: Dict[str, Any] = {} + take(src[key], bucket, sub) + if bucket: + dst[key] = bucket + + for block, spec in _GENERATED_FIELDS.items(): + if spec is None: + if block in intent: + generated[block] = intent.pop(block) + elif isinstance(intent.get(block), dict): + bucket = {} + take(intent[block], bucket, spec) + if bucket: + generated[block] = bucket + return intent, generated + + +def save_config(data: Dict[str, Any], path: Union[str, Path]) -> None: + """Write a config, preserving the intent/generated split when a sidecar is in use. + + Without this, any save through the API would write the MERGED config into the intent file; the + sidecar would then collide with it and every subsequent load would raise. Splitting on save is + what makes the split survive a round trip. + """ + path = Path(path) + sidecar = path.with_suffix("") + sidecar = sidecar.with_name(sidecar.name + ".design.yaml") + + if not sidecar.exists(): + with open(path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + return + + intent, generated = split_generated(data) + with open(path, "w", encoding="utf-8") as f: + yaml.dump(intent, f, default_flow_style=False, sort_keys=False) + with open(sidecar, "r", encoding="utf-8") as f: + header = "".join(ln for ln in f if ln.startswith("#") or not ln.strip()) + with open(sidecar, "w", encoding="utf-8") as f: + f.write(header) + yaml.dump(generated, f, default_flow_style=False, sort_keys=False) + + +def _deep_overlay(base: Dict[str, Any], overlay: Dict[str, Any], path: str = "") -> List[str]: + """Recursively overlay ``overlay`` onto ``base`` IN PLACE. Returns the paths that collided. + + A collision means a field owned by the generated sidecar was ALSO written by hand in the intent + file -- the one situation the split exists to make impossible-to-miss, so it is reported rather + than silently resolved either way. + """ + collisions: List[str] = [] + for key, val in overlay.items(): + here = f"{path}.{key}" if path else key + if isinstance(val, dict) and isinstance(base.get(key), dict): + collisions.extend(_deep_overlay(base[key], val, here)) + else: + if key in base and base[key] is not None: + collisions.append(here) + base[key] = val + return collisions + + +def _apply_design_sidecar(data: Dict[str, Any], path: Path) -> Dict[str, Any]: + """Overlay ``.design.yaml`` -- the optimizer-GENERATED half of a split config. + + Configs mix two kinds of content: what a human types (requirements, hardware choices, model + selections) and what the code writes back (chamber geometry, injector jet sizing, tank + pressures, pressure curves). Keeping both in one file is why staleness was invisible -- nothing + marked which half the optimizer owns, so a hand-edited requirement and a generated geometry + solved for a DIFFERENT requirement looked identical on disk. + + Split on disk, merged here, so the pydantic schema and every downstream consumer are unchanged. + Absent sidecar = single-file config, legacy behaviour, unchanged. + """ + sidecar = path.with_suffix("") + sidecar = sidecar.with_name(sidecar.name + ".design.yaml") + if not sidecar.exists(): + return data + + with open(sidecar, "r", encoding="utf-8") as f: + generated = yaml.safe_load(f) or {} + _log.info("applying design sidecar %s", sidecar) + + collisions = _deep_overlay(data, generated) + if collisions: + raise ValueError( + f"{path.name} hand-sets fields owned by {sidecar.name}: {sorted(collisions)}. " + f"Generated values belong in the .design.yaml only -- editing them in the intent file " + f"means the next optimizer run silently discards your edit. Remove them, or move the " + f"field out of the sidecar if it is genuinely hand-authored." + ) + return data + + def load_config(config_path: Union[str, Path]) -> PintleEngineConfig: """ Load engine configuration from YAML file. @@ -156,6 +289,7 @@ def load_config(config_path: Union[str, Path]) -> PintleEngineConfig: data = _apply_propellant_preset(data, path.resolve().parent) data = _apply_material_preset(data, path.resolve().parent) + data = _apply_design_sidecar(data, path.resolve()) # Re-stamp spray/discharge bindings for the declared injector type (fixes stale pintle-era # lefebvre SMD + fixed Cd left on impinging YAMLs — bogus ~1 µm D32 and supply-starved Pc). diff --git a/EngineDesign/tests/test_config_intent_derived_split.py b/EngineDesign/tests/test_config_intent_derived_split.py new file mode 100644 index 00000000..62882942 --- /dev/null +++ b/EngineDesign/tests/test_config_intent_derived_split.py @@ -0,0 +1,122 @@ +"""Configs are split by AUTHORSHIP: what a human types vs what the optimizer writes back. + +WHY +--- +A config used to hold both halves with nothing marking which was which. That is why staleness was +invisible: a hand-edited requirement and a geometry solved for a DIFFERENT requirement look +identical on disk. ``configs/canonical/.yaml`` now holds intent; ``.design.yaml`` holds +the generated half; ``io._apply_design_sidecar`` overlays them at load so the schema and every +downstream consumer are unchanged. + +The split is checkable rather than a matter of taste -- ``io._GENERATED_FIELDS`` lists exactly the +fields something in the codebase assigns. Notably ``chamber_geometry.design_thrust/design_MR/ +design_pressure`` and ``nozzle_efficiency`` are NOT generated (nothing assigns them; they are schema +declarations), so they stay intent. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from engine.pipeline.io import ( # noqa: E402 + _GENERATED_FIELDS, + load_config, + save_config, + split_generated, +) + +SPLIT = ["impinging", "pintle"] + + +@pytest.mark.parametrize("stem", SPLIT) +def test_sidecar_exists_and_intent_holds_no_generated_fields(stem: str) -> None: + """The intent file must not contain generated fields -- that is the whole invariant.""" + intent_path = ROOT / "configs" / "canonical" / f"{stem}.yaml" + sidecar_path = ROOT / "configs" / "canonical" / f"{stem}.design.yaml" + assert sidecar_path.exists(), f"missing generated half for {stem}" + + raw = yaml.safe_load(intent_path.read_text(encoding="utf-8")) + leaked = [] + for block, spec in _GENERATED_FIELDS.items(): + if spec is None: + if block in raw: + leaked.append(block) + elif isinstance(raw.get(block), dict) and isinstance(spec, list): + leaked += [f"{block}.{k}" for k in spec if k in raw[block]] + assert not leaked, f"{stem}.yaml hand-sets optimizer-generated fields: {leaked}" + + +@pytest.mark.parametrize("stem", SPLIT) +def test_generated_half_is_actually_populated(stem: str) -> None: + """Guard against an empty sidecar silently passing the invariant above.""" + gen = yaml.safe_load((ROOT / "configs" / "canonical" / f"{stem}.design.yaml").read_text(encoding="utf-8")) + assert gen, "sidecar is empty" + assert gen["chamber_geometry"]["A_throat"] > 0 + assert gen["injector"]["geometry"] + assert gen["lox_tank"]["initial_pressure_psi"] > 0 + + +@pytest.mark.parametrize("stem", SPLIT) +def test_load_merges_both_halves(stem: str) -> None: + cfg = load_config(str(ROOT / "configs" / "canonical" / f"{stem}.yaml")) + assert cfg.chamber_geometry.A_throat > 0 # from the sidecar + assert cfg.design_requirements.target_thrust > 0 # from the intent file + + +def test_collision_between_halves_is_an_error(tmp_path: Path) -> None: + """Hand-setting a generated field must FAIL, not silently lose the edit on the next run. + + This is the case the split exists to make impossible to miss -- silently preferring either side + would recreate the original bug in a new place. + """ + src = ROOT / "configs" / "canonical" / "impinging.yaml" + doc = yaml.safe_load(src.read_text(encoding="utf-8")) + doc.setdefault("chamber_geometry", {})["A_throat"] = 0.00123 # generated field, hand-set + + (tmp_path / "c.yaml").write_text(yaml.safe_dump(doc), encoding="utf-8") + sidecar = ROOT / "configs" / "canonical" / "impinging.design.yaml" + (tmp_path / "c.design.yaml").write_text(sidecar.read_text(encoding="utf-8"), encoding="utf-8") + + with pytest.raises(ValueError, match="chamber_geometry.A_throat"): + load_config(str(tmp_path / "c.yaml")) + + +def test_save_round_trip_keeps_the_split(tmp_path: Path) -> None: + """Saving a merged config must re-split it, or the next load raises on collisions. + + Regression cover for the backend PUT /api/config path, which used to dump the merged model + straight into the intent file. + """ + src = ROOT / "configs" / "canonical" / "impinging.yaml" + sidecar = ROOT / "configs" / "canonical" / "impinging.design.yaml" + (tmp_path / "c.yaml").write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / "c.design.yaml").write_text(sidecar.read_text(encoding="utf-8"), encoding="utf-8") + + merged = load_config(str(tmp_path / "c.yaml")).model_dump(mode="json") + save_config(merged, tmp_path / "c.yaml") + + # The intent file must not have absorbed the generated half... + raw = yaml.safe_load((tmp_path / "c.yaml").read_text(encoding="utf-8")) + assert "A_throat" not in (raw.get("chamber_geometry") or {}) + assert "pressure_curves" not in raw + # ...and the result must still load and resolve to the same geometry. + assert load_config(str(tmp_path / "c.yaml")).chamber_geometry.A_throat == pytest.approx( + merged["chamber_geometry"]["A_throat"] + ) + + +def test_split_generated_leaves_intent_fields_alone() -> None: + """design_* and nozzle_efficiency are hand-typed (nothing assigns them) -- they stay in intent.""" + merged = load_config(str(ROOT / "configs" / "canonical" / "impinging.yaml")).model_dump(mode="json") + intent, generated = split_generated(merged) + for key in ("design_thrust", "design_MR", "design_pressure", "nozzle_efficiency"): + assert key in intent["chamber_geometry"], f"{key} should stay in intent" + assert key not in generated.get("chamber_geometry", {}) + assert "A_throat" in generated["chamber_geometry"] diff --git a/EngineDesign/tests/test_material_preset.py b/EngineDesign/tests/test_material_preset.py index 35037bd4..f4a0ae0a 100644 --- a/EngineDesign/tests/test_material_preset.py +++ b/EngineDesign/tests/test_material_preset.py @@ -27,6 +27,17 @@ CANONICAL = ["canonical/impinging.yaml", "canonical/pintle.yaml"] +def _copy_split_config(tmp_path: Path, stem: str) -> Path: + """Copy canonical/impinging AND its generated sidecar. The intent half alone is not a valid + config -- chamber geometry, injector geometry and tank pressures live in .design.yaml.""" + src = ROOT / "configs" / "canonical" / "impinging.yaml" + sidecar = ROOT / "configs" / "canonical" / "impinging.design.yaml" + dst = tmp_path / f"{stem}.yaml" + dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / f"{stem}.design.yaml").write_text(sidecar.read_text(encoding="utf-8"), encoding="utf-8") + return dst + + @pytest.fixture(scope="module") def preset_doc() -> dict: with open(PRESET, "r", encoding="utf-8") as fh: @@ -77,12 +88,10 @@ def test_explicit_config_value_overrides_preset(tmp_path: Path) -> None: io._deep_merge_preset logs every override, so this is possible but never silent. """ - src = ROOT / "configs" / "canonical" / "impinging.yaml" - with open(src, "r", encoding="utf-8") as fh: + dst = _copy_split_config(tmp_path, "override") + with open(dst, "r", encoding="utf-8") as fh: doc = yaml.safe_load(fh) doc.setdefault("ablative_cooling", {})["heat_of_ablation"] = 9.99e6 - - dst = tmp_path / "override.yaml" with open(dst, "w", encoding="utf-8") as fh: yaml.safe_dump(doc, fh) @@ -94,12 +103,10 @@ def test_explicit_config_value_overrides_preset(tmp_path: Path) -> None: def test_unknown_material_preset_is_a_clear_error(tmp_path: Path) -> None: """A typo must fail loudly with the available names, not silently skip the merge.""" - src = ROOT / "configs" / "canonical" / "impinging.yaml" - with open(src, "r", encoding="utf-8") as fh: + dst = _copy_split_config(tmp_path, "bad") + with open(dst, "r", encoding="utf-8") as fh: doc = yaml.safe_load(fh) doc["material_preset"] = "no_such_material" - - dst = tmp_path / "bad.yaml" with open(dst, "w", encoding="utf-8") as fh: yaml.safe_dump(doc, fh) From bfad3f22b3a0e45dc31f0a3cddd8f210a0578958 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 15:17:40 -0700 Subject: [PATCH 26/35] thermal: document Bartz provenance with Huzel page refs, distinguish eq 4-14 from figure 4-24 --- EngineDesign/engine/pipeline/thermal/bartz.py | 39 ++++++++++++++++--- EngineDesign/tests/test_bartz_correlation.py | 32 +++++++++++++-- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/EngineDesign/engine/pipeline/thermal/bartz.py b/EngineDesign/engine/pipeline/thermal/bartz.py index 6ec49712..c6a02ca7 100644 --- a/EngineDesign/engine/pipeline/thermal/bartz.py +++ b/EngineDesign/engine/pipeline/thermal/bartz.py @@ -3,6 +3,23 @@ h_g = [ 0.026/D_t^0.2 * (mu^0.2 cp / Pr^0.6) * ((Pc)ns g / c*)^0.8 * (D_t/R)^0.1 ] * (A_t/A)^0.9 * sigma +PROVENANCE. Every equation and constant here is transcribed from Huzel & Huang, +"Design of Liquid Propellant Rocket Engines" (NASA SP-125), chapter IV. Nothing +is inferred, fitted, or carried over from another correlation: + + eq. (4-13) p. 100 the correlation above + eq. (4-14) p. 101 sigma, in closed form -- see bartz_sigma() + fig. 4-24 p. 101 sigma tabulated "as computed by Bartz" + Sample Calculation (4-3), pp. 102-103, works the A-1 stage engine end to + end and PRINTS every intermediate group, which is what + tests/test_bartz_correlation.py asserts against, term by term. + +The sample calculation is the reason this module can be trusted rather than +merely believed: its four bracketed groups (0.01366, 0.046, 4.02, 1.078), their +product (0.0027), and the three station values of h_g (0.00185 at the chamber, +0.0027 at the throat, 0.000507 at eps = 5) are all reproduced. A unit slip or a +mistyped exponent cannot survive that. + WHY THIS AND NOT DITTUS-BOELTER. The gas side used plain Dittus-Boelter, 0.023 Re^0.8 Pr^0.4 on chamber diameter. That is a correlation for turbulent flow in a smooth pipe at MODERATE wall-to-bulk temperature difference, with @@ -85,12 +102,22 @@ def bartz_sigma( cold wall thins the boundary layer. It is easy to assume a "correction factor" must be a knockdown; this one is not. - Huzel prints this equation directly (eq. 4-14) and ALSO tabulates it as - figure 4-24, "as computed by Bartz". The equation is the authority here; - the figure is a reading aid. - - Cross-checked anyway against the three sigma values Sample Calculation 4-3 - takes off figure 4-24 at Twg/(Tc)ns = 0.8, gamma ~ 1.2: + SOURCE: this is eq. (4-14) as printed on p. 101, transcribed directly -- + both 0.5 factors, both exponents, same bracket structure. It is NOT a + reconstruction from figure 4-24 and not a formula taken from a secondary + source. test_matches_huzel_equation_4_14_literally re-transcribes it + independently and requires agreement to 1e-12 over a grid of temperature + ratio, gamma and Mach, so a later "simplification" -- dropping a 0.5, + merging the brackets, swapping 0.68 and 0.12 -- fails loudly instead of + quietly shifting every heat flux. + + Huzel prints the equation AND tabulates it as figure 4-24 "as computed by + Bartz". The equation is the authority; the figure is a reading aid. Both + are on the same page, so if this ever needs re-checking, p. 101 has + everything. + + Cross-checked against the three sigma values Sample Calculation 4-3 takes + off figure 4-24 at Twg/(Tc)ns = 0.8, gamma ~ 1.2 (pp. 102-103): station M figure 4-24 eq. 4-14 chamber 0.4046 1.05 1.067 diff --git a/EngineDesign/tests/test_bartz_correlation.py b/EngineDesign/tests/test_bartz_correlation.py index d062316d..588eeeaa 100644 --- a/EngineDesign/tests/test_bartz_correlation.py +++ b/EngineDesign/tests/test_bartz_correlation.py @@ -5,6 +5,28 @@ each of the four bracketed terms is asserted separately, so a failure says WHICH one is wrong instead of just "the correlation". +WHAT IS BEING CHECKED AGAINST WHAT. Every reference value below is a number +printed in Huzel & Huang (NASA SP-125), chapter IV. None is inferred, curve-fit +or taken from a secondary source: + + eq. (4-13), p. 100 the correlation + eq. (4-14), p. 101 sigma in closed form -- transcribed verbatim and + re-transcribed independently in + TestSigmaAgainstFigure4_24, so the implementation + is pinned to the printed equation, not to a + reconstruction from the chart + fig. 4-24, p. 101 sigma tabulated; a cross-check on eq. (4-14), + NOT the source of it + eqs. (4-15)/(4-16) Pr and mu, checked in TestHuzelInputRelations + Sample Calc (4-3), pp. 102-103 all four groups, their product, and the + three station h_g values + +Two tolerances are deliberately loose, in both cases because HUZEL's side is +the imprecise one, not ours: his printed intermediates are rounded on the page +(see TestHuzelInputRelations), and figure 4-24 is a small log-scale plot read +to two significant figures. Both are documented where they occur rather than +tuned until they pass. + LO2/RP-1, (Pc)ns = 1000 psia, MR 2.35 (Tc)ns theoretical 6460 degR -> design 6460 x 0.975^2 = 6140 degR m = 22.5 lb/mol, gamma = 1.222 @@ -162,13 +184,15 @@ def test_sigma_scales_linearly(self): class TestSigmaAgainstFigure4_24: """eq. (4-14) as implemented vs the sigma values Huzel reads off figure 4-24. - The implementation follows Huzel's printed eq. (4-14), so this is a - consistency check between his equation and his own chart, not a validation - of the equation itself -- the equation is the primary source. + The implementation IS Huzel's printed eq. (4-14) (p. 101), so this class is + a consistency check between his equation and his own chart -- not a + validation of the equation, which is the primary source and is pinned + exactly by test_matches_huzel_equation_4_14_literally below. Sample Calculation 4-3 states "a (Twg/(Tc)ns) value of 0.8 is used to determine the a values from figure 4-24 (gamma ~ 1.2)" and then applies - 1.05 in the chamber, 1.0 at the throat and 0.8 at eps = 5. + 1.05 in the chamber, 1.0 at the throat and 0.8 at eps = 5. Those three + numbers appear as running prose on p. 103, not as a table. Tolerance is 5 percent because the CHART is the imprecise side: it is a small log-scale plot read to two significant figures, and the printed "1.0" From c2c735e7f3ec3f7d296fb01ca04dc11c241b7234 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 15:51:44 -0700 Subject: [PATCH 27/35] config: rename design sidecar to .outputs.yaml and move synced burn_time copy out of the intent file --- EngineDesign/backend/routers/config.py | 2 +- ...ing.design.yaml => impinging.outputs.yaml} | 14 +++- EngineDesign/configs/canonical/impinging.yaml | 2 - ...pintle.design.yaml => pintle.outputs.yaml} | 14 +++- EngineDesign/configs/canonical/pintle.yaml | 2 - EngineDesign/engine/pipeline/io.py | 81 +++++++++++-------- .../tests/test_config_intent_derived_split.py | 28 +++---- EngineDesign/tests/test_material_preset.py | 6 +- 8 files changed, 85 insertions(+), 64 deletions(-) rename EngineDesign/configs/canonical/{impinging.design.yaml => impinging.outputs.yaml} (87%) rename EngineDesign/configs/canonical/{pintle.design.yaml => pintle.outputs.yaml} (87%) diff --git a/EngineDesign/backend/routers/config.py b/EngineDesign/backend/routers/config.py index c21e19e6..9927094a 100644 --- a/EngineDesign/backend/routers/config.py +++ b/EngineDesign/backend/routers/config.py @@ -170,7 +170,7 @@ def deep_merge(base: dict, updates: dict) -> dict: new_config = PintleEngineConfig(**merged) # Save to disk if we have a path. MUST go through io.save_config: a split config keeps its - # optimizer-generated half in .design.yaml, and dumping the merged model into the + # optimizer-generated half in .outputs.yaml, and dumping the merged model into the # intent file would put generated fields where the loader forbids them -- every subsequent # load would then raise on the collision. if app_state.config_path: diff --git a/EngineDesign/configs/canonical/impinging.design.yaml b/EngineDesign/configs/canonical/impinging.outputs.yaml similarity index 87% rename from EngineDesign/configs/canonical/impinging.design.yaml rename to EngineDesign/configs/canonical/impinging.outputs.yaml index 3b5c9b6a..4f4795da 100644 --- a/EngineDesign/configs/canonical/impinging.design.yaml +++ b/EngineDesign/configs/canonical/impinging.outputs.yaml @@ -1,14 +1,18 @@ -# GENERATED -- optimizer output. Do NOT hand-edit. +# GENERATED -- optimizer OUTPUTS. Do NOT hand-edit. # -# This is the code-written half of impinging.yaml. The split is by authorship: what a human -# types (requirements, hardware choices, model selections) stays in the intent file; what Layer 1/2 -# write back lives here. io._apply_design_sidecar overlays this at load, so the schema and every +# This is the code-written half of impinging.yaml. The split is by authorship: what a human types +# (requirements, hardware choices, model selections) stays in the intent file; what Layer 1/2 write +# back lives here. io._apply_output_sidecar overlays this at load, so the schema and every # downstream consumer are unchanged. # # Editing a value here is pointless -- the next optimizer run overwrites it. Setting one of these # fields in the intent file instead is an ERROR the loader raises, because that edit would be # silently discarded. # +# NOTE thrust.burn_time is a synced COPY of design_requirements.target_burn_time (the hand-typed +# input), kept here because sync_burn_time_fields writes it and editing it by hand does nothing. +# An independent achieved-vs-target burn time is a planned future change, not this. +# # Regenerate by re-running Layer 1 against impinging.yaml's design_requirements. chamber_geometry: @@ -129,3 +133,5 @@ pressure_curves: combustion: cea: expansion_ratio: 4.608954911214158 +thrust: + burn_time: 10.0 diff --git a/EngineDesign/configs/canonical/impinging.yaml b/EngineDesign/configs/canonical/impinging.yaml index 59926e37..b4f04c1c 100644 --- a/EngineDesign/configs/canonical/impinging.yaml +++ b/EngineDesign/configs/canonical/impinging.yaml @@ -316,8 +316,6 @@ environment: latitude: 35.34722 longitude: -117.8099547 elevation: 626.67 -thrust: - burn_time: 10.0 design_requirements: target_thrust: 8000.0 target_apogee: 3048.0 diff --git a/EngineDesign/configs/canonical/pintle.design.yaml b/EngineDesign/configs/canonical/pintle.outputs.yaml similarity index 87% rename from EngineDesign/configs/canonical/pintle.design.yaml rename to EngineDesign/configs/canonical/pintle.outputs.yaml index 511f6961..e3b35a28 100644 --- a/EngineDesign/configs/canonical/pintle.design.yaml +++ b/EngineDesign/configs/canonical/pintle.outputs.yaml @@ -1,14 +1,18 @@ -# GENERATED -- optimizer output. Do NOT hand-edit. +# GENERATED -- optimizer OUTPUTS. Do NOT hand-edit. # -# This is the code-written half of pintle.yaml. The split is by authorship: what a human -# types (requirements, hardware choices, model selections) stays in the intent file; what Layer 1/2 -# write back lives here. io._apply_design_sidecar overlays this at load, so the schema and every +# This is the code-written half of pintle.yaml. The split is by authorship: what a human types +# (requirements, hardware choices, model selections) stays in the intent file; what Layer 1/2 write +# back lives here. io._apply_output_sidecar overlays this at load, so the schema and every # downstream consumer are unchanged. # # Editing a value here is pointless -- the next optimizer run overwrites it. Setting one of these # fields in the intent file instead is an ERROR the loader raises, because that edit would be # silently discarded. # +# NOTE thrust.burn_time is a synced COPY of design_requirements.target_burn_time (the hand-typed +# input), kept here because sync_burn_time_fields writes it and editing it by hand does nothing. +# An independent achieved-vs-target burn time is a planned future change, not this. +# # Regenerate by re-running Layer 1 against pintle.yaml's design_requirements. chamber_geometry: @@ -131,3 +135,5 @@ pressure_curves: combustion: cea: expansion_ratio: 4.539021344528996 +thrust: + burn_time: 6.0 diff --git a/EngineDesign/configs/canonical/pintle.yaml b/EngineDesign/configs/canonical/pintle.yaml index 237cb62e..32152b39 100644 --- a/EngineDesign/configs/canonical/pintle.yaml +++ b/EngineDesign/configs/canonical/pintle.yaml @@ -366,5 +366,3 @@ spray: weber: We_min: 15.0 stainless_steel_case: null -thrust: - burn_time: 6.0 diff --git a/EngineDesign/engine/pipeline/io.py b/EngineDesign/engine/pipeline/io.py index 676b34e2..a9aab74c 100644 --- a/EngineDesign/engine/pipeline/io.py +++ b/EngineDesign/engine/pipeline/io.py @@ -130,12 +130,14 @@ def _material_preset_search_dirs(config_dir: Path) -> List[Path]: return [config_dir / "materials", _PROJECT_ROOT / "configs" / "materials"] -# Fields the OPTIMIZER writes back, verified by grepping for assignments (not schema declarations): -# chamber_geometry A_throat/A_exit/... come from the chamber+nozzle sizing, Cf from -# layer1_static_optimization:5442, injector.geometry jet sizing from Layer 1, tank start pressures -# from Layer 1, pressure_curves from Layer 2. ``chamber_geometry.design_*`` and ``nozzle_efficiency`` -# are NOT here: nothing assigns them, so they are hand-typed intent. -_GENERATED_FIELDS: Dict[str, Any] = { +# Fields the OPTIMIZER writes back -> the .outputs.yaml sidecar. Verified by grepping for +# assignments (not schema declarations): chamber_geometry A_throat/... from chamber+nozzle sizing, +# Cf from layer1_static_optimization:5442, injector.geometry jet sizing from Layer 1, tank start +# pressures from Layer 1, pressure_curves from Layer 2. thrust.burn_time is written by +# sync_burn_time_fields (a synced COPY of design_requirements.target_burn_time, not an independent +# input -- editing it in the intent file does nothing, so it belongs with the outputs). NOT here: +# chamber_geometry.design_* and nozzle_efficiency (nothing assigns them -> hand-typed intent). +_OUTPUT_FIELDS: Dict[str, Any] = { "chamber_geometry": ["A_throat", "A_exit", "volume", "Lstar", "chamber_diameter", "exit_diameter", "expansion_ratio", "length", "length_cylindrical", "length_contraction", "Cf"], @@ -143,19 +145,20 @@ def _material_preset_search_dirs(config_dir: Path) -> List[Path]: "lox_tank": ["initial_pressure_psi"], "fuel_tank": ["initial_pressure_psi"], "combustion": {"cea": ["expansion_ratio"]}, + "thrust": None, # whole block -- its only field, burn_time, is a synced copy "pressure_curves": None, # whole block } -def split_generated(data: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, Any]]: - """Partition a resolved config into (hand-authored intent, optimizer-generated) halves. +def split_outputs(data: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, Any]]: + """Partition a resolved config into (hand-authored intent, optimizer-produced outputs) halves. The split is by AUTHORSHIP -- what a human types vs what the code writes back -- which is checkable against the source rather than a matter of taste. Used when saving so a round trip through the API cannot collapse a split config back into one file. """ intent = copy.deepcopy(data) - generated: Dict[str, Any] = {} + outputs: Dict[str, Any] = {} def take(src: Dict[str, Any], dst: Dict[str, Any], spec: Any) -> None: if spec is None: @@ -172,48 +175,59 @@ def take(src: Dict[str, Any], dst: Dict[str, Any], spec: Any) -> None: if bucket: dst[key] = bucket - for block, spec in _GENERATED_FIELDS.items(): + for block, spec in _OUTPUT_FIELDS.items(): if spec is None: if block in intent: - generated[block] = intent.pop(block) + outputs[block] = intent.pop(block) elif isinstance(intent.get(block), dict): bucket = {} take(intent[block], bucket, spec) if bucket: - generated[block] = bucket - return intent, generated + outputs[block] = bucket + return intent, outputs + + +# A split config keeps its optimizer-produced half in ``.outputs.yaml`` (the intent file +# ``.yaml`` holds only what a human types). "outputs" over "design" or "generated" because +# that is exactly what it is: the outputs of running the optimizer on the intent. +_OUTPUTS_SUFFIX = ".outputs.yaml" + + +def _outputs_sidecar_path(config_path: Path) -> Path: + """``configs/canonical/impinging.yaml`` -> ``configs/canonical/impinging.outputs.yaml``.""" + stem = config_path.with_suffix("") + return stem.with_name(stem.name + _OUTPUTS_SUFFIX) def save_config(data: Dict[str, Any], path: Union[str, Path]) -> None: - """Write a config, preserving the intent/generated split when a sidecar is in use. + """Write a config, preserving the intent/outputs split when an outputs sidecar is in use. Without this, any save through the API would write the MERGED config into the intent file; the - sidecar would then collide with it and every subsequent load would raise. Splitting on save is - what makes the split survive a round trip. + outputs sidecar would then collide with it and every subsequent load would raise. Splitting on + save is what makes the split survive a round trip. """ path = Path(path) - sidecar = path.with_suffix("") - sidecar = sidecar.with_name(sidecar.name + ".design.yaml") + sidecar = _outputs_sidecar_path(path) if not sidecar.exists(): with open(path, "w", encoding="utf-8") as f: yaml.dump(data, f, default_flow_style=False, sort_keys=False) return - intent, generated = split_generated(data) + intent, outputs = split_outputs(data) with open(path, "w", encoding="utf-8") as f: yaml.dump(intent, f, default_flow_style=False, sort_keys=False) with open(sidecar, "r", encoding="utf-8") as f: header = "".join(ln for ln in f if ln.startswith("#") or not ln.strip()) with open(sidecar, "w", encoding="utf-8") as f: f.write(header) - yaml.dump(generated, f, default_flow_style=False, sort_keys=False) + yaml.dump(outputs, f, default_flow_style=False, sort_keys=False) def _deep_overlay(base: Dict[str, Any], overlay: Dict[str, Any], path: str = "") -> List[str]: """Recursively overlay ``overlay`` onto ``base`` IN PLACE. Returns the paths that collided. - A collision means a field owned by the generated sidecar was ALSO written by hand in the intent + A collision means a field owned by the outputs sidecar was ALSO written by hand in the intent file -- the one situation the split exists to make impossible-to-miss, so it is reported rather than silently resolved either way. """ @@ -229,34 +243,33 @@ def _deep_overlay(base: Dict[str, Any], overlay: Dict[str, Any], path: str = "") return collisions -def _apply_design_sidecar(data: Dict[str, Any], path: Path) -> Dict[str, Any]: - """Overlay ``.design.yaml`` -- the optimizer-GENERATED half of a split config. +def _apply_output_sidecar(data: Dict[str, Any], path: Path) -> Dict[str, Any]: + """Overlay ``.outputs.yaml`` -- the optimizer-produced half of a split config. Configs mix two kinds of content: what a human types (requirements, hardware choices, model selections) and what the code writes back (chamber geometry, injector jet sizing, tank - pressures, pressure curves). Keeping both in one file is why staleness was invisible -- nothing - marked which half the optimizer owns, so a hand-edited requirement and a generated geometry - solved for a DIFFERENT requirement looked identical on disk. + pressures, pressure curves, synced burn time). Keeping both in one file is why staleness was + invisible -- nothing marked which half the optimizer owns, so a hand-edited requirement and an + output geometry solved for a DIFFERENT requirement looked identical on disk. Split on disk, merged here, so the pydantic schema and every downstream consumer are unchanged. Absent sidecar = single-file config, legacy behaviour, unchanged. """ - sidecar = path.with_suffix("") - sidecar = sidecar.with_name(sidecar.name + ".design.yaml") + sidecar = _outputs_sidecar_path(path) if not sidecar.exists(): return data with open(sidecar, "r", encoding="utf-8") as f: - generated = yaml.safe_load(f) or {} - _log.info("applying design sidecar %s", sidecar) + outputs = yaml.safe_load(f) or {} + _log.info("applying output sidecar %s", sidecar) - collisions = _deep_overlay(data, generated) + collisions = _deep_overlay(data, outputs) if collisions: raise ValueError( f"{path.name} hand-sets fields owned by {sidecar.name}: {sorted(collisions)}. " - f"Generated values belong in the .design.yaml only -- editing them in the intent file " + f"Output values belong in the .outputs.yaml only -- editing them in the intent file " f"means the next optimizer run silently discards your edit. Remove them, or move the " - f"field out of the sidecar if it is genuinely hand-authored." + f"field out of the outputs sidecar if it is genuinely hand-authored." ) return data @@ -289,7 +302,7 @@ def load_config(config_path: Union[str, Path]) -> PintleEngineConfig: data = _apply_propellant_preset(data, path.resolve().parent) data = _apply_material_preset(data, path.resolve().parent) - data = _apply_design_sidecar(data, path.resolve()) + data = _apply_output_sidecar(data, path.resolve()) # Re-stamp spray/discharge bindings for the declared injector type (fixes stale pintle-era # lefebvre SMD + fixed Cd left on impinging YAMLs — bogus ~1 µm D32 and supply-starved Pc). diff --git a/EngineDesign/tests/test_config_intent_derived_split.py b/EngineDesign/tests/test_config_intent_derived_split.py index 62882942..8ffc5431 100644 --- a/EngineDesign/tests/test_config_intent_derived_split.py +++ b/EngineDesign/tests/test_config_intent_derived_split.py @@ -4,11 +4,11 @@ --- A config used to hold both halves with nothing marking which was which. That is why staleness was invisible: a hand-edited requirement and a geometry solved for a DIFFERENT requirement look -identical on disk. ``configs/canonical/.yaml`` now holds intent; ``.design.yaml`` holds -the generated half; ``io._apply_design_sidecar`` overlays them at load so the schema and every +identical on disk. ``configs/canonical/.yaml`` now holds intent; ``.outputs.yaml`` holds +the generated half; ``io._apply_output_sidecar`` overlays them at load so the schema and every downstream consumer are unchanged. -The split is checkable rather than a matter of taste -- ``io._GENERATED_FIELDS`` lists exactly the +The split is checkable rather than a matter of taste -- ``io._OUTPUT_FIELDS`` lists exactly the fields something in the codebase assigns. Notably ``chamber_geometry.design_thrust/design_MR/ design_pressure`` and ``nozzle_efficiency`` are NOT generated (nothing assigns them; they are schema declarations), so they stay intent. @@ -26,10 +26,10 @@ sys.path.insert(0, str(ROOT)) from engine.pipeline.io import ( # noqa: E402 - _GENERATED_FIELDS, + _OUTPUT_FIELDS, load_config, save_config, - split_generated, + split_outputs, ) SPLIT = ["impinging", "pintle"] @@ -39,12 +39,12 @@ def test_sidecar_exists_and_intent_holds_no_generated_fields(stem: str) -> None: """The intent file must not contain generated fields -- that is the whole invariant.""" intent_path = ROOT / "configs" / "canonical" / f"{stem}.yaml" - sidecar_path = ROOT / "configs" / "canonical" / f"{stem}.design.yaml" + sidecar_path = ROOT / "configs" / "canonical" / f"{stem}.outputs.yaml" assert sidecar_path.exists(), f"missing generated half for {stem}" raw = yaml.safe_load(intent_path.read_text(encoding="utf-8")) leaked = [] - for block, spec in _GENERATED_FIELDS.items(): + for block, spec in _OUTPUT_FIELDS.items(): if spec is None: if block in raw: leaked.append(block) @@ -56,7 +56,7 @@ def test_sidecar_exists_and_intent_holds_no_generated_fields(stem: str) -> None: @pytest.mark.parametrize("stem", SPLIT) def test_generated_half_is_actually_populated(stem: str) -> None: """Guard against an empty sidecar silently passing the invariant above.""" - gen = yaml.safe_load((ROOT / "configs" / "canonical" / f"{stem}.design.yaml").read_text(encoding="utf-8")) + gen = yaml.safe_load((ROOT / "configs" / "canonical" / f"{stem}.outputs.yaml").read_text(encoding="utf-8")) assert gen, "sidecar is empty" assert gen["chamber_geometry"]["A_throat"] > 0 assert gen["injector"]["geometry"] @@ -81,8 +81,8 @@ def test_collision_between_halves_is_an_error(tmp_path: Path) -> None: doc.setdefault("chamber_geometry", {})["A_throat"] = 0.00123 # generated field, hand-set (tmp_path / "c.yaml").write_text(yaml.safe_dump(doc), encoding="utf-8") - sidecar = ROOT / "configs" / "canonical" / "impinging.design.yaml" - (tmp_path / "c.design.yaml").write_text(sidecar.read_text(encoding="utf-8"), encoding="utf-8") + sidecar = ROOT / "configs" / "canonical" / "impinging.outputs.yaml" + (tmp_path / "c.outputs.yaml").write_text(sidecar.read_text(encoding="utf-8"), encoding="utf-8") with pytest.raises(ValueError, match="chamber_geometry.A_throat"): load_config(str(tmp_path / "c.yaml")) @@ -95,9 +95,9 @@ def test_save_round_trip_keeps_the_split(tmp_path: Path) -> None: straight into the intent file. """ src = ROOT / "configs" / "canonical" / "impinging.yaml" - sidecar = ROOT / "configs" / "canonical" / "impinging.design.yaml" + sidecar = ROOT / "configs" / "canonical" / "impinging.outputs.yaml" (tmp_path / "c.yaml").write_text(src.read_text(encoding="utf-8"), encoding="utf-8") - (tmp_path / "c.design.yaml").write_text(sidecar.read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / "c.outputs.yaml").write_text(sidecar.read_text(encoding="utf-8"), encoding="utf-8") merged = load_config(str(tmp_path / "c.yaml")).model_dump(mode="json") save_config(merged, tmp_path / "c.yaml") @@ -112,10 +112,10 @@ def test_save_round_trip_keeps_the_split(tmp_path: Path) -> None: ) -def test_split_generated_leaves_intent_fields_alone() -> None: +def test_split_outputs_leaves_intent_fields_alone() -> None: """design_* and nozzle_efficiency are hand-typed (nothing assigns them) -- they stay in intent.""" merged = load_config(str(ROOT / "configs" / "canonical" / "impinging.yaml")).model_dump(mode="json") - intent, generated = split_generated(merged) + intent, generated = split_outputs(merged) for key in ("design_thrust", "design_MR", "design_pressure", "nozzle_efficiency"): assert key in intent["chamber_geometry"], f"{key} should stay in intent" assert key not in generated.get("chamber_geometry", {}) diff --git a/EngineDesign/tests/test_material_preset.py b/EngineDesign/tests/test_material_preset.py index f4a0ae0a..38cd1305 100644 --- a/EngineDesign/tests/test_material_preset.py +++ b/EngineDesign/tests/test_material_preset.py @@ -29,12 +29,12 @@ def _copy_split_config(tmp_path: Path, stem: str) -> Path: """Copy canonical/impinging AND its generated sidecar. The intent half alone is not a valid - config -- chamber geometry, injector geometry and tank pressures live in .design.yaml.""" + config -- chamber geometry, injector geometry and tank pressures live in .outputs.yaml.""" src = ROOT / "configs" / "canonical" / "impinging.yaml" - sidecar = ROOT / "configs" / "canonical" / "impinging.design.yaml" + sidecar = ROOT / "configs" / "canonical" / "impinging.outputs.yaml" dst = tmp_path / f"{stem}.yaml" dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") - (tmp_path / f"{stem}.design.yaml").write_text(sidecar.read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / f"{stem}.outputs.yaml").write_text(sidecar.read_text(encoding="utf-8"), encoding="utf-8") return dst From 35b11081a01b62358a0057c163644c5f06279949 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 15:55:05 -0700 Subject: [PATCH 28/35] nozzle: derive throat curvature radius from the rao arc coefficients, share it with Bartz --- EngineDesign/engine/core/nozzle_solver.py | 68 +++++++++++++-- EngineDesign/tests/test_bartz_correlation.py | 92 ++++++++++++++++++++ 2 files changed, 154 insertions(+), 6 deletions(-) diff --git a/EngineDesign/engine/core/nozzle_solver.py b/EngineDesign/engine/core/nozzle_solver.py index 0228a662..f269621a 100644 --- a/EngineDesign/engine/core/nozzle_solver.py +++ b/EngineDesign/engine/core/nozzle_solver.py @@ -4,6 +4,60 @@ from engine.core.nozzle_angles import lookup_angles_interp_bell +# ----------------------------- +# Throat contour geometry -- the single source of truth +# ----------------------------- +# The throat is rounded by two circular fillet arcs, one on each side, both +# passing through the throat point (0, R_t): +# +# entrance arc 1.5 * R_t on the converging side (blends the 45 deg cone +# into the throat) +# throat arc 0.382 * R_t on the diverging side (blends the throat into +# the bell) +# +# These coefficients are used in exactly one place each in rao() below, and are +# named here so that (a) they are not anonymous magic numbers, and (b) anything +# else that needs the throat curvature -- notably the Bartz gas-side heat +# transfer coefficient, which carries a (D_t/R)^0.1 term -- derives it from the +# SAME definition and cannot silently disagree with the contour actually drawn. +# +# If the throat treatment ever changes (different arcs, a MoC throat, a conical +# converging section with no fillet), these change here and every consumer +# follows. test_bartz_correlation.py fits a circle to the rao() throat and +# asserts it matches throat_curvature_radius(), so a change that breaks the +# coupling fails loudly instead of leaving Bartz on a stale radius. +THROAT_ENTRANCE_ARC_COEF = 1.5 # upstream / converging-side fillet, x R_t +THROAT_ARC_COEF = 0.382 # downstream / diverging-side fillet, x R_t + + +def throat_radius(area_throat: float) -> float: + """Throat radius R_t = sqrt(A_t / pi) [m].""" + return float(np.sqrt(area_throat / np.pi)) + + +def throat_curvature_radius(area_throat: float) -> float: + """Radius of curvature of the nozzle contour AT THE THROAT [m]. + + Bartz's (D_t/R)^0.1 term (Huzel & Huang eq. 4-13) needs a single radius of + curvature at the throat. The two fillet arcs meet there with different + radii, so the curvature is discontinuous; Huzel resolves this by taking + their MEAN -- Sample Calculation 4-3 writes it out as + + Mean radius of the throat contour = (18.68 + 4.75)/2 = 11.71 in + + where 18.68 = 1.5 * R_t and 4.75 = 0.382 * R_t for that engine's + R_t = 12.45 in. So R = 0.5*(1.5 + 0.382)*R_t = 0.941 * R_t, which for a + standard nozzle makes D_t/R = 2 R_t / (0.941 R_t) = 2.126 independent of + engine size -- exactly Huzel's value. + + This is the mean of both arcs by Huzel's convention. Physically the throat + heat flux is dominated by the tighter downstream arc, but the term is weak + (^0.1) and we match the validated reference rather than pick a single side. + """ + mean_coef = 0.5 * (THROAT_ENTRANCE_ARC_COEF + THROAT_ARC_COEF) + return mean_coef * throat_radius(area_throat) + + # ----------------------------- # Rotated parabola (Garcia Eq. 7) # ----------------------------- @@ -117,15 +171,17 @@ def rao(area_throat, r_t = np.sqrt(area_throat / np.pi) r_e = np.sqrt(area_exit / np.pi) - # ----- Entrance arc: 1.5 Rt + # ----- Entrance arc: THROAT_ENTRANCE_ARC_COEF * Rt (converging-side fillet) + r_ent = THROAT_ENTRANCE_ARC_COEF * r_t theta1 = np.linspace(deg2rad(-135), deg2rad(-90), steps) - x1 = 1.5 * r_t * np.cos(theta1) - y1 = 1.5 * r_t * np.sin(theta1) + 1.5 * r_t + r_t + x1 = r_ent * np.cos(theta1) + y1 = r_ent * np.sin(theta1) + r_ent + r_t - # ----- Throat arc: 0.382 Rt + # ----- Throat arc: THROAT_ARC_COEF * Rt (diverging-side fillet) + r_thr = THROAT_ARC_COEF * r_t theta2 = np.linspace(deg2rad(-90), theta_n - deg2rad(90), steps) - x2 = 0.382 * r_t * np.cos(theta2) - y2 = 0.382 * r_t * np.sin(theta2) + 0.382 * r_t + r_t + x2 = r_thr * np.cos(theta2) + y2 = r_thr * np.sin(theta2) + r_thr + r_t # Start of bell (point N) Nx, Ny = x2[-1], y2[-1] diff --git a/EngineDesign/tests/test_bartz_correlation.py b/EngineDesign/tests/test_bartz_correlation.py index 588eeeaa..d9197da2 100644 --- a/EngineDesign/tests/test_bartz_correlation.py +++ b/EngineDesign/tests/test_bartz_correlation.py @@ -48,6 +48,7 @@ import math +import numpy as np import pytest from engine.pipeline.thermal.bartz import ( @@ -340,6 +341,97 @@ def test_result_is_physically_plausible_for_a_large_kerolox_throat(self): assert 5_000.0 < h_si < 12_000.0 +class TestThroatCurvatureMatchesDrawnContour: + """The R that Bartz uses must match the nozzle actually drawn by rao(). + + throat_curvature_radius() returns 0.941*R_t analytically. That is only + correct if the contour rao() draws really has those fillet radii. This + fits circles to the generated throat arcs and checks both the analytic + helper AND that the drawn geometry realizes it -- so a future change to the + throat treatment that breaks the assumption fails here rather than leaving + Bartz on a stale radius. See nozzle_solver.throat_curvature_radius. + """ + + A_THROAT = 0.01 # 100 cm^2, R_t ~ 0.0564 m + + @staticmethod + def _fit_circle_radius(x, y): + """Algebraic (Kasa) circle fit -> radius. Linear least squares.""" + x = np.asarray(x, float) + y = np.asarray(y, float) + A = np.column_stack([x, y, np.ones_like(x)]) + b = -(x ** 2 + y ** 2) + D, E, F = np.linalg.lstsq(A, b, rcond=None)[0] + return np.sqrt(max((D * D + E * E) / 4.0 - F, 0.0)) + + def _contour(self): + from engine.core.nozzle_solver import rao + pts, _, _ = rao(self.A_THROAT, self.A_THROAT * 5.0, + steps=400, do_plot=False, method="garcia") + return pts[:, 0], pts[:, 1] + + def test_downstream_throat_arc_has_the_expected_radius(self): + """Fit the diverging-side fillet -> should be 0.382 * R_t.""" + from engine.core.nozzle_solver import throat_radius, THROAT_ARC_COEF + r_t = throat_radius(self.A_THROAT) + x, y = self._contour() + # Diverging throat arc: just downstream of the throat (x = 0). + mask = (x > 0.02 * r_t) & (x < 0.15 * r_t) + radius = self._fit_circle_radius(x[mask], y[mask]) + assert radius == pytest.approx(THROAT_ARC_COEF * r_t, rel=3e-2) + + def test_upstream_entrance_arc_has_the_expected_radius(self): + """Fit the converging-side fillet -> should be 1.5 * R_t.""" + from engine.core.nozzle_solver import throat_radius, THROAT_ENTRANCE_ARC_COEF + r_t = throat_radius(self.A_THROAT) + x, y = self._contour() + mask = (x > -0.4 * r_t) & (x < -0.05 * r_t) + radius = self._fit_circle_radius(x[mask], y[mask]) + assert radius == pytest.approx(THROAT_ENTRANCE_ARC_COEF * r_t, rel=3e-2) + + def test_helper_is_the_mean_of_the_two_drawn_arcs(self): + """throat_curvature_radius = mean of the two fitted radii.""" + from engine.core.nozzle_solver import ( + throat_curvature_radius, throat_radius, + THROAT_ARC_COEF, THROAT_ENTRANCE_ARC_COEF, + ) + r_t = throat_radius(self.A_THROAT) + x, y = self._contour() + down = self._fit_circle_radius( + x[(x > 0.02 * r_t) & (x < 0.15 * r_t)], + y[(x > 0.02 * r_t) & (x < 0.15 * r_t)]) + up = self._fit_circle_radius( + x[(x > -0.4 * r_t) & (x < -0.05 * r_t)], + y[(x > -0.4 * r_t) & (x < -0.05 * r_t)]) + assert throat_curvature_radius(self.A_THROAT) == pytest.approx( + 0.5 * (down + up), rel=3e-2) + + def test_analytic_helper_matches_the_coefficient_definition(self): + from engine.core.nozzle_solver import ( + throat_curvature_radius, throat_radius, + THROAT_ARC_COEF, THROAT_ENTRANCE_ARC_COEF, + ) + r_t = throat_radius(self.A_THROAT) + expected = 0.5 * (THROAT_ENTRANCE_ARC_COEF + THROAT_ARC_COEF) * r_t + assert throat_curvature_radius(self.A_THROAT) == pytest.approx(expected, rel=1e-12) + + def test_Dt_over_R_is_the_size_independent_constant(self): + """D_t/R = 2.126 for any throat, matching Huzel's sample (2.13).""" + from engine.core.nozzle_solver import throat_curvature_radius, throat_radius + for A_t in (1e-4, 1e-3, 1e-2, 0.05): + D_t = 2.0 * throat_radius(A_t) + R = throat_curvature_radius(A_t) + assert D_t / R == pytest.approx(2.126, rel=1e-3) + + def test_matches_huzel_sample_calc_4_3_curvature(self): + """Huzel: R = 11.71 in for D_t = 24.9 in -> A_t from that D_t.""" + from engine.core.nozzle_solver import throat_curvature_radius + D_t_m = 24.9 * 0.0254 + A_t = np.pi * (D_t_m / 2.0) ** 2 + R_m = throat_curvature_radius(A_t) + assert R_m / 0.0254 == pytest.approx(11.71, rel=1e-2) + + class TestRejectsBadInput: @pytest.mark.parametrize("bad", [0.0, -1.0, float("nan"), float("inf")]) def test_non_physical_inputs_raise(self, bad): From 12d975bc83830a364cad52a88c79029060e488f0 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 16:00:04 -0700 Subject: [PATCH 29/35] config: move the design-point stamp to the outputs sidecar, read required chamber fields directly --- EngineDesign/backend/routers/geometry.py | 21 +++++++---- .../configs/canonical/impinging.outputs.yaml | 4 ++ EngineDesign/configs/canonical/impinging.yaml | 3 -- .../configs/canonical/pintle.outputs.yaml | 4 ++ EngineDesign/configs/canonical/pintle.yaml | 3 -- EngineDesign/engine/pipeline/io.py | 20 ++++++---- .../tests/test_config_intent_derived_split.py | 37 ++++++++++++++----- 7 files changed, 60 insertions(+), 32 deletions(-) diff --git a/EngineDesign/backend/routers/geometry.py b/EngineDesign/backend/routers/geometry.py index d1779683..1ebfc83e 100644 --- a/EngineDesign/backend/routers/geometry.py +++ b/EngineDesign/backend/routers/geometry.py @@ -204,14 +204,19 @@ async def get_chamber_geometry(): # These are the inputs to solve_chamber_geometry_with_cea # ===================================================================== if chamber_geom is not None: - # New unified chamber_geometry section - Pc_design = getattr(chamber_geom, 'design_pressure', 2.0e6) - F_design = getattr(chamber_geom, 'design_thrust', 5000.0) - MR = getattr(chamber_geom, 'design_MR', 2.55) - Lstar = getattr(chamber_geom, 'Lstar', 1.0) - D_chamber_design = getattr(chamber_geom, 'chamber_diameter', D_chamber) - D_exit_design = getattr(chamber_geom, 'exit_diameter', D_exit) - nozzle_eff = getattr(chamber_geom, 'nozzle_efficiency', 0.95) + # New unified chamber_geometry section. These are REQUIRED schema fields + # (ChamberGeometryConfig: design_pressure/design_thrust/design_MR/Lstar/ + # chamber_diameter/nozzle_efficiency all Field(gt=0), no default), so read them + # directly. getattr-with-default here would be fail-open: a missing/renamed field + # would silently substitute a magic number and yield a physically different + # engine instead of raising -- the same masking pattern removed elsewhere. + Pc_design = chamber_geom.design_pressure + F_design = chamber_geom.design_thrust + MR = chamber_geom.design_MR + Lstar = chamber_geom.Lstar + D_chamber_design = chamber_geom.chamber_diameter + D_exit_design = chamber_geom.exit_diameter + nozzle_eff = chamber_geom.nozzle_efficiency else: # Legacy sections (chamber and nozzle should exist if we get here due to check above) Pc_design = getattr(chamber, 'design_pressure', 2.0e6) if chamber else 2.0e6 diff --git a/EngineDesign/configs/canonical/impinging.outputs.yaml b/EngineDesign/configs/canonical/impinging.outputs.yaml index 4f4795da..84b89fc1 100644 --- a/EngineDesign/configs/canonical/impinging.outputs.yaml +++ b/EngineDesign/configs/canonical/impinging.outputs.yaml @@ -15,7 +15,11 @@ # # Regenerate by re-running Layer 1 against impinging.yaml's design_requirements. + chamber_geometry: + design_thrust: 7000.0 + design_MR: 2.55 + design_pressure: 2413166.0 A_throat: 0.0017703959321061754 A_exit: 0.008159675026074325 volume: 0.0019492708804651003 diff --git a/EngineDesign/configs/canonical/impinging.yaml b/EngineDesign/configs/canonical/impinging.yaml index b4f04c1c..1195bb17 100644 --- a/EngineDesign/configs/canonical/impinging.yaml +++ b/EngineDesign/configs/canonical/impinging.yaml @@ -226,9 +226,6 @@ combustion: Ea_hydrogen: 40000.0 n_pre_hydrogen: 0.2 chamber_geometry: - design_pressure: 2413166.0 - design_thrust: 7000.0 - design_MR: 2.55 nozzle_efficiency: 0.95 chamber: null nozzle: null diff --git a/EngineDesign/configs/canonical/pintle.outputs.yaml b/EngineDesign/configs/canonical/pintle.outputs.yaml index e3b35a28..61ea983b 100644 --- a/EngineDesign/configs/canonical/pintle.outputs.yaml +++ b/EngineDesign/configs/canonical/pintle.outputs.yaml @@ -15,7 +15,11 @@ # # Regenerate by re-running Layer 1 against pintle.yaml's design_requirements. + chamber_geometry: + design_thrust: 7000.0 + design_MR: 2.55 + design_pressure: 2413166.0 A_throat: 0.0017651044259737317 A_exit: 0.00801184666481737 volume: 0.0021885625035635956 diff --git a/EngineDesign/configs/canonical/pintle.yaml b/EngineDesign/configs/canonical/pintle.yaml index 32152b39..4abbf434 100644 --- a/EngineDesign/configs/canonical/pintle.yaml +++ b/EngineDesign/configs/canonical/pintle.yaml @@ -21,9 +21,6 @@ ablative_cooling: use_physics_based_blowing: true chamber: null chamber_geometry: - design_MR: 2.55 - design_pressure: 2413166.0 - design_thrust: 7000.0 nozzle_efficiency: 0.95 combustion: cea: diff --git a/EngineDesign/engine/pipeline/io.py b/EngineDesign/engine/pipeline/io.py index a9aab74c..7da3459e 100644 --- a/EngineDesign/engine/pipeline/io.py +++ b/EngineDesign/engine/pipeline/io.py @@ -130,17 +130,21 @@ def _material_preset_search_dirs(config_dir: Path) -> List[Path]: return [config_dir / "materials", _PROJECT_ROOT / "configs" / "materials"] -# Fields the OPTIMIZER writes back -> the .outputs.yaml sidecar. Verified by grepping for -# assignments (not schema declarations): chamber_geometry A_throat/... from chamber+nozzle sizing, -# Cf from layer1_static_optimization:5442, injector.geometry jet sizing from Layer 1, tank start -# pressures from Layer 1, pressure_curves from Layer 2. thrust.burn_time is written by -# sync_burn_time_fields (a synced COPY of design_requirements.target_burn_time, not an independent -# input -- editing it in the intent file does nothing, so it belongs with the outputs). NOT here: -# chamber_geometry.design_* and nozzle_efficiency (nothing assigns them -> hand-typed intent). +# Fields the OPTIMIZER produces -> the .outputs.yaml sidecar. Verified by grepping for assignments: +# chamber_geometry A_throat/... from chamber+nozzle sizing, Cf from layer1_static_optimization:5442, +# injector.geometry jet sizing from Layer 1, tank start pressures from Layer 1, pressure_curves from +# Layer 2. thrust.burn_time is written by sync_burn_time_fields (a synced COPY of +# design_requirements.target_burn_time). ``chamber_geometry.design_thrust/design_MR/design_pressure`` +# are the "what was this geometry SOLVED FOR" stamp -- a property of the generated geometry, read by +# the geometry re-solve fallback and the native kernel. They differ from design_requirements.* when +# the geometry is stale (7000 vs target 8000), which is signal, not a bug. Belongs with the outputs. +# (Layer 1 does not yet WRITE them on solve -- deferred; see the provenance-stamp TODO.) NOT here: +# nozzle_efficiency, a hand-typed modelling assumption that nothing assigns. _OUTPUT_FIELDS: Dict[str, Any] = { "chamber_geometry": ["A_throat", "A_exit", "volume", "Lstar", "chamber_diameter", "exit_diameter", "expansion_ratio", "length", "length_cylindrical", - "length_contraction", "Cf"], + "length_contraction", "Cf", + "design_thrust", "design_MR", "design_pressure"], "injector": ["geometry"], "lox_tank": ["initial_pressure_psi"], "fuel_tank": ["initial_pressure_psi"], diff --git a/EngineDesign/tests/test_config_intent_derived_split.py b/EngineDesign/tests/test_config_intent_derived_split.py index 8ffc5431..2c3460e8 100644 --- a/EngineDesign/tests/test_config_intent_derived_split.py +++ b/EngineDesign/tests/test_config_intent_derived_split.py @@ -9,9 +9,11 @@ downstream consumer are unchanged. The split is checkable rather than a matter of taste -- ``io._OUTPUT_FIELDS`` lists exactly the -fields something in the codebase assigns. Notably ``chamber_geometry.design_thrust/design_MR/ -design_pressure`` and ``nozzle_efficiency`` are NOT generated (nothing assigns them; they are schema -declarations), so they stay intent. +fields the optimizer produces. ``chamber_geometry.design_thrust/design_MR/design_pressure`` are the +"what was this geometry SOLVED FOR" stamp: a property of the generated geometry (read by the +geometry re-solve fallback + native kernel), which differs from ``design_requirements.*`` when the +geometry is stale -- so they live with the outputs. ``nozzle_efficiency`` is a hand-typed modelling +assumption and stays intent. """ from __future__ import annotations @@ -112,11 +114,26 @@ def test_save_round_trip_keeps_the_split(tmp_path: Path) -> None: ) -def test_split_outputs_leaves_intent_fields_alone() -> None: - """design_* and nozzle_efficiency are hand-typed (nothing assigns them) -- they stay in intent.""" +def test_split_outputs_partitions_chamber_geometry_correctly() -> None: + """nozzle_efficiency is hand-typed intent; design_* (the solved-for stamp) and the solved + dimensions are outputs.""" merged = load_config(str(ROOT / "configs" / "canonical" / "impinging.yaml")).model_dump(mode="json") - intent, generated = split_outputs(merged) - for key in ("design_thrust", "design_MR", "design_pressure", "nozzle_efficiency"): - assert key in intent["chamber_geometry"], f"{key} should stay in intent" - assert key not in generated.get("chamber_geometry", {}) - assert "A_throat" in generated["chamber_geometry"] + intent, outputs = split_outputs(merged) + + assert intent["chamber_geometry"]["nozzle_efficiency"] > 0 + assert "nozzle_efficiency" not in outputs.get("chamber_geometry", {}) + + for key in ("design_thrust", "design_MR", "design_pressure", "A_throat"): + assert key in outputs["chamber_geometry"], f"{key} should be an output" + assert key not in intent.get("chamber_geometry", {}) + + +def test_design_stamp_lives_with_the_geometry_it_describes() -> None: + """The solved-for stamp must sit in the outputs file next to the geometry, so that comparing it + to design_requirements.* detects staleness. Regression cover for the reclassification.""" + out = yaml.safe_load( + (ROOT / "configs" / "canonical" / "impinging.outputs.yaml").read_text(encoding="utf-8") + ) + assert out["chamber_geometry"]["design_thrust"] > 0 + intent = yaml.safe_load((ROOT / "configs" / "canonical" / "impinging.yaml").read_text(encoding="utf-8")) + assert "design_thrust" not in (intent.get("chamber_geometry") or {}) From 2f3f2342adda18d4325213cc1837a2c11d51e394 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 16:03:40 -0700 Subject: [PATCH 30/35] thermal: add bartz_chamber_h_g, one gas-side provider for all cooling models to share --- EngineDesign/engine/pipeline/thermal/bartz.py | 65 +++++++++++++++++++ EngineDesign/tests/test_bartz_correlation.py | 58 +++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/EngineDesign/engine/pipeline/thermal/bartz.py b/EngineDesign/engine/pipeline/thermal/bartz.py index c6a02ca7..7be781ec 100644 --- a/EngineDesign/engine/pipeline/thermal/bartz.py +++ b/EngineDesign/engine/pipeline/thermal/bartz.py @@ -268,3 +268,68 @@ def bartz_h_g( sigma=sigma, ) return h_imperial * BTU_IN2_S_F_TO_W_M2_K + + +def bartz_chamber_h_g( + *, + A_throat: float, + chamber_area: float, + Pc: float, + mdot_total: float, + mu: float, + cp: float, + Pr: float, + gamma: float, + mach: float, + wall_temperature: float, + Tc: float, +) -> float: + """Bartz gas-side h_g [W/(m^2 K)] at a chamber/nozzle station, SI throughout. + + THE single entry point the cooling models should use, so the four former + Dittus-Boelter sites (regen, ablative-scalar, chamber-solver, and the C + mirror) cannot drift into computing the gas-side coefficient four different + ways -- which is exactly what happened before with the transport properties. + + It assembles the pieces eq. 4-13 needs from quantities a cooling model + already has, deriving the two it does not: + + * throat radius of curvature R -- from throat_curvature_radius(A_throat), + the same definition the drawn nozzle uses (see nozzle_solver). Imported + lazily so this leaf module stays free of the geometry/plotting deps. + * effective c* for the mass-flux term -- c* = Pc*A_t/mdot_total, which is + the c* that makes Pc*g/c* equal the physical throat mass flux + mdot_total/A_t. Using it avoids threading a separately-computed c* + (and the combustion/cooling-efficiency ambiguity that comes with it) + through every caller. + + sigma (eq. 4-14) is computed from the local Mach and the wall-to-stagnation + temperature ratio. NOTE the wall temperature is currently the failure-limit + placeholder the callers already pass for the convective delta-T; when that + is replaced by a solved wall temperature, sigma sharpens automatically. A + colder wall gives sigma > 1 -- Bartz raises h_g there, it is not a knockdown. + + Parameters (all SI): A_throat [m^2], chamber_area [m^2] at the station, + Pc [Pa], mdot_total [kg/s], mu [Pa.s], cp [J/(kg.K)], Pr [-], gamma [-], + mach [-] local, wall_temperature [K], Tc [K] stagnation. + """ + from engine.core.nozzle_solver import throat_curvature_radius + + A_t = max(A_throat, 1e-12) + D_throat = 2.0 * math.sqrt(A_t / math.pi) + R_curv = throat_curvature_radius(A_t) + cstar_eff = Pc * A_t / max(mdot_total, 1e-12) + area_ratio = A_t / max(chamber_area, 1e-12) + sigma = bartz_sigma(wall_temperature / max(Tc, 1.0), gamma, mach) + + return bartz_h_g( + D_throat_m=D_throat, + Pc_pa=Pc, + cstar_m_s=cstar_eff, + throat_curvature_radius_m=R_curv, + mu_pa_s=mu, + cp_j_kg_k=cp, + Pr=Pr, + area_ratio_At_over_A=area_ratio, + sigma=sigma, + ) diff --git a/EngineDesign/tests/test_bartz_correlation.py b/EngineDesign/tests/test_bartz_correlation.py index d9197da2..b29edd01 100644 --- a/EngineDesign/tests/test_bartz_correlation.py +++ b/EngineDesign/tests/test_bartz_correlation.py @@ -432,6 +432,64 @@ def test_matches_huzel_sample_calc_4_3_curvature(self): assert R_m / 0.0254 == pytest.approx(11.71, rel=1e-2) +class TestChamberProvider: + """bartz_chamber_h_g: the single SI entry point the cooling models call.""" + + # Representative canonical-impinging chamber state. + KW = dict( + A_throat=1.0e-3, chamber_area=3.0e-3, Pc=2.3e6, mdot_total=2.4, + mu=1.0e-4, cp=2460.0, Pr=0.64, gamma=1.14, mach=0.3, + wall_temperature=1200.0, Tc=2683.0, + ) + + def test_equals_the_hand_assembled_call(self): + """The provider must be exactly bartz_h_g with its derived pieces.""" + from engine.pipeline.thermal.bartz import ( + bartz_chamber_h_g, bartz_h_g, bartz_sigma, + ) + from engine.core.nozzle_solver import throat_curvature_radius + + k = self.KW + R_curv = throat_curvature_radius(k["A_throat"]) + D_throat = 2.0 * math.sqrt(k["A_throat"] / math.pi) + cstar_eff = k["Pc"] * k["A_throat"] / k["mdot_total"] + sigma = bartz_sigma(k["wall_temperature"] / k["Tc"], k["gamma"], k["mach"]) + expected = bartz_h_g( + D_throat, k["Pc"], cstar_eff, R_curv, k["mu"], k["cp"], k["Pr"], + area_ratio_At_over_A=k["A_throat"] / k["chamber_area"], sigma=sigma) + assert bartz_chamber_h_g(**k) == pytest.approx(expected, rel=1e-12) + + def test_effective_cstar_is_the_throat_mass_flux(self): + """c* = Pc*A_t/mdot makes Pc*g/c* equal mdot/A_t. Verify via the group.""" + from engine.pipeline.thermal.bartz import bartz_groups_imperial + k = self.KW + cstar_ft_s = (k["Pc"] * k["A_throat"] / k["mdot_total"]) / 0.3048 + g = bartz_groups_imperial( + 2.0 * math.sqrt(k["A_throat"] / math.pi) / 0.0254, + k["Pc"] / 6894.757293168361, cstar_ft_s, + 1.0, k["mu"] / 17.857967302549516, k["cp"] / 4186.8, k["Pr"]) + mass_flux_si = k["mdot_total"] / k["A_throat"] # kg/(m^2 s) + mass_flux_imp = g["mass_flux"] * 703.06957829 # lb/in^2/s -> kg/m^2/s + assert mass_flux_imp == pytest.approx(mass_flux_si, rel=1e-3) + + def test_result_is_plausible_for_a_chamber_station(self): + from engine.pipeline.thermal.bartz import bartz_chamber_h_g + h = bartz_chamber_h_g(**self.KW) + assert 1_000.0 < h < 8_000.0 + + def test_cold_wall_raises_h_via_sigma(self): + from engine.pipeline.thermal.bartz import bartz_chamber_h_g + cold = bartz_chamber_h_g(**{**self.KW, "wall_temperature": 800.0}) + hot = bartz_chamber_h_g(**{**self.KW, "wall_temperature": 2400.0}) + assert cold > hot + + def test_h_scales_down_away_from_the_throat(self): + from engine.pipeline.thermal.bartz import bartz_chamber_h_g + near_throat = bartz_chamber_h_g(**{**self.KW, "chamber_area": 1.1e-3}) + wide = bartz_chamber_h_g(**{**self.KW, "chamber_area": 5.0e-3}) + assert wide < near_throat + + class TestRejectsBadInput: @pytest.mark.parametrize("bad", [0.0, -1.0, float("nan"), float("inf")]) def test_non_physical_inputs_raise(self, bad): From 832bc9cc2aa2f544c2d6ea294b2bd402cbfea6ae Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 16:14:26 -0700 Subject: [PATCH 31/35] thermal: use Bartz for the gas-side film coefficient instead of Dittus-Boelter, mirror in C --- .../engine/native/include/ed_phys_const.h | 16 + EngineDesign/engine/native/include/ed_state.h | 1 + .../engine/native/python/ed_native.py | 1 + .../engine/native/python/native_injector.py | 1 + EngineDesign/engine/native/src/ed_cooling.c | 51 ++- .../native/tests/golden/residual_samples.json | 375 +++++++++--------- .../native/tests/test_residual_golden.c | 7 +- .../native/tools/export_residual_golden.py | 9 +- .../engine/pipeline/thermal/regen_cooling.py | 31 +- 9 files changed, 291 insertions(+), 201 deletions(-) diff --git a/EngineDesign/engine/native/include/ed_phys_const.h b/EngineDesign/engine/native/include/ed_phys_const.h index 10a0bdc2..723057ed 100644 --- a/EngineDesign/engine/native/include/ed_phys_const.h +++ b/EngineDesign/engine/native/include/ed_phys_const.h @@ -44,4 +44,20 @@ #define ED_LB_PER_IN_S_TO_PA_S 17.8579673 #define ED_HUZEL_COEFF 46.6e-10 +/* Bartz gas-side film coefficient (Huzel eq. 4-13). Mirrors thermal/bartz.py; + * the correlation is evaluated in Huzel's inch-pound units and converted at the + * boundary, exactly as the Python does, so the A/B parity holds to machine + * precision. Keep every constant here numerically identical to bartz.py. */ +#define ED_M_PER_IN 0.0254 +#define ED_M_PER_FT 0.3048 +#define ED_PA_PER_PSI 6894.757293168361 +#define ED_PA_S_PER_LB_IN_S 17.857967302549516 +#define ED_J_KG_K_PER_BTU_LB_F 4186.8 +#define ED_G_C_LBM_FT_LBF_S2 32.174049 +#define ED_BTU_IN2_S_F_TO_W_M2_K 2943611.716033232 /* J_per_Btu/(m2_per_in2*K_per_degF) */ +/* Throat fillet-arc coefficients; mean is the curvature radius Bartz needs. + * Mirror nozzle_solver.THROAT_ENTRANCE_ARC_COEF / THROAT_ARC_COEF. */ +#define ED_THROAT_ENTRANCE_ARC_COEF 1.5 +#define ED_THROAT_ARC_COEF 0.382 + #endif /* ED_PHYS_CONST_H */ diff --git a/EngineDesign/engine/native/include/ed_state.h b/EngineDesign/engine/native/include/ed_state.h index 6111ed6a..0226d11a 100644 --- a/EngineDesign/engine/native/include/ed_state.h +++ b/EngineDesign/engine/native/include/ed_state.h @@ -135,6 +135,7 @@ typedef struct { /* hot-gas / regen props used by estimate_hot_wall_heat_flux + gas prep */ double hot_gas_viscosity; double hot_gas_thermal_conductivity; + double hot_gas_cp; /* config cp; Bartz uses it directly */ double hot_gas_prandtl; double gas_turbulence_intensity; double recovery_factor; /* resolved (null -> 0.94) */ diff --git a/EngineDesign/engine/native/python/ed_native.py b/EngineDesign/engine/native/python/ed_native.py index 34371219..cb4604dd 100644 --- a/EngineDesign/engine/native/python/ed_native.py +++ b/EngineDesign/engine/native/python/ed_native.py @@ -75,6 +75,7 @@ class EdCooling(C.Structure): ("regen_enabled", _u8), ("film_enabled", _u8), ("ablative_enabled", _u8), ("graphite_enabled", _u8), ("use_cooling_coupling", _u8), ("hot_gas_viscosity", _d), ("hot_gas_thermal_conductivity", _d), + ("hot_gas_cp", _d), ("hot_gas_prandtl", _d), ("gas_turbulence_intensity", _d), ("recovery_factor", _d), ("radiation_emissivity_hot", _d), ("radiation_view_factor", _d), ("regen_chamber_inner_diameter", _d), diff --git a/EngineDesign/engine/native/python/native_injector.py b/EngineDesign/engine/native/python/native_injector.py index 2e399971..6b8fcbf5 100644 --- a/EngineDesign/engine/native/python/native_injector.py +++ b/EngineDesign/engine/native/python/native_injector.py @@ -170,6 +170,7 @@ def _fill_cooling(dst, cfg): if rg is not None: dst.hot_gas_viscosity = float(rg.hot_gas_viscosity) dst.hot_gas_thermal_conductivity = float(rg.hot_gas_thermal_conductivity) + dst.hot_gas_cp = float(rg.hot_gas_cp) dst.hot_gas_prandtl = float(rg.hot_gas_prandtl) dst.gas_turbulence_intensity = float(rg.gas_turbulence_intensity) dst.recovery_factor = float(rg.recovery_factor) if rg.recovery_factor is not None else 0.94 diff --git a/EngineDesign/engine/native/src/ed_cooling.c b/EngineDesign/engine/native/src/ed_cooling.c index d5ab4e06..85fb3cae 100644 --- a/EngineDesign/engine/native/src/ed_cooling.c +++ b/EngineDesign/engine/native/src/ed_cooling.c @@ -35,6 +35,40 @@ double ed_chamber_wetted_area(const EdGeometry *g) { return circumference * g->length; } +/* Bartz gas-side film coefficient (Huzel eq. 4-13) at a chamber station. + * Mirrors bartz.bartz_chamber_h_g: the correlation is evaluated in Huzel's + * inch-pound units and converted back to SI, so this matches the Python to + * machine precision. Returns W/(m^2 K). */ +static double ed_bartz_chamber_h_g(double A_throat, double chamber_area, double Pc, + double mdot_total, double mu_pa_s, double cp_si, + double Pr, double gamma, double mach, + double wall_T, double Tc) { + const double A_t = ed_max(A_throat, 1e-12); + const double r_t = sqrt(A_t / ED_PI); + const double D_throat_in = 2.0 * r_t / ED_M_PER_IN; + const double R_curv_in = 0.5 * (ED_THROAT_ENTRANCE_ARC_COEF + ED_THROAT_ARC_COEF) + * r_t / ED_M_PER_IN; + /* c* = Pc*A_t/mdot makes Pc*g/c* the physical throat mass flux. */ + const double cstar_ft_s = (Pc * A_t / ed_max(mdot_total, 1e-12)) / ED_M_PER_FT; + const double Pc_psia = Pc / ED_PA_PER_PSI; + const double mu_imp = mu_pa_s / ED_PA_S_PER_LB_IN_S; + const double cp_imp = cp_si / ED_J_KG_K_PER_BTU_LB_F; + const double area_ratio = A_t / ed_max(chamber_area, 1e-12); + + const double kM = 1.0 + 0.5 * (gamma - 1.0) * mach * mach; + const double sigma = 1.0 / (pow(0.5 * (wall_T / ed_max(Tc, 1.0)) * kM + 0.5, 0.68) + * pow(kM, 0.12)); + + const double diameter = 0.026 / pow(D_throat_in, 0.2); + const double transport = pow(mu_imp, 0.2) * cp_imp / pow(Pr, 0.6); + const double mass_flux = Pc_psia * ED_G_C_LBM_FT_LBF_S2 / cstar_ft_s; + const double mass_flux_group = pow(mass_flux, 0.8); + const double curvature = pow(D_throat_in / R_curv_in, 0.1); + const double h_imperial = diameter * transport * mass_flux_group * curvature + * pow(area_ratio, 0.9) * sigma; + return h_imperial * ED_BTU_IN2_S_F_TO_W_M2_K; +} + /* estimate_hot_wall_heat_flux -> {q_total, q_conv, q_rad}. */ static void hot_wall_flux(const EdEngineState *s, double Pc, double Tc, double gamma, double R, double M, double mdot_total, double wall_T, @@ -47,13 +81,20 @@ static void hot_wall_flux(const EdEngineState *s, double Pc, double Tc, double g const double mu_g = (M > 0 && Tc > 0) ? ed_gas_viscosity_huzel(Tc, M) : c->hot_gas_viscosity; const double k_g = c->hot_gas_thermal_conductivity; - const double cp_g = gamma * R / ed_max(gamma - 1.0, ED_EPS_SMALL); + /* cp from config, NOT gamma*R/(gamma-1). Dittus-Boelter only used cp via + * Pr (which is configured, so the derived value was dead); Bartz uses cp + * directly, so the config value must be carried through -- see the note in + * bartz.py. gamma*R/(gamma-1) overshoots by ~40% for a dissociating gas. */ + const double cp_g = (c->hot_gas_cp > 0) ? c->hot_gas_cp + : gamma * R / ed_max(gamma - 1.0, ED_EPS_SMALL); const double Pr_g = (c->hot_gas_prandtl > 0) ? c->hot_gas_prandtl : (mu_g * cp_g / ed_max(k_g, ED_EPS_SMALL)); - const double Re_g = rho_g * V_g * d / ed_max(mu_g, ED_EPS_TINY); - const double Nu_g = (Re_g < 2000.0) ? ED_NU_LAMINAR - : ED_NU_TURB_COEF * pow(Re_g, ED_NU_TURB_RE_EXP) * pow(Pr_g, ED_NU_TURB_PR_EXP); - const double h_g = Nu_g * k_g / d; + /* Bartz (eq. 4-13) at the chamber station, replacing Dittus-Boelter. */ + const double a_sound = sqrt(ed_max(gamma * R * ed_max(Tc, 1.0), 1.0)); + const double M_chamber = ed_min(V_g / a_sound, 0.99); + const double h_g = ed_bartz_chamber_h_g(s->geom.A_throat, A_cross, Pc, mdot_total, + mu_g, cp_g, Pr_g, gamma, M_chamber, + wall_T, Tc); const double Taw = Tc * c->recovery_factor; const double dT = ed_max(Taw - wall_T, 0.0); diff --git a/EngineDesign/engine/native/tests/golden/residual_samples.json b/EngineDesign/engine/native/tests/golden/residual_samples.json index 79e0f656..6da7ddf8 100644 --- a/EngineDesign/engine/native/tests/golden/residual_samples.json +++ b/EngineDesign/engine/native/tests/golden/residual_samples.json @@ -20,11 +20,12 @@ "graphite_enabled": 1, "use_cooling_coupling": 1, "hot_gas_viscosity": 4e-05, - "hot_gas_thermal_conductivity": 0.12, - "hot_gas_prandtl": 0.7, + "hot_gas_thermal_conductivity": 0.394, + "hot_gas_cp": 2463.0, + "hot_gas_prandtl": 0.644, "gas_turbulence_intensity": 0.1, "recovery_factor": 0.94, - "radiation_emissivity_hot": 0.85, + "radiation_emissivity_hot": 0.1, "radiation_view_factor": 1.0, "regen_chamber_inner_diameter": 0.08491, "ablative_coverage_fraction": 0.9, @@ -67,18 +68,18 @@ "P_tank_O": 3447378.646584, "P_tank_F": 3585273.7924473598, "Pc": 1800000.0, - "mdot_O": 1.7172745467545045, - "mdot_F": 0.9806411071967972, + "mdot_O": 1.717274546754505, + "mdot_F": 0.9806411071967974, "MR": 1.7511753628841893, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 5.673302077821216e-05, - "D32_F": 6.619836982615603e-05, - "u_O": 23.974801119462594, - "u_F": 36.93182196154271, + "D32_O": 5.673302077821215e-05, + "D32_F": 6.619836982615602e-05, + "u_O": 23.974801119462597, + "u_F": 36.93182196154272, "momentum_ratio_R": 1.0662080550786204, "R_opt": 1.0632572007031675, "Tc": 3000.442000000001, @@ -89,28 +90,28 @@ "fuel_latent_heat": 510000.0, "fuel_T_star_cap": 500.0, "eta_Lstar": 0.9999999999437091, - "eta_kinetics": 0.9527915089975729, + "eta_kinetics": 0.9527915089975728, "eta_mixing": 0.9599983613985529, "eta_total": 0.9146782873406365, - "cooling_eff": 0.9805496873112249, - "heat_removed": 495856.9057713169, - "eta_final": 0.8968875086422279, - "cstar_actual": 1645.0802166041626, - "mdot_demand": 1.9371168929192095 + "cooling_eff": 0.9650830405992765, + "heat_removed": 876852.926787907, + "eta_final": 0.8985675106475038, + "cstar_actual": 1648.16169341826, + "mdot_demand": 1.9334951725409455 }, { "P_tank_O": 3447378.646584, "P_tank_F": 3585273.7924473598, "Pc": 2200000.0, - "mdot_O": 1.494315046188041, - "mdot_F": 0.8638244424811437, - "MR": 1.729882801065403, + "mdot_O": 1.4943150461880415, + "mdot_F": 0.8638244424811438, + "MR": 1.7298828010654035, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 5.952893855958958e-05, + "D32_O": 5.952893855958959e-05, "D32_F": 6.946075911472761e-05, "u_O": 20.862072468194796, "u_F": 32.53240179471714, @@ -127,18 +128,18 @@ "eta_kinetics": 0.9814985320893502, "eta_mixing": 0.9599809002344764, "eta_total": 0.9422198444139422, - "cooling_eff": 0.9787600831181865, - "heat_removed": 468450.5493231528, - "eta_final": 0.9222071732341949, - "cstar_actual": 1692.574481014602, - "mdot_demand": 2.3011519400309237 + "cooling_eff": 0.9634358614764484, + "heat_removed": 794507.2913453056, + "eta_final": 0.9248337087482889, + "cstar_actual": 1697.395097372468, + "mdot_demand": 2.294616649160095 }, { "P_tank_O": 3447378.646584, "P_tank_F": 3585273.7924473598, "Pc": 2600000.0, - "mdot_O": 1.2316342986443036, - "mdot_F": 0.7285110525802317, + "mdot_O": 1.2316342986443038, + "mdot_F": 0.7285110525802319, "MR": 1.6906185490008916, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, @@ -149,7 +150,7 @@ "D32_F": 7.614031167192778e-05, "u_O": 17.19479707989128, "u_F": 27.43637839924836, - "momentum_ratio_R": 1.0293378682768095, + "momentum_ratio_R": 1.0293378682768093, "R_opt": 1.0632572007031675, "Tc": 3019.816, "gamma": 1.1731, @@ -162,19 +163,19 @@ "eta_kinetics": 0.9942887407517236, "eta_mixing": 0.959775783140413, "eta_total": 0.9542942548226806, - "cooling_eff": 0.9761408397408002, - "heat_removed": 433370.482493808, - "eta_final": 0.9315255952624326, - "cstar_actual": 1710.4905190909176, - "mdot_demand": 2.69105813338413 + "cooling_eff": 0.9614333119379744, + "heat_removed": 690593.2832909396, + "eta_final": 0.9357113383752467, + "cstar_actual": 1718.176484936871, + "mdot_demand": 2.6790201494610604 }, { "P_tank_O": 3447378.646584, "P_tank_F": 3861064.08417408, "Pc": 1800000.0, - "mdot_O": 1.7172745467545045, + "mdot_O": 1.717274546754505, "mdot_F": 1.0536671181885018, - "MR": 1.6298074762994388, + "MR": 1.6298074762994392, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, @@ -182,9 +183,9 @@ "chamber_length": 0.2202290337591734, "D32_O": 5.473110062610303e-05, "D32_F": 6.386244889732283e-05, - "u_O": 23.974801119462594, + "u_O": 23.974801119462597, "u_F": 39.682046907972634, - "momentum_ratio_R": 0.9923128752769803, + "momentum_ratio_R": 0.9923128752769805, "R_opt": 1.0632572007031675, "Tc": 3000.442000000001, "gamma": 1.16912, @@ -195,20 +196,20 @@ "fuel_T_star_cap": 500.0, "eta_Lstar": 0.9999999999717855, "eta_kinetics": 0.9508402841428883, - "eta_mixing": 0.9589832728080518, - "eta_total": 0.9118399275793578, - "cooling_eff": 0.9808134571524906, - "heat_removed": 502502.1264852846, - "eta_final": 0.8943448717387866, - "cstar_actual": 1640.4164860609742, - "mdot_demand": 1.942624147507297 + "eta_mixing": 0.958983272808052, + "eta_total": 0.911839927579358, + "cooling_eff": 0.9652841736805062, + "heat_removed": 895573.5774869522, + "eta_final": 0.8958724844779344, + "cstar_actual": 1643.218448928769, + "mdot_demand": 1.9393116477413983 }, { "P_tank_O": 3447378.646584, "P_tank_F": 3861064.08417408, "Pc": 2200000.0, - "mdot_O": 1.494315046188041, - "mdot_F": 0.9459123015667785, + "mdot_O": 1.4943150461880415, + "mdot_F": 0.9459123015667787, "MR": 1.5797606646122542, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, @@ -219,7 +220,7 @@ "D32_F": 6.634569406745966e-05, "u_O": 20.862072468194796, "u_F": 35.623904052480924, - "momentum_ratio_R": 0.9618417329329064, + "momentum_ratio_R": 0.9618417329329065, "R_opt": 1.0632572007031675, "Tc": 3011.273, "gamma": 1.171335, @@ -232,28 +233,28 @@ "eta_kinetics": 0.9802029443850289, "eta_mixing": 0.9578587038956297, "eta_total": 0.9388959218633209, - "cooling_eff": 0.9791451117631393, - "heat_removed": 476149.54582314176, - "eta_final": 0.9193153523468172, - "cstar_actual": 1687.2669727022617, - "mdot_demand": 2.3083904999312055 + "cooling_eff": 0.9637052822297181, + "heat_removed": 816318.5387576085, + "eta_final": 0.9216999679782567, + "cstar_actual": 1691.64358099804, + "mdot_demand": 2.3024182483733835 }, { "P_tank_O": 3447378.646584, "P_tank_F": 3861064.08417408, "Pc": 2600000.0, - "mdot_O": 1.2316342986443036, + "mdot_O": 1.2316342986443038, "mdot_F": 0.824187944924614, - "MR": 1.4943609721893683, + "MR": 1.4943609721893685, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, "D32_O": 6.123730545310866e-05, - "D32_F": 7.14541502979337e-05, + "D32_F": 7.145415029793371e-05, "u_O": 17.19479707989128, - "u_F": 31.039655814364203, + "u_F": 31.039655814364195, "momentum_ratio_R": 0.9098458895168869, "R_opt": 1.0632572007031675, "Tc": 3019.816, @@ -267,29 +268,29 @@ "eta_kinetics": 0.9864612631741922, "eta_mixing": 0.9548344359289281, "eta_total": 0.9419071837886678, - "cooling_eff": 0.976775970123939, - "heat_removed": 442698.986788475, - "eta_final": 0.9200323032118833, - "cstar_actual": 1689.3862497229147, - "mdot_demand": 2.724675558494112 + "cooling_eff": 0.9618316742672295, + "heat_removed": 717095.5254983978, + "eta_final": 0.9237567962840417, + "cstar_actual": 1696.2252567461753, + "mdot_demand": 2.713689944875557 }, { "P_tank_O": 3723168.93831072, "P_tank_F": 3585273.7924473598, "Pc": 1800000.0, - "mdot_O": 1.85546059150968, - "mdot_F": 0.9806411071967972, + "mdot_O": 1.8554605915096805, + "mdot_F": 0.9806411071967974, "MR": 1.8920893463395494, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 5.5566354892124236e-05, + "D32_O": 5.556635489212423e-05, "D32_F": 6.483705715971558e-05, - "u_O": 25.904010951840142, - "u_F": 36.93182196154271, - "momentum_ratio_R": 1.1520039310472432, + "u_O": 25.904010951840146, + "u_F": 36.93182196154272, + "momentum_ratio_R": 1.1520039310472434, "R_opt": 1.0632572007031675, "Tc": 3000.442000000001, "gamma": 1.16912, @@ -302,29 +303,29 @@ "eta_kinetics": 0.9490989224369234, "eta_mixing": 0.9586299749811161, "eta_total": 0.9098346762045865, - "cooling_eff": 0.9810383595722456, - "heat_removed": 508402.1114868174, - "eta_final": 0.8925827182256928, - "cstar_actual": 1637.1843261132917, - "mdot_demand": 1.9464593124687648 + "cooling_eff": 0.9654582112117264, + "heat_removed": 912191.5819110791, + "eta_final": 0.8939829277114604, + "cstar_actual": 1639.7526046342232, + "mdot_demand": 1.9434106515732417 }, { "P_tank_O": 3723168.93831072, "P_tank_F": 3585273.7924473598, "Pc": 2200000.0, - "mdot_O": 1.6512660582045917, - "mdot_F": 0.8638244424811437, - "MR": 1.9115759834969441, + "mdot_O": 1.6512660582045924, + "mdot_F": 0.8638244424811438, + "MR": 1.9115759834969448, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 5.795529842098006e-05, - "D32_F": 6.762457252638779e-05, - "u_O": 23.05325925641493, + "D32_O": 5.7955298420980056e-05, + "D32_F": 6.762457252638778e-05, + "u_O": 23.053259256414933, "u_F": 32.53240179471714, - "momentum_ratio_R": 1.1638684250002587, + "momentum_ratio_R": 1.1638684250002589, "R_opt": 1.0632572007031675, "Tc": 3011.273, "gamma": 1.171335, @@ -337,29 +338,29 @@ "eta_kinetics": 0.9790036667278557, "eta_mixing": 0.9582577151791158, "eta_total": 0.938137816830597, - "cooling_eff": 0.9794760611601041, - "heat_removed": 483125.9469518781, - "eta_final": 0.9188835336545724, - "cstar_actual": 1686.4744335417222, - "mdot_demand": 2.309475301356372 + "cooling_eff": 0.9639412406656657, + "heat_removed": 836082.7062006021, + "eta_final": 0.9210684865120905, + "cstar_actual": 1690.484590430749, + "mdot_demand": 2.3039967786048505 }, { "P_tank_O": 3723168.93831072, "P_tank_F": 3585273.7924473598, "Pc": 2600000.0, - "mdot_O": 1.4179651558876927, - "mdot_F": 0.7285110525802317, - "MR": 1.946387979791879, + "mdot_O": 1.4179651558876931, + "mdot_F": 0.7285110525802319, + "MR": 1.9463879797918795, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 6.283857607414533e-05, - "D32_F": 7.332257724416558e-05, - "u_O": 19.796154709789146, + "D32_O": 6.283857607414532e-05, + "D32_F": 7.332257724416557e-05, + "u_O": 19.79615470978915, "u_F": 27.43637839924836, - "momentum_ratio_R": 1.185063807056054, + "momentum_ratio_R": 1.1850638070560542, "R_opt": 1.0632572007031675, "Tc": 3019.816, "gamma": 1.1731, @@ -372,29 +373,29 @@ "eta_kinetics": 0.992816956321436, "eta_mixing": 0.9574937342240339, "eta_total": 0.9506160149091514, - "cooling_eff": 0.9773289810744866, - "heat_removed": 451457.93038776447, - "eta_final": 0.92906458124425, - "cstar_actual": 1705.971543802573, - "mdot_demand": 2.6981865202839224 + "cooling_eff": 0.9621878313902366, + "heat_removed": 741984.8243848654, + "eta_final": 0.9324704042753501, + "cstar_actual": 1712.2254009526357, + "mdot_demand": 2.688331466707044 }, { "P_tank_O": 3723168.93831072, "P_tank_F": 3861064.08417408, "Pc": 1800000.0, - "mdot_O": 1.85546059150968, + "mdot_O": 1.8554605915096805, "mdot_F": 1.0536671181885018, - "MR": 1.7609552006326696, + "MR": 1.76095520063267, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 5.3680127262820075e-05, - "D32_F": 6.263613091838025e-05, - "u_O": 25.904010951840142, + "D32_O": 5.368012726282007e-05, + "D32_F": 6.263613091838024e-05, + "u_O": 25.904010951840146, "u_F": 39.682046907972634, - "momentum_ratio_R": 1.0721625368545733, + "momentum_ratio_R": 1.0721625368545735, "R_opt": 1.0632572007031675, "Tc": 3000.442000000001, "gamma": 1.16912, @@ -407,29 +408,29 @@ "eta_kinetics": 0.9471478994181045, "eta_mixing": 0.9599851592954596, "eta_total": 0.9092479270653505, - "cooling_eff": 0.9812795151992314, - "heat_removed": 514982.3124817475, - "eta_final": 0.8922263650665933, - "cstar_actual": 1636.530699514069, - "mdot_demand": 1.9472367238435175 + "cooling_eff": 0.9656475192066258, + "heat_removed": 930721.6276545085, + "eta_final": 0.8934939870176564, + "cstar_actual": 1638.855784626453, + "mdot_demand": 1.9444741310886413 }, { "P_tank_O": 3723168.93831072, "P_tank_F": 3861064.08417408, "Pc": 2200000.0, - "mdot_O": 1.6512660582045917, - "mdot_F": 0.9459123015667785, - "MR": 1.7456862073465882, + "mdot_O": 1.6512660582045924, + "mdot_F": 0.9459123015667787, + "MR": 1.7456862073465884, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 5.5481086956525096e-05, + "D32_O": 5.548108695652509e-05, "D32_F": 6.473756310391392e-05, - "u_O": 23.05325925641493, + "u_O": 23.053259256414933, "u_F": 35.623904052480924, - "momentum_ratio_R": 1.0628659672593108, + "momentum_ratio_R": 1.0628659672593113, "R_opt": 1.0632572007031675, "Tc": 3011.273, "gamma": 1.171335, @@ -442,29 +443,29 @@ "eta_kinetics": 0.9776708606224255, "eta_mixing": 0.9599999711055908, "eta_total": 0.9385639979483018, - "cooling_eff": 0.9798187858236823, - "heat_removed": 490728.1543565683, - "eta_final": 0.9196226368875261, - "cstar_actual": 1687.8309478990607, - "mdot_demand": 2.3076191697289077 + "cooling_eff": 0.9641900542251326, + "heat_removed": 857619.3921554735, + "eta_final": 0.9216058332317594, + "cstar_actual": 1691.4708106332862, + "mdot_demand": 2.3026534221866632 }, { "P_tank_O": 3723168.93831072, "P_tank_F": 3861064.08417408, "Pc": 2600000.0, - "mdot_O": 1.4179651558876927, + "mdot_O": 1.4179651558876931, "mdot_F": 0.824187944924614, - "MR": 1.7204390874916153, + "MR": 1.720439087491616, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 5.922377026041524e-05, - "D32_F": 6.910467647271676e-05, - "u_O": 19.796154709789146, - "u_F": 31.039655814364203, - "momentum_ratio_R": 1.0474941871809456, + "D32_O": 5.922377026041526e-05, + "D32_F": 6.910467647271677e-05, + "u_O": 19.79615470978915, + "u_F": 31.039655814364195, + "momentum_ratio_R": 1.047494187180946, "R_opt": 1.0632572007031675, "Tc": 3019.816, "gamma": 1.1731, @@ -477,28 +478,28 @@ "eta_kinetics": 0.9920100362822578, "eta_mixing": 0.959952408606402, "eta_total": 0.9522824236908776, - "cooling_eff": 0.9778674804543092, - "heat_removed": 460622.3762011718, - "eta_final": 0.9312060143355214, - "cstar_actual": 1709.9036966264098, - "mdot_demand": 2.691981678592601 + "cooling_eff": 0.962543494997584, + "heat_removed": 768030.9725959164, + "eta_final": 0.93427762978167, + "cstar_actual": 1715.5438734778577, + "mdot_demand": 2.683131276697988 }, { "P_tank_O": 3998959.2300374396, "P_tank_F": 3585273.7924473598, "Pc": 1800000.0, - "mdot_O": 1.9840453987648603, - "mdot_F": 0.9806411071967972, + "mdot_O": 1.9840453987648607, + "mdot_F": 0.9806411071967974, "MR": 2.023212553710231, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 5.450752616624059e-05, + "D32_O": 5.450752616624058e-05, "D32_F": 6.360157322783368e-05, "u_O": 27.69917829229458, - "u_F": 36.93182196154271, + "u_F": 36.93182196154272, "momentum_ratio_R": 1.2318386654030913, "R_opt": 1.0632572007031675, "Tc": 3000.442000000001, @@ -512,18 +513,18 @@ "eta_kinetics": 0.9456644746137003, "eta_mixing": 0.955390458603255, "eta_total": 0.9034788160069008, - "cooling_eff": 0.9814557603410546, - "heat_removed": 519966.5247533827, - "eta_final": 0.8867244883160886, - "cstar_actual": 1626.4391010591505, - "mdot_demand": 1.9593187815737474 + "cooling_eff": 0.9657876917421081, + "heat_removed": 944754.4035642792, + "eta_final": 0.8878892691408099, + "cstar_actual": 1628.5755539286188, + "mdot_demand": 1.9567484419766692 }, { "P_tank_O": 3998959.2300374396, "P_tank_F": 3585273.7924473598, "Pc": 2200000.0, - "mdot_O": 1.7945422069968409, - "mdot_F": 0.8638244424811437, + "mdot_O": 1.794542206996841, + "mdot_F": 0.8638244424811438, "MR": 2.0774385612919417, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, @@ -547,27 +548,27 @@ "eta_kinetics": 0.9766662350258609, "eta_mixing": 0.9535907961433452, "eta_total": 0.9313399326246143, - "cooling_eff": 0.9800616238781573, - "heat_removed": 496363.64118659304, - "eta_final": 0.9127705267506532, - "cstar_actual": 1675.2549160752144, - "mdot_demand": 2.3249423196790167 + "cooling_eff": 0.9643692074097465, + "heat_removed": 873583.9341724998, + "eta_final": 0.9145972511960818, + "cstar_actual": 1678.607597847722, + "mdot_demand": 2.3202987140219746 }, { "P_tank_O": 3998959.2300374396, "P_tank_F": 3585273.7924473598, "Pc": 2600000.0, - "mdot_O": 1.5825066574982143, - "mdot_F": 0.7285110525802317, - "MR": 2.172247973305705, + "mdot_O": 1.582506657498215, + "mdot_F": 0.7285110525802319, + "MR": 2.1722479733057054, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 6.0800545500630155e-05, - "D32_F": 7.094452122366926e-05, - "u_O": 22.09331201900648, + "D32_O": 6.080054550063015e-05, + "D32_F": 7.094452122366925e-05, + "u_O": 22.093312019006483, "u_F": 27.43637839924836, "momentum_ratio_R": 1.322579300654494, "R_opt": 1.0632572007031675, @@ -582,19 +583,19 @@ "eta_kinetics": 0.9914092409250157, "eta_mixing": 0.9498921654489881, "eta_total": 0.9417318707084007, - "cooling_eff": 0.9782294641944284, - "heat_removed": 467170.2009348422, - "eta_final": 0.9212298632978955, - "cstar_actual": 1691.585239405628, - "mdot_demand": 2.7211335948364157 + "cooling_eff": 0.9627877548227844, + "heat_removed": 786642.902368155, + "eta_final": 0.9240437786580747, + "cstar_actual": 1696.7522209352712, + "mdot_demand": 2.7128471480289607 }, { "P_tank_O": 3998959.2300374396, "P_tank_F": 3861064.08417408, "Pc": 1800000.0, - "mdot_O": 1.9840453987648603, + "mdot_O": 1.9840453987648607, "mdot_F": 1.0536671181885018, - "MR": 1.882990713590735, + "MR": 1.8829907135907353, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, @@ -617,29 +618,29 @@ "eta_kinetics": 0.9437165734485586, "eta_mixing": 0.9587896798054453, "eta_total": 0.9048257112418707, - "cooling_eff": 0.9816785224517259, - "heat_removed": 526489.5258903311, - "eta_final": 0.8882479672882517, - "cstar_actual": 1629.2334817293777, - "mdot_demand": 1.9559582549264365 + "cooling_eff": 0.9659671307675839, + "heat_removed": 963115.4849061095, + "eta_final": 0.8892955257205932, + "cstar_actual": 1631.1549240910745, + "mdot_demand": 1.9536542058179065 }, { "P_tank_O": 3998959.2300374396, "P_tank_F": 3861064.08417408, "Pc": 2200000.0, - "mdot_O": 1.7945422069968409, - "mdot_F": 0.9459123015667785, - "MR": 1.897154951917233, + "mdot_O": 1.794542206996841, + "mdot_F": 0.9459123015667787, + "MR": 1.8971549519172328, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 5.4255670561816274e-05, - "D32_F": 6.330769798171117e-05, + "D32_O": 5.425567056181627e-05, + "D32_F": 6.330769798171116e-05, "u_O": 25.05353182724443, "u_F": 35.623904052480924, - "momentum_ratio_R": 1.1550881392797538, + "momentum_ratio_R": 1.155088139279754, "R_opt": 1.0632572007031675, "Tc": 3011.273, "gamma": 1.171335, @@ -652,29 +653,29 @@ "eta_kinetics": 0.9753047498596494, "eta_mixing": 0.9585371363069007, "eta_total": 0.9348658219569798, - "cooling_eff": 0.980371812701662, - "heat_removed": 503883.55897902785, - "eta_final": 0.9165161005047934, - "cstar_actual": 1682.1293611424571, - "mdot_demand": 2.3154408576450347 + "cooling_eff": 0.9646016309019131, + "heat_removed": 894885.7743139729, + "eta_final": 0.9181703802182352, + "cstar_actual": 1685.1655461870953, + "mdot_demand": 2.3112690972386862 }, { "P_tank_O": 3998959.2300374396, "P_tank_F": 3861064.08417408, "Pc": 2600000.0, - "mdot_O": 1.5825066574982143, + "mdot_O": 1.582506657498215, "mdot_F": 0.824187944924614, - "MR": 1.9200798400939503, + "MR": 1.920079840093951, "Lstar": 1.1010366919145165, "Ac": 0.010394407017099627, "At": 0.0017703959321061754, "Dinj": 0.002, "chamber_length": 0.2202290337591734, - "D32_O": 5.7505850326938364e-05, + "D32_O": 5.750585032693837e-05, "D32_F": 6.710013841836902e-05, - "u_O": 22.09331201900648, - "u_F": 31.039655814364203, - "momentum_ratio_R": 1.169046022056003, + "u_O": 22.093312019006483, + "u_F": 31.039655814364195, + "momentum_ratio_R": 1.1690460220560033, "R_opt": 1.0632572007031675, "Tc": 3019.816, "gamma": 1.1731, @@ -684,14 +685,14 @@ "fuel_latent_heat": 510000.0, "fuel_T_star_cap": 500.0, "eta_Lstar": 1.0, - "eta_kinetics": 0.9905482168900243, + "eta_kinetics": 0.9905482168900241, "eta_mixing": 0.9580826177887473, - "eta_total": 0.9490270286839703, - "cooling_eff": 0.9787005544448864, - "heat_removed": 476203.1927454524, - "eta_final": 0.9288132791561847, - "cstar_actual": 1705.5100966440093, - "mdot_demand": 2.6989165485056903 + "eta_total": 0.9490270286839702, + "cooling_eff": 0.9631122076740379, + "heat_removed": 812321.6033963484, + "eta_final": 0.9313588063303884, + "cstar_actual": 1710.1842570961835, + "mdot_demand": 2.691540051533274 } ] } \ No newline at end of file diff --git a/EngineDesign/engine/native/tests/test_residual_golden.c b/EngineDesign/engine/native/tests/test_residual_golden.c index aaddbbbb..4fd6e597 100644 --- a/EngineDesign/engine/native/tests/test_residual_golden.c +++ b/EngineDesign/engine/native/tests/test_residual_golden.c @@ -1,6 +1,8 @@ /* test_residual_golden.c - Parity of the Stage-3 residual physics vs Python: * ed_combustion_efficiency_advanced (eta components) and ed_cooling_evaluate * (ablative cooling_eff). Reads tests/golden/residual_samples.json. */ +#include + #include "ed_combustion.h" #include "ed_cooling.h" #include "ed_test_util.h" @@ -66,6 +68,7 @@ int main(void) { cg->use_cooling_coupling = (uint8_t)(int)need(cc, cl, "use_cooling_coupling"); cg->hot_gas_viscosity = need(cc, cl, "hot_gas_viscosity"); cg->hot_gas_thermal_conductivity = need(cc, cl, "hot_gas_thermal_conductivity"); + cg->hot_gas_cp = need(cc, cl, "hot_gas_cp"); cg->hot_gas_prandtl = need(cc, cl, "hot_gas_prandtl"); cg->gas_turbulence_intensity = need(cc, cl, "gas_turbulence_intensity"); cg->recovery_factor = need(cc, cl, "recovery_factor"); @@ -137,7 +140,9 @@ int main(void) { cmp("heat_removed", cool.heat_removed, need(p, e, "heat_removed")); /* derived residual quantities */ - double eta_final = eta.eta_total * cool.cooling_eff; + /* c* efficiency = combustion x sqrt(cooling): cooling is an energy + * fraction, c* ~ sqrt(Tc). Mirrors ed_chamber.c and combustion_eff. */ + double eta_final = eta.eta_total * sqrt(cool.cooling_eff); double cstar_actual = eta_final * cstar_ideal; double mdot_demand = Pc * At / cstar_actual; cmp("eta_final", eta_final, need(p, e, "eta_final")); diff --git a/EngineDesign/engine/native/tools/export_residual_golden.py b/EngineDesign/engine/native/tools/export_residual_golden.py index 897fe485..e31efd74 100644 --- a/EngineDesign/engine/native/tools/export_residual_golden.py +++ b/EngineDesign/engine/native/tools/export_residual_golden.py @@ -17,7 +17,7 @@ from engine.core.closure import flows from engine.core.chamber_solver import ChamberSolver from engine.pipeline.cea_cache import CEACache -from engine.pipeline.combustion_eff import calculate_Lstar +from engine.pipeline.combustion_eff import calculate_Lstar, blend_cooling_into_cstar from engine.pipeline.combustion_physics import calculate_combustion_efficiency_advanced EFF_MODEL = {"constant": 0, "linear": 1, "exponential": 2} @@ -47,6 +47,7 @@ def cooling_dict(cfg): "use_cooling_coupling": int(bool(eff.use_cooling_coupling)), "hot_gas_viscosity": float(rg.hot_gas_viscosity), "hot_gas_thermal_conductivity": float(rg.hot_gas_thermal_conductivity), + "hot_gas_cp": float(rg.hot_gas_cp), "hot_gas_prandtl": float(rg.hot_gas_prandtl), "gas_turbulence_intensity": float(rg.gas_turbulence_intensity), "recovery_factor": float(rg.recovery_factor) if rg.recovery_factor is not None else 0.94, @@ -141,7 +142,11 @@ def main(): cool = diag.get("cooling", {}) if isinstance(cool, dict) and isinstance(cool.get("ablative"), dict): heat_removed = float(cool["ablative"].get("heat_removed", 0.0)) - eta_final = res["eta_total"] * cooling_eff + # Matches ed_chamber.c: c* efficiency = combustion x + # sqrt(cooling), because cooling is an energy fraction and + # c* goes as sqrt(Tc). Was linear here; that is what made + # this golden stale after the sqrt fix. + eta_final = blend_cooling_into_cstar(res["eta_total"], cooling_eff) cstar_actual = eta_final * cstar_ideal mdot_demand = Pc * cg.A_throat / cstar_actual samples.append({ diff --git a/EngineDesign/engine/pipeline/thermal/regen_cooling.py b/EngineDesign/engine/pipeline/thermal/regen_cooling.py index c282c84a..07136ae4 100644 --- a/EngineDesign/engine/pipeline/thermal/regen_cooling.py +++ b/EngineDesign/engine/pipeline/thermal/regen_cooling.py @@ -591,12 +591,31 @@ def estimate_hot_wall_heat_flux( Pr_g = _tr["Pr"] mu_g_calculated = mu_g - Re_g = rho_g * V_g * chamber_d_inner / max(mu_g, EPSILON_TINY) - if Re_g < 2000: - Nu_g = NU_LAMINAR_ND - else: - Nu_g = NU_TURBULENT_COEFFICIENT_ND * (Re_g ** NU_TURBULENT_RE_EXPONENT_ND) * (Pr_g ** NU_TURBULENT_PR_EXPONENT_ND) - h_g = Nu_g * k_g / chamber_d_inner + # Gas-side film coefficient from Bartz (Huzel & Huang eq. 4-13), replacing + # the pipe-flow Dittus-Boelter form that was here. A thrust chamber violates + # every Dittus-Boelter assumption (strong wall-to-gas temperature ratio, + # accelerating flow, varying cross-section); Bartz was written for exactly + # this and carries the sigma property-variation correction plus the throat + # geometry terms. See thermal/bartz.py. Evaluated at the chamber station: + # area ratio A_t/A_chamber and the local (subsonic) Mach number. + from engine.pipeline.thermal.bartz import bartz_chamber_h_g + + A_throat_g = gas_props.get("A_throat", DEFAULT_THROAT_AREA_M2) + a_sound = np.sqrt(max(gamma * R_g * max(Tc, 1.0), 1.0)) + M_chamber = min(V_g / a_sound, 0.99) + h_g = bartz_chamber_h_g( + A_throat=A_throat_g, + chamber_area=A_cross, + Pc=Pc, + mdot_total=mdot_total, + mu=mu_g, + cp=cp_g, + Pr=Pr_g, + gamma=gamma, + mach=M_chamber, + wall_temperature=wall_temperature, + Tc=Tc, + ) # Calculate adiabatic wall temperature using recovery factor # Taw = Tc × recovery_factor (accounts for kinetic energy recovery in boundary layer) see Huzel 4-10 From 09ac92df0540a7ccb0c5cc312a6ed957c10775c0 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 16:20:23 -0700 Subject: [PATCH 32/35] native tests: rebuild all targets before ctest via a setup fixture, closing the stale-binary trap --- EngineDesign/engine/native/CMakeLists.txt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/EngineDesign/engine/native/CMakeLists.txt b/EngineDesign/engine/native/CMakeLists.txt index 554b4cfe..20db4251 100644 --- a/EngineDesign/engine/native/CMakeLists.txt +++ b/EngineDesign/engine/native/CMakeLists.txt @@ -101,6 +101,15 @@ set(ED_GOLDEN_DIR "${CMAKE_CURRENT_SOURCE_DIR}/tests/golden") # ---- Tests ---------------------------------------------------------------- enable_testing() +# Rebuild every target before any test runs. This closes the stale-binary trap: +# ctest was silently passing residual_golden because the test .exe had not been +# recompiled after a source change (a shared-library-only `--target` build +# leaves the test executables untouched, so old-binary-vs-old-golden agreed). +# As a CTest SETUP fixture the build runs first and is a no-op when up to date, +# so `ctest` can never again run a test binary that predates its sources. +add_test(NAME build_all COMMAND ${CMAKE_COMMAND} --build ${CMAKE_BINARY_DIR}) +set_tests_properties(build_all PROPERTIES FIXTURES_SETUP build_all) + add_executable(test_root_find tests/test_root_find.c) target_link_libraries(test_root_find PRIVATE ed_physics) add_test(NAME root_find COMMAND test_root_find) @@ -138,6 +147,13 @@ target_compile_definitions(test_chamber_golden PRIVATE ED_GOLDEN_DIR="${ED_GOLDE add_test(NAME chamber_golden COMMAND test_chamber_golden) set_tests_properties(chamber_golden PROPERTIES SKIP_RETURN_CODE 77) +# Every real test depends on the build fixture, so a stale executable can never +# be the thing under test. +set_tests_properties( + root_find cea_interp feed_discharge residual_golden + injector_golden nozzle_golden chamber_golden + PROPERTIES FIXTURES_REQUIRED build_all) + # ---- Benchmark ------------------------------------------------------------ add_executable(bench_evaluate bench/bench_evaluate.c) target_link_libraries(bench_evaluate PRIVATE ed_physics) From 2a053f98e2b5a8aa6fc3fc1f9a21463384601c8f Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 16:54:10 -0700 Subject: [PATCH 33/35] config: stamp the achieved design point (F/MR/Pc) on regeneration, add split-aware regenerate_config script --- EngineDesign/engine/optimizer/helpers.py | 46 +++++++++- EngineDesign/scripts/regenerate_config.py | 102 ++++++++++++++++++++++ EngineDesign/tests/test_design_stamp.py | 62 +++++++++++++ 3 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 EngineDesign/scripts/regenerate_config.py create mode 100644 EngineDesign/tests/test_design_stamp.py diff --git a/EngineDesign/engine/optimizer/helpers.py b/EngineDesign/engine/optimizer/helpers.py index d870c4a2..a70a984e 100644 --- a/EngineDesign/engine/optimizer/helpers.py +++ b/EngineDesign/engine/optimizer/helpers.py @@ -8,10 +8,54 @@ from __future__ import annotations -from typing import Dict, Any, List, Tuple +from typing import Dict, Any, List, Optional, Tuple import numpy as np +def stamp_achieved_design_point( + config: Any, + thrust_n: float, + mr: float, + pc_pa: float, +) -> None: + """Record what the optimized geometry ACTUALLY achieves into the design-point stamp. + + ``chamber_geometry.design_thrust/design_MR/design_pressure`` is the "what was this geometry + solved for" provenance stamp (read by the geometry re-solve fallback + native kernel, split into + ``.outputs.yaml``). Layer 1 optimizes the geometry but does NOT update the stamp, so after a + regeneration it still shows the SEED values (e.g. 7000 N while the geometry now makes 8000 N) -- + a lying provenance record. Call this once on the optimized config, before saving, so the stamp + reflects the achieved operating point. + + Achieved (not target): ``design_pressure`` is consumed downstream as the actual Pc the geometry + was built around, so the stamp records reality; the staleness signal is then requirement-target + vs achieved-stamp under the guard's normal tolerance. Non-finite/non-positive inputs are skipped + (the old stamp is left rather than writing garbage). + """ + cg = getattr(config, "chamber_geometry", None) + if cg is None: + return + if np.isfinite(thrust_n) and thrust_n > 0: + cg.design_thrust = float(thrust_n) + if np.isfinite(mr) and mr > 0: + cg.design_MR = float(mr) + if np.isfinite(pc_pa) and pc_pa > 0: + cg.design_pressure = float(pc_pa) + + +def stamp_achieved_design_point_from_results(config: Any, results: Dict[str, Any]) -> Optional[Tuple[float, float, float]]: + """Pull achieved F/MR/Pc from a Layer 1 ``results`` dict and stamp them. Returns what was stamped + (or None if the performance block is missing).""" + perf = results.get("performance") if isinstance(results, dict) else None + if not isinstance(perf, dict): + return None + f = float(perf.get("F", float("nan"))) + mr = float(perf.get("MR", float("nan"))) + pc = float(perf.get("Pc", float("nan"))) + stamp_achieved_design_point(config, f, mr, pc) + return (f, mr, pc) + + def generate_segmented_pressure_curve( segments: List[Dict[str, Any]], n_points: int = 200, diff --git a/EngineDesign/scripts/regenerate_config.py b/EngineDesign/scripts/regenerate_config.py new file mode 100644 index 00000000..5e79c9f9 --- /dev/null +++ b/EngineDesign/scripts/regenerate_config.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Regenerate a config's optimizer-produced half by re-running Layer 1 against its requirements. + +Runs Layer 1 on the config's own ``design_requirements``, stamps the ACHIEVED design point +(F/MR/Pc) into ``chamber_geometry.design_*``, and writes the result through ``io.save_config`` so a +split config (``.yaml`` + ``.outputs.yaml``) re-splits correctly instead of being +collapsed into one file. + + py -3.13 scripts/regenerate_config.py configs/canonical/impinging.yaml # full run + py -3.13 scripts/regenerate_config.py configs/canonical/impinging.yaml --smoke # fast, capped + py -3.13 scripts/regenerate_config.py configs/canonical/impinging.yaml --dry-run # don't write + +Use --smoke to validate the pipeline; use a full run to produce a real design. +""" + +from __future__ import annotations + +import argparse +import copy +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from engine.core.runner import PintleEngineRunner +from engine.optimizer.helpers import stamp_achieved_design_point_from_results +from engine.optimizer.layers.layer1_static_optimization import run_layer1_optimization +from engine.pipeline.io import load_config, save_config + +PSI = 6894.757 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("config", help="path to the config's intent file (e.g. configs/canonical/impinging.yaml)") + ap.add_argument("--smoke", action="store_true", help="fast run, capped iterations (validation only)") + ap.add_argument("--max-iterations", type=int, default=90) + ap.add_argument("--cma-restarts", type=int, default=3) + ap.add_argument("--dry-run", action="store_true", help="run + stamp but do NOT write to disk") + args = ap.parse_args() + + cfg_path = Path(args.config).resolve() + cfg = load_config(str(cfg_path)) + req = cfg.design_requirements.model_dump() + + print(f"config : {cfg_path}") + print(f"target : F={req.get('target_thrust')} N O/F={req.get('optimal_of_ratio')} " + f"preset={cfg.propellant_preset}") + print(f"stamp BEFORE: design_thrust={cfg.chamber_geometry.design_thrust} " + f"design_MR={cfg.chamber_geometry.design_MR} " + f"design_pressure={cfg.chamber_geometry.design_pressure}") + + runner = PintleEngineRunner(copy.deepcopy(cfg)) + pcfg = { + "mode": "optimizer_controlled", + "max_lox_pressure_psi": float(req["max_lox_tank_pressure_psi"]), + "max_fuel_pressure_psi": float(req["max_fuel_tank_pressure_psi"]), + } + _thr = req.get("layer1_thrust_validation_rel_tol") + thrust_tol = float(_thr) if _thr is not None else 0.10 + + smoke = bool(args.smoke) + opt_cfg, results = run_layer1_optimization( + copy.deepcopy(cfg), runner, req, + target_burn_time=float(req.get("target_burn_time", 6.0)), + tolerances={"thrust": thrust_tol, "apogee": 0.15}, + pressure_config=pcfg, + layer1_smoke=smoke, + layer1_max_iterations=min(int(args.max_iterations), 24) if smoke else int(args.max_iterations), + layer1_cma_restarts=1 if smoke else max(1, int(args.cma_restarts)), + ) + + perf = results.get("performance") or {} + valid = perf.get("pressure_candidate_valid") + print(f"\nachieved : F={perf.get('F')} N MR={perf.get('MR')} Pc={perf.get('Pc')} Pa valid={valid}") + if not valid: + print(f"failure_reasons: {perf.get('failure_reasons')}") + + stamped = stamp_achieved_design_point_from_results(opt_cfg, results) + if stamped: + f, mr, pc = stamped + print(f"stamp AFTER : design_thrust={f} design_MR={mr} design_pressure={pc}") + + if args.dry_run: + print("\n--dry-run: not writing") + return 0 + if not valid: + print("\nNOT writing: Layer 1 did not converge to a valid design. Fix requirements/bounds and retry.") + return 1 + + save_config(opt_cfg.model_dump(mode="json"), cfg_path) + print(f"\nwrote (split-aware) {cfg_path.name} + {cfg_path.stem}.outputs.yaml") + # Prove it reloads. + rc = load_config(str(cfg_path)) + print(f"reload OK: A_throat={rc.chamber_geometry.A_throat:.6e} " + f"stamp design_thrust={rc.chamber_geometry.design_thrust}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/EngineDesign/tests/test_design_stamp.py b/EngineDesign/tests/test_design_stamp.py new file mode 100644 index 00000000..ea3dd2d5 --- /dev/null +++ b/EngineDesign/tests/test_design_stamp.py @@ -0,0 +1,62 @@ +"""The design-point stamp records what the optimized geometry ACHIEVES, not the seed values. + +Layer 1 optimizes geometry but does not touch chamber_geometry.design_*, so after a regeneration the +stamp would still show seed values (7000 N while the geometry makes 8000 N) -- a lying provenance +record. stamp_achieved_design_point fixes that at regen time without touching Layer 1's logic. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from engine.optimizer.helpers import ( # noqa: E402 + stamp_achieved_design_point, + stamp_achieved_design_point_from_results, +) +from engine.pipeline.io import load_config # noqa: E402 + + +def _canonical(): + return load_config(str(ROOT / "configs" / "canonical" / "impinging.yaml")) + + +def test_stamps_achieved_values(): + cfg = _canonical() + assert cfg.chamber_geometry.design_thrust == 7000.0 # seed + stamp_achieved_design_point(cfg, thrust_n=8020.0, mr=2.772, pc_pa=2.5e6) + assert cfg.chamber_geometry.design_thrust == pytest.approx(8020.0) + assert cfg.chamber_geometry.design_MR == pytest.approx(2.772) + assert cfg.chamber_geometry.design_pressure == pytest.approx(2.5e6) + + +def test_skips_non_finite_and_nonpositive(): + cfg = _canonical() + before = (cfg.chamber_geometry.design_thrust, cfg.chamber_geometry.design_MR, + cfg.chamber_geometry.design_pressure) + stamp_achieved_design_point(cfg, thrust_n=float("nan"), mr=-1.0, pc_pa=0.0) + after = (cfg.chamber_geometry.design_thrust, cfg.chamber_geometry.design_MR, + cfg.chamber_geometry.design_pressure) + assert before == after # garbage never overwrites a good stamp + + +def test_from_results_reads_performance_block(): + cfg = _canonical() + stamped = stamp_achieved_design_point_from_results( + cfg, {"performance": {"F": 8000.0, "MR": 2.8, "Pc": 2.4e6}} + ) + assert stamped == (8000.0, 2.8, 2.4e6) + assert cfg.chamber_geometry.design_thrust == pytest.approx(8000.0) + + +def test_from_results_missing_performance_is_noop(): + cfg = _canonical() + before = cfg.chamber_geometry.design_thrust + assert stamp_achieved_design_point_from_results(cfg, {}) is None + assert cfg.chamber_geometry.design_thrust == before From 71b4896e692307fb9087a5c2778dd4e4eec0b0e0 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 17:24:47 -0700 Subject: [PATCH 34/35] config: un-merge presets in save_config so a regenerated config keeps preset fields out of the intent file --- EngineDesign/engine/pipeline/io.py | 43 ++++++++++++++++++- .../tests/test_config_intent_derived_split.py | 13 +++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/EngineDesign/engine/pipeline/io.py b/EngineDesign/engine/pipeline/io.py index 7da3459e..ca53de85 100644 --- a/EngineDesign/engine/pipeline/io.py +++ b/EngineDesign/engine/pipeline/io.py @@ -203,12 +203,52 @@ def _outputs_sidecar_path(config_path: Path) -> Path: return stem.with_name(stem.name + _OUTPUTS_SUFFIX) +def _strip_preset_provided(intent: Dict[str, Any], preset: Dict[str, Any]) -> None: + """Remove leaves from ``intent`` that equal what ``preset`` supplies (redundant duplication of a + preset value), in place. Emptied dicts are pruned. Leaves that DIFFER from the preset (genuine + overrides) and leaves absent from the preset are kept.""" + for key, p_val in preset.items(): + if key not in intent: + continue + i_val = intent[key] + if isinstance(p_val, dict) and isinstance(i_val, dict): + _strip_preset_provided(i_val, p_val) + if not i_val: + del intent[key] + elif i_val == p_val: + del intent[key] + + +def _unmerge_presets(intent: Dict[str, Any], config_dir: Path) -> None: + """Inverse of the preset merge: drop intent fields a declared preset already provides. + + ``save_config`` is handed the fully-RESOLVED config (``model_dump`` -- presets already merged + in). Writing that straight to the intent file re-inlines every preset value (fluids block, + material constants), silently reintroducing the duplication the presets removed and shadowing + future preset edits. Un-merging restores a source-shaped intent file that only carries preset + NAMES + genuinely hand-authored fields + real overrides.""" + for kind, dirs in (("propellant_preset", _preset_search_dirs(config_dir)), + ("material_preset", _material_preset_search_dirs(config_dir))): + name = intent.get(kind) + if not name or str(name).strip().lower() == "custom": + continue + name = str(name).strip().lower() + for d in dirs: + cand = d / f"{name}.yaml" + if cand.exists(): + with open(cand, "r", encoding="utf-8") as f: + preset = yaml.safe_load(f) or {} + _strip_preset_provided(intent, preset) + break + + def save_config(data: Dict[str, Any], path: Union[str, Path]) -> None: """Write a config, preserving the intent/outputs split when an outputs sidecar is in use. Without this, any save through the API would write the MERGED config into the intent file; the outputs sidecar would then collide with it and every subsequent load would raise. Splitting on - save is what makes the split survive a round trip. + save is what makes the split survive a round trip. Presets are un-merged so the intent file stays + source-shaped (preset names + hand-authored fields), not a re-inlined resolved dump. """ path = Path(path) sidecar = _outputs_sidecar_path(path) @@ -219,6 +259,7 @@ def save_config(data: Dict[str, Any], path: Union[str, Path]) -> None: return intent, outputs = split_outputs(data) + _unmerge_presets(intent, path.resolve().parent) with open(path, "w", encoding="utf-8") as f: yaml.dump(intent, f, default_flow_style=False, sort_keys=False) with open(sidecar, "r", encoding="utf-8") as f: diff --git a/EngineDesign/tests/test_config_intent_derived_split.py b/EngineDesign/tests/test_config_intent_derived_split.py index 2c3460e8..73b0d36e 100644 --- a/EngineDesign/tests/test_config_intent_derived_split.py +++ b/EngineDesign/tests/test_config_intent_derived_split.py @@ -101,13 +101,24 @@ def test_save_round_trip_keeps_the_split(tmp_path: Path) -> None: (tmp_path / "c.yaml").write_text(src.read_text(encoding="utf-8"), encoding="utf-8") (tmp_path / "c.outputs.yaml").write_text(sidecar.read_text(encoding="utf-8"), encoding="utf-8") + # material sidecar too, so preset un-merge has something to strip against + mat = ROOT / "configs" / "materials" / "phenolic_graphite.yaml" + (tmp_path / "materials").mkdir(exist_ok=True) + (tmp_path / "materials" / "phenolic_graphite.yaml").write_text(mat.read_text(encoding="utf-8"), encoding="utf-8") + merged = load_config(str(tmp_path / "c.yaml")).model_dump(mode="json") save_config(merged, tmp_path / "c.yaml") - # The intent file must not have absorbed the generated half... raw = yaml.safe_load((tmp_path / "c.yaml").read_text(encoding="utf-8")) + # ...the intent file must not have absorbed the generated half... assert "A_throat" not in (raw.get("chamber_geometry") or {}) assert "pressure_curves" not in raw + # ...must not re-inline PRESET-provided fields (save writes the resolved dump; without preset + # un-merge the fluids block + material constants get baked back in, reintroducing duplication)... + assert "fluids" not in raw, "propellant preset fields re-inlined into intent" + assert "heat_of_ablation" not in (raw.get("ablative_cooling") or {}), "material preset re-inlined" + assert raw.get("material_preset") == "phenolic_graphite" # the NAME stays + assert (raw.get("ablative_cooling") or {}).get("enabled") is not None # non-preset fields stay # ...and the result must still load and resolve to the same geometry. assert load_config(str(tmp_path / "c.yaml")).chamber_geometry.A_throat == pytest.approx( merged["chamber_geometry"]["A_throat"] From 7b18542e687ce0a7db5aed2413daed20ecc6b651 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Tue, 21 Jul 2026 17:24:47 -0700 Subject: [PATCH 35/35] config: regenerate canonical/impinging via Layer 1 for methalox 8000N O/F 2.8, stamp achieved design point --- .../configs/canonical/impinging.outputs.yaml | 52 +++++++++---------- EngineDesign/configs/canonical/impinging.yaml | 30 +++++++---- EngineDesign/tests/test_design_stamp.py | 3 +- 3 files changed, 47 insertions(+), 38 deletions(-) diff --git a/EngineDesign/configs/canonical/impinging.outputs.yaml b/EngineDesign/configs/canonical/impinging.outputs.yaml index 84b89fc1..7f54c52a 100644 --- a/EngineDesign/configs/canonical/impinging.outputs.yaml +++ b/EngineDesign/configs/canonical/impinging.outputs.yaml @@ -17,39 +17,44 @@ chamber_geometry: - design_thrust: 7000.0 - design_MR: 2.55 - design_pressure: 2413166.0 - A_throat: 0.0017703959321061754 - A_exit: 0.008159675026074325 - volume: 0.0019492708804651003 - Lstar: 1.1010366919145165 - chamber_diameter: 0.11504160142419745 - exit_diameter: 0.10192752776058954 - expansion_ratio: 4.608954911214158 - length: 0.2202290337591734 + A_throat: 0.002425540111880856 + A_exit: 0.010815194235761527 + volume: 0.0035051842569332873 + Lstar: 1.4451149415192455 + chamber_diameter: 0.1641719813562731 + exit_diameter: 0.11734706210622983 + expansion_ratio: 4.458880800521998 + length: 0.21876176450630397 length_cylindrical: 0.121 length_contraction: 0.0451 - Cf: 1.6603014402898282 + Cf: 1.3772953558205892 + design_thrust: 7831.462191034452 + design_MR: 2.8045236242582545 + design_pressure: 2344268.2811699566 injector: geometry: oxidizer: n_elements: 20 - d_jet: 0.002 - impingement_angle: 50.0 - spacing: 0.006 + d_jet: 0.002671058356778086 + impingement_angle: 49.09007750725155 + spacing: 0.0030939045708254526 fuel: n_elements: 20 - d_jet: 0.002 - impingement_angle: 60.0 - spacing: 0.006 + d_jet: 0.002083277638100483 + impingement_angle: 40.91254168986332 + spacing: 0.01483180665163078 lox_tank: - initial_pressure_psi: 563.4671262691785 + initial_pressure_psi: 504.256762637218 fuel_tank: - initial_pressure_psi: 567.6435444099167 + initial_pressure_psi: 509.2929362707189 +combustion: + cea: + expansion_ratio: 4.458880800521998 +thrust: + burn_time: 6.8 pressure_curves: n_points: 200 - target_burn_time_s: 6.0 + target_burn_time_s: 6.8 initial_lox_pressure_pa: 3704289.4239973365 initial_fuel_pressure_pa: 3610619.76028896 lox_segments: @@ -134,8 +139,3 @@ pressure_curves: start_pressure_pa: 2393118.9933614894 end_pressure_pa: 2004754.8014037265 k: 1.1673408263818559 -combustion: - cea: - expansion_ratio: 4.608954911214158 -thrust: - burn_time: 10.0 diff --git a/EngineDesign/configs/canonical/impinging.yaml b/EngineDesign/configs/canonical/impinging.yaml index 1195bb17..8664577f 100644 --- a/EngineDesign/configs/canonical/impinging.yaml +++ b/EngineDesign/configs/canonical/impinging.yaml @@ -175,17 +175,6 @@ combustion: cea: use_parallel_cea_build: false cea_parallel_workers: null - cache_file: output/cache/cea_cache_LOX_CH4_3D.npz - Pc_range: - - 1000000.0 - - 9000000.0 - MR_range: - - 2.4 - - 4.2 - eps_range: - - 4.0 - - 15.0 - n_points: 34 efficiency: model: exponential C: 0.3 @@ -195,6 +184,10 @@ combustion: use_mixture_coupling: false use_cooling_coupling: true use_turbulence_coupling: true + mixing_model: rupe + 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 @@ -299,6 +292,10 @@ rocket: 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 @@ -313,8 +310,10 @@ environment: latitude: 35.34722 longitude: -117.8099547 elevation: 626.67 + atmosphere_model: standard_atmosphere design_requirements: target_thrust: 8000.0 + target_chamber_pressure_psi: null target_apogee: 3048.0 optimal_of_ratio: 2.8 target_burn_time: 6.8 @@ -337,6 +336,7 @@ design_requirements: feed_stability_min: 0.15 lox_tank_capacity_kg: null fuel_tank_capacity_kg: null + propellant_tank_fill_factor: 0.9 copv_free_volume_L: 4.5 copv_free_volume_m3: null injector_dp_ratio_O_min: 0.2 @@ -344,6 +344,12 @@ design_requirements: injector_dp_ratio_F_min: 0.2 injector_dp_ratio_F_max: 0.4 feed_pressure_model: dome_regulated + blowdown_ullage_frac_min: 0.05 + blowdown_ullage_frac_max: 0.6 + blowdown_n_polytropic_lox: 1.4 + blowdown_n_polytropic_fuel: 1.2 + blowdown_n_polytropic_lox_hi: 1.5 + blowdown_n_polytropic_fuel_hi: 1.3 W_geom_ao_af_momentum: 3500.0 W_MOM: 30000.0 impinging_momentum_R_min: 0.95 @@ -374,6 +380,7 @@ design_requirements: W_DP_HIGH: 25000.0 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 @@ -393,3 +400,4 @@ design_requirements: layer1_P_F_start_psi_min: null layer1_P_F_start_psi_max: null frozen_parameters: null +design_valid_for: null diff --git a/EngineDesign/tests/test_design_stamp.py b/EngineDesign/tests/test_design_stamp.py index ea3dd2d5..30f4c6a3 100644 --- a/EngineDesign/tests/test_design_stamp.py +++ b/EngineDesign/tests/test_design_stamp.py @@ -29,7 +29,8 @@ def _canonical(): def test_stamps_achieved_values(): cfg = _canonical() - assert cfg.chamber_geometry.design_thrust == 7000.0 # seed + # Do not assume canonical's current stamp (it changes whenever canonical is regenerated) -- + # only that stamping OVERWRITES it with the achieved values, whatever it was. stamp_achieved_design_point(cfg, thrust_n=8020.0, mr=2.772, pc_pa=2.5e6) assert cfg.chamber_geometry.design_thrust == pytest.approx(8020.0) assert cfg.chamber_geometry.design_MR == pytest.approx(2.772)