From db6ef5911ec40941ffb41a5908bee60a769ce8d0 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Thu, 3 Sep 2026 18:36:57 -0700 Subject: [PATCH 01/20] Add numba physics kernels and 3-way Layer-1 benchmark, declare numba dep --- EngineDesign/requirements-base.txt | 8 + .../scripts/bench_layer1_native_vs_python.py | 192 +++++ EngineDesign/scripts/bench_layer1_numba.py | 157 ++++ EngineDesign/scripts/numba_eval.py | 750 ++++++++++++++++++ 4 files changed, 1107 insertions(+) create mode 100644 EngineDesign/scripts/bench_layer1_native_vs_python.py create mode 100644 EngineDesign/scripts/bench_layer1_numba.py create mode 100644 EngineDesign/scripts/numba_eval.py diff --git a/EngineDesign/requirements-base.txt b/EngineDesign/requirements-base.txt index a67eca7b9..a2118f9e8 100644 --- a/EngineDesign/requirements-base.txt +++ b/EngineDesign/requirements-base.txt @@ -24,6 +24,14 @@ ezdxf cma CoolProp +# JIT accelerator for the Layer-1 optimizer inner loop (engine/accel). Without +# it the physics kernels fall back to pure Python, which is ~25-30x slower per +# candidate -- the optimizer still runs, so a missing numba degrades rather than +# breaks (engine/accel.available() returns False on ImportError). Pinned to a +# floor rather than exactly: numba constrains the numpy upper bound, so a numba +# too old for the resolved numpy is the failure mode to watch. +numba>=0.60 + # FastAPI backend fastapi uvicorn[standard] diff --git a/EngineDesign/scripts/bench_layer1_native_vs_python.py b/EngineDesign/scripts/bench_layer1_native_vs_python.py new file mode 100644 index 000000000..9c525408c --- /dev/null +++ b/EngineDesign/scripts/bench_layer1_native_vs_python.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Benchmark the Layer-1 optimizer inner loop: plain-Python vs full-native (C). + +This is the *heaviest optimizer path on the config with the most C ports completed* +(impinging + ablative + advanced combustion => _can_handle_chamber is True, so every +converging candidate runs the whole physics chain in C via ed_evaluate). + +Correct measurement is fiddly, so this script pins down the variables: + + * FORCE SERIAL (num_workers=1, in-process) -- the native fast-eval path lives in + the ProcessPool worker function `_eval_candidate`; with a real pool it runs in + child processes and is invisible/uncontrolled. Serial runs it in-process so the + per-candidate cost is measured cleanly and the native-vs-fallback split is counted. + * ONE CONDITION PER SUBPROCESS -- native availability is cached per-process and the + Python run pins ED_USE_NATIVE=0; running both in one process cross-poisons. The + driver re-execs this script once per mode. + * WARMUP -- a throwaway 1-iteration solve first (loads the CEA cache, builds/loads + the native lib, warms imports), then the counters reset and the timed run happens. + +Two conditions, identical budget: + * python : ED_USE_NATIVE=0 -> native disabled everywhere, incl. the chamber solve + inside runner.evaluate. True all-Python baseline. + * native : ED_USE_NATIVE=1, ED_LAYER1_NATIVE_EVAL=1 -> single C ed_evaluate per + candidate; Python fallback only on a non-converged native solve. + +The us/candidate NATIVE number is the bar a Numba kernel has to approach; Numba slots +in as a third mode once its kernel exists. + +Run: + .venv/bin/python -m scripts.bench_layer1_native_vs_python --max-iterations 12 +""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +# --- per-candidate counters --------------------------------------------------- +class _Counters: + def reset(self): + self.native_calls = 0 # native_injector.evaluate invocations + self.native_none = 0 # ... that returned None (fell back to Python) + self.runner_calls = 0 # PintleEngineRunner.evaluate invocations + + +C = _Counters() +C.reset() + + +def _install_instrumentation(): + from engine.native.python import native_injector as ni + from engine.core.runner import PintleEngineRunner + if getattr(ni.evaluate, "_benched", False): + return + _orig_native, _orig_runner = ni.evaluate, PintleEngineRunner.evaluate + + def native_evaluate(*a, **k): + r = _orig_native(*a, **k) + C.native_calls += 1 + if r is None: + C.native_none += 1 + return r + + def runner_evaluate(self, *a, **k): + C.runner_calls += 1 + return _orig_runner(self, *a, **k) + + native_evaluate._benched = True + ni.evaluate = native_evaluate + PintleEngineRunner.evaluate = runner_evaluate + + +def _one_optimization(config_path: Path, max_iterations: int, cma_restarts: int, seed: int): + import numpy as np + import engine.optimizer.layers.layer1_static_optimization as L1 + from engine.core.runner import PintleEngineRunner + from engine.pipeline.io import load_config + + L1._get_num_workers = lambda cfg: 1 # force serial, in-process + + base_cfg = load_config(str(config_path)) + cfg = copy.deepcopy(base_cfg) + req = cfg.design_requirements.model_dump() + 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"]), + } + np.random.seed(seed) + t0 = time.perf_counter() + L1.run_layer1_optimization( + cfg, PintleEngineRunner(copy.deepcopy(base_cfg)), req, + target_burn_time=float(req.get("target_burn_time", 6.0)), + tolerances={"thrust": 0.10, "apogee": 0.15}, + pressure_config=pcfg, layer1_smoke=True, + layer1_max_iterations=int(max_iterations), layer1_cma_restarts=int(cma_restarts), + ) + return time.perf_counter() - t0 + + +def _run_condition(mode: str, config_path: Path, max_iterations: int, cma_restarts: int, seed: int): + """Runs one condition in THIS process. Emits a JSON result line for the driver.""" + if mode == "python": + os.environ["ED_USE_NATIVE"] = "0" + os.environ["ED_LAYER1_NATIVE_EVAL"] = "0" + else: + os.environ["ED_USE_NATIVE"] = "1" + os.environ["ED_LAYER1_NATIVE_EVAL"] = "1" + + _install_instrumentation() + # warmup (loads CEA cache, builds native lib, warms imports) — discarded + _one_optimization(config_path, max_iterations=1, cma_restarts=1, seed=seed) + C.reset() + wall = _one_optimization(config_path, max_iterations, cma_restarts, seed) + cands = C.native_calls if C.native_calls else C.runner_calls + out = { + "mode": mode, "wall_s": wall, "candidates": cands, + "native_calls": C.native_calls, "native_fallbacks": C.native_none, + "runner_calls": C.runner_calls, + "us_per_candidate": (wall / cands * 1e6) if cands else None, + } + print("BENCH_JSON " + json.dumps(out)) + return out + + +def _print_condition(o: dict): + print(f"\n[{o['mode'].upper()}]") + print(f" wall-clock : {o['wall_s']:8.3f} s") + print(f" candidate evals : {o['candidates']}") + if o["native_calls"]: + went = o["native_calls"] - o["native_fallbacks"] + print(f" went native (C) : {went}/{o['native_calls']} " + f"({100.0*went/o['native_calls']:.0f}%) | fallback: {o['native_fallbacks']}") + print(f" runner.evaluate : {o['runner_calls']} (fallback + finalization replay)") + print(f" us / candidate : {o['us_per_candidate']:8.1f}") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--config", type=str, default="configs/impinging_lox_ch4_8000N.yaml") + ap.add_argument("--max-iterations", type=int, default=12) + ap.add_argument("--cma-restarts", type=int, default=1) + ap.add_argument("--seed", type=int, default=0) + ap.add_argument("--mode", choices=["python", "native"], default=None, + help="internal: run a single condition in this process") + args = ap.parse_args() + + cfg_path = (ROOT / args.config) if not os.path.isabs(args.config) else Path(args.config) + + if args.mode: # child: run one condition + _run_condition(args.mode, cfg_path, args.max_iterations, args.cma_restarts, args.seed) + return + + # driver: spawn one subprocess per condition + print(f"config: {cfg_path}") + print(f"budget: max_iterations={args.max_iterations} cma_restarts={args.cma_restarts} " + f"seed={args.seed} (serial, in-process, warmup discarded)") + results = {} + for mode in ("python", "native"): + cmd = [sys.executable, str(Path(__file__).resolve()), + "--config", args.config, "--max-iterations", str(args.max_iterations), + "--cma-restarts", str(args.cma_restarts), "--seed", str(args.seed), + "--mode", mode] + p = subprocess.run(cmd, cwd=str(ROOT), capture_output=True, text=True) + line = next((l for l in p.stdout.splitlines() if l.startswith("BENCH_JSON ")), None) + if not line: + print(f"!! {mode} run produced no result. stderr tail:\n" + "\n".join(p.stderr.splitlines()[-15:])) + return + results[mode] = json.loads(line[len("BENCH_JSON "):]) + _print_condition(results[mode]) + + py, nat = results["python"], results["native"] + print("\n" + "=" * 60) + print(f" end-to-end optimizer wall speedup : {py['wall_s'] / nat['wall_s']:5.2f}x") + if py["us_per_candidate"] and nat["us_per_candidate"]: + print(f" per-candidate speedup (C vs Py) : {py['us_per_candidate'] / nat['us_per_candidate']:5.2f}x") + print("=" * 60) + print("\nNext: add a Numba kernel as a third mode and compare us/candidate.") + + +if __name__ == "__main__": + main() diff --git a/EngineDesign/scripts/bench_layer1_numba.py b/EngineDesign/scripts/bench_layer1_numba.py new file mode 100644 index 000000000..1b8725a46 --- /dev/null +++ b/EngineDesign/scripts/bench_layer1_numba.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Three-way Layer-1 optimizer benchmark: plain Python vs C vs Numba. + +Same harness as bench_layer1_native_vs_python.py (serial in-process, subprocess per +condition, warmup discarded), with a third mode that patches native_injector.evaluate +to the Numba core (numba_eval.make_native_signature_evaluate) — Numba does the +chamber+nozzle+thrust physics, the C diagnostic injector solve + Python stability tail +is identical to the C mode, so the only difference is the chamber-solve core. + +Run: .venv/bin/python scripts/bench_layer1_numba.py --max-iterations 8 +""" +from __future__ import annotations +import argparse, copy, json, os, subprocess, sys, time +from statistics import median +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts")) + + +class _C: + def reset(self): + self.native_calls = self.native_none = self.runner_calls = 0 +C = _C(); C.reset() + + +def _install_instrumentation(): + from engine.native.python import native_injector as ni + from engine.core.runner import PintleEngineRunner + if getattr(ni.evaluate, "_benched", False): + return + _on, _or = ni.evaluate, PintleEngineRunner.evaluate + def nev(*a, **k): + r = _on(*a, **k); C.native_calls += 1 + if r is None: C.native_none += 1 + return r + def rev(self, *a, **k): + C.runner_calls += 1; return _or(self, *a, **k) + nev._benched = True + ni.evaluate = nev; PintleEngineRunner.evaluate = rev + + +def _one(cfg_path, max_it, restarts, seed): + import numpy as np + import engine.optimizer.layers.layer1_static_optimization as L1 + from engine.core.runner import PintleEngineRunner + from engine.pipeline.io import load_config + L1._get_num_workers = lambda cfg: 1 + base = load_config(str(cfg_path)); cfg = copy.deepcopy(base) + req = cfg.design_requirements.model_dump() + 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"])} + # Layer 1 draws its CMA seed base from np.random.SeedSequence().entropy (fresh OS + # entropy) unless requirements["layer1_random_seed"] is pinned -- np.random.seed() + # only touches the legacy global RandomState and does NOT reach it. Without this pin + # every run walks a different candidate trajectory and the modes are not comparable. + req["layer1_random_seed"] = int(seed) + np.random.seed(seed); t0 = time.perf_counter() + L1.run_layer1_optimization(cfg, PintleEngineRunner(copy.deepcopy(base)), req, + target_burn_time=float(req.get("target_burn_time", 6.0)), + tolerances={"thrust": 0.10, "apogee": 0.15}, pressure_config=pcfg, + layer1_smoke=True, layer1_max_iterations=int(max_it), layer1_cma_restarts=int(restarts)) + return time.perf_counter() - t0 + + +def _run_condition(mode, cfg_path, max_it, restarts, seed): + if mode == "python": + os.environ["ED_USE_NATIVE"] = "0"; os.environ["ED_LAYER1_NATIVE_EVAL"] = "0" + else: + os.environ["ED_USE_NATIVE"] = "1"; os.environ["ED_LAYER1_NATIVE_EVAL"] = "1" + if mode == "numba": + import numba_eval + from engine.native.python import native_injector as ni + ni.evaluate = numba_eval.make_native_signature_evaluate() # patch BEFORE instrumentation + _install_instrumentation() + _one(cfg_path, 1, 1, seed) # warmup (JIT compile, CEA load) — discarded + C.reset() + wall = _one(cfg_path, max_it, restarts, seed) + cands = C.native_calls if C.native_calls else C.runner_calls + out = {"mode": mode, "wall_s": wall, "candidates": cands, + "native_calls": C.native_calls, "native_fallbacks": C.native_none, + "runner_calls": C.runner_calls, + "us_per_candidate": (wall / cands * 1e6) if cands else None} + print("BENCH_JSON " + json.dumps(out)) + + +def _pc(mode, runs): + """Summarise N repetitions of one mode. + + A single wall time is not a measurement here: on a loaded box the SAME work + (seed pinned, byte-identical candidate trajectory) was observed to vary + 6.81s..11.59s, a 1.7x spread that dwarfs the C-vs-Numba difference. Report + min (least interference) and median instead. + """ + us = sorted(r["us_per_candidate"] for r in runs) + walls = sorted(r["wall_s"] for r in runs) + o = runs[0] + print(f"\n[{mode.upper()}] reps={len(runs)} candidates={o['candidates']}") + print(f" wall_s min={walls[0]:.3f} median={median(walls):.3f} max={walls[-1]:.3f}") + print(f" us/candidate min={us[0]:.0f} median={median(us):.0f} max={us[-1]:.0f}") + if o["native_calls"]: + went = o["native_calls"] - o["native_fallbacks"] + print(f" accel path: {went}/{o['native_calls']} ({100.0*went/o['native_calls']:.0f}%)" + f" fallback={o['native_fallbacks']} runner.evaluate={o['runner_calls']}") + # The seed is pinned, so every rep must do byte-identical work. If it does + # not, the trajectory is diverging run-to-run and NO comparison here means + # anything -- say so loudly rather than printing a confident ratio. + if len({(r["candidates"], r["native_fallbacks"], r["runner_calls"]) for r in runs}) > 1: + print(" !! WORK VARIED ACROSS REPS -- seed not reaching the optimizer;" + " these numbers are NOT comparable") + return {"min": us[0], "median": median(us)} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--config", default="configs/impinging_lox_ch4_8000N.yaml") + ap.add_argument("--max-iterations", type=int, default=8) + ap.add_argument("--cma-restarts", type=int, default=1) + ap.add_argument("--seed", type=int, default=0) + ap.add_argument("--reps", type=int, default=3, + help="timed repetitions per accelerated mode (noise control)") + ap.add_argument("--python-reps", type=int, default=1, + help="reps for the Python baseline (~10x slower, 1 is usually enough)") + ap.add_argument("--mode", choices=["python", "native", "numba"], default=None) + a = ap.parse_args() + cfg_path = (ROOT / a.config) if not os.path.isabs(a.config) else Path(a.config) + if a.mode: + _run_condition(a.mode, cfg_path, a.max_iterations, a.cma_restarts, a.seed); return + print(f"config: {cfg_path}\nbudget: max_iterations={a.max_iterations} restarts={a.cma_restarts} " + f"seed={a.seed} reps={a.reps} (serial, warmup discarded)") + agg = {} + for mode in ("python", "native", "numba"): + runs = [] + for _ in range(a.python_reps if mode == "python" else a.reps): + cmd = [sys.executable, str(Path(__file__).resolve()), "--config", a.config, + "--max-iterations", str(a.max_iterations), "--cma-restarts", str(a.cma_restarts), + "--seed", str(a.seed), "--mode", mode] + p = subprocess.run(cmd, cwd=str(ROOT), capture_output=True, text=True) + line = next((l for l in p.stdout.splitlines() if l.startswith("BENCH_JSON ")), None) + if not line: + print(f"!! {mode} produced no result. stderr tail:\n" + + "\n".join(p.stderr.splitlines()[-20:])); return + runs.append(json.loads(line[len("BENCH_JSON "):])) + agg[mode] = _pc(mode, runs) + py, nat, nb = agg["python"], agg["native"], agg["numba"] + print("\n" + "=" * 68) + for stat in ("min", "median"): + print(f" [{stat:6s}] per-candidate us: python={py[stat]:.0f} C={nat[stat]:.0f} numba={nb[stat]:.0f}") + print(f" speedup vs python: C={py[stat]/nat[stat]:.1f}x numba={py[stat]/nb[stat]:.1f}x") + print(f" numba vs C: {nb[stat]/nat[stat]:.2f}x the C time" + f" ({nat[stat]/nb[stat]:.2f}x speed of C)") + print("=" * 68) + + +if __name__ == "__main__": + main() diff --git a/EngineDesign/scripts/numba_eval.py b/EngineDesign/scripts/numba_eval.py new file mode 100644 index 000000000..be2f3aecd --- /dev/null +++ b/EngineDesign/scripts/numba_eval.py @@ -0,0 +1,750 @@ +"""Numba port of the C ed_evaluate impinging inner-loop physics. + +Faithful mirror of engine/native/src/{ed_injector_impinging,ed_discharge,ed_feed_loss, +ed_spray,ed_combustion_physics,ed_cea,ed_nozzle,ed_chamber,ed_evaluate,ed_root_find}.c +for the config class the C path actually handles: impinging injector, advanced/ +exponential combustion, ablative cooling DISABLED (cooling_eff==1, so ed_cooling.c +is a no-op and is not ported — asserted at extract time). + +Inputs are pulled from the SAME native EdEngineState (native_injector.build_state) +and the SAME CEA cache, so there is no risk of misreading the config: we reuse the +C's own reconciled scalar inputs and only re-implement the arithmetic in @njit. +""" +from __future__ import annotations +import numpy as np +from numba import njit + +# ---- parameter vector layout (mirrors the fields the C residual reads) ------- +_NAMES = [ + # fluids + "RHO_O", "MU_O", "SIG_O", "T_O", + "RHO_F", "MU_F", "SIG_F", "T_F", "LAT_F", + # injector geom + "DJO", "DJF", "NO", "NF", "ANG_O", "ANG_F", + # discharge O (16) + "DO_CDINF", "DO_ARE", "DO_CDMIN", "DO_GEOM", "DO_DREF", "DO_DMIN", "DO_EXPS", + "DO_LOGG", "DO_CDMAX", "DO_CDFLOOR", "DO_UPC", "DO_PREF", "DO_AP", "DO_UTC", "DO_TREF", "DO_AT", + # discharge F (16) + "DF_CDINF", "DF_ARE", "DF_CDMIN", "DF_GEOM", "DF_DREF", "DF_DMIN", "DF_EXPS", + "DF_LOGG", "DF_CDMAX", "DF_CDFLOOR", "DF_UPC", "DF_PREF", "DF_AP", "DF_UTC", "DF_TREF", "DF_AT", + # feed O / F + "FO_DIN", "FO_AH", "FO_K0", "FO_K1", "FO_PHI", + "FF_DIN", "FF_AH", "FF_K0", "FF_K1", "FF_PHI", + # spray + "SP_SMDMODEL", "SP_SMDC", "SP_SMDM", "SP_SMDP", "SP_SMDCING", "SP_SMDWECORR", + "SP_GASR", "SP_GAST", "SP_ANGMODEL", "SP_ANGK", "SP_ANGN", "SP_WEMIN", + "SP_EVAPK", "SP_EVAPXLIM", "SP_EVAPUSE", + # solver + "SV_CLMAX", "SV_CLCDRED", "SV_PCMIN", "SV_PCMAX", "SV_TOL", "SV_MAXIT", + # geom + "G_EPS", "G_AT", "G_AE", "G_VOL", "G_LSTAR", "G_DCHAM", "G_NOZZEFF", + # combustion + "C_MODEL", "C_C", "C_TAUREF", "C_TAUREFP", "C_TAUREFT", "C_NPRESS", "C_TSTARCAP", + "C_HASFLOOR", "C_TAUFLOOR", "C_EMPEAK", "C_SIGMA", "C_ROPT", +] +_IDX = {n: i for i, n in enumerate(_NAMES)} +globals().update(_IDX) # module-level int constants for njit +NP = len(_NAMES) + +# physical constants (ed_phys_const.h) +PI = np.pi +G0 = 9.80665 +P_SEA = 101325.0 +PRANDTL = 0.8 +D_M_REF = 5e-5; D_M_TREF = 1500.0; D_M_PREF = 2.5e6; U_SLIP_CAP = 50.0; D_MIN_GAS = 1e-6 +CS_C_L = 0.1; CS_C_U = 0.5; CS_U_RMS_CAP = 200.0 +GAS_MU = 7e-5; GAS_RHO_L = 800.0; GAS_CP_L = 2000.0; GAS_T_INJ = 293.0; GAS_CP_G = 2200.0 +SMD_INGEBO = 1; SPRAYANG_J = 1; PHI_NONE = 0; PHI_SQRTP = 1; PHI_LOGP = 2 +EFF_CONSTANT = 0; EFF_LINEAR = 1 + + +def _assert_supported(st): + """Guard the assumptions that let this port skip cooling / use the impinging path.""" + assert int(st.injector.type) == 1, "not impinging" + assert int(getattr(st.cooling, "ablative_enabled")) == 0, "ablative on: cooling port needed" + assert int(getattr(st.cooling, "film_enabled")) == 0 and int(getattr(st.cooling, "regen_enabled")) == 0 + assert int(getattr(st.cooling, "graphite_enabled")) == 0 + + +def extract_params(config): + """Flatten the native EdEngineState scalars this port needs into a float64 vec. + + Delegates the field mapping to _params_from_state so there is ONE table of + field names; the two used to carry independent copies of the whole mapping, + which would drift the moment a field was added on one side only. + """ + from engine.native.python import native_injector as ni + st = ni.build_state(config) + _assert_supported(st) + return _params_from_state(st) + + +def cea_arrays(cache): + """Grids + 7 property tables (float64, C-order), Cf_vac with the _ensure_cea fallback.""" + assert getattr(cache, "use_3d", False), "cache is not a 3D grid" + Pc = np.ascontiguousarray(cache.Pc_grid, np.float64) + MR = np.ascontiguousarray(cache.MR_grid, np.float64) + eps = np.ascontiguousarray(cache.eps_grid, np.float64) + cf_vac = getattr(cache, "Cf_vac_table", None) + if cf_vac is None: + from engine.pipeline.cea_cache import _isentropic_cf_vac + gt = np.asarray(cache.gamma_table, np.float64) + cf_vac = np.empty_like(gt) + for k in range(gt.shape[2]): + ek = float(eps[k]) + for i in range(gt.shape[0]): + for j in range(gt.shape[1]): + cf_vac[i, j, k] = _isentropic_cf_vac(gt[i, j, k], ek) + A = lambda t: np.ascontiguousarray(t, np.float64) + return (Pc, MR, eps, A(cache.cstar_table), A(cache.Cf_table), A(cache.Tc_table), + A(cache.gamma_table), A(cache.R_table), A(cache.M_table), A(cf_vac)) + + +# ------------------------- njit kernels -------------------------------------- +@njit(cache=True) +def _clip(x, lo, hi): + return lo if x < lo else (hi if x > hi else x) + +@njit(cache=True) +def _tri(table, ip, im, ie, w0, w1, w2, w3, w4, w5, w6, w7): + f0 = table[ip-1, im-1, ie-1]; f1 = table[ip, im-1, ie-1] + f2 = table[ip-1, im, ie-1]; f3 = table[ip, im, ie-1] + f4 = table[ip-1, im-1, ie]; f5 = table[ip, im-1, ie] + f6 = table[ip-1, im, ie]; f7 = table[ip, im, ie] + if (np.isnan(f0) or np.isnan(f1) or np.isnan(f2) or np.isnan(f3) or + np.isnan(f4) or np.isnan(f5) or np.isnan(f6) or np.isnan(f7)): + return f0 + return f0*w0 + f1*w1 + f2*w2 + f3*w3 + f4*w4 + f5*w5 + f6*w6 + f7*w7 + +@njit(cache=True) +def cea_eval(Pcg, MRg, epsg, cstar, Cf, Tc, gam, Rt, Mt, Cfvac, MR, Pc, eps): + Pc_c = _clip(Pc, Pcg[0], Pcg[-1]); MR_c = _clip(MR, MRg[0], MRg[-1]); eps_c = _clip(eps, epsg[0], epsg[-1]) + npc = Pcg.shape[0]; nmr = MRg.shape[0]; nep = epsg.shape[0] + ip = np.searchsorted(Pcg, Pc_c, side="left"); ip = 1 if ip < 1 else (npc-1 if ip > npc-1 else ip) + im = np.searchsorted(MRg, MR_c, side="left"); im = 1 if im < 1 else (nmr-1 if im > nmr-1 else im) + ie = np.searchsorted(epsg, eps_c, side="left"); ie = 1 if ie < 1 else (nep-1 if ie > nep-1 else ie) + Pc0, Pc1 = Pcg[ip-1], Pcg[ip]; MR0, MR1 = MRg[im-1], MRg[im]; e0, e1 = epsg[ie-1], epsg[ie] + wx = (Pc_c-Pc0)/(Pc1-Pc0) if Pc1 != Pc0 else 0.0 + wy = (MR_c-MR0)/(MR1-MR0) if MR1 != MR0 else 0.0 + wz = (eps_c-e0)/(e1-e0) if e1 != e0 else 0.0 + w0=(1-wx)*(1-wy)*(1-wz); w1=wx*(1-wy)*(1-wz); w2=(1-wx)*wy*(1-wz); w3=wx*wy*(1-wz) + w4=(1-wx)*(1-wy)*wz; w5=wx*(1-wy)*wz; w6=(1-wx)*wy*wz; w7=wx*wy*wz + return (_tri(cstar,ip,im,ie,w0,w1,w2,w3,w4,w5,w6,w7), + _tri(Cf,ip,im,ie,w0,w1,w2,w3,w4,w5,w6,w7), + _tri(Tc,ip,im,ie,w0,w1,w2,w3,w4,w5,w6,w7), + _tri(gam,ip,im,ie,w0,w1,w2,w3,w4,w5,w6,w7), + _tri(Rt,ip,im,ie,w0,w1,w2,w3,w4,w5,w6,w7), + _tri(Mt,ip,im,ie,w0,w1,w2,w3,w4,w5,w6,w7), + _tri(Cfvac,ip,im,ie,w0,w1,w2,w3,w4,w5,w6,w7)) + +@njit(cache=True) +def _reynolds(rho, u, d, mu): + if mu <= 0.0: + return 1e6 + return rho*u*d/mu + +@njit(cache=True) +def _cd_inf_orifice(d_hyd, cdinf, geom, dref, dmin, exps, logg, cdmax, cdfloor): + if geom == 0.0: + return cdinf + if not np.isfinite(d_hyd) or d_hyd <= 0.0: + return cdinf + d = dmin if dmin > d_hyd else d_hyd + if dref <= 0.0: + return _clip(cdinf, cdfloor, cdmax) + ratio = d/dref + if ratio < 1.0: + cd = cdinf * ratio**(exps if exps > 0.0 else 0.0) + else: + cd = cdinf + logg*np.log(ratio) + return _clip(cd, cdfloor, cdmax) + +@njit(cache=True) +def _cd_from_re(Re, P_in, T_in, d_hyd, cdinf, aRe, cdmin, geom, dref, dmin, exps, logg, + cdmax, cdfloor, upc, Pref, aP, utc, Tref, aT): + cd_inf_eff = _cd_inf_orifice(d_hyd, cdinf, geom, dref, dmin, exps, logg, cdmax, cdfloor) + if Re <= 0.0: + return cdmin + cd = cd_inf_eff - aRe/np.sqrt(Re if Re > 1e-6 else 1e-6) + if upc != 0.0 and np.isfinite(P_in) and Pref > 0.0: + cd *= 1.0 + aP*(P_in/Pref - 1.0) + if utc != 0.0 and np.isfinite(T_in) and Tref > 0.0: + cd *= 1.0 + aT*(T_in/Tref - 1.0) + return _clip(cd, cdmin, cd_inf_eff) + +@njit(cache=True) +def _dpf(mdot, rho, din, ah, k0, k1, phi, P_tank): + if phi == PHI_NONE: + keff = k0 + elif phi == PHI_SQRTP: + keff = k0 + k1*np.sqrt(P_tank if P_tank > 0 else 0.0) + elif phi == PHI_LOGP: + keff = k0 + k1*np.log(P_tank) + else: + return np.nan + A = PI*(din*0.5)**2 if din > 0.0 else ah + if not (A > 0.0) or not (rho > 0.0) or mdot < 0.0: + return np.nan + v = mdot/(rho*A) + dp = keff*(rho*0.5)*v*v + return 0.0 if dp < 0.0 else dp + +@njit(cache=True) +def _bern(mdot_seed, dP, Pi, rho, area, dhyd, mu, Tin, cd_cap, + cdinf, aRe, cdmin, geom, dref, dmin, exps, logg, cdmax, cdfloor, upc, Pref, aP, utc, Tref, aT): + if dP <= 0.0: + c0 = _cd_from_re(0.0, Pi, Tin, dhyd, cdinf, aRe, cdmin, geom, dref, dmin, exps, logg, cdmax, cdfloor, upc, Pref, aP, utc, Tref, aT) + return 0.0, c0 if c0 < cd_cap else cd_cap + cdlo = _cd_from_re(0.0, Pi, Tin, dhyd, cdinf, aRe, cdmin, geom, dref, dmin, exps, logg, cdmax, cdfloor, upc, Pref, aP, utc, Tref, aT) + cdlo = cdlo if cdlo < cd_cap else cd_cap + m = mdot_seed if mdot_seed > 1e-18 else cdlo*area*np.sqrt(2.0*rho*dP) + cd = cdlo + for _ in range(120): + m_was = m + u = m/(rho*area) if area > 0.0 else 0.0 + Re = _reynolds(rho, u, dhyd, mu) + cd = _cd_from_re(Re, Pi, Tin, dhyd, cdinf, aRe, cdmin, geom, dref, dmin, exps, logg, cdmax, cdfloor, upc, Pref, aP, utc, Tref, aT) + cd = cd if cd < cd_cap else cd_cap + m = cd*area*np.sqrt(2.0*rho*dP) + denom = np.abs(m_was) if np.abs(m_was) > 1e-18 else 1e-18 + if np.abs(m - m_was)/denom < 1e-12: + break + return m, cd + + +@njit(cache=True) +def _ingebo(d_jet, u_rel, rho_l, mu_l, sigma, rho_g, C): + if d_jet <= 0 or u_rel <= 0 or sigma <= 0 or rho_g <= 0 or rho_l <= 0 or mu_l <= 0 or C <= 0: + return d_jet + We_g = rho_g*u_rel*u_rel*d_jet/sigma + Re_l = rho_l*u_rel*d_jet/mu_l + p = We_g*Re_l + if p <= 0: + return d_jet + return C*d_jet*p**(-0.25) + +@njit(cache=True) +def _lefebvre(d_or, We, Oh, C, m, p): + if We <= 0 or d_or <= 0: + return d_or + return C*d_or*We**(-m)*(1.0+Oh)**p + +@njit(cache=True) +def _ohnesorge(mu, rho, sigma, d): + if rho <= 0 or sigma <= 0 or d <= 0: + return 0.0 + arg = rho*sigma*d + return mu/np.sqrt(arg if arg > 1e-12 else 1e-12) if arg > 0 else 0.0 + +@njit(cache=True) +def injector_solve(P, P_tank_O, P_tank_F, Pc): + """Returns (ok, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, + Pi_O, Pi_F, dpi_O, dpi_F, A_geom_O, A_geom_F). ok=0 => NaN/invalid.""" + rho_O = P[RHO_O]; mu_O = P[MU_O]; sig_O = P[SIG_O]; tO = P[T_O] + rho_F = P[RHO_F]; mu_F = P[MU_F]; sig_F = P[SIG_F]; tF = P[T_F] + djo = P[DJO]; djf = P[DJF]; nO = int(P[NO]); nF = int(P[NF]) + A_O = nO*PI*(djo*0.5)**2; A_F = nF*PI*(djf*0.5)**2 + max_iter = int(P[SV_CLMAX]); Cd_red = P[SV_CLCDRED] + Cd_O_eff = _cd_inf_orifice(djo, P[DO_CDINF], P[DO_GEOM], P[DO_DREF], P[DO_DMIN], P[DO_EXPS], P[DO_LOGG], P[DO_CDMAX], P[DO_CDFLOOR]) + Cd_F_eff = _cd_inf_orifice(djf, P[DF_CDINF], P[DF_GEOM], P[DF_DREF], P[DF_DMIN], P[DF_EXPS], P[DF_LOGG], P[DF_CDMAX], P[DF_CDFLOOR]) + imp_sep = _clip(P[ANG_O] + P[ANG_F], 1.0, 179.0) + imp_angle = imp_sep*PI/180.0 + + mdot_O = 0.1; mdot_F = 0.1 + Cd_O = 0.0; Cd_F = 0.0; Pi_O = P_tank_O; Pi_F = P_tank_F + dpi_O = 0.0; dpi_F = 0.0 + We_O = 0.0; We_F = 0.0; D32_O = 0.0; D32_F = 0.0; u_rel = 0.0 + u_O = 0.0; u_F = 0.0 + constraints_ok = 0 + + for iteration in range(max_iter): + mo = mdot_O; mf = mdot_F + for fp in range(1, 151): + mo_prev = mo; mf_prev = mf + dpf_O = _dpf(mo, rho_O, P[FO_DIN], P[FO_AH], P[FO_K0], P[FO_K1], P[FO_PHI], P_tank_O) + dpf_F = _dpf(mf, rho_F, P[FF_DIN], P[FF_AH], P[FF_K0], P[FF_K1], P[FF_PHI], P_tank_F) + Pi_O = P_tank_O - dpf_O; Pi_F = P_tank_F - dpf_F + dpi_O = Pi_O - Pc if Pi_O - Pc > 0 else 0.0 + dpi_F = Pi_F - Pc if Pi_F - Pc > 0 else 0.0 + if Pi_O < Pc: + mo_new = 0.0 + else: + mo_new, _c = _bern(mo, dpi_O, Pi_O, rho_O, A_O, djo, mu_O, tO, Cd_O_eff, + P[DO_CDINF], P[DO_ARE], P[DO_CDMIN], P[DO_GEOM], P[DO_DREF], P[DO_DMIN], P[DO_EXPS], P[DO_LOGG], P[DO_CDMAX], P[DO_CDFLOOR], P[DO_UPC], P[DO_PREF], P[DO_AP], P[DO_UTC], P[DO_TREF], P[DO_AT]) + if Pi_F < Pc: + mf_new = 0.0 + else: + mf_new, _c = _bern(mf, dpi_F, Pi_F, rho_F, A_F, djf, mu_F, tF, Cd_F_eff, + P[DF_CDINF], P[DF_ARE], P[DF_CDMIN], P[DF_GEOM], P[DF_DREF], P[DF_DMIN], P[DF_EXPS], P[DF_LOGG], P[DF_CDMAX], P[DF_CDFLOOR], P[DF_UPC], P[DF_PREF], P[DF_AP], P[DF_UTC], P[DF_TREF], P[DF_AT]) + w = 0.35 + mo = mo_prev + w*(mo_new - mo_prev) + mf = mf_prev + w*(mf_new - mf_prev) + dpf_O = _dpf(mo, rho_O, P[FO_DIN], P[FO_AH], P[FO_K0], P[FO_K1], P[FO_PHI], P_tank_O) + dpf_F = _dpf(mf, rho_F, P[FF_DIN], P[FF_AH], P[FF_K0], P[FF_K1], P[FF_PHI], P_tank_F) + Pi_O = P_tank_O - dpf_O; Pi_F = P_tank_F - dpf_F + dpi_O = Pi_O - Pc if Pi_O - Pc > 0 else 0.0 + dpi_F = Pi_F - Pc if Pi_F - Pc > 0 else 0.0 + if Pi_O < Pc: + Cd_O = _cd_from_re(0.0, Pi_O, tO, djo, P[DO_CDINF], P[DO_ARE], P[DO_CDMIN], P[DO_GEOM], P[DO_DREF], P[DO_DMIN], P[DO_EXPS], P[DO_LOGG], P[DO_CDMAX], P[DO_CDFLOOR], P[DO_UPC], P[DO_PREF], P[DO_AP], P[DO_UTC], P[DO_TREF], P[DO_AT]) + Cd_O = Cd_O if Cd_O < Cd_O_eff else Cd_O_eff + else: + u_o2 = mo/(rho_O*A_O) if A_O > 0 else 0.0 + Re_o2 = _reynolds(rho_O, u_o2, djo, mu_O) + Cd_O = _cd_from_re(Re_o2, Pi_O, tO, djo, P[DO_CDINF], P[DO_ARE], P[DO_CDMIN], P[DO_GEOM], P[DO_DREF], P[DO_DMIN], P[DO_EXPS], P[DO_LOGG], P[DO_CDMAX], P[DO_CDFLOOR], P[DO_UPC], P[DO_PREF], P[DO_AP], P[DO_UTC], P[DO_TREF], P[DO_AT]) + Cd_O = Cd_O if Cd_O < Cd_O_eff else Cd_O_eff + if Pi_F < Pc: + Cd_F = _cd_from_re(0.0, Pi_F, tF, djf, P[DF_CDINF], P[DF_ARE], P[DF_CDMIN], P[DF_GEOM], P[DF_DREF], P[DF_DMIN], P[DF_EXPS], P[DF_LOGG], P[DF_CDMAX], P[DF_CDFLOOR], P[DF_UPC], P[DF_PREF], P[DF_AP], P[DF_UTC], P[DF_TREF], P[DF_AT]) + Cd_F = Cd_F if Cd_F < Cd_F_eff else Cd_F_eff + else: + u_f2 = mf/(rho_F*A_F) if A_F > 0 else 0.0 + Re_f2 = _reynolds(rho_F, u_f2, djf, mu_F) + Cd_F = _cd_from_re(Re_f2, Pi_F, tF, djf, P[DF_CDINF], P[DF_ARE], P[DF_CDMIN], P[DF_GEOM], P[DF_DREF], P[DF_DMIN], P[DF_EXPS], P[DF_LOGG], P[DF_CDMAX], P[DF_CDFLOOR], P[DF_UPC], P[DF_PREF], P[DF_AP], P[DF_UTC], P[DF_TREF], P[DF_AT]) + Cd_F = Cd_F if Cd_F < Cd_F_eff else Cd_F_eff + den_o = max(abs(mo_prev), abs(mo)); den_o = den_o if den_o > 1e-18 else 1e-18 + den_f = max(abs(mf_prev), abs(mf)); den_f = den_f if den_f > 1e-18 else 1e-18 + if abs(mo - mo_prev)/den_o < 1e-6 and abs(mf - mf_prev)/den_f < 1e-6: + break + mdot_O = mo; mdot_F = mf + u_O = mdot_O/(rho_O*A_O) if A_O > 0 else 0.0 + u_F = mdot_F/(rho_F*A_F) if A_F > 0 else 0.0 + u_rel = np.sqrt(u_O*u_O + u_F*u_F - 2.0*u_O*u_F*np.cos(imp_angle)) + rho_gas = Pc/(P[SP_GASR]*P[SP_GAST]); rho_gas = rho_gas if rho_gas > 1e-6 else 1e-6 + if P[SP_SMDMODEL] == SMD_INGEBO: + D32_O = _ingebo(djo, u_rel, rho_O, mu_O, sig_O, rho_gas, P[SP_SMDCING]) + D32_F = _ingebo(djf, u_rel, rho_F, mu_F, sig_F, rho_gas, P[SP_SMDCING]) + We_O = rho_gas*u_rel*u_rel*djo/sig_O if sig_O > 0 else np.inf + We_F = rho_gas*u_rel*u_rel*djf/sig_F if sig_F > 0 else np.inf + else: + alpha = 0.35 + uo_ = max(u_O, 0.0); uf_ = max(u_F, 0.0); ur_ = max(u_rel, 0.0) + ue_O = np.sqrt(uo_*uo_ + (alpha*ur_)**2); ue_F = np.sqrt(uf_*uf_ + (alpha*ur_)**2) + We_O = rho_O*ue_O*ue_O*djo/sig_O if sig_O > 0 else np.inf + We_F = rho_F*ue_F*ue_F*djf/sig_F if sig_F > 0 else np.inf + weO = We_O; weF = We_F + if P[SP_SMDWECORR] > 0 and np.isfinite(P[SP_SMDWECORR]): + weO = min(We_O, P[SP_SMDWECORR]); weF = min(We_F, P[SP_SMDWECORR]) + Oh_O = _ohnesorge(mu_O, rho_O, sig_O, djo); Oh_F = _ohnesorge(mu_F, rho_F, sig_F, djf) + D32_O = _lefebvre(djo, weO, Oh_O, P[SP_SMDC], P[SP_SMDM], P[SP_SMDP]) + D32_F = _lefebvre(djf, weF, Oh_F, P[SP_SMDC], P[SP_SMDM], P[SP_SMDP]) + te_O = P[SP_EVAPK]*D32_O*D32_O; te_F = P[SP_EVAPK]*D32_F*D32_F + x_star = max(u_rel*te_O, u_rel*te_F) + constraints_ok = 1 + if We_O < P[SP_WEMIN] or We_F < P[SP_WEMIN]: + constraints_ok = 0 + if P[SP_EVAPUSE] != 0 and x_star >= P[SP_EVAPXLIM]: + constraints_ok = 0 + if constraints_ok: + break + Cd_O_eff *= Cd_red; Cd_F_eff *= Cd_red + Cd_O_eff = max(Cd_O_eff, P[DO_CDMIN]); Cd_F_eff = max(Cd_F_eff, P[DF_CDMIN]) + + # momentum ratio (bulk jet velocities) + A_jet_O = PI*(djo*0.5)**2; A_jet_F = PI*(djf*0.5)**2 + n_O = nO if nO >= 1 else 1; n_F = nF if nF >= 1 else 1 + den_O = rho_O*n_O*A_jet_O; den_F = rho_F*n_F*A_jet_F + v_O = mdot_O/den_O if den_O > 0 else np.nan + v_F = mdot_F/den_F if den_F > 0 else np.nan + mom_R = np.nan + if rho_O > 0 and rho_F > 0 and np.isfinite(v_O) and np.isfinite(v_F) and v_F != 0.0: + num = rho_O*v_O*v_O; den = rho_F*v_F*v_F + if den > 0 and num >= 0: + mom_R = np.sqrt(num/den) + if not (np.isfinite(mdot_O) and np.isfinite(mdot_F)) or mdot_F <= 0.0: + return (0, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, Pi_O, Pi_F, dpi_O, dpi_F, A_O, A_F) + return (1, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, Pi_O, Pi_F, dpi_O, dpi_F, A_O, A_F) + + +@njit(cache=True) +def _gasification(Tc, Pc, tau_res, SMD, L_eff, cp_g, rho_g, U_slip, T_star_cap): + rho_l = GAS_RHO_L; cp_l = GAS_CP_L; T_inj = GAS_T_INJ; mu_g = GAS_MU; Pr = PRANDTL + D = SMD if SMD > D_MIN_GAS else D_MIN_GAS + D_sq = D*D + dT_safe = max(200.0, 0.10*Tc) + T_star_upper = min(T_star_cap, Tc - dT_safe) + T_star_lower = T_inj + 50.0 + T_star = _clip(T_star_upper, T_star_lower, Tc - dT_safe) + k_g = mu_g*cp_g/Pr + D_m = D_M_REF*(Tc/D_M_TREF)**1.75*(D_M_PREF/(Pc if Pc > 1e3 else 1e3)) + Us = min(abs(U_slip), U_SLIP_CAP); Us = max(Us, 0.1) + Re = rho_g*Us*D/(mu_g if mu_g > 1e-10 else 1e-10) + Sc = mu_g/(rho_g*(D_m if D_m > 1e-12 else 1e-12)) + Nu = 2.0 + 0.6*np.sqrt(max(Re, 0.0))*Pr**(1.0/3.0) + Sh = 2.0 + 0.6*np.sqrt(max(Re, 0.0))*Sc**(1.0/3.0) + dT_initial = Tc - T_inj; dT_final = Tc - T_star + if dT_final <= 0 or dT_initial <= dT_final: + tau_heat = 1e-9 + else: + tau_heat = (rho_l*cp_l*D_sq)/(6.0*Nu*k_g)*np.log(dT_initial/dT_final) + energy_available = cp_g*(Tc - T_star) + energy_required = energy_available + L_eff + Phi = _clip(energy_available/max(energy_required, 1e-6), 1e-6, 1.0) + denom = 6.0*rho_g*D_m*Sh*Phi + tau_gasify = np.inf if denom <= 0 else (rho_l*D_sq)/denom + th = max(tau_heat, 1e-12); tg = max(tau_gasify, 1e-12) + tau_vap = 1.0/(1.0/th + 1.0/tg) + if tau_vap <= 0 or not np.isfinite(tau_vap): + return 1.0 + return 1.0 - np.exp(-tau_res/tau_vap) + +@njit(cache=True) +def _eta_advanced(P, Lstar, Pc, Tc, gamma, R, MR, Ac, At, Dinj, mdot_total, + u_F, u_O, D32_O, D32_F, mom_R, R_opt): + if R <= 0 or Tc <= 0 or Ac <= 0 or At <= 0 or Dinj <= 0 or Lstar <= 0 or mdot_total <= 0: + return -1.0 + rho_ch = Pc/(R*Tc) + U_bulk = mdot_total/(rho_ch*Ac) + G_throat = mdot_total/At + tau_res = (Lstar*rho_ch)/G_throat + U_rms = np.sqrt(0.5*(u_F*u_F + u_O*u_O)) + if not np.isfinite(U_rms) or U_rms < 0 or U_rms > CS_U_RMS_CAP: + return -1.0 + dU = abs(u_F - u_O) + U_mix = np.sqrt(dU*dU + CS_C_U*U_rms*U_rms) + if not np.isfinite(U_mix) or U_mix <= 0: + return -1.0 + cp_g = gamma*R/(gamma - 1.0) if gamma > 1.0 else GAS_CP_G + # eta_Lstar + model = int(P[C_MODEL]) + if model == EFF_CONSTANT: + eta_L = 1.0 - P[C_C] + elif model == EFF_LINEAR: + eta_L = _clip(1.0 - P[C_C]*(1.0 - Lstar/1.0), 0.0, 1.0) + else: + o_ok = D32_O > 0 and np.isfinite(D32_O); f_ok = D32_F > 0 and np.isfinite(D32_F) + if o_ok and f_ok: + if np.isfinite(MR) and MR > 0: + SMD = (MR/(1.0+MR))*D32_O + (1.0/(1.0+MR))*D32_F + else: + SMD = 0.5*(D32_O + D32_F) + elif o_ok: + SMD = D32_O + elif f_ok: + SMD = D32_F + else: + return -1.0 + if SMD <= 0: + return -1.0 + U_slip = max(U_bulk, U_mix) + eta_L = _gasification(Tc, Pc, tau_res, SMD, P[LAT_F], cp_g, rho_ch, U_slip, P[C_TSTARCAP]) + # eta_kinetics + Ea_norm = 12.0 if MR < 1.5 else (8.0 if MR > 3.0 else 10.0) + pf = (P[C_TAUREFP]/(Pc if Pc > 1e5 else 1e5))**P[C_NPRESS] + Tc_eff = Tc + if P[C_HASFLOOR] != 0 and np.isfinite(P[C_TAUFLOOR]) and P[C_TAUFLOOR] > 0: + Tc_eff = max(Tc_eff, P[C_TAUFLOOR]) + exp_arg = _clip(Ea_norm*(P[C_TAUREFT]/max(Tc_eff, 1000.0) - 1.0), -20.0, 20.0) + tau_chem = P[C_TAUREF]*pf*np.exp(exp_arg) + Da = np.inf if tau_chem <= 0 else tau_res/tau_chem + eta_k = 1.0 - np.exp(-np.sqrt(Da)) + # eta_mixing (Rupe) + if not (mom_R > 0.0 and np.isfinite(mom_R)): + return -1.0 + Ro = R_opt if (R_opt > 0 and np.isfinite(R_opt)) else 1.0 + sig = P[C_SIGMA] if (P[C_SIGMA] > 0 and np.isfinite(P[C_SIGMA])) else 1.5 + z = np.log(mom_R/Ro) + eta_m = P[C_EMPEAK]*np.exp(-(z*z)/(2.0*sig*sig)) + eta_total = eta_L*eta_k*eta_m + if not np.isfinite(eta_total): + return -1.0 + return eta_total + +@njit(cache=True) +def _residual(Pc, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F): + if not (np.isfinite(Pc) and Pc > 0): + return np.nan + ok, mO, mF, uO, uF, D32O, D32F, momR, CdO, CdF, PiO, PiF, dpiO, dpiF, AgO, AgF = injector_solve(P, P_O, P_F, Pc) + if ok == 0: + return np.nan + mdot_supply = mO + mF + MR = mO/mF + cs_id, cf_id, tc, gm, Rg, Mg, cfv = cea_eval(Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, MR, Pc, P[G_EPS]) + if not (cs_id > 0 and np.isfinite(cs_id)): + return np.nan + Lstar = P[G_LSTAR] if P[G_LSTAR] > 0 else (P[G_VOL]/P[G_AT] if P[G_AT] > 0 else 0.0) + Dinj = P[DJO] + Ac = PI*(P[G_DCHAM]*0.5)**2 + if P[C_ROPT] > 0.0: + R_opt = P[C_ROPT] + else: + sO = np.sin(P[ANG_O]*PI/180.0); sF = np.sin(P[ANG_F]*PI/180.0) + R_opt = np.sqrt(sF/sO) if (sO > 0 and sF > 0) else 1.0 + eta_total = _eta_advanced(P, Lstar, Pc, tc, gm, Rg, MR, Ac, P[G_AT], Dinj, mdot_supply, uF, uO, D32O, D32F, momR, R_opt) + if eta_total < 0: + return np.nan + eta_final = eta_total * 1.0 # cooling_eff == 1 (ablative disabled) + if not (np.isfinite(eta_final) and eta_final > 0.0 and eta_final <= 1.0): + return np.nan + cstar_actual = eta_final*cs_id + mdot_demand = Pc*P[G_AT]/cstar_actual + r = mdot_supply - mdot_demand + return r if np.isfinite(r) else np.nan + +@njit(cache=True) +def _sign(x): + return -1.0 if x < 0 else (1.0 if x > 0 else 0.0) + +@njit(cache=True) +def _brentq(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F, a, b, xtol, rtol, maxit): + fa = _residual(a, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F) + fb = _residual(b, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F) + if not np.isfinite(fa) or not np.isfinite(fb): + return np.nan + if fa == 0.0: + return a + if fb == 0.0: + return b + if _sign(fa) == _sign(fb): + return np.nan + c = a; fc = fa; d = b - a; e = d + for _ in range(maxit): + if _sign(fb) == _sign(fc): + c = a; fc = fa; d = b - a; e = d + if abs(fc) < abs(fb): + a = b; b = c; c = a; fa = fb; fb = fc; fc = fa + tol = 2.0*rtol*abs(b) + 0.5*xtol + m = 0.5*(c - b) + if fb == 0.0 or abs(m) <= tol: + return b + if abs(e) < tol or abs(fa) <= abs(fb): + d = m; e = m + else: + s = fb/fa + if a == c: + p = 2.0*m*s; q = 1.0 - s + else: + qa = fa/fc; r = fb/fc + p = s*(2.0*m*qa*(qa - r) - (b - a)*(r - 1.0)) + q = (qa - 1.0)*(r - 1.0)*(s - 1.0) + if p > 0.0: + q = -q + else: + p = -p + if 2.0*p < min(3.0*m*q - abs(tol*q), abs(e*q)): + e = d; d = p/q + else: + d = m; e = m + a = b; fa = fb + if abs(d) > tol: + b += d + else: + b += tol if m > 0.0 else -tol + fb = _residual(b, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F) + if not np.isfinite(fb): + return np.nan + return b + +@njit(cache=True) +def _solve_exit_mach(eps, g): + tol = 1e-10 + if eps > 10.0: + pref = ((g+1.0)/(g-1.0))**((g+1.0)/4.0); Mg = pref*eps**((g-1.0)/2.0) + elif eps > 1.5: + Mg = 1.0 + np.sqrt(2.0*(eps-1.0)/(g+1.0)) + else: + Mg = 1.0 + 0.5*(eps-1.0) + M = max(Mg, 1.0+1e-6) + for _ in range(50): + term = (2.0/(g+1.0))*(1.0+(g-1.0)/2.0*M*M) + A = (1.0/M)*term**((g+1.0)/(2.0*(g-1.0))) + err = A - eps + if abs(err) < tol: + return M + num = 2.0*(M*M-1.0); den = M*(2.0+(g-1.0)*M*M); dA = A*(num/den) + if abs(dA) < 1e-12: + M = M*(0.99 if err > 0 else 1.01) + else: + step = _clip(err/dA, -0.5*M, 0.5*M); M = M - step + if M <= 1.0: + M = 1.0 + 1e-6 + return M + +@njit(cache=True) +def evaluate_core(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F, Pa): + """Returns (ok, Pc, F, Isp, MR, cstar_actual, gamma, Tc, mdot_total, v_exit, Cf_actual).""" + Pc_min = 100000.0 + Pc_max = min(P_O, P_F)*(1.0 - 0.15) + Pc_min = max(Pc_min, P[SV_PCMIN]); Pc_max = min(Pc_max, P[SV_PCMAX]) + if Pc_max <= Pc_min: + return (0.0,)*21 + xtol = P[SV_TOL]; rtol = P[SV_TOL]*1e-3; maxit = int(P[SV_MAXIT]) if P[SV_MAXIT] > 0 else 100 + rmin = _residual(Pc_min, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F) + rmax = _residual(Pc_max, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F) + if not np.isfinite(rmin) or not np.isfinite(rmax): + return (0.0,)*21 + if _sign(rmin) == _sign(rmax): + if rmin > 0 and rmax > 0 and rmax < 0.1: + Pc = Pc_max + else: + return (0.0,)*21 + else: + Pc = _brentq(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F, Pc_min, Pc_max, xtol, rtol, maxit) + if not np.isfinite(Pc): + return (0.0,)*21 + # recompute converged state + ok, mO, mF, uO, uF, D32O, D32F, momR, CdO, CdF, PiO, PiF, dpiO, dpiF, AgO, AgF = injector_solve(P, P_O, P_F, Pc) + if ok == 0: + return (0.0,)*21 + mdot_total = mO + mF; MR = mO/mF + cs_id, cf_id, tc, gm, Rg, Mg, cfv = cea_eval(Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, MR, Pc, P[G_EPS]) + Lstar = P[G_LSTAR] if P[G_LSTAR] > 0 else (P[G_VOL]/P[G_AT] if P[G_AT] > 0 else 0.0) + Ac = PI*(P[G_DCHAM]*0.5)**2 + if P[C_ROPT] > 0.0: + R_opt = P[C_ROPT] + else: + sO = np.sin(P[ANG_O]*PI/180.0); sF = np.sin(P[ANG_F]*PI/180.0) + R_opt = np.sqrt(sF/sO) if (sO > 0 and sF > 0) else 1.0 + eta_total = _eta_advanced(P, Lstar, Pc, tc, gm, Rg, MR, Ac, P[G_AT], P[DJO], mdot_total, uF, uO, D32O, D32F, momR, R_opt) + cstar_actual = eta_total*cs_id + # nozzle CEA at converged point (Pa ignored in 3D lookup) + cs2, cf2, tc2, gm2, Rg2, Mg2, cfv2 = cea_eval(Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, MR, Pc, P[G_EPS]) + if not np.isfinite(cfv2): + return (0.0,)*21 + M_exit = _solve_exit_mach(P[G_EPS], gm2) + factor = 1.0 + (gm2-1.0)/2.0*M_exit*M_exit + T_exit = tc2/factor + P_exit = Pc*factor**(-gm2/(gm2-1.0)) + v_exit = M_exit*np.sqrt(gm2*Rg2*T_exit) + thr = 2.0/(gm2+1.0) + T_throat = tc2*thr + P_throat = Pc*thr**(gm2/(gm2-1.0)) + F = P[G_NOZZEFF]*cfv2*Pc*P[G_AT] - Pa*P[G_AE] + if not np.isfinite(F): + return (0.0,)*21 + Isp = F/(mdot_total*G0) + Cf_actual = F/(Pc*P[G_AT]) + return (1.0, Pc, F, Isp, MR, cstar_actual, gm, tc, mdot_total, v_exit, Cf_actual, + mO, mF, cs_id, eta_total, Rg2, P_exit, P_throat, T_exit, T_throat, cf2) + + +# ---- Python wrappers matching native_injector ------------------------------- +class NumbaEvaluator: + """Parity/bench helper: core physics only (Pc, F, Isp, ...).""" + def __init__(self, config, cache): + self.P = extract_params(config) + self.cea = cea_arrays(cache) + + def chamber_solve(self, P_O, P_F): + r = evaluate_core(self.P, *self.cea, float(P_O), float(P_F), 101325.0) + return float(r[1]) if r[0] else None + + def evaluate(self, P_O, P_F, Pa=101325.0): + r = evaluate_core(self.P, *self.cea, float(P_O), float(P_F), float(Pa)) + if not r[0]: + return None + (_, Pc, F, Isp, MR, csa, gm, tc, mdt, vex, cfa, + mO, mF, cs_id, eta, Rg, Pex, Pth, Tex, Tth, cf_id) = r + return {"Pc": Pc, "F": F, "Isp": Isp, "MR": MR, "cstar_actual": csa, + "gamma": gm, "Tc": tc, "mdot_total": mdt, "v_exit": vex, "Cf_actual": cfa} + + +def _cea_arrays_cached(cache): + """Memoise cea_arrays for a cache, storing the result ON the cache object. + + NOT keyed on id(cache): a freed cache's id can be reused, which would + silently serve a previous config's CEA tables in a multi-config process + (test sessions, GUI config switches). This is the same hazard + native_injector._ensure_cea documents and defends against with a token; tying + the memo to the object's own lifetime is simpler and cannot leak. + """ + arr = getattr(cache, "_numba_cea_arrays", None) + if arr is None: + arr = cea_arrays(cache) + try: + cache._numba_cea_arrays = arr + except Exception: + pass # cache rejects attributes -> rebuild per call (correct, slower) + return arr + +def make_native_signature_evaluate(): + """Return an evaluate(config, cache, P_O, P_F, P_ambient) that drops into + native_injector.evaluate's slot: Numba computes the chamber+nozzle+thrust core, + then the SAME C diagnostic injector solve + Python stability tail runs (identical + to native_injector.evaluate), isolating the C-vs-Numba difference to the core.""" + from engine.native.python import native_injector as ni + from engine.pipeline.stability.analysis import comprehensive_stability_analysis + + def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): + if not ni._can_handle_chamber(config): + return None + if not ni._ensure_cea(cache): + return None + st = ni.build_state(config) # once, reused for core + diag (as C path does) + P = _params_from_state(st) + arr = _cea_arrays_cached(cache) + r = evaluate_core(P, *arr, float(P_tank_O), float(P_tank_F), float(P_ambient)) + if not r[0]: + return None + (_, Pc, F, Isp, MR, csa, gm, tc, mdt, vex, cfa, + mO, mF, cs_id, eta, Rg, Pex, Pth, Tex, Tth, cf_id) = r + if F != F: + return None + # --- identical tail to native_injector.evaluate: C diag solve + Python stability --- + rci, ir = ni._nat().injector_solve(st, float(P_tank_O), float(P_tank_F), float(Pc)) + if rci != 0: + return None + diagnostics = ni._result_to_diag(config, ir) + diagnostics.update({ + "mdot_O": mO, "mdot_F": mF, "mdot_total": mdt, "Pc": Pc, "MR": MR, + "cstar_ideal": cs_id, "cstar_actual": csa, "eta_cstar": eta, + "gamma": gm, "R": Rg, "Tc": tc, "SMD": max(ir.D32_O, ir.D32_F), + }) + try: + stab = comprehensive_stability_analysis( + config=config, Pc=Pc, MR=MR, mdot_total=mdt, + cstar=csa, gamma=gm, R=Rg, Tc=tc, diagnostics=diagnostics) + except Exception: + return None + return { + "Pc": Pc, "mdot_O": mO, "mdot_F": mF, "mdot_total": mdt, "MR": MR, + "F": F, "Isp": Isp, "v_exit": vex, "P_exit": Pex, "P_throat": Pth, + "T_exit": Tex, "T_throat": Tth, "Tc": tc, "eps": float(P[G_EPS]), + "A_throat": float(P[G_AT]), "A_exit": float(P[G_AE]), + "cstar_actual": csa, "cstar_ideal": cs_id, "eta_cstar": eta, "gamma": gm, "R": Rg, + "Cf": cfa, "Cf_actual": cfa, "Cf_ideal": cf_id, + "Cd_O": ir.Cd_O, "Cd_F": ir.Cd_F, "A_geom_O": ir.A_geom_O, "A_geom_F": ir.A_geom_F, + "stability": stab, "stability_results": stab, + "diagnostics": diagnostics, "P_ambient": float(P_ambient), + "native_fast_eval": True, "numba_fast_eval": True, + } + return evaluate + + +def _params_from_state(st): + """Build the param vector from an already-built EdEngineState (avoids a 2nd build_state).""" + def g(path): + o = st + for part in path.split("."): + o = getattr(o, part) + return float(o) + P = np.zeros(NP) + P[_IDX["RHO_O"]] = g("fluid_O.density"); P[_IDX["MU_O"]] = g("fluid_O.viscosity"); P[_IDX["SIG_O"]] = g("fluid_O.surface_tension"); P[_IDX["T_O"]] = g("fluid_O.temperature") + P[_IDX["RHO_F"]] = g("fluid_F.density"); P[_IDX["MU_F"]] = g("fluid_F.viscosity"); P[_IDX["SIG_F"]] = g("fluid_F.surface_tension"); P[_IDX["T_F"]] = g("fluid_F.temperature"); P[_IDX["LAT_F"]] = g("fluid_F.latent_heat") + P[_IDX["DJO"]] = g("injector.imp_O.d_jet"); P[_IDX["DJF"]] = g("injector.imp_F.d_jet") + P[_IDX["NO"]] = g("injector.imp_O.n_elements"); P[_IDX["NF"]] = g("injector.imp_F.n_elements") + P[_IDX["ANG_O"]] = g("injector.imp_O.impingement_angle"); P[_IDX["ANG_F"]] = g("injector.imp_F.impingement_angle") + for pre, side in (("DO", "discharge_O"), ("DF", "discharge_F")): + for suf, fld in (("CDINF","Cd_inf"),("ARE","a_Re"),("CDMIN","Cd_min"),("GEOM","use_geometry_cd"), + ("DREF","d_ref_m"),("DMIN","d_min_m"),("EXPS","cd_small_hole_exponent"),("LOGG","cd_large_hole_log_gain"), + ("CDMAX","cd_inf_max"),("CDFLOOR","cd_inf_min_geom"),("UPC","use_pressure_correction"),("PREF","P_ref"), + ("AP","a_P"),("UTC","use_temperature_correction"),("TREF","T_ref"),("AT","a_T")): + P[_IDX[f"{pre}_{suf}"]] = g(f"{side}.{fld}") + for pre, side in (("FO", "feed_O"), ("FF", "feed_F")): + for suf, fld in (("DIN","d_inlet"),("AH","A_hydraulic"),("K0","K0"),("K1","K1"),("PHI","phi_type")): + P[_IDX[f"{pre}_{suf}"]] = g(f"{side}.{fld}") + for suf, fld in (("SMDMODEL","smd_model"),("SMDC","smd_C"),("SMDM","smd_m"),("SMDP","smd_p"),("SMDCING","smd_C_ingebo"), + ("SMDWECORR","smd_we_corr_max"),("GASR","chamber_gas_R"),("GAST","chamber_gas_T"),("ANGMODEL","spray_angle_model"), + ("ANGK","spray_angle_k"),("ANGN","spray_angle_n"),("WEMIN","we_min"),("EVAPK","evap_K"), + ("EVAPXLIM","evap_x_star_limit"),("EVAPUSE","evap_use_constraint")): + P[_IDX[f"SP_{suf}"]] = g(f"spray.{fld}") + for suf, fld in (("CLMAX","closure_max_iterations"),("CLCDRED","closure_Cd_reduction_factor"),("PCMIN","Pc_min_bound"), + ("PCMAX","Pc_max_bound"),("TOL","tolerance"),("MAXIT","max_iterations")): + P[_IDX[f"SV_{suf}"]] = g(f"solver.{fld}") + for suf, fld in (("EPS","expansion_ratio"),("AT","A_throat"),("AE","A_exit"),("VOL","volume"),("LSTAR","Lstar"), + ("DCHAM","chamber_diameter"),("NOZZEFF","nozzle_efficiency")): + P[_IDX[f"G_{suf}"]] = g(f"geom.{fld}") + for suf, fld in (("MODEL","model"),("C","C"),("TAUREF","tau_ref"),("TAUREFP","tau_ref_P"),("TAUREFT","tau_ref_T"), + ("NPRESS","n_pressure"),("TSTARCAP","T_star_fuel_cap_K"),("HASFLOOR","has_tau_Tc_floor"), + ("TAUFLOOR","tau_Tc_floor"),("EMPEAK","Em_peak"),("SIGMA","mixing_sigma"),("ROPT","R_opt")): + P[_IDX[f"C_{suf}"]] = g(f"comb.{fld}") + return P + From c3089629eb9a561964e2ec13406cdfc1b37fa38a Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 01:19:26 -0700 Subject: [PATCH 02/20] Free numba kernels from the C EdEngineState with pure-Python param extraction --- EngineDesign/engine/accel/__init__.py | 12 + EngineDesign/engine/accel/params.py | 244 ++++++++++++++++++ EngineDesign/scripts/numba_eval.py | 6 +- .../test_accel_params_match_native_state.py | 111 ++++++++ 4 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 EngineDesign/engine/accel/__init__.py create mode 100644 EngineDesign/engine/accel/params.py create mode 100644 EngineDesign/tests/test_accel_params_match_native_state.py diff --git a/EngineDesign/engine/accel/__init__.py b/EngineDesign/engine/accel/__init__.py new file mode 100644 index 000000000..ba1e6c0b1 --- /dev/null +++ b/EngineDesign/engine/accel/__init__.py @@ -0,0 +1,12 @@ +"""Numba-backed physics accelerator for the Layer-1 optimizer inner loop. + +Replaces the hand-written C port at engine/native. During the migration both +backends exist and must be *simultaneously* callable -- the parity suite, the +benchmark and CI all compare them -- so selection lives here rather than being +baked into either implementation. + +Today this package holds only the pure-Python parameter extraction (params.py), +which is what frees the Numba kernels from depending on the C EdEngineState. +The dispatcher and the evaluate/solve/chamber_solve surface land with the +call-site switch. +""" diff --git a/EngineDesign/engine/accel/params.py b/EngineDesign/engine/accel/params.py new file mode 100644 index 000000000..36b9ea372 --- /dev/null +++ b/EngineDesign/engine/accel/params.py @@ -0,0 +1,244 @@ +"""Config -> flat scalar state, in pure Python (no C dependency). + +WHY THIS EXISTS + The Numba kernels used to read their inputs out of the C `EdEngineState` + struct (numba_eval.extract_params called native_injector.build_state), so + Numba could not run without the C library loaded. That made the C port + impossible to delete. This module reproduces that reconciliation with no + ctypes involved. + +WHY IT IS A TRANSCRIPTION, NOT A REWRITE + `native_injector.build_state` encodes decisions that are NOT recoverable by + reading the config schema -- see the inline notes on smd_model and + we_corr_max below. Re-deriving "what the config means" from the YAML would + silently produce different numbers on some configs. So each field here is a + line-for-line transcription of the corresponding `_fill_*` helper, and + tests/test_accel_params_match_native_state.py asserts exact equality against + the real EdEngineState for every committed config while the C port still + exists to compare against. + +The returned object mirrors EdEngineState's attribute paths exactly +(`st.fluid_O.density`, `st.injector.imp_O.d_jet`, ...) so the existing +param-vector mapping reads from either interchangeably. +""" +from __future__ import annotations + +from types import SimpleNamespace + +# Enum mappings -- mirror native_injector._PHI / _INJ / _EFF_MODEL. +_PHI = {"none": 0, "sqrtP": 1, "logP": 2} +_INJ = {"pintle": 0, "impinging": 1, "coaxial": 2} +_EFF_MODEL = {"constant": 0, "linear": 1, "exponential": 2} + + +def _ns(**kw): + return SimpleNamespace(**kw) + + +def _discharge(c): + """Mirrors native_injector._fill_discharge.""" + return _ns( + Cd_inf=float(c.Cd_inf), a_Re=float(c.a_Re), Cd_min=float(c.Cd_min), + use_geometry_cd=int(bool(c.use_geometry_cd)), + d_ref_m=float(c.d_ref_m), d_min_m=float(c.d_min_m), + cd_small_hole_exponent=float(c.cd_small_hole_exponent), + cd_large_hole_log_gain=float(c.cd_large_hole_log_gain), + cd_inf_max=float(c.cd_inf_max), cd_inf_min_geom=float(c.cd_inf_min_geom), + use_pressure_correction=int(bool(c.use_pressure_correction)), + P_ref=float(c.P_ref), a_P=float(c.a_P), + use_temperature_correction=int(bool(c.use_temperature_correction)), + T_ref=float(c.T_ref), a_T=float(c.a_T), + ) + + +def _feed(c): + """Mirrors native_injector._fill_feed.""" + return _ns( + d_inlet=float(getattr(c, "d_inlet", 0.0) or 0.0), + A_hydraulic=float(getattr(c, "A_hydraulic", 0.0) or 0.0), + K0=float(c.K0), K1=float(c.K1), phi_type=_PHI[c.phi_type], + ) + + +def _fluid(f): + """Mirrors native_injector._fill_fluid. + + Note the `or` defaults: a missing OR zero temperature becomes 0.0, and a + missing OR zero latent_heat becomes 300e3. Falsy checks, not `is None`. + """ + return _ns( + density=float(f.density), viscosity=float(f.viscosity), + surface_tension=float(f.surface_tension), + temperature=float(getattr(f, "temperature", 0.0) or 0.0), + latent_heat=float(getattr(f, "latent_heat", 300e3) or 300e3), + ) + + +def _comb(eff): + """Mirrors native_injector._fill_comb.""" + floor = getattr(eff, "tau_Tc_floor_K", None) + ropt = getattr(eff, "R_opt", None) + return _ns( + model=_EFF_MODEL.get(eff.model, 2), + C=float(eff.C), K=float(eff.K), + cooling_efficiency_floor=float(eff.cooling_efficiency_floor), + use_cooling_coupling=int(bool(eff.use_cooling_coupling)), + tau_ref=float(eff.tau_ref), tau_ref_P=float(eff.tau_ref_P), + tau_ref_T=float(eff.tau_ref_T), n_pressure=float(eff.n_pressure), + T_star_fuel_cap_K=float(getattr(eff, "T_star_fuel_cap_K", 1000.0)), + has_tau_Tc_floor=int(floor is not None), + tau_Tc_floor=float(floor or 0.0), + # Rupe momentum-ratio mixing; R_opt<=0 => derive from impingement angles. + Em_peak=float(getattr(eff, "Em_peak", 0.96)), + mixing_sigma=float(getattr(eff, "mixing_sigma", 1.5)), + R_opt=float(ropt) if ropt is not None else 0.0, + ) + + +def _cooling(cfg): + """Mirrors native_injector._fill_cooling. + + The C struct is zero-initialised, so fields guarded by `if rg is not None` / + `if ab is not None` stay 0.0 when that config block is absent. The defaults + below reproduce that -- do not "improve" them into schema defaults. + """ + rg, ab = cfg.regen_cooling, cfg.ablative_cooling + fc = getattr(cfg, "film_cooling", None) + eff = cfg.combustion.efficiency + c = _ns( + regen_enabled=int(bool(rg and rg.enabled)), + film_enabled=int(bool(fc and fc.enabled)), + ablative_enabled=int(bool(ab and ab.enabled)), + graphite_enabled=int(bool(getattr(cfg, "graphite_insert", None) + and cfg.graphite_insert.enabled)), + use_cooling_coupling=int(bool(eff.use_cooling_coupling)), + cooling_efficiency_floor=float(eff.cooling_efficiency_floor), + # zero-init defaults for the two optional blocks + hot_gas_viscosity=0.0, hot_gas_thermal_conductivity=0.0, + hot_gas_prandtl=0.0, gas_turbulence_intensity=0.0, recovery_factor=0.0, + radiation_emissivity_hot=0.0, radiation_view_factor=0.0, + regen_chamber_inner_diameter=0.0, + ablative_coverage_fraction=0.0, ablative_surface_temperature_limit=0.0, + ablative_material_density=0.0, ablative_heat_of_ablation=0.0, + ablative_specific_heat=0.0, ablative_pyrolysis_temperature=0.0, + ablative_use_physics_based_blowing=0, ablative_blowing_efficiency=0.0, + ablative_blowing_coefficient=0.0, ablative_blowing_min_reduction_factor=0.0, + ablative_turbulence_reference_intensity=0.0, ablative_turbulence_sensitivity=0.0, + ablative_turbulence_exponent=0.0, ablative_turbulence_max_multiplier=0.0, + ablative_surface_emissivity=0.0, ablative_ambient_temperature=0.0, + ablative_radiative_sink_minimum_threshold=0.0, + ablative_radiative_sink_fallback_temperature=0.0, + ) + if rg is not None: + c.hot_gas_viscosity = float(rg.hot_gas_viscosity) + c.hot_gas_thermal_conductivity = float(rg.hot_gas_thermal_conductivity) + c.hot_gas_prandtl = float(rg.hot_gas_prandtl) + c.gas_turbulence_intensity = float(rg.gas_turbulence_intensity) + c.recovery_factor = float(rg.recovery_factor) if rg.recovery_factor is not None else 0.94 + c.radiation_emissivity_hot = float(rg.radiation_emissivity_hot) + c.radiation_view_factor = float(rg.radiation_view_factor) + c.regen_chamber_inner_diameter = float(rg.chamber_inner_diameter or 0.0) + if ab is not None: + c.ablative_coverage_fraction = float(ab.coverage_fraction) + c.ablative_surface_temperature_limit = float(ab.surface_temperature_limit) + c.ablative_material_density = float(ab.material_density) + c.ablative_heat_of_ablation = float(ab.heat_of_ablation) + c.ablative_specific_heat = float(ab.specific_heat) + c.ablative_pyrolysis_temperature = float(ab.pyrolysis_temperature) + c.ablative_use_physics_based_blowing = int(bool(ab.use_physics_based_blowing)) + c.ablative_blowing_efficiency = float(ab.blowing_efficiency) + c.ablative_blowing_coefficient = float(ab.blowing_coefficient) + c.ablative_blowing_min_reduction_factor = float(ab.blowing_min_reduction_factor) + c.ablative_turbulence_reference_intensity = float(ab.turbulence_reference_intensity) + c.ablative_turbulence_sensitivity = float(ab.turbulence_sensitivity) + c.ablative_turbulence_exponent = float(ab.turbulence_exponent) + c.ablative_turbulence_max_multiplier = float(ab.turbulence_max_multiplier) + c.ablative_surface_emissivity = float(ab.surface_emissivity) + c.ablative_ambient_temperature = float(ab.ambient_temperature) + c.ablative_radiative_sink_minimum_threshold = float(ab.radiative_sink_minimum_threshold) + c.ablative_radiative_sink_fallback_temperature = float(ab.radiative_sink_fallback_temperature) + return c + + +def _geom(cg): + """Mirrors native_injector._fill_geom. `cg` must come from ensure_chamber_geometry.""" + return _ns( + A_throat=float(cg.A_throat), A_exit=float(cg.A_exit), volume=float(cg.volume), + Lstar=float(cg.Lstar) if cg.Lstar else 0.0, + length=float(cg.length), + length_cylindrical=float(cg.length_cylindrical or 0.0), + length_contraction=float(cg.length_contraction or 0.0), + chamber_diameter=float(cg.chamber_diameter or 0.0), + exit_diameter=float(cg.exit_diameter or 0.0), + expansion_ratio=float(cg.expansion_ratio), + nozzle_efficiency=float(cg.nozzle_efficiency), + Cf=float(cg.Cf or 0.0), + design_pressure=float(cg.design_pressure or 0.0), + ) + + +def _imp(b): + """Mirrors native_injector._fill_imp.""" + return _ns( + n_elements=int(b.n_elements), d_jet=float(b.d_jet), + impingement_angle=float(b.impingement_angle), + spacing=float(getattr(b, "spacing", 0.0) or 0.0), + ) + + +def build_state(config): + """Pure-Python equivalent of native_injector.build_state. + + Returns a namespace with EdEngineState's attribute paths. Raises the same way + build_state does on a config it cannot map (e.g. a pintle config, whose + injector geometry has no .oxidizer/.fuel impinging branches). + """ + from engine.pipeline.config_schemas import ensure_chamber_geometry + + g = config.injector.geometry + sp = config.spray + + spray = _ns( + # Impinging atomization is ALWAYS Ingebo (see impinging.py); a stale + # `lefebvre` in YAML is deliberately ignored. Reading spray.smd.model + # here instead would change results on configs that carry the stale key. + smd_model=1, + smd_C=float(sp.smd.C), smd_m=float(sp.smd.m), smd_p=float(sp.smd.p), + smd_C_ingebo=float(sp.smd.C_ingebo), + # Falsy check, not `is None`: we_corr_max of 0.0 and null both -> 0.0. + smd_we_corr_max=float(getattr(sp.smd, "we_corr_max", None)) + if getattr(sp.smd, "we_corr_max", None) else 0.0, + chamber_gas_R=float(sp.smd.chamber_gas_R), + chamber_gas_T=float(sp.smd.chamber_gas_T), + spray_angle_model=0 if sp.spray_angle.model == "J" else 1, + spray_angle_k=float(sp.spray_angle.k), spray_angle_n=float(sp.spray_angle.n), + we_min=float(sp.weber.get("We_min", 15.0)), + evap_K=float(sp.evaporation.K), + evap_x_star_limit=float(sp.evaporation.x_star_limit), + evap_use_constraint=int(bool(sp.evaporation.use_constraint)), + ) + + solver = _ns( + closure_max_iterations=int(config.solver.closure.max_iterations), + closure_Cd_reduction_factor=float(config.solver.closure.Cd_reduction_factor), + Pc_min_bound=float(config.solver.Pc_bounds[0]), + Pc_max_bound=float(config.solver.Pc_bounds[1]), + tolerance=float(config.solver.tolerance), + max_iterations=int(config.solver.max_iterations), + ) + + return _ns( + injector=_ns(type=_INJ[config.injector.type], + imp_O=_imp(g.oxidizer), imp_F=_imp(g.fuel)), + discharge_O=_discharge(config.discharge["oxidizer"]), + discharge_F=_discharge(config.discharge["fuel"]), + feed_O=_feed(config.feed_system["oxidizer"]), + feed_F=_feed(config.feed_system["fuel"]), + fluid_O=_fluid(config.fluids["oxidizer"]), + fluid_F=_fluid(config.fluids["fuel"]), + spray=spray, + solver=solver, + comb=_comb(config.combustion.efficiency), + cooling=_cooling(config), + geom=_geom(ensure_chamber_geometry(config)), + ) diff --git a/EngineDesign/scripts/numba_eval.py b/EngineDesign/scripts/numba_eval.py index be2f3aecd..0f0a0fecf 100644 --- a/EngineDesign/scripts/numba_eval.py +++ b/EngineDesign/scripts/numba_eval.py @@ -67,14 +67,14 @@ def _assert_supported(st): def extract_params(config): - """Flatten the native EdEngineState scalars this port needs into a float64 vec. + """Flatten the config scalars this port needs into a float64 vec. Delegates the field mapping to _params_from_state so there is ONE table of field names; the two used to carry independent copies of the whole mapping, which would drift the moment a field was added on one side only. """ - from engine.native.python import native_injector as ni - st = ni.build_state(config) + from engine.accel.params import build_state + st = build_state(config) # pure Python -- no C library required _assert_supported(st) return _params_from_state(st) diff --git a/EngineDesign/tests/test_accel_params_match_native_state.py b/EngineDesign/tests/test_accel_params_match_native_state.py new file mode 100644 index 000000000..fbc4ca7cf --- /dev/null +++ b/EngineDesign/tests/test_accel_params_match_native_state.py @@ -0,0 +1,111 @@ +"""Exact-equality check: engine.accel.params.build_state == native_injector.build_state. + +TEMPORARY BY DESIGN. This test exists only while the C port does, and is deleted +with it. Its whole job is to prove that the pure-Python config->scalar +reconciliation in engine/accel/params.py is a faithful transcription of +native_injector.build_state, so the Numba kernels can stop reading the C +EdEngineState and the C tree can be removed. + +Tolerance is EXACT (==), deliberately. Both sides are float() of the same Python +config objects -- there is no arithmetic between them, so any difference at all +is a mapping bug (a wrong field name, a missing `or` default, a schema default +substituted for a zero-init), not a rounding artifact. A tolerance here would +hide exactly the class of bug the test is for. +""" +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def _configs(): + """Every committed config, so a mapping bug cannot hide in an unexercised one.""" + out = [] + for d in (ROOT / "configs", ROOT / "configs" / "canonical"): + if d.is_dir(): + out.extend(sorted(p for p in d.glob("*.yaml"))) + return out + + +CONFIGS = _configs() + + +@pytest.fixture(scope="module") +def native(): + """The C side is the reference. Skip (don't fail) when it can't be built.""" + if os.environ.get("ED_USE_NATIVE") == "0": + pytest.skip("ED_USE_NATIVE=0") + try: + from engine.native.python import autobuild, ed_native, native_injector + ed_native.load(autobuild.ensure_lib()) + except Exception as e: # pragma: no cover - toolchain-dependent + if os.environ.get("ED_REQUIRE_NATIVE") == "1": + pytest.fail(f"ED_REQUIRE_NATIVE=1 but native unavailable: {e}") + pytest.skip(f"native unavailable: {e}") + if not native_injector.native_enabled(): + pytest.skip("native_injector.native_enabled() is False") + return native_injector + + +def _walk(py, prefix=""): + """Yield (dotted_path, value) for every scalar leaf of the Python state tree.""" + for name, val in vars(py).items(): + path = f"{prefix}.{name}" if prefix else name + if isinstance(val, SimpleNamespace): + yield from _walk(val, path) + else: + yield path, val + + +def _get(obj, path): + for part in path.split("."): + obj = getattr(obj, part) + return obj + + +@pytest.mark.parametrize("cfg_path", CONFIGS, ids=lambda p: p.parent.name + "/" + p.name) +def test_python_state_matches_native_state(native, cfg_path): + from engine.accel import params as accel_params + from engine.pipeline.io import load_config + + try: + cfg = load_config(str(cfg_path)) + except Exception as e: + # Some committed YAMLs are overlays/fragments, not standalone configs + # (e.g. a bare `cea: {}` that fails schema validation). Nothing to + # compare -- they never reach build_state in production either. + pytest.skip(f"config does not load standalone: {type(e).__name__}") + + # build_state only maps impinging configs; for anything else BOTH sides must + # fail, and failing the same way is itself the contract worth asserting. + try: + want = native.build_state(cfg) + except Exception as e_native: + with pytest.raises(type(e_native)): + accel_params.build_state(cfg) + pytest.skip(f"config not mappable by build_state ({type(e_native).__name__})") + + got = accel_params.build_state(cfg) + + mismatches = [] + checked = 0 + for path, py_val in _walk(got): + try: + c_val = _get(want, path) + except AttributeError: + mismatches.append(f"{path}: absent on EdEngineState") + continue + checked += 1 + if float(py_val) != float(c_val): + mismatches.append(f"{path}: python={py_val!r} native={c_val!r}") + + assert checked > 50, f"only {checked} fields compared -- walker missed the tree" + assert not mismatches, ( + f"{len(mismatches)} field(s) diverge from EdEngineState for {cfg_path.name}:\n " + + "\n ".join(mismatches) + ) From 235cd2baa8bcebbd5882ac6e20bcec3010b35038 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 01:25:54 -0700 Subject: [PATCH 03/20] Port ablative cooling to numba, closing the last coverage gap vs the C kernel --- EngineDesign/scripts/numba_eval.py | 254 +++++++++++++++++++++++++++-- 1 file changed, 236 insertions(+), 18 deletions(-) diff --git a/EngineDesign/scripts/numba_eval.py b/EngineDesign/scripts/numba_eval.py index 0f0a0fecf..bf5662c6d 100644 --- a/EngineDesign/scripts/numba_eval.py +++ b/EngineDesign/scripts/numba_eval.py @@ -41,6 +41,16 @@ # combustion "C_MODEL", "C_C", "C_TAUREF", "C_TAUREFP", "C_TAUREFT", "C_NPRESS", "C_TSTARCAP", "C_HASFLOOR", "C_TAUFLOOR", "C_EMPEAK", "C_SIGMA", "C_ROPT", + # chamber lengths (ablative wetted area only) + "G_LEN", "G_LCYL", "G_LCONTR", + # cooling gates + hot-gas block (ed_cooling.c) + "K_ABLEN", "K_FILMEN", "K_REGENEN", "K_USECOUP", "K_EFFFLOOR", + "K_HGMU", "K_HGK", "K_HGPR", "K_TI", "K_RECOV", "K_EMISHOT", "K_VIEWF", "K_DREGEN", + # ablative material / blowing / turbulence / radiative sink + "AB_COV", "AB_TSURF", "AB_TPYRO", "AB_HABL", "AB_CP", "AB_USEPHYS", + "AB_BLOWEFF", "AB_BLOWC", "AB_BLOWMIN", + "AB_TIREF", "AB_TISENS", "AB_TIEXP", "AB_TIMAX", + "AB_EMIS", "AB_TAMB", "AB_SINKMIN", "AB_SINKFB", ] _IDX = {n: i for i, n in enumerate(_NAMES)} globals().update(_IDX) # module-level int constants for njit @@ -50,6 +60,10 @@ PI = np.pi G0 = 9.80665 P_SEA = 101325.0 +# ed_cooling.c / ed_phys_const.h +RANKINE_PER_K = 1.8; HUZEL_COEFF = 46.6e-10; LB_S_PER_IN2_TO_PA_S = 6894.76 +STEFAN = 5.670374419e-8; MIN_DENS = 0.01; EPS_SMALL = 1e-6; EPS_TINY = 1e-8 +NU_LAMINAR = 4.36; NU_TURB_COEF = 0.023; NU_TURB_RE_EXP = 0.8; NU_TURB_PR_EXP = 0.4 PRANDTL = 0.8 D_M_REF = 5e-5; D_M_TREF = 1500.0; D_M_PREF = 2.5e6; U_SLIP_CAP = 50.0; D_MIN_GAS = 1e-6 CS_C_L = 0.1; CS_C_U = 0.5; CS_U_RMS_CAP = 200.0 @@ -61,9 +75,14 @@ def _assert_supported(st): """Guard the assumptions that let this port skip cooling / use the impinging path.""" assert int(st.injector.type) == 1, "not impinging" - assert int(getattr(st.cooling, "ablative_enabled")) == 0, "ablative on: cooling port needed" + # Ablative IS ported (see _cooling_evaluate). Film/regen are not -- C refuses + # them too (ed_cooling.c:147), so they stay a Python fallback. assert int(getattr(st.cooling, "film_enabled")) == 0 and int(getattr(st.cooling, "regen_enabled")) == 0 - assert int(getattr(st.cooling, "graphite_enabled")) == 0 + # No graphite gate, deliberately: C does not check it either (ed_cooling.c + # refuses only film/regen at :147), because graphite never enters the chamber + # residual -- it lives in the burn/recession path (runner.py), and + # chamber_solver.py references it zero times. Gating on it here would reject + # configs/canonical/impinging.yaml, which C handles fine. def extract_params(config): @@ -448,6 +467,164 @@ def _eta_advanced(P, Lstar, Pc, Tc, gamma, R, MR, Ac, At, Dinj, mdot_total, return -1.0 return eta_total +@njit(cache=True) +def _gas_viscosity_huzel(T_K, M): + """ed_gas_viscosity_huzel: Huzel-Huang gas viscosity correlation.""" + mu_lb_s_in2 = HUZEL_COEFF*np.sqrt(M)*(T_K*RANKINE_PER_K)**0.6 + return mu_lb_s_in2*LB_S_PER_IN2_TO_PA_S + + +@njit(cache=True) +def _chamber_wetted_area(P): + """ed_chamber_wetted_area: frustum when both sub-lengths present, else cylinder.""" + d = P[G_DCHAM] + if d <= 0: + d = 0.08 # matches Python's final fallback + if d < 1e-6: + d = 1e-6 + area_cross = PI*(d*0.5)*(d*0.5) + circumference = PI*d + if P[G_LCYL] > 0 and P[G_LCONTR] > 0: + area_cyl = circumference*P[G_LCYL] + r1 = d*0.5 + A_t = P[G_AT] if P[G_AT] > 0 else area_cross/3.0 + r2 = np.sqrt(A_t/PI) + slant = np.sqrt((r1-r2)*(r1-r2) + P[G_LCONTR]*P[G_LCONTR]) + return area_cyl + PI*(r1+r2)*slant + return circumference*P[G_LEN] + + +@njit(cache=True) +def _hot_wall_flux(P, Pc, Tc, gamma, R, M, mdot_total, wall_T): + """estimate_hot_wall_heat_flux -> (q_total, q_conv, q_rad). + + NOTE the diameter: this uses the REGEN block's chamber_inner_diameter, while + _cooling_evaluate's bulk-velocity term uses geom.chamber_diameter. They are + different numbers and ed_cooling.c:43 vs :151 keeps them distinct -- do not + collapse them. + """ + d = P[K_DREGEN] + A_cross = PI*d*d/4.0 + rho_g = max(Pc/(R*max(Tc, 1.0)), MIN_DENS) + V_g = mdot_total/(rho_g*A_cross) + mu_g = _gas_viscosity_huzel(Tc, M) if (M > 0 and Tc > 0) else P[K_HGMU] + k_g = P[K_HGK] + cp_g = gamma*R/max(gamma-1.0, EPS_SMALL) + Pr_g = P[K_HGPR] if P[K_HGPR] > 0 else (mu_g*cp_g/max(k_g, EPS_SMALL)) + Re_g = rho_g*V_g*d/max(mu_g, EPS_TINY) + if Re_g < 2000.0: + Nu_g = NU_LAMINAR + else: + Nu_g = NU_TURB_COEF*Re_g**NU_TURB_RE_EXP*Pr_g**NU_TURB_PR_EXP + h_g = Nu_g*k_g/d + Taw = Tc*P[K_RECOV] + dT = max(Taw - wall_T, 0.0) + q_conv = h_g*dT + qr = P[K_EMISHOT]*P[K_VIEWF]*STEFAN*(Tc**4 - wall_T**4) + q_rad = max(qr, 0.0) + return q_conv + q_rad, q_conv, q_rad + + +@njit(cache=True) +def _ablative_heat_removed(P, surface_T, area, ti, q_conv, q_rad, gas_mdot): + """compute_ablative_response -> heat_removed (cooling_power).""" + if P[K_ABLEN] == 0 or area <= 0: + return 0.0 + turb = 1.0 + if ti > 0 and P[AB_TIREF] > 0: + ratio = (ti/P[AB_TIREF])**P[AB_TIEXP] + turb = 1.0 + P[AB_TISENS]*ratio + turb = _clip(turb, 1.0, P[AB_TIMAX]) + + below_pyro = surface_T < P[AB_TPYRO] + use_physics = False + convective_reduction = 1.0 + if below_pyro: + convective_reduction = 1.0 + elif P[AB_USEPHYS] != 0 and gas_mdot > 0: + use_physics = True + else: + convective_reduction = 1.0 - _clip(P[AB_BLOWEFF], 0.0, 1.0) + + T_sink = P[AB_SINKFB] if P[AB_TAMB] < P[AB_SINKMIN] else P[AB_TAMB] + radiative_relief = max(P[AB_EMIS]*STEFAN*(surface_T**4 - T_sink**4), 0.0) + + if use_physics: + q_conv_prov = q_conv*turb + q_total_prov = max(q_conv_prov + q_rad - radiative_relief, 0.0) + if q_total_prov > 0: + dT_pyro = max(surface_T - P[AB_TPYRO], 0.0) + energy_per_mass = P[AB_HABL] + P[AB_CP]*dT_pyro + if energy_per_mass > 0: + mass_flux_prov = q_total_prov/max(energy_per_mass, EPS_SMALL) + m_dot_pyro = mass_flux_prov*area + B = m_dot_pyro/max(gas_mdot, EPS_SMALL) + blow = 1.0/(1.0 + P[AB_BLOWC]*B) + convective_reduction = max(blow, P[AB_BLOWMIN]) + else: + convective_reduction = 1.0 + else: + convective_reduction = 1.0 + + q_conv_eff = q_conv*turb*convective_reduction + effective_heat_flux = max(q_conv_eff + q_rad - radiative_relief, 0.0) + if below_pyro or effective_heat_flux <= 0: + return 0.0 + dT_pyro = max(surface_T - P[AB_TPYRO], 0.0) + if P[AB_HABL] + P[AB_CP]*dT_pyro <= 0: + return 0.0 + return effective_heat_flux*area + + +@njit(cache=True) +def _cooling_evaluate(P, Pc, mdot_total, Tc, gamma, R, M): + """ed_cooling_evaluate -> (ok, cooling_eff, effective_Tc). + + ok==0 mirrors C's ED_ERR_NOT_IMPLEMENTED (film/regen enabled), which the + caller turns into a Python fallback rather than a wrong number. + """ + if mdot_total <= 0: + return 1.0, 1.0, Tc + if P[K_FILMEN] != 0 or P[K_REGENEN] != 0: + return 0.0, 1.0, Tc # not ported -> fall back to Python + if P[K_USECOUP] == 0 or P[K_ABLEN] == 0: + return 1.0, 1.0, Tc + + d = P[G_DCHAM] + if d <= 0: + d = 0.08 + if d < 1e-6: + d = 1e-6 + area_cross = PI*(d*0.5)*(d*0.5) + rho_g = max(Pc/(R*max(Tc, 1.0)), 1e-6) + velocity_g = mdot_total/(rho_g*area_cross) + mu_g = _gas_viscosity_huzel(Tc, M) if (M > 0 and Tc > 0) else P[K_HGMU] + Re_g = rho_g*velocity_g*d/max(mu_g, 1e-8) + + ti = 0.05 + if Re_g > 0: + ti = _clip(0.16*Re_g**(-0.125), 0.02, 0.25) + ti = max(ti, _clip(P[K_TI], 0.0, 0.5)) + + # hot-wall flux uses Tc_ideal (film disabled => effective_Tc == Tc here) + q_total, q_conv, q_rad = _hot_wall_flux(P, Pc, Tc, gamma, R, M, mdot_total, P[AB_TSURF]) + abl_area = _chamber_wetted_area(P)*_clip(P[AB_COV], 0.0, 1.0) + heat_removed = _ablative_heat_removed(P, P[AB_TSURF], abl_area, ti, q_conv, q_rad, mdot_total) + + effective_Tc = Tc + cp = gamma*R/max(gamma-1.0, EPS_SMALL) + if heat_removed > 0 and mdot_total > 0: + delta_T = heat_removed/max(mdot_total*cp, EPS_SMALL) + effective_Tc = max(effective_Tc - delta_T, 1.0) + + cooling_eff = 1.0 + if heat_removed > 0: + available = mdot_total*cp*max(effective_Tc, 1.0) + if available > 0: + cooling_eff = _clip(1.0 - heat_removed/available, P[K_EFFFLOOR], 1.0) + return 1.0, cooling_eff, effective_Tc + + @njit(cache=True) def _residual(Pc, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F): if not (np.isfinite(Pc) and Pc > 0): @@ -471,7 +648,10 @@ def _residual(Pc, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F eta_total = _eta_advanced(P, Lstar, Pc, tc, gm, Rg, MR, Ac, P[G_AT], Dinj, mdot_supply, uF, uO, D32O, D32F, momR, R_opt) if eta_total < 0: return np.nan - eta_final = eta_total * 1.0 # cooling_eff == 1 (ablative disabled) + cok, cooling_eff, _tc_eff = _cooling_evaluate(P, Pc, mdot_supply, tc, gm, Rg, Mg) + if cok == 0.0: + return np.nan + eta_final = eta_total*cooling_eff if not (np.isfinite(eta_final) and eta_final > 0.0 and eta_final <= 1.0): return np.nan cstar_actual = eta_final*cs_id @@ -565,25 +745,25 @@ def evaluate_core(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F Pc_max = min(P_O, P_F)*(1.0 - 0.15) Pc_min = max(Pc_min, P[SV_PCMIN]); Pc_max = min(Pc_max, P[SV_PCMAX]) if Pc_max <= Pc_min: - return (0.0,)*21 + return (0.0,)*22 xtol = P[SV_TOL]; rtol = P[SV_TOL]*1e-3; maxit = int(P[SV_MAXIT]) if P[SV_MAXIT] > 0 else 100 rmin = _residual(Pc_min, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F) rmax = _residual(Pc_max, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F) if not np.isfinite(rmin) or not np.isfinite(rmax): - return (0.0,)*21 + return (0.0,)*22 if _sign(rmin) == _sign(rmax): if rmin > 0 and rmax > 0 and rmax < 0.1: Pc = Pc_max else: - return (0.0,)*21 + return (0.0,)*22 else: Pc = _brentq(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F, Pc_min, Pc_max, xtol, rtol, maxit) if not np.isfinite(Pc): - return (0.0,)*21 + return (0.0,)*22 # recompute converged state ok, mO, mF, uO, uF, D32O, D32F, momR, CdO, CdF, PiO, PiF, dpiO, dpiF, AgO, AgF = injector_solve(P, P_O, P_F, Pc) if ok == 0: - return (0.0,)*21 + return (0.0,)*22 mdot_total = mO + mF; MR = mO/mF cs_id, cf_id, tc, gm, Rg, Mg, cfv = cea_eval(Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, MR, Pc, P[G_EPS]) Lstar = P[G_LSTAR] if P[G_LSTAR] > 0 else (P[G_VOL]/P[G_AT] if P[G_AT] > 0 else 0.0) @@ -594,11 +774,18 @@ def evaluate_core(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F sO = np.sin(P[ANG_O]*PI/180.0); sF = np.sin(P[ANG_F]*PI/180.0) R_opt = np.sqrt(sF/sO) if (sO > 0 and sF > 0) else 1.0 eta_total = _eta_advanced(P, Lstar, Pc, tc, gm, Rg, MR, Ac, P[G_AT], P[DJO], mdot_total, uF, uO, D32O, D32F, momR, R_opt) - cstar_actual = eta_total*cs_id + # Cooling at the converged point, matching the residual. C reports + # eta_cstar = eta_total*cooling_eff and derives cstar_actual from THAT + # (ed_chamber.c:91-92), so report eta_final here, not eta_total. + cok, cooling_eff, Tc_eff = _cooling_evaluate(P, Pc, mdot_total, tc, gm, Rg, Mg) + if cok == 0.0: + return (0.0,)*22 + eta_final = eta_total*cooling_eff + cstar_actual = eta_final*cs_id # nozzle CEA at converged point (Pa ignored in 3D lookup) cs2, cf2, tc2, gm2, Rg2, Mg2, cfv2 = cea_eval(Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, MR, Pc, P[G_EPS]) if not np.isfinite(cfv2): - return (0.0,)*21 + return (0.0,)*22 M_exit = _solve_exit_mach(P[G_EPS], gm2) factor = 1.0 + (gm2-1.0)/2.0*M_exit*M_exit T_exit = tc2/factor @@ -609,11 +796,16 @@ def evaluate_core(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F P_throat = Pc*thr**(gm2/(gm2-1.0)) F = P[G_NOZZEFF]*cfv2*Pc*P[G_AT] - Pa*P[G_AE] if not np.isfinite(F): - return (0.0,)*21 + return (0.0,)*22 Isp = F/(mdot_total*G0) Cf_actual = F/(Pc*P[G_AT]) + # Index 7 is Tc_IDEAL (EdEvaluateResult.Tc = ch.Tc_ideal, ed_evaluate.c:89) and + # index 21 is Tc_EFFECTIVE (out->Tc_effective = ch.Tc, :111). The wrapper dict's + # "Tc" must use the EFFECTIVE one -- native_injector.py:538 does, and it feeds + # comprehensive_stability_analysis. They are equal only while cooling is off. return (1.0, Pc, F, Isp, MR, cstar_actual, gm, tc, mdot_total, v_exit, Cf_actual, - mO, mF, cs_id, eta_total, Rg2, P_exit, P_throat, T_exit, T_throat, cf2) + mO, mF, cs_id, eta_final, Rg2, P_exit, P_throat, T_exit, T_throat, cf2, + Tc_eff) # ---- Python wrappers matching native_injector ------------------------------- @@ -632,9 +824,9 @@ def evaluate(self, P_O, P_F, Pa=101325.0): if not r[0]: return None (_, Pc, F, Isp, MR, csa, gm, tc, mdt, vex, cfa, - mO, mF, cs_id, eta, Rg, Pex, Pth, Tex, Tth, cf_id) = r + mO, mF, cs_id, eta, Rg, Pex, Pth, Tex, Tth, cf_id, tc_eff) = r return {"Pc": Pc, "F": F, "Isp": Isp, "MR": MR, "cstar_actual": csa, - "gamma": gm, "Tc": tc, "mdot_total": mdt, "v_exit": vex, "Cf_actual": cfa} + "gamma": gm, "Tc": tc_eff, "mdot_total": mdt, "v_exit": vex, "Cf_actual": cfa} def _cea_arrays_cached(cache): @@ -675,7 +867,7 @@ def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): if not r[0]: return None (_, Pc, F, Isp, MR, csa, gm, tc, mdt, vex, cfa, - mO, mF, cs_id, eta, Rg, Pex, Pth, Tex, Tth, cf_id) = r + mO, mF, cs_id, eta, Rg, Pex, Pth, Tex, Tth, cf_id, tc_eff) = r if F != F: return None # --- identical tail to native_injector.evaluate: C diag solve + Python stability --- @@ -686,18 +878,18 @@ def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): diagnostics.update({ "mdot_O": mO, "mdot_F": mF, "mdot_total": mdt, "Pc": Pc, "MR": MR, "cstar_ideal": cs_id, "cstar_actual": csa, "eta_cstar": eta, - "gamma": gm, "R": Rg, "Tc": tc, "SMD": max(ir.D32_O, ir.D32_F), + "gamma": gm, "R": Rg, "Tc": tc_eff, "SMD": max(ir.D32_O, ir.D32_F), }) try: stab = comprehensive_stability_analysis( config=config, Pc=Pc, MR=MR, mdot_total=mdt, - cstar=csa, gamma=gm, R=Rg, Tc=tc, diagnostics=diagnostics) + cstar=csa, gamma=gm, R=Rg, Tc=tc_eff, diagnostics=diagnostics) except Exception: return None return { "Pc": Pc, "mdot_O": mO, "mdot_F": mF, "mdot_total": mdt, "MR": MR, "F": F, "Isp": Isp, "v_exit": vex, "P_exit": Pex, "P_throat": Pth, - "T_exit": Tex, "T_throat": Tth, "Tc": tc, "eps": float(P[G_EPS]), + "T_exit": Tex, "T_throat": Tth, "Tc": tc_eff, "eps": float(P[G_EPS]), "A_throat": float(P[G_AT]), "A_exit": float(P[G_AE]), "cstar_actual": csa, "cstar_ideal": cs_id, "eta_cstar": eta, "gamma": gm, "R": Rg, "Cf": cfa, "Cf_actual": cfa, "Cf_ideal": cf_id, @@ -746,5 +938,31 @@ def g(path): ("NPRESS","n_pressure"),("TSTARCAP","T_star_fuel_cap_K"),("HASFLOOR","has_tau_Tc_floor"), ("TAUFLOOR","tau_Tc_floor"),("EMPEAK","Em_peak"),("SIGMA","mixing_sigma"),("ROPT","R_opt")): P[_IDX[f"C_{suf}"]] = g(f"comb.{fld}") + for suf, fld in (("LEN","length"),("LCYL","length_cylindrical"),("LCONTR","length_contraction")): + P[_IDX[f"G_{suf}"]] = g(f"geom.{fld}") + for name, fld in (("K_ABLEN","ablative_enabled"),("K_FILMEN","film_enabled"), + ("K_REGENEN","regen_enabled"),("K_USECOUP","use_cooling_coupling"), + ("K_EFFFLOOR","cooling_efficiency_floor"),("K_HGMU","hot_gas_viscosity"), + ("K_HGK","hot_gas_thermal_conductivity"),("K_HGPR","hot_gas_prandtl"), + ("K_TI","gas_turbulence_intensity"),("K_RECOV","recovery_factor"), + ("K_EMISHOT","radiation_emissivity_hot"),("K_VIEWF","radiation_view_factor"), + ("K_DREGEN","regen_chamber_inner_diameter"), + ("AB_COV","ablative_coverage_fraction"), + ("AB_TSURF","ablative_surface_temperature_limit"), + ("AB_TPYRO","ablative_pyrolysis_temperature"), + ("AB_HABL","ablative_heat_of_ablation"),("AB_CP","ablative_specific_heat"), + ("AB_USEPHYS","ablative_use_physics_based_blowing"), + ("AB_BLOWEFF","ablative_blowing_efficiency"), + ("AB_BLOWC","ablative_blowing_coefficient"), + ("AB_BLOWMIN","ablative_blowing_min_reduction_factor"), + ("AB_TIREF","ablative_turbulence_reference_intensity"), + ("AB_TISENS","ablative_turbulence_sensitivity"), + ("AB_TIEXP","ablative_turbulence_exponent"), + ("AB_TIMAX","ablative_turbulence_max_multiplier"), + ("AB_EMIS","ablative_surface_emissivity"), + ("AB_TAMB","ablative_ambient_temperature"), + ("AB_SINKMIN","ablative_radiative_sink_minimum_threshold"), + ("AB_SINKFB","ablative_radiative_sink_fallback_temperature")): + P[_IDX[name]] = g(f"cooling.{fld}") return P From f3ee62d7b6be51d11bf4f6556849d548f455e62d Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 01:36:27 -0700 Subject: [PATCH 04/20] Add live A/B parity suite for the numba accelerator vs C and Python --- EngineDesign/tests/test_numba_ab_parity.py | 233 +++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 EngineDesign/tests/test_numba_ab_parity.py diff --git a/EngineDesign/tests/test_numba_ab_parity.py b/EngineDesign/tests/test_numba_ab_parity.py new file mode 100644 index 000000000..92ed0eb69 --- /dev/null +++ b/EngineDesign/tests/test_numba_ab_parity.py @@ -0,0 +1,233 @@ +"""Live A/B parity for the Numba accelerator: Numba vs C, and Numba vs Python. + +Sibling of test_native_ab_parity.py, and deliberately shaped like it -- same +points, same tolerance, same opt-in gating -- so the two read as one story while +both backends exist. + +THREE COMPARISONS, each answering a different question: + + Numba vs C at 1e-12 -- the regression guard. These two implement the same + physics from the same inputs, so they agree to a few + ULP (measured: worst 9.7e-16 over 300 points). Any + loosening here means a real divergence, not noise. + Numba vs Python at 2e-3 -- the contract. Same RTOL as the C suite and for the + same reason: the accelerated chamber solve lands + within ~1e-3 of Python, so 2e-3 has headroom without + masking a physics bug. + Randomized sweep -- 200 fixed-seed points across the operating box, + which the C suite never had. Also asserts the + accelerated path does not bail where Python converges. + +BOTH CONFIGS ARE EXERCISED ON PURPOSE. configs/canonical/impinging.yaml has +ablative cooling ON and impinging_lox_ch4_8000N.yaml has it off; those are +different code paths through _cooling_evaluate, and the ablative one is what the +project's default configs actually take. + +SCOPE: this covers the pure-Numba surface (chamber + nozzle + thrust core). The +injector *diagnostics* dict is still assembled by a C call inside +make_native_signature_evaluate, so asserting on it here would be partly circular; +that coverage lands when the diagnostics are surfaced from Numba's own solve. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PSI_TO_PA = 6894.76 +PA_AMBIENT = 101325.0 + +pytestmark = pytest.mark.skipif( + os.environ.get("ED_REQUIRE_ACCEL") != "1" and os.environ.get("ED_AB_PARITY") != "1", + reason="A/B parity runs in the parity CI job; set ED_AB_PARITY=1 to run locally", +) + +POINTS_PSI = [(563.467, 567.644), (518.4, 550.6), (597.3, 584.7)] + +RTOL = 2e-3 # Numba vs Python -- matches the C suite's target +RTOL_TIGHT = 1e-12 # Numba vs C -- same physics, same inputs; ULP-level or bust + +CONFIGS = [ + ("configs/canonical/impinging.yaml", True), # ablative ON (default configs) + ("configs/impinging_lox_ch4_8000N.yaml", False), # ablative off +] + +# Fields NumbaEvaluator.evaluate() produces; all are core physics, no diagnostics. +CORE_FIELDS = ["Pc", "F", "Isp", "MR", "cstar_actual", "gamma", + "Tc", "mdot_total", "v_exit", "Cf_actual"] + + +def _rel(got, want): + return abs(float(got) - float(want)) / max(abs(float(want)), 1e-12) + + +def _assert_close(name, got, want, rtol): + assert got is not None and want is not None, f"{name}: missing value ({got} vs {want})" + rel = _rel(got, want) + assert rel <= rtol, f"{name}: got={float(got):.10g} want={float(want):.10g} rel={rel:.3e} > {rtol:g}" + + +def _py_field(ref, key): + """Python's result dict keeps some fields top-level and some in diagnostics.""" + if key in ref and ref[key] is not None: + return ref[key] + return (ref.get("diagnostics") or {}).get(key) + + +_RIGS = {} + + +def _rig(cfg_rel): + """Config + C shim + Numba evaluator + live Python reference, built once.""" + if cfg_rel in _RIGS: + return _RIGS[cfg_rel] + if os.environ.get("ED_USE_NATIVE", "1") == "0": + pytest.skip("ED_USE_NATIVE=0 -- no C side to compare against") + try: + from engine.native.python import autobuild, ed_native, native_injector + ed_native.load(autobuild.ensure_lib()) + except Exception as exc: + if os.environ.get("ED_REQUIRE_ACCEL") == "1": + pytest.fail(f"ED_REQUIRE_ACCEL=1 but the C reference is unavailable: {exc}") + pytest.skip(f"C reference unavailable ({exc})") + if not native_injector.native_enabled(): + pytest.skip("native kernel not enabled") + + import sys + sys.path.insert(0, str(ROOT / "scripts")) + import numba_eval + from engine.core.runner import PintleEngineRunner + from engine.pipeline.io import load_config + + config = load_config(str(ROOT / cfg_rel)) + assert native_injector._can_handle_chamber(config), f"C cannot handle {cfg_rel}" + runner = PintleEngineRunner(config) + native_injector._ensure_cea(runner.cea_cache) + + points = [(po * PSI_TO_PA, pf * PSI_TO_PA) for po, pf in POINTS_PSI] + rig = { + "config": config, "runner": runner, "cache": runner.cea_cache, + "ni": native_injector, "nb": numba_eval.NumbaEvaluator(config, runner.cea_cache), + "mod": numba_eval, "points": points, + "reference": {p: runner.evaluate(p[0], p[1], P_ambient=PA_AMBIENT, silent=True) + for p in points}, + } + _RIGS[cfg_rel] = rig + return rig + + +@pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).split("/")[-1]) +class TestNumbaMatchesC: + """The regression guard. Numba and C must agree to ULP, not to a tolerance.""" + + def test_core_fields(self, cfg_rel, ablative): + r = _rig(cfg_rel) + for p_o, p_f in r["points"]: + c = r["ni"].evaluate(r["config"], r["cache"], p_o, p_f, PA_AMBIENT) + n = r["nb"].evaluate(p_o, p_f, PA_AMBIENT) + assert c is not None and n is not None, f"a backend bailed at {p_o:.0f}/{p_f:.0f}" + for k in CORE_FIELDS: + if k in c and k in n: + _assert_close(f"{k}@{p_o:.0f}/{p_f:.0f}", n[k], c[k], RTOL_TIGHT) + + +@pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).split("/")[-1]) +class TestNumbaMatchesPython: + """The contract: what the optimizer consumes must match the authoritative path.""" + + def test_wrapper_core_fields(self, cfg_rel, ablative): + r = _rig(cfg_rel) + for p in r["points"]: + ref = r["reference"][p] + n = r["nb"].evaluate(p[0], p[1], PA_AMBIENT) + assert n is not None, f"numba bailed where Python converged at {p}" + for k in CORE_FIELDS: + want = _py_field(ref, k) + if want: + _assert_close(f"{k}@{p[0]:.0f}/{p[1]:.0f}", n[k], want, RTOL) + + def test_kernel_level_raw_tuple(self, cfg_rel, ablative): + """Kernel level: the raw evaluate_core tuple, with no wrapper in the way. + + The C suite keeps this level because a wrapper override once hid a kernel + computing retired momentum-method thrust. Numba has no ctypes struct, so + this is simply the returned tuple -- same property, less machinery. + """ + r = _rig(cfg_rel) + nb, mod = r["nb"], r["mod"] + for p in r["points"]: + ref = r["reference"][p] + raw = mod.evaluate_core(nb.P, *nb.cea, p[0], p[1], PA_AMBIENT) + assert raw[0], f"kernel did not converge at {p}" + _assert_close("kernel Pc", raw[1], _py_field(ref, "Pc"), RTOL) + _assert_close("kernel F", raw[2], _py_field(ref, "F"), RTOL) + _assert_close("kernel Isp", raw[3], _py_field(ref, "Isp"), RTOL) + _assert_close("kernel MR", raw[4], _py_field(ref, "MR"), RTOL) + + +@pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).split("/")[-1]) +class TestRandomizedSweep: + """Breadth the three fixed points cannot give. Fixed seed, so failures repeat.""" + + N = 200 + + def test_sweep_matches_c(self, cfg_rel, ablative): + import numpy as np + r = _rig(cfg_rel) + rng = np.random.default_rng(20260904) + lo, hi = 3.0e6, 5.5e6 + matched = c_only = nb_only = 0 + worst = 0.0 + for _ in range(self.N): + p_o = float(rng.uniform(lo, hi)); p_f = float(rng.uniform(lo, hi)) + c = r["ni"].evaluate(r["config"], r["cache"], p_o, p_f, PA_AMBIENT) + n = r["nb"].evaluate(p_o, p_f, PA_AMBIENT) + if c is None and n is None: + continue + # A convergence disagreement is a real divergence: same physics, same + # inputs, so one backend bailing where the other did not is a bug. + assert c is not None, f"C bailed where Numba converged at {p_o:.0f}/{p_f:.0f}" + assert n is not None, f"Numba bailed where C converged at {p_o:.0f}/{p_f:.0f}" + matched += 1 + for k in CORE_FIELDS: + if k in c and k in n and c[k]: + worst = max(worst, _rel(n[k], c[k])) + assert matched > self.N // 2, f"only {matched}/{self.N} points converged" + assert worst <= RTOL_TIGHT, f"worst Numba-vs-C divergence {worst:.3e} over {matched} points" + + +class TestCoolingIsActuallyApplied: + """Pins the Tc_ideal/Tc_effective distinction. + + ed_evaluate.c returns Tc_ideal as `.Tc` but Tc_effective as `.Tc_effective`, + and native_injector.py:538 puts the EFFECTIVE one into the dict the optimizer + and comprehensive_stability_analysis consume. Returning the ideal value is a + silent ~0.8% error that lands in stability, not a crash -- so assert both that + the two differ and that the wrapper exposes the effective one. + """ + + def test_effective_tc_differs_and_is_reported(self): + r = _rig("configs/canonical/impinging.yaml") + nb, mod = r["nb"], r["mod"] + p_o, p_f = r["points"][0] + raw = mod.evaluate_core(nb.P, *nb.cea, p_o, p_f, PA_AMBIENT) + assert raw[0], "kernel did not converge" + tc_ideal, tc_eff = float(raw[7]), float(raw[21]) + assert tc_eff < tc_ideal - 1.0, ( + f"cooling not applied: Tc_ideal={tc_ideal:.2f} Tc_effective={tc_eff:.2f}. " + "If ablative is genuinely inactive for this config the test is vacuous." + ) + reported = r["nb"].evaluate(p_o, p_f, PA_AMBIENT)["Tc"] + assert _rel(reported, tc_eff) < 1e-12, ( + f"wrapper reported Tc={reported:.4f}, expected the EFFECTIVE {tc_eff:.4f} " + f"(not the ideal {tc_ideal:.4f})" + ) + + def test_matches_c_effective_tc(self): + r = _rig("configs/canonical/impinging.yaml") + p_o, p_f = r["points"][0] + c = r["ni"].evaluate(r["config"], r["cache"], p_o, p_f, PA_AMBIENT) + n = r["nb"].evaluate(p_o, p_f, PA_AMBIENT) + _assert_close("Tc (effective)", n["Tc"], c["Tc"], RTOL_TIGHT) From aea1d6a7978c31787d8cc8e109af152710553e6c Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 01:54:36 -0700 Subject: [PATCH 05/20] Move accelerator into engine/accel and drop the C injector solve from evaluate --- EngineDesign/engine/accel/__init__.py | 119 +++++++- EngineDesign/engine/accel/cea.py | 49 ++++ EngineDesign/engine/accel/diagnostics.py | 86 ++++++ .../numba_eval.py => engine/accel/kernels.py} | 270 ++---------------- EngineDesign/engine/accel/params.py | 149 ++++++++++ EngineDesign/scripts/bench_layer1_numba.py | 4 +- EngineDesign/tests/test_numba_ab_parity.py | 62 +++- 7 files changed, 486 insertions(+), 253 deletions(-) create mode 100644 EngineDesign/engine/accel/cea.py create mode 100644 EngineDesign/engine/accel/diagnostics.py rename EngineDesign/{scripts/numba_eval.py => engine/accel/kernels.py} (69%) diff --git a/EngineDesign/engine/accel/__init__.py b/EngineDesign/engine/accel/__init__.py index ba1e6c0b1..efa0bc1db 100644 --- a/EngineDesign/engine/accel/__init__.py +++ b/EngineDesign/engine/accel/__init__.py @@ -2,11 +2,118 @@ Replaces the hand-written C port at engine/native. During the migration both backends exist and must be *simultaneously* callable -- the parity suite, the -benchmark and CI all compare them -- so selection lives here rather than being -baked into either implementation. +benchmark and CI all compare them -- so backend selection lives here rather than +being baked into either implementation. -Today this package holds only the pure-Python parameter extraction (params.py), -which is what frees the Numba kernels from depending on the C EdEngineState. -The dispatcher and the evaluate/solve/chamber_solve surface land with the -call-site switch. +Public surface mirrors native_injector's, so call sites change an import and +nothing else. Every entry point returns None rather than raising when it cannot +handle a config, because every caller already treats None as "use Python". """ +from __future__ import annotations + +import os + +__all__ = ["available", "enabled", "can_handle", "can_handle_chamber", "evaluate"] + + +def available() -> bool: + """False (never raises) when numba is absent, so a missing dep degrades to Python.""" + try: + import numba # noqa: F401 + except Exception: + return False + return True + + +def enabled() -> bool: + if os.environ.get("ED_ACCEL", "numba") == "off": + return False + if os.environ.get("ED_USE_NATIVE") == "0": # honour the historical switch + return False + return available() + + +def can_handle(config) -> bool: + """Mirrors native_injector._can_handle.""" + inj = getattr(config, "injector", None) + if inj is None or inj.type != "impinging": + return False + regen = getattr(config, "regen_cooling", None) + if regen is not None and getattr(regen, "enabled", False): + return False # regen-coupled feed loss not ported + return True + + +def can_handle_chamber(config) -> bool: + """Mirrors native_injector._can_handle_chamber. + + No ablative gate: ablative IS ported (kernels._cooling_evaluate). No graphite + gate either -- graphite never enters the chamber residual, exactly as the C + kernel treats it. + """ + if not can_handle(config): + return False + fc = getattr(config, "film_cooling", None) + if fc is not None and getattr(fc, "enabled", False): + return False + eff = config.combustion.efficiency + if not getattr(eff, "use_advanced_model", True): + return False + return True + + +def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): + """Single-call chamber + nozzle + thrust + stability. None => caller falls back. + + Signature matches native_injector.evaluate so it drops into the same slot. + """ + from engine.accel import diagnostics as _diag + from engine.accel import kernels as _k + from engine.accel import params as _p + from engine.pipeline.stability.analysis import comprehensive_stability_analysis + + if not can_handle_chamber(config): + return None + if not getattr(cache, "use_3d", False): + return None + try: + P = _p.extract_params(config) + except AssertionError: + return None # config outside the ported subset + arr = _k._cea_arrays_cached(cache) + + r = _k.evaluate_core(P, *arr, float(P_tank_O), float(P_tank_F), float(P_ambient)) + if not r[0]: + return None + (_, Pc, F, Isp, MR, csa, gm, tc, mdt, vex, cfa, + mO, mF, cs_id, eta, Rg, Pex, Pth, Tex, Tth, cf_id, tc_eff) = r + if F != F: + return None + + sol = _k.injector_solve(P, float(P_tank_O), float(P_tank_F), float(Pc)) + if not sol[0]: + return None + diag = _diag.build_diag(P, sol) + diag.update({ + "mdot_O": mO, "mdot_F": mF, "mdot_total": mdt, "Pc": Pc, "MR": MR, + "cstar_ideal": cs_id, "cstar_actual": csa, "eta_cstar": eta, + "gamma": gm, "R": Rg, "Tc": tc_eff, "SMD": max(sol[5], sol[6]), + }) + try: + stab = comprehensive_stability_analysis( + config=config, Pc=Pc, MR=MR, mdot_total=mdt, + cstar=csa, gamma=gm, R=Rg, Tc=tc_eff, diagnostics=diag) + except Exception: + return None + return { + "Pc": Pc, "mdot_O": mO, "mdot_F": mF, "mdot_total": mdt, "MR": MR, + "F": F, "Isp": Isp, "v_exit": vex, "P_exit": Pex, "P_throat": Pth, + "T_exit": Tex, "T_throat": Tth, "Tc": tc_eff, + "eps": float(P[_k.G_EPS]), "A_throat": float(P[_k.G_AT]), "A_exit": float(P[_k.G_AE]), + "cstar_actual": csa, "cstar_ideal": cs_id, "eta_cstar": eta, "gamma": gm, "R": Rg, + "Cf": cfa, "Cf_actual": cfa, "Cf_ideal": cf_id, + "Cd_O": sol[8], "Cd_F": sol[9], "A_geom_O": sol[14], "A_geom_F": sol[15], + "stability": stab, "stability_results": stab, + "diagnostics": diag, "P_ambient": float(P_ambient), + "native_fast_eval": True, "numba_fast_eval": True, + } diff --git a/EngineDesign/engine/accel/cea.py b/EngineDesign/engine/accel/cea.py new file mode 100644 index 000000000..9e8574413 --- /dev/null +++ b/EngineDesign/engine/accel/cea.py @@ -0,0 +1,49 @@ +"""CEA cache tables -> plain contiguous float64 arrays the njit kernels can read. + +Kept separate from kernels.py because this is the only place that touches the +CEACache object model; the kernels below it see nothing but arrays. +""" +from __future__ import annotations + +import numpy as np + + +def cea_arrays(cache): + """Grids + 7 property tables (float64, C-order), Cf_vac with the _ensure_cea fallback.""" + assert getattr(cache, "use_3d", False), "cache is not a 3D grid" + Pc = np.ascontiguousarray(cache.Pc_grid, np.float64) + MR = np.ascontiguousarray(cache.MR_grid, np.float64) + eps = np.ascontiguousarray(cache.eps_grid, np.float64) + cf_vac = getattr(cache, "Cf_vac_table", None) + if cf_vac is None: + from engine.pipeline.cea_cache import _isentropic_cf_vac + gt = np.asarray(cache.gamma_table, np.float64) + cf_vac = np.empty_like(gt) + for k in range(gt.shape[2]): + ek = float(eps[k]) + for i in range(gt.shape[0]): + for j in range(gt.shape[1]): + cf_vac[i, j, k] = _isentropic_cf_vac(gt[i, j, k], ek) + A = lambda t: np.ascontiguousarray(t, np.float64) + return (Pc, MR, eps, A(cache.cstar_table), A(cache.Cf_table), A(cache.Tc_table), + A(cache.gamma_table), A(cache.R_table), A(cache.M_table), A(cf_vac)) + + + +def _cea_arrays_cached(cache): + """Memoise cea_arrays for a cache, storing the result ON the cache object. + + NOT keyed on id(cache): a freed cache's id can be reused, which would + silently serve a previous config's CEA tables in a multi-config process + (test sessions, GUI config switches). This is the same hazard + native_injector._ensure_cea documents and defends against with a token; tying + the memo to the object's own lifetime is simpler and cannot leak. + """ + arr = getattr(cache, "_numba_cea_arrays", None) + if arr is None: + arr = cea_arrays(cache) + try: + cache._numba_cea_arrays = arr + except Exception: + pass # cache rejects attributes -> rebuild per call (correct, slower) + return arr diff --git a/EngineDesign/engine/accel/diagnostics.py b/EngineDesign/engine/accel/diagnostics.py new file mode 100644 index 000000000..bf133bc21 --- /dev/null +++ b/EngineDesign/engine/accel/diagnostics.py @@ -0,0 +1,86 @@ +"""Assemble the injector diagnostics dict from Numba's own solve outputs. + +Replaces the second, C-side injector solve that the Numba path used to make for +diagnostics alone (native_injector._nat().injector_solve + _result_to_diag). The +physics was always there -- injector_solve computed these values and discarded +them -- so this is plumbing, not new physics. + +Everything is derived from the flat param vector plus the solve tuple; no config +object is needed, which is what keeps this callable from inside a worker without +re-parsing YAML. + +DELIBERATE OMISSIONS, measured rather than assumed. C's _result_to_diag also +emits A_eff_O/F, J, TMR, theta, turbulence_intensity_mix and +feed_orifice_coupling_iterations. A grep of every reader on the stability and +optimizer paths shows none of them is ever read off an accelerated result: +A_eff_O/F is recomputed downstream from Cd by +engine/core/injectors/flow_capacity.effective_flow_areas_from_cd, and +turbulence_intensity_mix is consumed only at chamber_solver.py:180, which is the +full-Python chamber path and never sees this dict. Emitting them would mean +porting spray quantities no consumer wants. If a future consumer needs one, the +parity test below is where that will surface. +""" +from __future__ import annotations + +import math + +from engine.accel.params import _IDX + +_DJO, _DJF = _IDX["DJO"], _IDX["DJF"] +_NO, _NF = _IDX["NO"], _IDX["NF"] +_RHO_O, _RHO_F = _IDX["RHO_O"], _IDX["RHO_F"] +_ANG_O, _ANG_F = _IDX["ANG_O"], _IDX["ANG_F"] + + +def build_diag(P, sol): + """Mirror native_injector._result_to_diag using Numba's injector_solve tuple. + + `sol` is the 24-tuple from kernels.injector_solve. + """ + (_ok, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, + Pi_O, Pi_F, dpi_O, dpi_F, A_geom_O, A_geom_F, + dpf_O, dpf_F, We_O, We_F, u_rel, x_star, constraints_ok, n_iter) = sol + + djo, djf = float(P[_DJO]), float(P[_DJF]) + rho_O, rho_F = float(P[_RHO_O]), float(P[_RHO_F]) + n_O, n_F = max(1, int(P[_NO])), max(1, int(P[_NF])) + + mdot_bn_O = Cd_O * A_geom_O * math.sqrt(2.0 * rho_O * dpi_O) if dpi_O > 0 else 0.0 + mdot_bn_F = Cd_F * A_geom_F * math.sqrt(2.0 * rho_F * dpi_F) if dpi_F > 0 else 0.0 + + diag = { + "injector_type": "impinging", + "iterations": int(n_iter), + "constraints_satisfied": bool(constraints_ok), + "We_O": We_O, "We_F": We_F, + "D32_O": D32_O, "D32_F": D32_F, + "x_star": x_star, "u_rel": u_rel, "V_rel": u_rel, + "u_O": u_O, "u_F": u_F, + "Cd_O": Cd_O, "Cd_F": Cd_F, + "P_injector_O": Pi_O, "P_injector_F": Pi_F, + "delta_p_injector_O": dpi_O, "delta_p_injector_F": dpi_F, + "delta_p_feed_O": dpf_O, "delta_p_feed_F": dpf_F, + "mdot_from_bernoulli_O": mdot_bn_O, "mdot_from_bernoulli_F": mdot_bn_F, + "A_geom_O": A_geom_O, "A_geom_F": A_geom_F, + "A_jet_O": math.pi * (djo / 2.0) ** 2, "A_jet_F": math.pi * (djf / 2.0) ** 2, + "d_jet_O": djo, "d_jet_F": djf, + "momentum_ratio_n_elements_O": n_O, "momentum_ratio_n_elements_F": n_F, + "rho_O_momentum": rho_O, "rho_F_momentum": rho_F, + "MR": (mdot_O / mdot_F) if mdot_F > 0 else float("nan"), + } + # v_*_bulk = mdot / (rho * n_elements * A_jet); A_geom IS n_elements*A_jet + # (impinging.py:58). Conditionally included exactly as _result_to_diag does. + for tag, mdot, rho, area in (("O", mdot_O, rho_O, A_geom_O), + ("F", mdot_F, rho_F, A_geom_F)): + if rho > 0 and area > 0: + v_bulk = mdot / (rho * area) + if math.isfinite(v_bulk): + diag[f"v_{tag}_bulk"] = v_bulk + + # Same conditional inclusion as _result_to_diag: absent, not NaN, when invalid. + if math.isfinite(mom_R) and mom_R > 0: + diag["momentum_ratio_R"] = mom_R + # Same included-angle convention as impinging.py: separation = theta_O + theta_F. + imp_sep = float(P[_ANG_O]) + float(P[_ANG_F]) + diag["impingement_angle_deg"] = max(1.0, min(179.0, imp_sep)) + return diag diff --git a/EngineDesign/scripts/numba_eval.py b/EngineDesign/engine/accel/kernels.py similarity index 69% rename from EngineDesign/scripts/numba_eval.py rename to EngineDesign/engine/accel/kernels.py index bf5662c6d..87360f9b4 100644 --- a/EngineDesign/scripts/numba_eval.py +++ b/EngineDesign/engine/accel/kernels.py @@ -1,61 +1,25 @@ """Numba port of the C ed_evaluate impinging inner-loop physics. Faithful mirror of engine/native/src/{ed_injector_impinging,ed_discharge,ed_feed_loss, -ed_spray,ed_combustion_physics,ed_cea,ed_nozzle,ed_chamber,ed_evaluate,ed_root_find}.c -for the config class the C path actually handles: impinging injector, advanced/ -exponential combustion, ablative cooling DISABLED (cooling_eff==1, so ed_cooling.c -is a no-op and is not ported — asserted at extract time). - -Inputs are pulled from the SAME native EdEngineState (native_injector.build_state) -and the SAME CEA cache, so there is no risk of misreading the config: we reuse the -C's own reconciled scalar inputs and only re-implement the arithmetic in @njit. +ed_spray,ed_combustion_physics,ed_cea,ed_nozzle,ed_chamber,ed_cooling,ed_evaluate, +ed_root_find}.c for the config class the accelerated path handles: impinging +injector, advanced/exponential combustion, ablative cooling (film/regen fall back +to Python, as the C kernel also refuses them). + +All state arrives as one flat float64 vector built by params.build_params, plus +the CEA tables as plain arrays -- @njit sees no Python objects. """ from __future__ import annotations + import numpy as np from numba import njit -# ---- parameter vector layout (mirrors the fields the C residual reads) ------- -_NAMES = [ - # fluids - "RHO_O", "MU_O", "SIG_O", "T_O", - "RHO_F", "MU_F", "SIG_F", "T_F", "LAT_F", - # injector geom - "DJO", "DJF", "NO", "NF", "ANG_O", "ANG_F", - # discharge O (16) - "DO_CDINF", "DO_ARE", "DO_CDMIN", "DO_GEOM", "DO_DREF", "DO_DMIN", "DO_EXPS", - "DO_LOGG", "DO_CDMAX", "DO_CDFLOOR", "DO_UPC", "DO_PREF", "DO_AP", "DO_UTC", "DO_TREF", "DO_AT", - # discharge F (16) - "DF_CDINF", "DF_ARE", "DF_CDMIN", "DF_GEOM", "DF_DREF", "DF_DMIN", "DF_EXPS", - "DF_LOGG", "DF_CDMAX", "DF_CDFLOOR", "DF_UPC", "DF_PREF", "DF_AP", "DF_UTC", "DF_TREF", "DF_AT", - # feed O / F - "FO_DIN", "FO_AH", "FO_K0", "FO_K1", "FO_PHI", - "FF_DIN", "FF_AH", "FF_K0", "FF_K1", "FF_PHI", - # spray - "SP_SMDMODEL", "SP_SMDC", "SP_SMDM", "SP_SMDP", "SP_SMDCING", "SP_SMDWECORR", - "SP_GASR", "SP_GAST", "SP_ANGMODEL", "SP_ANGK", "SP_ANGN", "SP_WEMIN", - "SP_EVAPK", "SP_EVAPXLIM", "SP_EVAPUSE", - # solver - "SV_CLMAX", "SV_CLCDRED", "SV_PCMIN", "SV_PCMAX", "SV_TOL", "SV_MAXIT", - # geom - "G_EPS", "G_AT", "G_AE", "G_VOL", "G_LSTAR", "G_DCHAM", "G_NOZZEFF", - # combustion - "C_MODEL", "C_C", "C_TAUREF", "C_TAUREFP", "C_TAUREFT", "C_NPRESS", "C_TSTARCAP", - "C_HASFLOOR", "C_TAUFLOOR", "C_EMPEAK", "C_SIGMA", "C_ROPT", - # chamber lengths (ablative wetted area only) - "G_LEN", "G_LCYL", "G_LCONTR", - # cooling gates + hot-gas block (ed_cooling.c) - "K_ABLEN", "K_FILMEN", "K_REGENEN", "K_USECOUP", "K_EFFFLOOR", - "K_HGMU", "K_HGK", "K_HGPR", "K_TI", "K_RECOV", "K_EMISHOT", "K_VIEWF", "K_DREGEN", - # ablative material / blowing / turbulence / radiative sink - "AB_COV", "AB_TSURF", "AB_TPYRO", "AB_HABL", "AB_CP", "AB_USEPHYS", - "AB_BLOWEFF", "AB_BLOWC", "AB_BLOWMIN", - "AB_TIREF", "AB_TISENS", "AB_TIEXP", "AB_TIMAX", - "AB_EMIS", "AB_TAMB", "AB_SINKMIN", "AB_SINKFB", -] -_IDX = {n: i for i, n in enumerate(_NAMES)} -globals().update(_IDX) # module-level int constants for njit -NP = len(_NAMES) +from engine.accel.cea import cea_arrays, _cea_arrays_cached +from engine.accel.params import _IDX, NP, extract_params +globals().update(_IDX) # param indices as module-level int constants + +# physical constants (ed_phys_const.h) # physical constants (ed_phys_const.h) PI = np.pi G0 = 9.80665 @@ -72,53 +36,6 @@ EFF_CONSTANT = 0; EFF_LINEAR = 1 -def _assert_supported(st): - """Guard the assumptions that let this port skip cooling / use the impinging path.""" - assert int(st.injector.type) == 1, "not impinging" - # Ablative IS ported (see _cooling_evaluate). Film/regen are not -- C refuses - # them too (ed_cooling.c:147), so they stay a Python fallback. - assert int(getattr(st.cooling, "film_enabled")) == 0 and int(getattr(st.cooling, "regen_enabled")) == 0 - # No graphite gate, deliberately: C does not check it either (ed_cooling.c - # refuses only film/regen at :147), because graphite never enters the chamber - # residual -- it lives in the burn/recession path (runner.py), and - # chamber_solver.py references it zero times. Gating on it here would reject - # configs/canonical/impinging.yaml, which C handles fine. - - -def extract_params(config): - """Flatten the config scalars this port needs into a float64 vec. - - Delegates the field mapping to _params_from_state so there is ONE table of - field names; the two used to carry independent copies of the whole mapping, - which would drift the moment a field was added on one side only. - """ - from engine.accel.params import build_state - st = build_state(config) # pure Python -- no C library required - _assert_supported(st) - return _params_from_state(st) - - -def cea_arrays(cache): - """Grids + 7 property tables (float64, C-order), Cf_vac with the _ensure_cea fallback.""" - assert getattr(cache, "use_3d", False), "cache is not a 3D grid" - Pc = np.ascontiguousarray(cache.Pc_grid, np.float64) - MR = np.ascontiguousarray(cache.MR_grid, np.float64) - eps = np.ascontiguousarray(cache.eps_grid, np.float64) - cf_vac = getattr(cache, "Cf_vac_table", None) - if cf_vac is None: - from engine.pipeline.cea_cache import _isentropic_cf_vac - gt = np.asarray(cache.gamma_table, np.float64) - cf_vac = np.empty_like(gt) - for k in range(gt.shape[2]): - ek = float(eps[k]) - for i in range(gt.shape[0]): - for j in range(gt.shape[1]): - cf_vac[i, j, k] = _isentropic_cf_vac(gt[i, j, k], ek) - A = lambda t: np.ascontiguousarray(t, np.float64) - return (Pc, MR, eps, A(cache.cstar_table), A(cache.Cf_table), A(cache.Tc_table), - A(cache.gamma_table), A(cache.R_table), A(cache.M_table), A(cf_vac)) - - # ------------------------- njit kernels -------------------------------------- @njit(cache=True) def _clip(x, lo, hi): @@ -258,7 +175,13 @@ def _ohnesorge(mu, rho, sigma, d): @njit(cache=True) def injector_solve(P, P_tank_O, P_tank_F, Pc): """Returns (ok, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, - Pi_O, Pi_F, dpi_O, dpi_F, A_geom_O, A_geom_F). ok=0 => NaN/invalid.""" + Pi_O, Pi_F, dpi_O, dpi_F, A_geom_O, A_geom_F, + dpf_O, dpf_F, We_O, We_F, u_rel, x_star, constraints_ok, n_iter). + ok=0 => NaN/invalid. + + The trailing eight are already computed by the solve; they are returned so the + diagnostics dict can be assembled here instead of by a second solve in C. + """ rho_O = P[RHO_O]; mu_O = P[MU_O]; sig_O = P[SIG_O]; tO = P[T_O] rho_F = P[RHO_F]; mu_F = P[MU_F]; sig_F = P[SIG_F]; tF = P[T_F] djo = P[DJO]; djf = P[DJF]; nO = int(P[NO]); nF = int(P[NF]) @@ -273,10 +196,12 @@ def injector_solve(P, P_tank_O, P_tank_F, Pc): Cd_O = 0.0; Cd_F = 0.0; Pi_O = P_tank_O; Pi_F = P_tank_F dpi_O = 0.0; dpi_F = 0.0 We_O = 0.0; We_F = 0.0; D32_O = 0.0; D32_F = 0.0; u_rel = 0.0 + dpf_O = 0.0; dpf_F = 0.0; x_star = 0.0; n_iter = 0 u_O = 0.0; u_F = 0.0 constraints_ok = 0 for iteration in range(max_iter): + n_iter = iteration + 1 mo = mdot_O; mf = mdot_F for fp in range(1, 151): mo_prev = mo; mf_prev = mf @@ -369,8 +294,8 @@ def injector_solve(P, P_tank_O, P_tank_F, Pc): if den > 0 and num >= 0: mom_R = np.sqrt(num/den) if not (np.isfinite(mdot_O) and np.isfinite(mdot_F)) or mdot_F <= 0.0: - return (0, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, Pi_O, Pi_F, dpi_O, dpi_F, A_O, A_F) - return (1, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, Pi_O, Pi_F, dpi_O, dpi_F, A_O, A_F) + return (0, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, Pi_O, Pi_F, dpi_O, dpi_F, A_O, A_F, dpf_O, dpf_F, We_O, We_F, u_rel, x_star, float(constraints_ok), float(n_iter)) + return (1, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, Pi_O, Pi_F, dpi_O, dpi_F, A_O, A_F, dpf_O, dpf_F, We_O, We_F, u_rel, x_star, float(constraints_ok), float(n_iter)) @njit(cache=True) @@ -629,7 +554,8 @@ def _cooling_evaluate(P, Pc, mdot_total, Tc, gamma, R, M): def _residual(Pc, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F): if not (np.isfinite(Pc) and Pc > 0): return np.nan - ok, mO, mF, uO, uF, D32O, D32F, momR, CdO, CdF, PiO, PiF, dpiO, dpiF, AgO, AgF = injector_solve(P, P_O, P_F, Pc) + (ok, mO, mF, uO, uF, D32O, D32F, momR, CdO, CdF, PiO, PiF, dpiO, dpiF, AgO, AgF, + dpfO, dpfF, WeO, WeF, urel, xstar, constr, nit) = injector_solve(P, P_O, P_F, Pc) if ok == 0: return np.nan mdot_supply = mO + mF @@ -761,7 +687,8 @@ def evaluate_core(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F if not np.isfinite(Pc): return (0.0,)*22 # recompute converged state - ok, mO, mF, uO, uF, D32O, D32F, momR, CdO, CdF, PiO, PiF, dpiO, dpiF, AgO, AgF = injector_solve(P, P_O, P_F, Pc) + (ok, mO, mF, uO, uF, D32O, D32F, momR, CdO, CdF, PiO, PiF, dpiO, dpiF, AgO, AgF, + dpfO, dpfF, WeO, WeF, urel, xstar, constr, nit) = injector_solve(P, P_O, P_F, Pc) if ok == 0: return (0.0,)*22 mdot_total = mO + mF; MR = mO/mF @@ -808,6 +735,8 @@ def evaluate_core(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F Tc_eff) + + # ---- Python wrappers matching native_injector ------------------------------- class NumbaEvaluator: """Parity/bench helper: core physics only (Pc, F, Isp, ...).""" @@ -827,142 +756,3 @@ def evaluate(self, P_O, P_F, Pa=101325.0): mO, mF, cs_id, eta, Rg, Pex, Pth, Tex, Tth, cf_id, tc_eff) = r return {"Pc": Pc, "F": F, "Isp": Isp, "MR": MR, "cstar_actual": csa, "gamma": gm, "Tc": tc_eff, "mdot_total": mdt, "v_exit": vex, "Cf_actual": cfa} - - -def _cea_arrays_cached(cache): - """Memoise cea_arrays for a cache, storing the result ON the cache object. - - NOT keyed on id(cache): a freed cache's id can be reused, which would - silently serve a previous config's CEA tables in a multi-config process - (test sessions, GUI config switches). This is the same hazard - native_injector._ensure_cea documents and defends against with a token; tying - the memo to the object's own lifetime is simpler and cannot leak. - """ - arr = getattr(cache, "_numba_cea_arrays", None) - if arr is None: - arr = cea_arrays(cache) - try: - cache._numba_cea_arrays = arr - except Exception: - pass # cache rejects attributes -> rebuild per call (correct, slower) - return arr - -def make_native_signature_evaluate(): - """Return an evaluate(config, cache, P_O, P_F, P_ambient) that drops into - native_injector.evaluate's slot: Numba computes the chamber+nozzle+thrust core, - then the SAME C diagnostic injector solve + Python stability tail runs (identical - to native_injector.evaluate), isolating the C-vs-Numba difference to the core.""" - from engine.native.python import native_injector as ni - from engine.pipeline.stability.analysis import comprehensive_stability_analysis - - def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): - if not ni._can_handle_chamber(config): - return None - if not ni._ensure_cea(cache): - return None - st = ni.build_state(config) # once, reused for core + diag (as C path does) - P = _params_from_state(st) - arr = _cea_arrays_cached(cache) - r = evaluate_core(P, *arr, float(P_tank_O), float(P_tank_F), float(P_ambient)) - if not r[0]: - return None - (_, Pc, F, Isp, MR, csa, gm, tc, mdt, vex, cfa, - mO, mF, cs_id, eta, Rg, Pex, Pth, Tex, Tth, cf_id, tc_eff) = r - if F != F: - return None - # --- identical tail to native_injector.evaluate: C diag solve + Python stability --- - rci, ir = ni._nat().injector_solve(st, float(P_tank_O), float(P_tank_F), float(Pc)) - if rci != 0: - return None - diagnostics = ni._result_to_diag(config, ir) - diagnostics.update({ - "mdot_O": mO, "mdot_F": mF, "mdot_total": mdt, "Pc": Pc, "MR": MR, - "cstar_ideal": cs_id, "cstar_actual": csa, "eta_cstar": eta, - "gamma": gm, "R": Rg, "Tc": tc_eff, "SMD": max(ir.D32_O, ir.D32_F), - }) - try: - stab = comprehensive_stability_analysis( - config=config, Pc=Pc, MR=MR, mdot_total=mdt, - cstar=csa, gamma=gm, R=Rg, Tc=tc_eff, diagnostics=diagnostics) - except Exception: - return None - return { - "Pc": Pc, "mdot_O": mO, "mdot_F": mF, "mdot_total": mdt, "MR": MR, - "F": F, "Isp": Isp, "v_exit": vex, "P_exit": Pex, "P_throat": Pth, - "T_exit": Tex, "T_throat": Tth, "Tc": tc_eff, "eps": float(P[G_EPS]), - "A_throat": float(P[G_AT]), "A_exit": float(P[G_AE]), - "cstar_actual": csa, "cstar_ideal": cs_id, "eta_cstar": eta, "gamma": gm, "R": Rg, - "Cf": cfa, "Cf_actual": cfa, "Cf_ideal": cf_id, - "Cd_O": ir.Cd_O, "Cd_F": ir.Cd_F, "A_geom_O": ir.A_geom_O, "A_geom_F": ir.A_geom_F, - "stability": stab, "stability_results": stab, - "diagnostics": diagnostics, "P_ambient": float(P_ambient), - "native_fast_eval": True, "numba_fast_eval": True, - } - return evaluate - - -def _params_from_state(st): - """Build the param vector from an already-built EdEngineState (avoids a 2nd build_state).""" - def g(path): - o = st - for part in path.split("."): - o = getattr(o, part) - return float(o) - P = np.zeros(NP) - P[_IDX["RHO_O"]] = g("fluid_O.density"); P[_IDX["MU_O"]] = g("fluid_O.viscosity"); P[_IDX["SIG_O"]] = g("fluid_O.surface_tension"); P[_IDX["T_O"]] = g("fluid_O.temperature") - P[_IDX["RHO_F"]] = g("fluid_F.density"); P[_IDX["MU_F"]] = g("fluid_F.viscosity"); P[_IDX["SIG_F"]] = g("fluid_F.surface_tension"); P[_IDX["T_F"]] = g("fluid_F.temperature"); P[_IDX["LAT_F"]] = g("fluid_F.latent_heat") - P[_IDX["DJO"]] = g("injector.imp_O.d_jet"); P[_IDX["DJF"]] = g("injector.imp_F.d_jet") - P[_IDX["NO"]] = g("injector.imp_O.n_elements"); P[_IDX["NF"]] = g("injector.imp_F.n_elements") - P[_IDX["ANG_O"]] = g("injector.imp_O.impingement_angle"); P[_IDX["ANG_F"]] = g("injector.imp_F.impingement_angle") - for pre, side in (("DO", "discharge_O"), ("DF", "discharge_F")): - for suf, fld in (("CDINF","Cd_inf"),("ARE","a_Re"),("CDMIN","Cd_min"),("GEOM","use_geometry_cd"), - ("DREF","d_ref_m"),("DMIN","d_min_m"),("EXPS","cd_small_hole_exponent"),("LOGG","cd_large_hole_log_gain"), - ("CDMAX","cd_inf_max"),("CDFLOOR","cd_inf_min_geom"),("UPC","use_pressure_correction"),("PREF","P_ref"), - ("AP","a_P"),("UTC","use_temperature_correction"),("TREF","T_ref"),("AT","a_T")): - P[_IDX[f"{pre}_{suf}"]] = g(f"{side}.{fld}") - for pre, side in (("FO", "feed_O"), ("FF", "feed_F")): - for suf, fld in (("DIN","d_inlet"),("AH","A_hydraulic"),("K0","K0"),("K1","K1"),("PHI","phi_type")): - P[_IDX[f"{pre}_{suf}"]] = g(f"{side}.{fld}") - for suf, fld in (("SMDMODEL","smd_model"),("SMDC","smd_C"),("SMDM","smd_m"),("SMDP","smd_p"),("SMDCING","smd_C_ingebo"), - ("SMDWECORR","smd_we_corr_max"),("GASR","chamber_gas_R"),("GAST","chamber_gas_T"),("ANGMODEL","spray_angle_model"), - ("ANGK","spray_angle_k"),("ANGN","spray_angle_n"),("WEMIN","we_min"),("EVAPK","evap_K"), - ("EVAPXLIM","evap_x_star_limit"),("EVAPUSE","evap_use_constraint")): - P[_IDX[f"SP_{suf}"]] = g(f"spray.{fld}") - for suf, fld in (("CLMAX","closure_max_iterations"),("CLCDRED","closure_Cd_reduction_factor"),("PCMIN","Pc_min_bound"), - ("PCMAX","Pc_max_bound"),("TOL","tolerance"),("MAXIT","max_iterations")): - P[_IDX[f"SV_{suf}"]] = g(f"solver.{fld}") - for suf, fld in (("EPS","expansion_ratio"),("AT","A_throat"),("AE","A_exit"),("VOL","volume"),("LSTAR","Lstar"), - ("DCHAM","chamber_diameter"),("NOZZEFF","nozzle_efficiency")): - P[_IDX[f"G_{suf}"]] = g(f"geom.{fld}") - for suf, fld in (("MODEL","model"),("C","C"),("TAUREF","tau_ref"),("TAUREFP","tau_ref_P"),("TAUREFT","tau_ref_T"), - ("NPRESS","n_pressure"),("TSTARCAP","T_star_fuel_cap_K"),("HASFLOOR","has_tau_Tc_floor"), - ("TAUFLOOR","tau_Tc_floor"),("EMPEAK","Em_peak"),("SIGMA","mixing_sigma"),("ROPT","R_opt")): - P[_IDX[f"C_{suf}"]] = g(f"comb.{fld}") - for suf, fld in (("LEN","length"),("LCYL","length_cylindrical"),("LCONTR","length_contraction")): - P[_IDX[f"G_{suf}"]] = g(f"geom.{fld}") - for name, fld in (("K_ABLEN","ablative_enabled"),("K_FILMEN","film_enabled"), - ("K_REGENEN","regen_enabled"),("K_USECOUP","use_cooling_coupling"), - ("K_EFFFLOOR","cooling_efficiency_floor"),("K_HGMU","hot_gas_viscosity"), - ("K_HGK","hot_gas_thermal_conductivity"),("K_HGPR","hot_gas_prandtl"), - ("K_TI","gas_turbulence_intensity"),("K_RECOV","recovery_factor"), - ("K_EMISHOT","radiation_emissivity_hot"),("K_VIEWF","radiation_view_factor"), - ("K_DREGEN","regen_chamber_inner_diameter"), - ("AB_COV","ablative_coverage_fraction"), - ("AB_TSURF","ablative_surface_temperature_limit"), - ("AB_TPYRO","ablative_pyrolysis_temperature"), - ("AB_HABL","ablative_heat_of_ablation"),("AB_CP","ablative_specific_heat"), - ("AB_USEPHYS","ablative_use_physics_based_blowing"), - ("AB_BLOWEFF","ablative_blowing_efficiency"), - ("AB_BLOWC","ablative_blowing_coefficient"), - ("AB_BLOWMIN","ablative_blowing_min_reduction_factor"), - ("AB_TIREF","ablative_turbulence_reference_intensity"), - ("AB_TISENS","ablative_turbulence_sensitivity"), - ("AB_TIEXP","ablative_turbulence_exponent"), - ("AB_TIMAX","ablative_turbulence_max_multiplier"), - ("AB_EMIS","ablative_surface_emissivity"), - ("AB_TAMB","ablative_ambient_temperature"), - ("AB_SINKMIN","ablative_radiative_sink_minimum_threshold"), - ("AB_SINKFB","ablative_radiative_sink_fallback_temperature")): - P[_IDX[name]] = g(f"cooling.{fld}") - return P - diff --git a/EngineDesign/engine/accel/params.py b/EngineDesign/engine/accel/params.py index 36b9ea372..aec94752b 100644 --- a/EngineDesign/engine/accel/params.py +++ b/EngineDesign/engine/accel/params.py @@ -23,6 +23,7 @@ """ from __future__ import annotations +import numpy as np from types import SimpleNamespace # Enum mappings -- mirror native_injector._PHI / _INJ / _EFF_MODEL. @@ -242,3 +243,151 @@ def build_state(config): cooling=_cooling(config), geom=_geom(ensure_chamber_geometry(config)), ) + + +# --------------------------------------------------------------------------- +# Parameter-vector layout +# +# The kernels take one flat float64 array rather than a struct, so every scalar +# needs a stable index. _NAMES is that layout and the single source of truth for +# it; kernels.py pulls these names into its module globals so @njit code can use +# them as compile-time constants (P[G_AT] and friends). +# +# APPEND ONLY. Inserting a name shifts every index after it, which silently +# invalidates the cached .nbc artifacts compiled against the old layout. +# --------------------------------------------------------------------------- +# ---- parameter vector layout (mirrors the fields the C residual reads) ------- +_NAMES = [ + # fluids + "RHO_O", "MU_O", "SIG_O", "T_O", + "RHO_F", "MU_F", "SIG_F", "T_F", "LAT_F", + # injector geom + "DJO", "DJF", "NO", "NF", "ANG_O", "ANG_F", + # discharge O (16) + "DO_CDINF", "DO_ARE", "DO_CDMIN", "DO_GEOM", "DO_DREF", "DO_DMIN", "DO_EXPS", + "DO_LOGG", "DO_CDMAX", "DO_CDFLOOR", "DO_UPC", "DO_PREF", "DO_AP", "DO_UTC", "DO_TREF", "DO_AT", + # discharge F (16) + "DF_CDINF", "DF_ARE", "DF_CDMIN", "DF_GEOM", "DF_DREF", "DF_DMIN", "DF_EXPS", + "DF_LOGG", "DF_CDMAX", "DF_CDFLOOR", "DF_UPC", "DF_PREF", "DF_AP", "DF_UTC", "DF_TREF", "DF_AT", + # feed O / F + "FO_DIN", "FO_AH", "FO_K0", "FO_K1", "FO_PHI", + "FF_DIN", "FF_AH", "FF_K0", "FF_K1", "FF_PHI", + # spray + "SP_SMDMODEL", "SP_SMDC", "SP_SMDM", "SP_SMDP", "SP_SMDCING", "SP_SMDWECORR", + "SP_GASR", "SP_GAST", "SP_ANGMODEL", "SP_ANGK", "SP_ANGN", "SP_WEMIN", + "SP_EVAPK", "SP_EVAPXLIM", "SP_EVAPUSE", + # solver + "SV_CLMAX", "SV_CLCDRED", "SV_PCMIN", "SV_PCMAX", "SV_TOL", "SV_MAXIT", + # geom + "G_EPS", "G_AT", "G_AE", "G_VOL", "G_LSTAR", "G_DCHAM", "G_NOZZEFF", + # combustion + "C_MODEL", "C_C", "C_TAUREF", "C_TAUREFP", "C_TAUREFT", "C_NPRESS", "C_TSTARCAP", + "C_HASFLOOR", "C_TAUFLOOR", "C_EMPEAK", "C_SIGMA", "C_ROPT", + # chamber lengths (ablative wetted area only) + "G_LEN", "G_LCYL", "G_LCONTR", + # cooling gates + hot-gas block (ed_cooling.c) + "K_ABLEN", "K_FILMEN", "K_REGENEN", "K_USECOUP", "K_EFFFLOOR", + "K_HGMU", "K_HGK", "K_HGPR", "K_TI", "K_RECOV", "K_EMISHOT", "K_VIEWF", "K_DREGEN", + # ablative material / blowing / turbulence / radiative sink + "AB_COV", "AB_TSURF", "AB_TPYRO", "AB_HABL", "AB_CP", "AB_USEPHYS", + "AB_BLOWEFF", "AB_BLOWC", "AB_BLOWMIN", + "AB_TIREF", "AB_TISENS", "AB_TIEXP", "AB_TIMAX", + "AB_EMIS", "AB_TAMB", "AB_SINKMIN", "AB_SINKFB", +] +_IDX = {n: i for i, n in enumerate(_NAMES)} +globals().update(_IDX) # module-level int constants for njit +NP = len(_NAMES) + + +def _assert_supported(st): + """Guard the assumptions that let this port skip cooling / use the impinging path.""" + assert int(st.injector.type) == 1, "not impinging" + # Ablative IS ported (see _cooling_evaluate). Film/regen are not -- C refuses + # them too (ed_cooling.c:147), so they stay a Python fallback. + assert int(getattr(st.cooling, "film_enabled")) == 0 and int(getattr(st.cooling, "regen_enabled")) == 0 + # No graphite gate, deliberately: C does not check it either (ed_cooling.c + # refuses only film/regen at :147), because graphite never enters the chamber + # residual -- it lives in the burn/recession path (runner.py), and + # chamber_solver.py references it zero times. Gating on it here would reject + # configs/canonical/impinging.yaml, which C handles fine. + + +def extract_params(config): + """Flatten the config scalars this port needs into a float64 vec. + + Delegates the field mapping to _params_from_state so there is ONE table of + field names; the two used to carry independent copies of the whole mapping, + which would drift the moment a field was added on one side only. + """ + st = build_state(config) # pure Python -- no C library required + _assert_supported(st) + return _params_from_state(st) + + + +def _params_from_state(st): + """Build the param vector from an already-built EdEngineState (avoids a 2nd build_state).""" + def g(path): + o = st + for part in path.split("."): + o = getattr(o, part) + return float(o) + P = np.zeros(NP) + P[_IDX["RHO_O"]] = g("fluid_O.density"); P[_IDX["MU_O"]] = g("fluid_O.viscosity"); P[_IDX["SIG_O"]] = g("fluid_O.surface_tension"); P[_IDX["T_O"]] = g("fluid_O.temperature") + P[_IDX["RHO_F"]] = g("fluid_F.density"); P[_IDX["MU_F"]] = g("fluid_F.viscosity"); P[_IDX["SIG_F"]] = g("fluid_F.surface_tension"); P[_IDX["T_F"]] = g("fluid_F.temperature"); P[_IDX["LAT_F"]] = g("fluid_F.latent_heat") + P[_IDX["DJO"]] = g("injector.imp_O.d_jet"); P[_IDX["DJF"]] = g("injector.imp_F.d_jet") + P[_IDX["NO"]] = g("injector.imp_O.n_elements"); P[_IDX["NF"]] = g("injector.imp_F.n_elements") + P[_IDX["ANG_O"]] = g("injector.imp_O.impingement_angle"); P[_IDX["ANG_F"]] = g("injector.imp_F.impingement_angle") + for pre, side in (("DO", "discharge_O"), ("DF", "discharge_F")): + for suf, fld in (("CDINF","Cd_inf"),("ARE","a_Re"),("CDMIN","Cd_min"),("GEOM","use_geometry_cd"), + ("DREF","d_ref_m"),("DMIN","d_min_m"),("EXPS","cd_small_hole_exponent"),("LOGG","cd_large_hole_log_gain"), + ("CDMAX","cd_inf_max"),("CDFLOOR","cd_inf_min_geom"),("UPC","use_pressure_correction"),("PREF","P_ref"), + ("AP","a_P"),("UTC","use_temperature_correction"),("TREF","T_ref"),("AT","a_T")): + P[_IDX[f"{pre}_{suf}"]] = g(f"{side}.{fld}") + for pre, side in (("FO", "feed_O"), ("FF", "feed_F")): + for suf, fld in (("DIN","d_inlet"),("AH","A_hydraulic"),("K0","K0"),("K1","K1"),("PHI","phi_type")): + P[_IDX[f"{pre}_{suf}"]] = g(f"{side}.{fld}") + for suf, fld in (("SMDMODEL","smd_model"),("SMDC","smd_C"),("SMDM","smd_m"),("SMDP","smd_p"),("SMDCING","smd_C_ingebo"), + ("SMDWECORR","smd_we_corr_max"),("GASR","chamber_gas_R"),("GAST","chamber_gas_T"),("ANGMODEL","spray_angle_model"), + ("ANGK","spray_angle_k"),("ANGN","spray_angle_n"),("WEMIN","we_min"),("EVAPK","evap_K"), + ("EVAPXLIM","evap_x_star_limit"),("EVAPUSE","evap_use_constraint")): + P[_IDX[f"SP_{suf}"]] = g(f"spray.{fld}") + for suf, fld in (("CLMAX","closure_max_iterations"),("CLCDRED","closure_Cd_reduction_factor"),("PCMIN","Pc_min_bound"), + ("PCMAX","Pc_max_bound"),("TOL","tolerance"),("MAXIT","max_iterations")): + P[_IDX[f"SV_{suf}"]] = g(f"solver.{fld}") + for suf, fld in (("EPS","expansion_ratio"),("AT","A_throat"),("AE","A_exit"),("VOL","volume"),("LSTAR","Lstar"), + ("DCHAM","chamber_diameter"),("NOZZEFF","nozzle_efficiency")): + P[_IDX[f"G_{suf}"]] = g(f"geom.{fld}") + for suf, fld in (("MODEL","model"),("C","C"),("TAUREF","tau_ref"),("TAUREFP","tau_ref_P"),("TAUREFT","tau_ref_T"), + ("NPRESS","n_pressure"),("TSTARCAP","T_star_fuel_cap_K"),("HASFLOOR","has_tau_Tc_floor"), + ("TAUFLOOR","tau_Tc_floor"),("EMPEAK","Em_peak"),("SIGMA","mixing_sigma"),("ROPT","R_opt")): + P[_IDX[f"C_{suf}"]] = g(f"comb.{fld}") + for suf, fld in (("LEN","length"),("LCYL","length_cylindrical"),("LCONTR","length_contraction")): + P[_IDX[f"G_{suf}"]] = g(f"geom.{fld}") + for name, fld in (("K_ABLEN","ablative_enabled"),("K_FILMEN","film_enabled"), + ("K_REGENEN","regen_enabled"),("K_USECOUP","use_cooling_coupling"), + ("K_EFFFLOOR","cooling_efficiency_floor"),("K_HGMU","hot_gas_viscosity"), + ("K_HGK","hot_gas_thermal_conductivity"),("K_HGPR","hot_gas_prandtl"), + ("K_TI","gas_turbulence_intensity"),("K_RECOV","recovery_factor"), + ("K_EMISHOT","radiation_emissivity_hot"),("K_VIEWF","radiation_view_factor"), + ("K_DREGEN","regen_chamber_inner_diameter"), + ("AB_COV","ablative_coverage_fraction"), + ("AB_TSURF","ablative_surface_temperature_limit"), + ("AB_TPYRO","ablative_pyrolysis_temperature"), + ("AB_HABL","ablative_heat_of_ablation"),("AB_CP","ablative_specific_heat"), + ("AB_USEPHYS","ablative_use_physics_based_blowing"), + ("AB_BLOWEFF","ablative_blowing_efficiency"), + ("AB_BLOWC","ablative_blowing_coefficient"), + ("AB_BLOWMIN","ablative_blowing_min_reduction_factor"), + ("AB_TIREF","ablative_turbulence_reference_intensity"), + ("AB_TISENS","ablative_turbulence_sensitivity"), + ("AB_TIEXP","ablative_turbulence_exponent"), + ("AB_TIMAX","ablative_turbulence_max_multiplier"), + ("AB_EMIS","ablative_surface_emissivity"), + ("AB_TAMB","ablative_ambient_temperature"), + ("AB_SINKMIN","ablative_radiative_sink_minimum_threshold"), + ("AB_SINKFB","ablative_radiative_sink_fallback_temperature")): + P[_IDX[name]] = g(f"cooling.{fld}") + return P + + diff --git a/EngineDesign/scripts/bench_layer1_numba.py b/EngineDesign/scripts/bench_layer1_numba.py index 1b8725a46..8558136e0 100644 --- a/EngineDesign/scripts/bench_layer1_numba.py +++ b/EngineDesign/scripts/bench_layer1_numba.py @@ -70,9 +70,9 @@ def _run_condition(mode, cfg_path, max_it, restarts, seed): else: os.environ["ED_USE_NATIVE"] = "1"; os.environ["ED_LAYER1_NATIVE_EVAL"] = "1" if mode == "numba": - import numba_eval + from engine import accel from engine.native.python import native_injector as ni - ni.evaluate = numba_eval.make_native_signature_evaluate() # patch BEFORE instrumentation + ni.evaluate = accel.evaluate # patch BEFORE instrumentation _install_instrumentation() _one(cfg_path, 1, 1, seed) # warmup (JIT compile, CEA load) — discarded C.reset() diff --git a/EngineDesign/tests/test_numba_ab_parity.py b/EngineDesign/tests/test_numba_ab_parity.py index 92ed0eb69..ba342354f 100644 --- a/EngineDesign/tests/test_numba_ab_parity.py +++ b/EngineDesign/tests/test_numba_ab_parity.py @@ -95,9 +95,7 @@ def _rig(cfg_rel): if not native_injector.native_enabled(): pytest.skip("native kernel not enabled") - import sys - sys.path.insert(0, str(ROOT / "scripts")) - import numba_eval + from engine.accel import kernels from engine.core.runner import PintleEngineRunner from engine.pipeline.io import load_config @@ -109,8 +107,8 @@ def _rig(cfg_rel): points = [(po * PSI_TO_PA, pf * PSI_TO_PA) for po, pf in POINTS_PSI] rig = { "config": config, "runner": runner, "cache": runner.cea_cache, - "ni": native_injector, "nb": numba_eval.NumbaEvaluator(config, runner.cea_cache), - "mod": numba_eval, "points": points, + "ni": native_injector, "nb": kernels.NumbaEvaluator(config, runner.cea_cache), + "mod": kernels, "points": points, "reference": {p: runner.evaluate(p[0], p[1], P_ambient=PA_AMBIENT, silent=True) for p in points}, } @@ -231,3 +229,57 @@ def test_matches_c_effective_tc(self): c = r["ni"].evaluate(r["config"], r["cache"], p_o, p_f, PA_AMBIENT) n = r["nb"].evaluate(p_o, p_f, PA_AMBIENT) _assert_close("Tc (effective)", n["Tc"], c["Tc"], RTOL_TIGHT) + + +class TestDiagnosticsMatchC: + """The injector diagnostics dict, assembled from Numba's own solve. + + This used to come from a second solve in C (native_injector._nat().injector_solve + + _result_to_diag). Numba's injector_solve already computed every value; they + were simply discarded. Assembling them here is what removes the last C call + from the accelerated evaluate path. + """ + + # C also emits these. None is read off an accelerated result anywhere on the + # stability or optimizer path: A_eff_O/F is recomputed downstream from Cd by + # flow_capacity.effective_flow_areas_from_cd, turbulence_intensity_mix is read + # only at chamber_solver.py:180 (the full-Python path, which never sees this + # dict), and J/TMR/theta/feed_orifice_coupling_iterations have no reader at all. + KNOWN_ABSENT = { + "A_eff_O", "A_eff_F", "J", "TMR", "theta", + "turbulence_intensity_mix", "feed_orifice_coupling_iterations", + } + + @pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).split("/")[-1]) + def test_fields_match_and_nothing_new_is_missing(self, cfg_rel, ablative): + import math + from engine.accel import diagnostics, params + r = _rig(cfg_rel) + ni, kernels = r["ni"], r["mod"] + + P = params.extract_params(r["config"]) + arr = kernels.cea_arrays(r["cache"]) + state = ni.build_state(r["config"]) + + for p_o, p_f in r["points"]: + core = kernels.evaluate_core(P, *arr, p_o, p_f, PA_AMBIENT) + assert core[0], f"kernel did not converge at {p_o:.0f}/{p_f:.0f}" + Pc = core[1] + got = diagnostics.build_diag(P, kernels.injector_solve(P, p_o, p_f, Pc)) + rc, ir = ni._nat().injector_solve(state, p_o, p_f, Pc) + assert rc == 0, "C injector solve failed" + want = ni._result_to_diag(r["config"], ir) + + # A newly-missing field means a consumer could silently get None. + newly_absent = (set(want) - set(got)) - self.KNOWN_ABSENT + assert not newly_absent, ( + f"fields C provides but Numba dropped: {sorted(newly_absent)}. " + "Either produce them or justify the omission in KNOWN_ABSENT." + ) + + for k in sorted(set(want) & set(got)): + a, b = got[k], want[k] + if isinstance(b, bool) or not isinstance(b, (int, float)): + assert a == b, f"{k}: numba={a!r} C={b!r}" + elif b and math.isfinite(float(b)): + _assert_close(f"diag[{k}]", a, b, RTOL_TIGHT) From 53a610bb260272f9f5369970b17bf4dab4b2097e Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 02:07:16 -0700 Subject: [PATCH 06/20] Route production through engine.accel: solve, chamber_solve, dispatcher, call sites --- EngineDesign/backend/main.py | 32 +++-- EngineDesign/engine/accel/__init__.py | 133 +++++++++++++++++- EngineDesign/engine/accel/diagnostics.py | 43 ++++-- EngineDesign/engine/accel/params.py | 96 ++++++++----- EngineDesign/engine/core/chamber_solver.py | 15 +- EngineDesign/engine/core/closure.py | 60 ++++---- .../layers/layer1_static_optimization.py | 72 ++++++---- EngineDesign/scripts/bench_layer1_numba.py | 21 +-- EngineDesign/tests/test_numba_ab_parity.py | 19 +-- 9 files changed, 342 insertions(+), 149 deletions(-) diff --git a/EngineDesign/backend/main.py b/EngineDesign/backend/main.py index 9969bdc50..8405c8a0f 100644 --- a/EngineDesign/backend/main.py +++ b/EngineDesign/backend/main.py @@ -18,21 +18,23 @@ if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) -# Enable the native physics kernel (engine/native) for backend-launched work, -# including the Layer-1 optimizer. This MUST run before the engine/optimizer -# modules import and before the Layer-1 ProcessPool spawns, so worker processes -# inherit the flag. Opt out with ED_USE_NATIVE=0. The native path self-checks -# against Python on first use and falls back automatically on any mismatch, so -# enabling it cannot change results — only speed (chamber solve ~400x; an -# optimizer candidate ~60x). -os.environ.setdefault("ED_USE_NATIVE", "1") -if os.environ.get("ED_USE_NATIVE") == "1": - try: - from engine.native.python import autobuild as _ed_autobuild - _ed_lib = _ed_autobuild.ensure_lib() # build once here so pool workers don't race - print(f"[native] kernel enabled (ED_USE_NATIVE=1): {_ed_lib}") - except Exception as _ed_err: # pragma: no cover - native is best-effort - print(f"[native] kernel unavailable, using Python path: {_ed_err}") +# Warm the physics accelerator (engine/accel) for backend-launched work, including +# the Layer-1 optimizer. This MUST run before the engine/optimizer modules import +# and before the Layer-1 ProcessPool spawns. Opt out with ED_ACCEL=off. +# +# Correction to what this comment used to claim: there is NO runtime self-check +# against Python (see engine/accel/__init__.py and the note in native_injector). +# Equivalence is enforced ahead of time by tests/test_numba_ab_parity.py, which +# diffs the accelerated and Python paths live on the same inputs. +try: + from engine import accel as _accel + if _accel.enabled(): + _accel.warmup() + print("[accel] numba kernels warmed (ED_ACCEL=numba)") + else: + print("[accel] disabled; using the Python path") +except Exception as _ed_err: # pragma: no cover - accelerator is best-effort + print(f"[accel] unavailable, using Python path: {_ed_err}") # Import control router first (required for controller) from backend.routers import control diff --git a/EngineDesign/engine/accel/__init__.py b/EngineDesign/engine/accel/__init__.py index efa0bc1db..c5a64d971 100644 --- a/EngineDesign/engine/accel/__init__.py +++ b/EngineDesign/engine/accel/__init__.py @@ -13,7 +13,24 @@ import os -__all__ = ["available", "enabled", "can_handle", "can_handle_chamber", "evaluate"] +__all__ = ["available", "enabled", "can_handle", "can_handle_chamber", + "evaluate", "solve", "chamber_solve", "warmup", "require"] + + +def _c_backend(): + """native_injector when ED_ACCEL=c, else None. + + Lets a single run route the whole accelerated surface through the C port, so + the parity suite and CI can exercise both backends without either + implementation knowing the other exists. Deleted with the C tree. + """ + if os.environ.get("ED_ACCEL") != "c": + return None + try: + from engine.native.python import native_injector + except Exception: + return None + return native_injector def available() -> bool: @@ -26,13 +43,27 @@ def available() -> bool: def enabled() -> bool: - if os.environ.get("ED_ACCEL", "numba") == "off": + mode = os.environ.get("ED_ACCEL", "numba") + if mode == "off": return False if os.environ.get("ED_USE_NATIVE") == "0": # honour the historical switch return False + if mode == "c": + ni = _c_backend() + return bool(ni and ni.native_enabled()) return available() +def require() -> bool: + """Strict mode: a genuine accelerator failure raises instead of falling back. + + Honours ED_REQUIRE_NATIVE too, so the existing CI parity job keeps its contract + while both backends coexist. + """ + return (os.environ.get("ED_REQUIRE_ACCEL") == "1" + or os.environ.get("ED_REQUIRE_NATIVE") == "1") + + def can_handle(config) -> bool: """Mirrors native_injector._can_handle.""" inj = getattr(config, "injector", None) @@ -67,6 +98,9 @@ def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): Signature matches native_injector.evaluate so it drops into the same slot. """ + _ni = _c_backend() + if _ni is not None: + return _ni.evaluate(config, cache, P_tank_O, P_tank_F, P_ambient) from engine.accel import diagnostics as _diag from engine.accel import kernels as _k from engine.accel import params as _p @@ -117,3 +151,98 @@ def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): "diagnostics": diag, "P_ambient": float(P_ambient), "native_fast_eval": True, "numba_fast_eval": True, } + + +def solve(config, P_tank_O, P_tank_F, Pc): + """Injector mass flows at a given Pc -> (mdot_O, mdot_F, diagnostics), or None. + + Mirrors native_injector.solve. This sits on the FALLBACK path: closure.flows + calls it on every residual iteration of the Python chamber solve, so it runs + far more often than evaluate() does. + + The param vector is rebuilt per call rather than cached on the config, exactly + as the C path rebuilds its state per call. Caching would be wrong here: Layer 1 + mutates the worker's config in place between candidates + (_apply_x_to_worker_config_inplace), so a config-keyed cache would serve stale + geometry. + """ + _ni = _c_backend() + if _ni is not None: + return _ni.solve(config, P_tank_O, P_tank_F, Pc) + from engine.accel import diagnostics as _diag + from engine.accel import kernels as _k + from engine.accel import params as _p + + if not can_handle(config): + return None + try: + P = _p.extract_params(config) + except AssertionError: + return None + sol = _k.injector_solve(P, float(P_tank_O), float(P_tank_F), float(Pc)) + if not sol[0]: + return None + return float(sol[1]), float(sol[2]), _diag.build_diag(P, sol) + + +def chamber_solve(config, cache, P_tank_O, P_tank_F): + """Whole chamber residual loop -> (Pc, diagnostics), or None. + + Mirrors native_injector.chamber_solve. The only consumer + (chamber_solver._native_chamber_pc) reads element 0, so the second element is + a plain dict here rather than the C path's ctypes struct. + + Shares evaluate_core's Brent solve instead of duplicating it. That computes a + little more than Pc (nozzle/thrust), which is deliberate: a second, subtly + different root-find is exactly how the two paths would drift apart. + """ + _ni = _c_backend() + if _ni is not None: + return _ni.chamber_solve(config, cache, P_tank_O, P_tank_F) + from engine.accel import kernels as _k + from engine.accel import params as _p + + if not can_handle_chamber(config): + return None + if not getattr(cache, "use_3d", False): + return None + try: + P = _p.extract_params(config) + except AssertionError: + return None + arr = _k._cea_arrays_cached(cache) + r = _k.evaluate_core(P, *arr, float(P_tank_O), float(P_tank_F), 101325.0) + if not r[0]: + return None + Pc = float(r[1]) + if not (Pc > 0.0) or Pc != Pc: + return None + return Pc, {"Pc": Pc, "mdot_O": r[11], "mdot_F": r[12], "mdot_total": r[8], + "MR": r[4], "cstar_ideal": r[13], "cstar_actual": r[5], + "eta_cstar": r[14], "gamma": r[6], "R": r[15], + "Tc": r[21], "Tc_ideal": r[7], "converged": True} + + +def warmup(): + """Force the JIT to compile/load before a ProcessPool is built. + + @njit(cache=True) persists compiled code, but each worker process still + deserializes it on first call -- un-warmed, that lands inside the first CMA + generation and skews it. Call this in the parent AND in the pool's worker + initialiser, which is where the C path called autobuild.prewarm(). + + Never raises: a warmup failure must not block optimization, exactly as the + C prewarm didn't. + """ + try: + import numpy as np + from engine.accel import kernels as _k + from engine.accel.params import NP + n = 2 + grid = np.linspace(1.0, 2.0, n) + tab = np.ones((n, n, n), dtype=np.float64) + _k.evaluate_core(np.zeros(NP), grid, grid, grid, + tab, tab, tab, tab, tab, tab, tab, 1.0, 1.0, 1.0) + return True + except Exception: + return False diff --git a/EngineDesign/engine/accel/diagnostics.py b/EngineDesign/engine/accel/diagnostics.py index bf133bc21..8c7093395 100644 --- a/EngineDesign/engine/accel/diagnostics.py +++ b/EngineDesign/engine/accel/diagnostics.py @@ -9,16 +9,15 @@ object is needed, which is what keeps this callable from inside a worker without re-parsing YAML. -DELIBERATE OMISSIONS, measured rather than assumed. C's _result_to_diag also -emits A_eff_O/F, J, TMR, theta, turbulence_intensity_mix and -feed_orifice_coupling_iterations. A grep of every reader on the stability and -optimizer paths shows none of them is ever read off an accelerated result: -A_eff_O/F is recomputed downstream from Cd by -engine/core/injectors/flow_capacity.effective_flow_areas_from_cd, and -turbulence_intensity_mix is consumed only at chamber_solver.py:180, which is the -full-Python chamber path and never sees this dict. Emitting them would mean -porting spray quantities no consumer wants. If a future consumer needs one, the -parity test below is where that will surface. +OMISSIONS. C's _result_to_diag also emits J, TMR, theta and +feed_orifice_coupling_iterations; no reader for those exists anywhere, and they +are impinging spray quantities the accelerated path never needs. + +A_eff_O/F and the turbulence block ARE emitted, after an initial omission proved +wrong: A_eff is asserted present (not merely derivable) by +tests/test_flow_capacity_effective_area.py, and turbulence_intensity_mix reaches +chamber_solver.py:180 through *closure* diagnostics -- i.e. the accel.solve path, +which is distinct from the accel.evaluate path the first analysis looked at. """ from __future__ import annotations @@ -30,6 +29,7 @@ _NO, _NF = _IDX["NO"], _IDX["NF"] _RHO_O, _RHO_F = _IDX["RHO_O"], _IDX["RHO_F"] _ANG_O, _ANG_F = _IDX["ANG_O"], _IDX["ANG_F"] +_MU_O, _MU_F = _IDX["MU_O"], _IDX["MU_F"] def build_diag(P, sol): @@ -48,8 +48,31 @@ def build_diag(P, sol): mdot_bn_O = Cd_O * A_geom_O * math.sqrt(2.0 * rho_O * dpi_O) if dpi_O > 0 else 0.0 mdot_bn_F = Cd_F * A_geom_F * math.sqrt(2.0 * rho_F * dpi_F) if dpi_F > 0 else 0.0 + # Effective flow areas. flow_capacity.effective_flow_areas_from_cd recomputes + # these downstream, but tests and consumers require them PRESENT on the result, + # so emit them here exactly as Cd * A_geom. + A_eff_O = Cd_O * A_geom_O + A_eff_F = Cd_F * A_geom_F + + # Shear-layer turbulence, mirroring impinging.py:155-172 (d_hyd == d_jet for + # impinging, impinging.py:117-118). Consumed via closure diagnostics at + # chamber_solver.py:180 -- that is the accel.solve path, not the evaluate path. + def _ti(rho, u, d, mu): + Re = (rho * u * d / mu) if mu > 0 else 0.0 + t = 0.16 * (Re ** -0.125) if Re > 0 else 0.1 + return min(max(t, 0.02), 0.3) + + ti_O = _ti(rho_O, u_O, djo, float(P[_MU_O])) + ti_F = _ti(rho_F, u_F, djf, float(P[_MU_F])) + v_tot = max(u_O + u_F, 1e-6) + ti_mix = min(max((ti_O * u_O + ti_F * u_F) / v_tot, 0.02), 0.35) + diag = { "injector_type": "impinging", + "A_eff_O": A_eff_O, "A_eff_F": A_eff_F, + "turbulence_intensity_O": ti_O, "turbulence_intensity_F": ti_F, + "turbulence_length_O": 0.07 * djo, "turbulence_length_F": 0.07 * djf, + "turbulence_intensity_mix": ti_mix, "iterations": int(n_iter), "constraints_satisfied": bool(constraints_ok), "We_O": We_O, "We_F": We_F, diff --git a/EngineDesign/engine/accel/params.py b/EngineDesign/engine/accel/params.py index aec94752b..9df439376 100644 --- a/EngineDesign/engine/accel/params.py +++ b/EngineDesign/engine/accel/params.py @@ -24,6 +24,7 @@ from __future__ import annotations import numpy as np +from operator import attrgetter as _attrgetter from types import SimpleNamespace # Enum mappings -- mirror native_injector._PHI / _INJ / _EFF_MODEL. @@ -325,45 +326,56 @@ def extract_params(config): -def _params_from_state(st): - """Build the param vector from an already-built EdEngineState (avoids a 2nd build_state).""" - def g(path): - o = st - for part in path.split("."): - o = getattr(o, part) - return float(o) - P = np.zeros(NP) - P[_IDX["RHO_O"]] = g("fluid_O.density"); P[_IDX["MU_O"]] = g("fluid_O.viscosity"); P[_IDX["SIG_O"]] = g("fluid_O.surface_tension"); P[_IDX["T_O"]] = g("fluid_O.temperature") - P[_IDX["RHO_F"]] = g("fluid_F.density"); P[_IDX["MU_F"]] = g("fluid_F.viscosity"); P[_IDX["SIG_F"]] = g("fluid_F.surface_tension"); P[_IDX["T_F"]] = g("fluid_F.temperature"); P[_IDX["LAT_F"]] = g("fluid_F.latent_heat") - P[_IDX["DJO"]] = g("injector.imp_O.d_jet"); P[_IDX["DJF"]] = g("injector.imp_F.d_jet") - P[_IDX["NO"]] = g("injector.imp_O.n_elements"); P[_IDX["NF"]] = g("injector.imp_F.n_elements") - P[_IDX["ANG_O"]] = g("injector.imp_O.impingement_angle"); P[_IDX["ANG_F"]] = g("injector.imp_F.impingement_angle") +# --------------------------------------------------------------------------- +# state -> param vector +# +# The (index, attribute-path) mapping is STATIC, so it is built once at import +# and frozen into attrgetters. It used to be walked per call with str.split(".") +# and f-string key lookups, which cost 96 us per extraction -- 6x the cost of +# building the state itself, and paid on EVERY residual iteration of the fallback +# path via accel.solve. Same field list, same order; only the lookup is hoisted. +# --------------------------------------------------------------------------- +def _build_path_table(): + paths = { + "RHO_O": "fluid_O.density", "MU_O": "fluid_O.viscosity", + "SIG_O": "fluid_O.surface_tension", "T_O": "fluid_O.temperature", + "RHO_F": "fluid_F.density", "MU_F": "fluid_F.viscosity", + "SIG_F": "fluid_F.surface_tension", "T_F": "fluid_F.temperature", + "LAT_F": "fluid_F.latent_heat", + "DJO": "injector.imp_O.d_jet", "DJF": "injector.imp_F.d_jet", + "NO": "injector.imp_O.n_elements", "NF": "injector.imp_F.n_elements", + "ANG_O": "injector.imp_O.impingement_angle", + "ANG_F": "injector.imp_F.impingement_angle", + } for pre, side in (("DO", "discharge_O"), ("DF", "discharge_F")): for suf, fld in (("CDINF","Cd_inf"),("ARE","a_Re"),("CDMIN","Cd_min"),("GEOM","use_geometry_cd"), - ("DREF","d_ref_m"),("DMIN","d_min_m"),("EXPS","cd_small_hole_exponent"),("LOGG","cd_large_hole_log_gain"), - ("CDMAX","cd_inf_max"),("CDFLOOR","cd_inf_min_geom"),("UPC","use_pressure_correction"),("PREF","P_ref"), - ("AP","a_P"),("UTC","use_temperature_correction"),("TREF","T_ref"),("AT","a_T")): - P[_IDX[f"{pre}_{suf}"]] = g(f"{side}.{fld}") + ("DREF","d_ref_m"),("DMIN","d_min_m"),("EXPS","cd_small_hole_exponent"), + ("LOGG","cd_large_hole_log_gain"),("CDMAX","cd_inf_max"),("CDFLOOR","cd_inf_min_geom"), + ("UPC","use_pressure_correction"),("PREF","P_ref"),("AP","a_P"), + ("UTC","use_temperature_correction"),("TREF","T_ref"),("AT","a_T")): + paths[f"{pre}_{suf}"] = f"{side}.{fld}" for pre, side in (("FO", "feed_O"), ("FF", "feed_F")): for suf, fld in (("DIN","d_inlet"),("AH","A_hydraulic"),("K0","K0"),("K1","K1"),("PHI","phi_type")): - P[_IDX[f"{pre}_{suf}"]] = g(f"{side}.{fld}") - for suf, fld in (("SMDMODEL","smd_model"),("SMDC","smd_C"),("SMDM","smd_m"),("SMDP","smd_p"),("SMDCING","smd_C_ingebo"), - ("SMDWECORR","smd_we_corr_max"),("GASR","chamber_gas_R"),("GAST","chamber_gas_T"),("ANGMODEL","spray_angle_model"), - ("ANGK","spray_angle_k"),("ANGN","spray_angle_n"),("WEMIN","we_min"),("EVAPK","evap_K"), + paths[f"{pre}_{suf}"] = f"{side}.{fld}" + for suf, fld in (("SMDMODEL","smd_model"),("SMDC","smd_C"),("SMDM","smd_m"),("SMDP","smd_p"), + ("SMDCING","smd_C_ingebo"),("SMDWECORR","smd_we_corr_max"),("GASR","chamber_gas_R"), + ("GAST","chamber_gas_T"),("ANGMODEL","spray_angle_model"),("ANGK","spray_angle_k"), + ("ANGN","spray_angle_n"),("WEMIN","we_min"),("EVAPK","evap_K"), ("EVAPXLIM","evap_x_star_limit"),("EVAPUSE","evap_use_constraint")): - P[_IDX[f"SP_{suf}"]] = g(f"spray.{fld}") - for suf, fld in (("CLMAX","closure_max_iterations"),("CLCDRED","closure_Cd_reduction_factor"),("PCMIN","Pc_min_bound"), - ("PCMAX","Pc_max_bound"),("TOL","tolerance"),("MAXIT","max_iterations")): - P[_IDX[f"SV_{suf}"]] = g(f"solver.{fld}") - for suf, fld in (("EPS","expansion_ratio"),("AT","A_throat"),("AE","A_exit"),("VOL","volume"),("LSTAR","Lstar"), - ("DCHAM","chamber_diameter"),("NOZZEFF","nozzle_efficiency")): - P[_IDX[f"G_{suf}"]] = g(f"geom.{fld}") - for suf, fld in (("MODEL","model"),("C","C"),("TAUREF","tau_ref"),("TAUREFP","tau_ref_P"),("TAUREFT","tau_ref_T"), - ("NPRESS","n_pressure"),("TSTARCAP","T_star_fuel_cap_K"),("HASFLOOR","has_tau_Tc_floor"), - ("TAUFLOOR","tau_Tc_floor"),("EMPEAK","Em_peak"),("SIGMA","mixing_sigma"),("ROPT","R_opt")): - P[_IDX[f"C_{suf}"]] = g(f"comb.{fld}") - for suf, fld in (("LEN","length"),("LCYL","length_cylindrical"),("LCONTR","length_contraction")): - P[_IDX[f"G_{suf}"]] = g(f"geom.{fld}") + paths[f"SP_{suf}"] = f"spray.{fld}" + for suf, fld in (("CLMAX","closure_max_iterations"),("CLCDRED","closure_Cd_reduction_factor"), + ("PCMIN","Pc_min_bound"),("PCMAX","Pc_max_bound"),("TOL","tolerance"), + ("MAXIT","max_iterations")): + paths[f"SV_{suf}"] = f"solver.{fld}" + for suf, fld in (("EPS","expansion_ratio"),("AT","A_throat"),("AE","A_exit"),("VOL","volume"), + ("LSTAR","Lstar"),("DCHAM","chamber_diameter"),("NOZZEFF","nozzle_efficiency"), + ("LEN","length"),("LCYL","length_cylindrical"),("LCONTR","length_contraction")): + paths[f"G_{suf}"] = f"geom.{fld}" + for suf, fld in (("MODEL","model"),("C","C"),("TAUREF","tau_ref"),("TAUREFP","tau_ref_P"), + ("TAUREFT","tau_ref_T"),("NPRESS","n_pressure"),("TSTARCAP","T_star_fuel_cap_K"), + ("HASFLOOR","has_tau_Tc_floor"),("TAUFLOOR","tau_Tc_floor"),("EMPEAK","Em_peak"), + ("SIGMA","mixing_sigma"),("ROPT","R_opt")): + paths[f"C_{suf}"] = f"comb.{fld}" for name, fld in (("K_ABLEN","ablative_enabled"),("K_FILMEN","film_enabled"), ("K_REGENEN","regen_enabled"),("K_USECOUP","use_cooling_coupling"), ("K_EFFFLOOR","cooling_efficiency_floor"),("K_HGMU","hot_gas_viscosity"), @@ -387,7 +399,19 @@ def g(path): ("AB_TAMB","ablative_ambient_temperature"), ("AB_SINKMIN","ablative_radiative_sink_minimum_threshold"), ("AB_SINKFB","ablative_radiative_sink_fallback_temperature")): - P[_IDX[name]] = g(f"cooling.{fld}") - return P + paths[name] = f"cooling.{fld}" + missing = set(_NAMES) - set(paths) + assert not missing, f"param(s) with no source path: {sorted(missing)}" + return paths +_PATHS = _build_path_table() +_GETTERS = tuple((_IDX[n], _attrgetter(_PATHS[n])) for n in _NAMES) + + +def _params_from_state(st): + """Flatten a state namespace (or EdEngineState) into the float64 param vector.""" + P = np.zeros(NP) + for i, get in _GETTERS: + P[i] = get(st) + return P diff --git a/EngineDesign/engine/core/chamber_solver.py b/EngineDesign/engine/core/chamber_solver.py index ca56c15e5..01d7e2c07 100644 --- a/EngineDesign/engine/core/chamber_solver.py +++ b/EngineDesign/engine/core/chamber_solver.py @@ -239,17 +239,16 @@ def _native_chamber_pc(self, P_tank_O: float, P_tank_F: float): ed_native.py. A solve that raises or returns a non-finite Pc falls back to the Python solver for that call. """ - from engine.native.python import native_injector - if not native_injector.native_enabled(): + from engine import accel + if not accel.enabled(): return None try: - res = native_injector.chamber_solve(self.config, self.cea_cache, - P_tank_O, P_tank_F) + res = accel.chamber_solve(self.config, self.cea_cache, P_tank_O, P_tank_F) except Exception: - # Strict mode (ED_REQUIRE_NATIVE=1, CI parity job): surface a genuine - # native failure loudly instead of silently falling back to Python (which - # would report a false green). Default runs fall back quietly. - if native_injector.require_native(): + # Strict mode (ED_REQUIRE_ACCEL=1, CI parity job): surface a genuine + # accelerator failure loudly instead of silently falling back to Python + # (which would report a false green). Default runs fall back quietly. + if accel.require(): raise return None if res is None: diff --git a/EngineDesign/engine/core/closure.py b/EngineDesign/engine/core/closure.py index ee3948c54..95db9dab0 100644 --- a/EngineDesign/engine/core/closure.py +++ b/EngineDesign/engine/core/closure.py @@ -1,18 +1,18 @@ """Closure logic: solve branch flows with spray constraints. -The impinging branch routes through the native C kernel (engine/native) when -``ED_USE_NATIVE`` is on (the default). The Python injector models below run for -everything native doesn't cover — pintle, coaxial, an unbuilt library, or -``ED_USE_NATIVE=0``. Dispatch is pure capability routing: ``native_injector.solve`` -returns ``None`` for any config it can't handle and the Python model runs. +The impinging branch routes through the accelerator (engine/accel) when enabled +(the default). The Python injector models below run for everything it doesn't +cover — pintle, coaxial, a missing numba, or ``ED_ACCEL=off``. Dispatch is pure +capability routing: ``accel.solve`` returns ``None`` for any config it can't +handle and the Python model runs. This native fast-path matters for performance even with the Layer-1 single-call ``ed_evaluate`` seam: ~30% of CMA candidates don't converge in the single-call native path and fall back to ``runner.evaluate`` → ``chamber_solver`` → here, whose Brent residual solves the injector many times. Keeping that native keeps the fallback fast. -Native↔Python parity is guaranteed by the golden test suite (engine/native/tests) -and the load-time ABI assert in ed_native.py — there is no runtime self-check. +Accelerator↔Python parity is enforced by tests/test_numba_ab_parity.py, which +diffs both live on the same inputs — there is no runtime self-check. """ import os @@ -21,14 +21,13 @@ from engine.pipeline.config_schemas import PintleEngineConfig from engine.core.injectors import get_injector_model -# Build the native kernel on startup (background, non-blocking) when enabled, so the -# first evaluation does not pay the one-time CMake build cost. No-op by default. -if os.environ.get("ED_USE_NATIVE", "1") != "0": - try: - from engine.native.python import autobuild as _autobuild - _autobuild.prewarm() - except Exception: # pragma: no cover - never let prewarm break import - pass +# Warm the JIT on import so the first evaluation does not pay compile/load cost. +try: + from engine import accel as _accel + if _accel.enabled(): + _accel.warmup() +except Exception: # pragma: no cover - never let warmup break import + pass def _try_native_flows( @@ -37,28 +36,21 @@ def _try_native_flows( Pc: float, config: PintleEngineConfig, ) -> Optional[Tuple[float, float, Dict[str, Any]]]: - """Native (mdot_O, mdot_F, diagnostics), or None if native is disabled or can't - handle this config (the caller then falls back to the Python injector model). + """Accelerated (mdot_O, mdot_F, diagnostics), or None if the accelerator is + disabled or can't handle this config (caller falls back to the Python model). - Parity is guaranteed by the golden test suite + the load-time ABI assert, not a - runtime self-check (capability-dispatch architecture). Strict mode - (``ED_REQUIRE_NATIVE=1``, the CI parity job) makes a *genuine* native failure — - the library won't import or won't enable — raise instead of silently falling back - to Python, which would report a false green. A config the kernel simply doesn't - handle (``solve`` returns None) still falls back quietly; default runs are - unaffected. + Parity is enforced by the A/B suite, not a runtime self-check (capability + dispatch). Strict mode (``ED_REQUIRE_ACCEL=1``/``ED_REQUIRE_NATIVE=1``, the CI + parity job) makes a *genuine* accelerator failure raise instead of silently + falling back to Python, which would report a false green. A config the kernel + simply doesn't handle (``solve`` returns None) still falls back quietly. """ - try: - from engine.native.python import native_injector - except Exception: - if os.environ.get("ED_REQUIRE_NATIVE", "0") == "1": - raise + from engine import accel + if not accel.enabled(): + if accel.require(): + raise RuntimeError("strict mode set but the accelerator is not enabled/available") return None - if not native_injector.available(): - if native_injector.require_native(): - raise RuntimeError("ED_REQUIRE_NATIVE=1 but the native path is not enabled/available") - return None - return native_injector.solve(config, P_tank_O, P_tank_F, Pc) + return accel.solve(config, P_tank_O, P_tank_F, Pc) def flows( diff --git a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py index 557a0cbba..fa7895ac1 100644 --- a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py +++ b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py @@ -1256,7 +1256,19 @@ def _init_worker(config_dict: dict, bounds_array: np.ndarray, requirements_dict: debug_strict: If True, re-raise exceptions; if False, return penalties """ global _worker_runner, _worker_base_config, _worker_bounds, _worker_requirements, _worker_constants, _worker_debug_strict - + + # Warm the JIT in THIS process. @njit(cache=True) persists compiled code to + # disk, but the cache is not shared memory -- every worker still loads and + # deserializes it on first call. Un-warmed, that cost lands inside the first + # CMA generation and skews it. (The C path never needed this: one .so was + # mmapped into every child for free.) + try: + from engine import accel as _accel + if _accel.enabled(): + _accel.warmup() + except Exception: + pass # a warmup failure must never stop a worker from starting + # Reconstruct config from dict _worker_base_config = _dict_to_config(config_dict) @@ -1882,15 +1894,15 @@ def _hinge_band(val, lo, hi, scale=1.0): def _native_fast_eval_enabled() -> bool: """Whether the Layer-1 inner loop uses the single-call native ed_evaluate. - On whenever the native kernel is enabled (the default). Set - ED_LAYER1_NATIVE_EVAL=0 to force the full Python+shifting path for the inner - loop (debugging / A-B parity) without disabling native elsewhere. + On whenever the accelerator is enabled (the default). Set + ED_LAYER1_NATIVE_EVAL=0 to force the full Python path for the inner loop + (debugging / A-B parity) without disabling the accelerator elsewhere. """ import os if os.environ.get("ED_LAYER1_NATIVE_EVAL", "1") != "1": return False - from engine.native.python import native_injector - return native_injector.native_enabled() + from engine import accel + return accel.enabled() def _eval_candidate(x_raw): @@ -1922,16 +1934,21 @@ def _eval_candidate(x_raw): P_F_Pa = P_F_psi * 6894.76 # Evaluate using worker's runner (reused across calls). - # Inner-loop fast path: single native ed_evaluate call (chamber + frozen - # nozzle) + native-accelerated stability, ~4x faster per candidate. Falls - # back to the full Python+shifting path for unsupported configs (e.g. - # pintle) or any non-converged native solve. The winning design is always - # re-evaluated at full Python fidelity at finalization, so the frozen - # inner-loop nozzle never sets a reported number. + # Inner-loop fast path: one accelerated evaluate (chamber + nozzle + thrust) + # + accelerated stability, ~25x faster per candidate. Falls back to the full + # Python path for unsupported configs (e.g. pintle) or a non-converged + # accelerated solve. The winning design is always re-evaluated at full + # Python fidelity at finalization. + # + # NOTE: both paths compute the SAME delivered thrust + # (F = zeta_n*Cf_vac*Pc*At - Pa*Ae). The old "frozen vs shifting nozzle" + # distinction is gone -- the shifting-equilibrium nozzle was retired + # 2026-06-28 (reaction_chemistry.py) in favour of CEA's Cf_vac, which is + # itself a shifting-equilibrium coefficient baked into the cache. result = None if _native_fast_eval_enabled(): - from engine.native.python import native_injector as _ni - result = _ni.evaluate( + from engine import accel as _accel + result = _accel.evaluate( _worker_runner.config, _worker_runner.cea_cache, P_O_Pa, P_F_Pa, _worker_constants['P_ambient']) if result is None: @@ -4500,18 +4517,21 @@ def __init__(self, x, fun, success=True): optimizer_mode, ) - # Native kernel is the Layer-1 inner-loop accelerator (single ed_evaluate call - # per candidate). Build it once here in the parent — before the worker pool is - # created — so workers load a ready library instead of racing to compile it. - # (Mirrors backend/main.py startup; replaces the old closure-import prewarm.) - # The build is otherwise lazy/idempotent, so this just front-loads it. - if os.environ.get("ED_USE_NATIVE", "1") != "0": - try: - from engine.native.python import autobuild as _ed_autobuild - _ed_autobuild.ensure_lib() - except Exception as e: # never let a build hiccup block optimization - layer1_logger.warning( - "Native kernel pre-build failed (%s); Layer-1 falls back to Python evaluation.", e) + # The accelerator (engine/accel) is the Layer-1 inner-loop fast path: one + # evaluate() per candidate. Warm the JIT once here in the parent — before the + # worker pool is created — so workers deserialize ready-compiled kernels rather + # than each compiling inside the first CMA generation. + # + # Unlike the old C prewarm, this is NOT free per worker: a .so is mmapped into + # every child at no cost, whereas each worker still loads the cached njit + # artifacts on first call. _init_worker warms them again for that reason. + try: + from engine import accel as _accel + if _accel.enabled(): + _accel.warmup() + except Exception as e: # never let a warmup hiccup block optimization + layer1_logger.warning( + "Accelerator warmup failed (%s); Layer-1 falls back to Python evaluation.", e) # Create evaluator executor for candidate scoring. # On some macOS/sandboxed environments, ProcessPool creation can fail with diff --git a/EngineDesign/scripts/bench_layer1_numba.py b/EngineDesign/scripts/bench_layer1_numba.py index 8558136e0..9f81f637f 100644 --- a/EngineDesign/scripts/bench_layer1_numba.py +++ b/EngineDesign/scripts/bench_layer1_numba.py @@ -25,7 +25,12 @@ def reset(self): def _install_instrumentation(): - from engine.native.python import native_injector as ni + """Count accelerated evaluates and Python fallbacks. + + Wraps engine.accel.evaluate, which is what Layer 1 now calls; the ED_ACCEL + dispatcher inside it routes to numba or C, so one wrapper counts both modes. + """ + from engine import accel as ni from engine.core.runner import PintleEngineRunner if getattr(ni.evaluate, "_benched", False): return @@ -65,14 +70,12 @@ def _one(cfg_path, max_it, restarts, seed): def _run_condition(mode, cfg_path, max_it, restarts, seed): - if mode == "python": - os.environ["ED_USE_NATIVE"] = "0"; os.environ["ED_LAYER1_NATIVE_EVAL"] = "0" - else: - os.environ["ED_USE_NATIVE"] = "1"; os.environ["ED_LAYER1_NATIVE_EVAL"] = "1" - if mode == "numba": - from engine import accel - from engine.native.python import native_injector as ni - ni.evaluate = accel.evaluate # patch BEFORE instrumentation + # One knob now selects the backend: the accel dispatcher routes ED_ACCEL=c to + # native_injector and ED_ACCEL=numba to the kernels. "python" disables the + # accelerator entirely so every candidate takes the authoritative Python path. + os.environ["ED_ACCEL"] = {"python": "off", "native": "c", "numba": "numba"}[mode] + os.environ["ED_USE_NATIVE"] = "0" if mode == "python" else "1" + os.environ["ED_LAYER1_NATIVE_EVAL"] = "0" if mode == "python" else "1" _install_instrumentation() _one(cfg_path, 1, 1, seed) # warmup (JIT compile, CEA load) — discarded C.reset() diff --git a/EngineDesign/tests/test_numba_ab_parity.py b/EngineDesign/tests/test_numba_ab_parity.py index ba342354f..7f0055744 100644 --- a/EngineDesign/tests/test_numba_ab_parity.py +++ b/EngineDesign/tests/test_numba_ab_parity.py @@ -240,15 +240,16 @@ class TestDiagnosticsMatchC: from the accelerated evaluate path. """ - # C also emits these. None is read off an accelerated result anywhere on the - # stability or optimizer path: A_eff_O/F is recomputed downstream from Cd by - # flow_capacity.effective_flow_areas_from_cd, turbulence_intensity_mix is read - # only at chamber_solver.py:180 (the full-Python path, which never sees this - # dict), and J/TMR/theta/feed_orifice_coupling_iterations have no reader at all. - KNOWN_ABSENT = { - "A_eff_O", "A_eff_F", "J", "TMR", "theta", - "turbulence_intensity_mix", "feed_orifice_coupling_iterations", - } + # C also emits these four; no reader for any of them exists anywhere, and they + # are impinging spray quantities the accelerated path never needs. + # + # A_eff_O/F and turbulence_intensity_mix were briefly on this list and that was + # WRONG -- the suite caught it. A_eff is asserted present (not merely derivable + # from Cd) by test_flow_capacity_effective_area, and turbulence_intensity_mix + # reaches chamber_solver.py:180 via *closure* diagnostics, i.e. the accel.solve + # path rather than the accel.evaluate path the first analysis examined. Both are + # now produced. Treat every addition here with that in mind. + KNOWN_ABSENT = {"J", "TMR", "theta", "feed_orifice_coupling_iterations"} @pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).split("/")[-1]) def test_fields_match_and_nothing_new_is_missing(self, cfg_rel, ablative): From c3296fd467e849b7cbf21565076186c8d08046d8 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 02:10:58 -0700 Subject: [PATCH 07/20] CI: replace native-parity with a matrixed accel-parity job covering numba and C --- .github/workflows/engine-design-ci.yml | 87 +++++++++++++++----------- 1 file changed, 51 insertions(+), 36 deletions(-) diff --git a/.github/workflows/engine-design-ci.yml b/.github/workflows/engine-design-ci.yml index d58aafdc1..068cab105 100644 --- a/.github/workflows/engine-design-ci.yml +++ b/.github/workflows/engine-design-ci.yml @@ -241,19 +241,25 @@ jobs: working-directory: EngineDesign/engine/native run: ctest --test-dir build --output-on-failure - # Python <-> native parity: re-run the impinging tests with ED_USE_NATIVE=1 so - # chamber_solver's residual/Brent loop and closure's injector flows route - # through the C kernel. autobuild compiles libed_physics on demand. + # Accelerator parity. The Layer-1 inner loop runs on engine/accel (numba); the + # C port in engine/native is the outgoing implementation and is kept here as a + # numeric oracle until it is deleted. The matrix runs the production impinging + # tests through BOTH backends so neither can rot while both exist. # - # CRITICAL: the native path silently falls back to Python on any load failure - # (chamber_solver._native_chamber_pc), so `ED_USE_NATIVE=1 pytest` alone can be - # a FALSE GREEN — everything runs on Python and passes. The pre-flight below - # positively proves the lib compiles + ctypes-loads (ensure_lib/load raise on - # failure) before the parity run, so a broken native path fails loudly here. - native-parity: - name: Python-native parity (ED_USE_NATIVE=1) + # CRITICAL: every accelerated entry point returns None on any failure and the + # caller silently falls back to Python (chamber_solver._native_chamber_pc, + # closure.flows), so `pytest` alone can be a FALSE GREEN — everything runs on + # Python and passes. The pre-flight positively proves the selected backend is + # real, and ED_REQUIRE_ACCEL=1 turns a genuine accelerator failure into a raise + # rather than a quiet fallback. + accel-parity: + name: Accelerator parity (ED_ACCEL=${{ matrix.backend }}) runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + backend: [numba, c] steps: - name: Checkout code @@ -280,30 +286,34 @@ jobs: - name: Verify committed CEA cache is present run: test -f output/cache/cea_cache_LOX_CH4_3D.npz - # Positive proof the native path is real (not a silent Python fallback): - # ensure_lib() compiles and raises on build failure; load() ctypes-loads and - # raises if broken; available() confirms the injector shim is wired. - - name: Pre-flight — native library compiles and loads + # Positive proof the selected backend is real, not a silent Python fallback. + # The C library is built in BOTH legs: the numba parity suite diffs numba + # against C on identical inputs, so it needs the oracle present regardless of + # which backend production is routed through. + - name: Pre-flight — backend is genuinely available env: - ED_USE_NATIVE: '1' + ED_ACCEL: ${{ matrix.backend }} run: | python -c " from engine.native.python import autobuild, ed_native, native_injector p = autobuild.ensure_lib(verbose=True) ed_native.load(p) assert native_injector.available(), 'native_injector.available() is False' - print('native lib built + ctypes-loaded OK:', p) + print('C oracle built + ctypes-loaded OK:', p) + import numba + from engine import accel + assert accel.available(), 'accel.available() is False (numba missing?)' + assert accel.enabled(), 'accel.enabled() is False for ED_ACCEL=${{ matrix.backend }}' + print('accel enabled, numba', numba.__version__) " - # ED_REQUIRE_NATIVE=1 makes a genuine native failure (lib won't build/load, - # native not enabled, or a solver error) RAISE instead of silently falling - # back to Python — so this job can't pass on the Python path and report a - # false green. NOTE: since the runtime parity self-checks were removed - # (capability-dispatch architecture), this flag alone only proves native RAN; - # it no longer compares native's numbers to Python's. - - name: Run impinging tests through the native path (strict) + # ED_REQUIRE_ACCEL=1 makes a genuine accelerator failure RAISE instead of + # falling back, so this step cannot pass on the Python path and report a false + # green. It proves the backend RAN; the numeric comparison is the next step. + - name: Run impinging tests through ED_ACCEL=${{ matrix.backend }} (strict) env: - ED_USE_NATIVE: '1' + ED_ACCEL: ${{ matrix.backend }} + ED_REQUIRE_ACCEL: '1' ED_REQUIRE_NATIVE: '1' run: | python -m pytest \ @@ -311,21 +321,26 @@ jobs: tests/test_flow_capacity_effective_area.py \ --timeout=180 --timeout-method=thread -q - # Live A/B numeric parity: run the C kernel and the authoritative Python - # physics on the same inputs and diff the outputs field by field — at the - # wrapper level (the dict Layer-1 consumes) AND the raw EdEvaluateResult - # struct level (the C kernel's own numbers, no Python shim). This is the - # actual parity enforcement for this job: unlike the golden-vector C tests - # (frozen snapshots that go stale when the PYTHON side changes), a live - # comparison fails no matter which side drifted. ED_REQUIRE_NATIVE=1 turns - # the test's no-toolchain skip into a hard failure here. - - name: Live A/B parity — native vs Python on identical inputs (strict) + # Live A/B numeric parity, run once (it is backend-independent: it calls the + # numba kernels and the C kernel directly and diffs them, plus both against + # the authoritative Python physics). Unlike the golden-vector C tests — frozen + # snapshots that go stale when the PYTHON side changes — a live comparison + # fails no matter which side drifted. + # + # test_accel_params_match_native_state asserts the pure-Python config->scalar + # extraction reproduces the C EdEngineState EXACTLY; it is deleted with the C + # tree, having done its job. + - name: Live A/B parity — numba vs C vs Python on identical inputs (strict) + if: matrix.backend == 'numba' env: - ED_USE_NATIVE: '1' + ED_REQUIRE_ACCEL: '1' ED_REQUIRE_NATIVE: '1' + ED_AB_PARITY: '1' run: | python -m pytest \ + tests/test_numba_ab_parity.py \ tests/test_native_ab_parity.py \ + tests/test_accel_params_match_native_state.py \ --timeout=300 --timeout-method=thread -v # Aggregate status, mirroring daq-server-ci.yml's build-summary. @@ -338,7 +353,7 @@ jobs: - frontend - frontend-e2e - native-kernel - - native-parity + - accel-parity if: always() steps: @@ -354,4 +369,4 @@ jobs: echo "| Frontend build | ${{ needs.frontend.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Frontend E2E (Playwright) | ${{ needs.frontend-e2e.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Native kernel (C tests) | ${{ needs.native-kernel.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| Python-native parity | ${{ needs.native-parity.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Accelerator parity (numba + C) | ${{ needs.accel-parity.result }} |" >> $GITHUB_STEP_SUMMARY From c4e56d679ed588daed816a674e98496271d019e4 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 02:22:15 -0700 Subject: [PATCH 08/20] Vectorise the chug sweep and add a numba kernel for it, removing the last C dependency --- EngineDesign/engine/accel/__init__.py | 17 +- EngineDesign/engine/accel/stability.py | 150 ++++++++++++++++++ .../engine/pipeline/stability/analysis.py | 30 ++-- .../engine/pipeline/stability/chug.py | 87 +++++++--- 4 files changed, 247 insertions(+), 37 deletions(-) create mode 100644 EngineDesign/engine/accel/stability.py diff --git a/EngineDesign/engine/accel/__init__.py b/EngineDesign/engine/accel/__init__.py index c5a64d971..6b18b321a 100644 --- a/EngineDesign/engine/accel/__init__.py +++ b/EngineDesign/engine/accel/__init__.py @@ -14,7 +14,8 @@ import os __all__ = ["available", "enabled", "can_handle", "can_handle_chamber", - "evaluate", "solve", "chamber_solve", "warmup", "require"] + "evaluate", "solve", "chamber_solve", "warmup", "require", + "chug_margin_fast"] def _c_backend(): @@ -246,3 +247,17 @@ def warmup(): return True except Exception: return False + + +def chug_margin_fast(streams, chamber, **kw): + """Chug gain/phase margin. Dispatches like the rest of this surface. + + fast_acoustic deliberately has no counterpart here: it measured 10.5 us in + Python against 4.2 us in C, and acoustic.fast_acoustic has no loop to compile. + A 6 us difference does not justify a kernel, so that path stays pure Python. + """ + _ni = _c_backend() + if _ni is not None: + return _ni.chug_margin_fast(streams, chamber, **kw) + from engine.accel import stability as _stab + return _stab.chug_margin_fast(streams, chamber, **kw) diff --git a/EngineDesign/engine/accel/stability.py b/EngineDesign/engine/accel/stability.py new file mode 100644 index 000000000..f5a9640a3 --- /dev/null +++ b/EngineDesign/engine/accel/stability.py @@ -0,0 +1,150 @@ +"""Numba kernel for the chug gain/phase-margin scan. + +This is the dominant per-evaluation stability cost: a 200-point complex frequency +sweep run on every candidate. Vectorising the pure-Python version in numpy took it +from 1537 us to 112 us (and that speed-up stands on its own -- it is what every +pintle and coaxial config runs today, since no accelerated injector path covers +them). But an end-to-end Layer-1 measurement still put the remaining gap at ~8.8% +of wall time versus the C kernel, above the 3% we were willing to absorb, so the +scan itself is compiled here. + +Mirrors engine/pipeline/stability/chug.py exactly, including numpy's unwrap +semantics, which numba does not provide. +""" +from __future__ import annotations + +import numpy as np +from numba import njit + +_TWO_PI = 2.0 * np.pi + + +@njit(cache=True) +def _unwrap(p): + """np.unwrap(p) for a 1-D float64 array (numba has no np.unwrap). + + Faithful to numpy: correct by the modulo-wrapped difference, zero the + correction where the raw step is below the discontinuity threshold, and + resolve the -pi boundary toward +pi when the raw step is positive. + """ + n = p.shape[0] + out = np.empty(n) + if n == 0: + return out + out[0] = p[0] + run = 0.0 + for i in range(1, n): + dd = p[i] - p[i - 1] + ddmod = (dd + np.pi) % _TWO_PI - np.pi + if ddmod == -np.pi and dd > 0.0: + ddmod = np.pi + corr = ddmod - dd + if abs(dd) < np.pi: # below discont -> no correction + corr = 0.0 + run += corr + out[i] = p[i] + run + return out + + +@njit(cache=True) +def chug_margin_kernel(omega, tau, inert, res, invG, Zhf, wc, K_c, theta_c): + """Returns (gain_margin, f_chug_hz, phase_margin_deg, stable). + + Per-stream inputs are the s-independent primitives already resolved by the + Python wrapper: transport lag, feed inertance, linearised resistance, 1/G_inj + (inf when G<=0), and the regulator high-frequency impedance and corner. + """ + n = omega.shape[0] + ns = tau.shape[0] + mag = np.empty(n) + ang = np.empty(n) + + for i in range(n): + s = complex(0.0, omega[i]) + acc = complex(0.0, 0.0) + for k in range(ns): + Zr = complex(0.0, 0.0) + if Zhf[k] > 0.0: + Zr = Zhf[k] * (s / wc[k]) / (1.0 + s / wc[k]) + Zf = Zr + inert[k] * s + res[k] + invG[k] + if Zf == 0.0: + continue # scalar path skips a zero-impedance stream + acc += np.exp(-s * tau[k]) / Zf + L = K_c / (theta_c * s + 1.0) * acc + mag[i] = abs(L) + ang[i] = np.arctan2(L.imag, L.real) + + phase = _unwrap(ang) + + # --- phase crossover (angle through -pi): worst-case gain margin --- + target = -np.pi + gm_best = np.inf + f_pc = np.nan + for i in range(n - 1): + g0 = phase[i] - target + g1 = phase[i + 1] - target + if g0 == 0.0 or g0 * g1 < 0.0: + dg = g0 - g1 + frac = g0 / dg if dg != 0.0 else 0.0 + w_c = omega[i] + frac * (omega[i + 1] - omega[i]) + mag_c = mag[i] + frac * (mag[i + 1] - mag[i]) + gm = 1.0 / mag_c if mag_c > 0 else np.inf + if gm < gm_best: + gm_best = gm + f_pc = w_c / _TWO_PI + + # --- gain crossover (|L| = 1): phase margin at the FIRST crossing --- + pm_deg = np.nan + for i in range(n - 1): + h0 = mag[i] - 1.0 + h1 = mag[i + 1] - 1.0 + if h0 == 0.0 or h0 * h1 < 0.0: + dh = h0 - h1 + frac = h0 / dh if dh != 0.0 else 0.0 + ph_c = phase[i] + frac * (phase[i + 1] - phase[i]) + pm_deg = np.degrees(ph_c - target) + break + + if not np.isfinite(gm_best): + # No phase crossover in band: stable if |L|<1 throughout (no encirclement). + mmax = mag.max() + gm_best = 1.0 / mmax if mmax > 1e-12 else 1.0 / 1e-12 + if mmax < 1.0 and gm_best < 1.0: + gm_best = 1.0 + + return gm_best, f_pc, pm_deg, gm_best > 1.0 + + +def chug_margin_fast(streams, chamber, *, with_regulator: bool = True, + f_lo: float = 2.0, f_hi: float = 2000.0): + """Drop-in for chug.chug_margin_fast / native_injector.chug_margin_fast. + + Resolves the s-independent per-stream primitives once here, in Python, then + hands the kernel nothing but arrays and floats. + """ + from engine.pipeline.stability.chug import _freq_grid + + omega = np.ascontiguousarray(_freq_grid(f_lo, f_hi), dtype=np.float64) + ns = len(streams) + tau = np.empty(ns); inert = np.empty(ns); res = np.empty(ns) + invG = np.empty(ns); Zhf = np.zeros(ns); wc = np.ones(ns) + for k, st in enumerate(streams): + G = st.G_inj() + tau[k] = st.tau_conv + inert[k] = st.inertance() + res[k] = st.resistance() + invG[k] = (1.0 / G) if G > 0 else np.inf + if with_regulator and st.regulator.enabled and st.regulator.Z_hf > 0.0: + Zhf[k] = float(st.regulator.Z_hf) + wc[k] = 2.0 * np.pi * max(st.regulator.corner_hz, 1e-6) + + gm, f_pc, pm, stable = chug_margin_kernel( + omega, tau, inert, res, invG, Zhf, wc, + float(chamber.K_c()), float(chamber.theta_c())) + return { + "gain_margin": float(gm), + "stable": bool(stable), + "f_chug_hz": float(f_pc), + "phase_margin_deg": float(pm), + "margin": float(gm), + } diff --git a/EngineDesign/engine/pipeline/stability/analysis.py b/EngineDesign/engine/pipeline/stability/analysis.py index 34f5a1800..9ea5f1a54 100644 --- a/EngineDesign/engine/pipeline/stability/analysis.py +++ b/EngineDesign/engine/pipeline/stability/analysis.py @@ -480,30 +480,26 @@ def compute_physical_stability(config, Pc: float, MR: float, mdot_total: float, """ from engine.pipeline.stability import chug, acoustic inp = build_stability_inputs(config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg) - # Native fast path: the 200-pt complex chug sweep is the dominant per-eval - # stability cost. Run it in C (~machine-precision parity) when native is - # enabled; fall back to Python on any issue. - from engine.native.python import native_injector - _native = native_injector.native_enabled() + # Accelerated fast path: the 200-pt complex chug sweep is the dominant + # per-eval stability cost. Run the compiled kernel when the accelerator is + # enabled; fall back to the (now vectorised) Python sweep on any issue. + from engine import accel chug_fast = None - if _native: + if accel.enabled(): try: - chug_fast = native_injector.chug_margin_fast(inp["streams"], inp["chamber"]) + chug_fast = accel.chug_margin_fast(inp["streams"], inp["chamber"]) except Exception: chug_fast = None if chug_fast is None: chug_fast = chug.chug_margin_fast(inp["streams"], inp["chamber"]) - ac_fast = None - 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"]) - except Exception: - ac_fast = None - 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"]) + # fast_acoustic stays pure Python on purpose: 10.5 us here vs 4.2 us in C, and + # acoustic.fast_acoustic is two mode_growth_rate calls with no loop. A ~6 us + # difference does not earn a kernel. (The chug sweep did: 200 complex points, + # measured at ~8.8% of Layer-1 wall time when left unaccelerated.) + ac_fast = acoustic.fast_acoustic(inp["D_ch"], inp["L_ch"], inp["gas"], + n=inp["n_interaction"], tau_sens=inp["tau_sens"]) + return { "chug": chug_fast, "acoustic": ac_fast, diff --git a/EngineDesign/engine/pipeline/stability/chug.py b/EngineDesign/engine/pipeline/stability/chug.py index 84e393021..d114aac57 100644 --- a/EngineDesign/engine/pipeline/stability/chug.py +++ b/EngineDesign/engine/pipeline/stability/chug.py @@ -23,6 +23,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from functools import lru_cache from typing import Dict, List, Optional, Tuple import numpy as np @@ -120,6 +121,38 @@ def chug_open_loop(s: complex, streams: List[ChugStream], chamber: ChugChamber, return chamber.Y_ch(s) * acc +def _open_loop_grid(omega: np.ndarray, streams: List[ChugStream], chamber: ChugChamber, + *, with_regulator: bool = True) -> np.ndarray: + """Vectorised L(iw) over a whole frequency grid. + + Same computation as calling chug_open_loop once per point: every operation in + Z_feed and Y_ch is element-wise in s, and the per-stream primitives (G_inj, + inertance, resistance, regulator corner) do not depend on s at all. The + per-point version recomputed all of them at each of the 200 grid points, which + made this the dominant per-eval stability cost. + + Term order is kept identical to Z_feed/chug_open_loop deliberately, since + floating-point addition is not associative. Agreement is ~1 ULP rather than + bit-for-bit: numpy's complex exp takes a different code path on arrays than on + scalars, which is far below any tolerance here but is not exactly zero. + """ + s_arr = 1j * np.asarray(omega, dtype=np.float64) + acc = np.zeros_like(s_arr) + for st in streams: + G = st.G_inj() + Zr = 0.0 + 0.0j + if with_regulator and st.regulator.enabled and st.regulator.Z_hf > 0.0: + wc = 2.0 * np.pi * max(st.regulator.corner_hz, 1e-6) + Zr = complex(st.regulator.Z_hf) * (s_arr / wc) / (1.0 + s_arr / wc) + Zf = Zr + st.inertance() * s_arr + st.resistance() + (1.0 / G if G > 0 else np.inf) + with np.errstate(divide="ignore", invalid="ignore"): + term = np.exp(-s_arr * st.tau_conv) / Zf + # The scalar path skips a stream whose Z_feed is exactly 0 (`continue`); + # element-wise that is a zero contribution at those frequencies. + acc = acc + np.where(Zf == 0, 0.0 + 0.0j, term) + return chamber.K_c() / (chamber.theta_c() * s_arr + 1.0) * acc + + def chug_characteristic(s: complex, streams: List[ChugStream], chamber: ChugChamber, *, with_regulator: bool = True) -> complex: """F(s) = 1 + L(s). Roots s = alpha + i*omega give chug frequency/growth rate.""" @@ -130,10 +163,19 @@ def chug_characteristic(s: complex, streams: List[ChugStream], chamber: ChugCham # Fast tier: gain/phase-margin proxy (no transcendental root-find) # --------------------------------------------------------------------------- +@lru_cache(maxsize=8) +def _freq_grid_cached(f_lo: float, f_hi: float, n: int) -> np.ndarray: + grid = 2.0 * np.pi * np.logspace(np.log10(f_lo), np.log10(f_hi), n) + grid.flags.writeable = False # cached and shared: never mutate in place + return grid + + def _freq_grid(f_lo: float = 2.0, f_hi: float = 2000.0, n: int = 200) -> np.ndarray: # 200 log-spaced points spans the chug band finely enough for crossover detection while keeping - # the per-eval cost ~0.5 ms (fast-tier budget). The rich tier refines via root-find anyway. - return 2.0 * np.pi * np.logspace(np.log10(f_lo), np.log10(f_hi), n) + # the per-eval cost inside the fast-tier budget. The rich tier refines via root-find anyway. + # The grid depends only on its arguments, so it is memoised: rebuilding it per + # call cost ~7 us of the ~135 us fast-tier budget, on every candidate. + return _freq_grid_cached(f_lo, f_hi, n) def chug_margin_fast(streams: List[ChugStream], chamber: ChugChamber, @@ -148,7 +190,7 @@ def chug_margin_fast(streams: List[ChugStream], chamber: ChugChamber, the chug-frequency estimate), ``phase_margin_deg``, ``margin`` (= gain_margin, gate-facing). """ omega = _freq_grid(f_lo, f_hi) - L = np.array([chug_open_loop(1j * w, streams, chamber, with_regulator=with_regulator) for w in omega]) + L = _open_loop_grid(omega, streams, chamber, with_regulator=with_regulator) phase = np.unwrap(np.angle(L)) mag = np.abs(L) @@ -157,26 +199,33 @@ def chug_margin_fast(streams: List[ChugStream], chamber: ChugChamber, g = phase - target gm_best = np.inf f_pc = float("nan") - for i in range(len(omega) - 1): - if g[i] == 0.0 or g[i] * g[i + 1] < 0.0: - # linear-interpolate the crossover - frac = g[i] / (g[i] - g[i + 1]) if (g[i] - g[i + 1]) != 0 else 0.0 - w_c = omega[i] + frac * (omega[i + 1] - omega[i]) - mag_c = mag[i] + frac * (mag[i + 1] - mag[i]) - gm = 1.0 / mag_c if mag_c > 0 else np.inf - if gm < gm_best: # worst-case (smallest) gain margin - gm_best = gm - f_pc = w_c / (2.0 * np.pi) + # Vectorised sign-change scan. argmin returns the FIRST minimum, matching the + # scalar loop's strict `gm < gm_best` (which also kept the earliest tie). + g0, g1 = g[:-1], g[1:] + hits = np.flatnonzero((g0 == 0.0) | (g0 * g1 < 0.0)) + if hits.size: + dg = g[hits] - g[hits + 1] + safe = dg != 0.0 + frac = np.where(safe, g[hits] / np.where(safe, dg, 1.0), 0.0) + w_c = omega[hits] + frac * (omega[hits + 1] - omega[hits]) + mag_c = mag[hits] + frac * (mag[hits + 1] - mag[hits]) + pos = mag_c > 0 + gm = np.where(pos, 1.0 / np.where(pos, mag_c, 1.0), np.inf) + k = int(np.argmin(gm)) # worst-case (smallest) gain margin + gm_best = float(gm[k]) + f_pc = float(w_c[k] / (2.0 * np.pi)) # Phase margin at gain crossover (|L|=1), if any pm_deg = float("nan") h = mag - 1.0 - for i in range(len(omega) - 1): - if h[i] == 0.0 or h[i] * h[i + 1] < 0.0: - frac = h[i] / (h[i] - h[i + 1]) if (h[i] - h[i + 1]) != 0 else 0.0 - ph_c = phase[i] + frac * (phase[i + 1] - phase[i]) - pm_deg = float(np.degrees(ph_c - target)) # phase above -180 - break + h0, h1 = h[:-1], h[1:] + gain_hits = np.flatnonzero((h0 == 0.0) | (h0 * h1 < 0.0)) + if gain_hits.size: + i = int(gain_hits[0]) # scalar loop broke at the FIRST crossing + dh = h[i] - h[i + 1] + frac = h[i] / dh if dh != 0 else 0.0 + ph_c = phase[i] + frac * (phase[i + 1] - phase[i]) + pm_deg = float(np.degrees(ph_c - target)) # phase above -180 if not np.isfinite(gm_best): # No phase crossover in band: stable if |L|<1 throughout (no encirclement possible) From 80d023d5e6fb7dafb1e10cef1971c314a862d9c6 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 02:29:40 -0700 Subject: [PATCH 09/20] Retire the C oracle from the parity suite; guard numba against Python at 1e-6 --- .../test_accel_params_match_native_state.py | 111 ------ EngineDesign/tests/test_native_ab_parity.py | 265 --------------- EngineDesign/tests/test_numba_ab_parity.py | 316 ++++++++---------- 3 files changed, 143 insertions(+), 549 deletions(-) delete mode 100644 EngineDesign/tests/test_accel_params_match_native_state.py delete mode 100644 EngineDesign/tests/test_native_ab_parity.py diff --git a/EngineDesign/tests/test_accel_params_match_native_state.py b/EngineDesign/tests/test_accel_params_match_native_state.py deleted file mode 100644 index fbc4ca7cf..000000000 --- a/EngineDesign/tests/test_accel_params_match_native_state.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Exact-equality check: engine.accel.params.build_state == native_injector.build_state. - -TEMPORARY BY DESIGN. This test exists only while the C port does, and is deleted -with it. Its whole job is to prove that the pure-Python config->scalar -reconciliation in engine/accel/params.py is a faithful transcription of -native_injector.build_state, so the Numba kernels can stop reading the C -EdEngineState and the C tree can be removed. - -Tolerance is EXACT (==), deliberately. Both sides are float() of the same Python -config objects -- there is no arithmetic between them, so any difference at all -is a mapping bug (a wrong field name, a missing `or` default, a schema default -substituted for a zero-init), not a rounding artifact. A tolerance here would -hide exactly the class of bug the test is for. -""" -from __future__ import annotations - -import os -from pathlib import Path -from types import SimpleNamespace - -import pytest - -ROOT = Path(__file__).resolve().parents[1] - - -def _configs(): - """Every committed config, so a mapping bug cannot hide in an unexercised one.""" - out = [] - for d in (ROOT / "configs", ROOT / "configs" / "canonical"): - if d.is_dir(): - out.extend(sorted(p for p in d.glob("*.yaml"))) - return out - - -CONFIGS = _configs() - - -@pytest.fixture(scope="module") -def native(): - """The C side is the reference. Skip (don't fail) when it can't be built.""" - if os.environ.get("ED_USE_NATIVE") == "0": - pytest.skip("ED_USE_NATIVE=0") - try: - from engine.native.python import autobuild, ed_native, native_injector - ed_native.load(autobuild.ensure_lib()) - except Exception as e: # pragma: no cover - toolchain-dependent - if os.environ.get("ED_REQUIRE_NATIVE") == "1": - pytest.fail(f"ED_REQUIRE_NATIVE=1 but native unavailable: {e}") - pytest.skip(f"native unavailable: {e}") - if not native_injector.native_enabled(): - pytest.skip("native_injector.native_enabled() is False") - return native_injector - - -def _walk(py, prefix=""): - """Yield (dotted_path, value) for every scalar leaf of the Python state tree.""" - for name, val in vars(py).items(): - path = f"{prefix}.{name}" if prefix else name - if isinstance(val, SimpleNamespace): - yield from _walk(val, path) - else: - yield path, val - - -def _get(obj, path): - for part in path.split("."): - obj = getattr(obj, part) - return obj - - -@pytest.mark.parametrize("cfg_path", CONFIGS, ids=lambda p: p.parent.name + "/" + p.name) -def test_python_state_matches_native_state(native, cfg_path): - from engine.accel import params as accel_params - from engine.pipeline.io import load_config - - try: - cfg = load_config(str(cfg_path)) - except Exception as e: - # Some committed YAMLs are overlays/fragments, not standalone configs - # (e.g. a bare `cea: {}` that fails schema validation). Nothing to - # compare -- they never reach build_state in production either. - pytest.skip(f"config does not load standalone: {type(e).__name__}") - - # build_state only maps impinging configs; for anything else BOTH sides must - # fail, and failing the same way is itself the contract worth asserting. - try: - want = native.build_state(cfg) - except Exception as e_native: - with pytest.raises(type(e_native)): - accel_params.build_state(cfg) - pytest.skip(f"config not mappable by build_state ({type(e_native).__name__})") - - got = accel_params.build_state(cfg) - - mismatches = [] - checked = 0 - for path, py_val in _walk(got): - try: - c_val = _get(want, path) - except AttributeError: - mismatches.append(f"{path}: absent on EdEngineState") - continue - checked += 1 - if float(py_val) != float(c_val): - mismatches.append(f"{path}: python={py_val!r} native={c_val!r}") - - assert checked > 50, f"only {checked} fields compared -- walker missed the tree" - assert not mismatches, ( - f"{len(mismatches)} field(s) diverge from EdEngineState for {cfg_path.name}:\n " - + "\n ".join(mismatches) - ) diff --git a/EngineDesign/tests/test_native_ab_parity.py b/EngineDesign/tests/test_native_ab_parity.py deleted file mode 100644 index ebb0c4fea..000000000 --- a/EngineDesign/tests/test_native_ab_parity.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Live A/B parity: native C kernel vs the authoritative Python physics. - -Unlike the golden-vector C tests (which compare C against a *frozen snapshot* of -Python's output, committed as JSON), this suite runs BOTH implementations live on -the same inputs and diffs the results field by field. A golden test can only be -as current as its snapshot: when the Python physics changes and the snapshot is -not regenerated, the golden suite keeps certifying C against retired physics and -stays green. This suite closes that gap — it re-derives the Python reference at -test time, so any C-vs-Python drift fails here regardless of which side moved. - -Two comparison levels, on purpose: - -1. **Wrapper level** (`native_injector.evaluate()` vs `runner.evaluate()`): - validates the contract the Layer-1 optimizer actually consumes — the dict the - inner loop ranks candidates on must match the authoritative Python path. - -2. **Kernel (struct) level** (raw `EdEvaluateResult` off the ctypes call vs - `runner.evaluate()`): validates the C kernel's *own* numbers, with no Python - shim papering over them. This is what catches C physics left behind after a - Python-side change even when a wrapper override hides it from consumers. - -Native availability policy (mirrors the CI parity job's contract): - * default: if the native library cannot build/load, SKIP (dev machines without - a C toolchain must not fail the general gate); - * ED_REQUIRE_NATIVE=1 (the CI parity job): a missing/broken library FAILS — - otherwise the job would silently pass on the Python fallback (false green). - -No rocketcea/Fortran involved: both sides read the committed CEA cache tables -(output/cache/*.npz). This tests C≡Python *consistency* on top of those tables; -table *correctness* is the separate, CEA-driven regenerate-cea-cache workflow. -""" - -import ctypes -import os -from pathlib import Path - -import numpy as np -import pytest - -ROOT = Path(__file__).resolve().parents[1] -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", -) - -# Tank-pressure points (psi) on the canonical impinging engine — nominal plus -# off-nominal, same operating window the manual parity tools exercised. -POINTS_PSI = [(563.467, 567.644), (518.4, 550.6), (597.3, 584.7)] - -# Program parity target: the native chamber solve lands within ~1e-3 of Python -# (see engine/native/tools/check_evaluate_parity.py), so 2e-3 gives headroom -# without masking a real physics divergence (the retired-nozzle drift is ~15%). -RTOL = 2e-3 - - -def _require_native() -> bool: - return os.environ.get("ED_REQUIRE_NATIVE", "0") == "1" - - -def _rel(got: float, want: float) -> float: - return abs(got - want) / max(abs(want), 1e-12) - - -def _assert_close(name: str, got, want, rtol: float = RTOL) -> None: - assert got is not None and want is not None, f"{name}: missing value (native={got}, python={want})" - rel = _rel(float(got), float(want)) - assert rel <= rtol, ( - f"{name}: native={float(got):.8g} python={float(want):.8g} rel={rel:.3e} > rtol={rtol:g}" - ) - - -@pytest.fixture(scope="module") -def native_injector_mod(): - """The native shim module, with the library proven built + loaded. - - Skips when the toolchain is absent (default), fails under ED_REQUIRE_NATIVE=1 - so the CI parity job cannot false-green on the Python fallback. - """ - if os.environ.get("ED_USE_NATIVE", "1") == "0": - if _require_native(): - pytest.fail("ED_REQUIRE_NATIVE=1 but ED_USE_NATIVE=0 disables the native path") - pytest.skip("ED_USE_NATIVE=0 — native path disabled") - try: - from engine.native.python import autobuild, ed_native, native_injector - lib_path = autobuild.ensure_lib() - ed_native.load(lib_path) - except Exception as exc: - if _require_native(): - pytest.fail(f"ED_REQUIRE_NATIVE=1 but the native library failed to build/load: {exc}") - pytest.skip(f"native library unavailable ({exc}); install cmake + a C compiler to run A/B parity") - if not native_injector.native_enabled(): - if _require_native(): - pytest.fail("ED_REQUIRE_NATIVE=1 but native_injector.native_enabled() is False") - pytest.skip("native kernel not enabled") - return native_injector - - -@pytest.fixture(scope="module") -def rig(native_injector_mod): - """Config + runner + per-point live Python reference results.""" - 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) - return {"config": config, "runner": runner, "points": points, "reference": reference} - - -# --------------------------------------------------------------------------- -# Level 1 — wrapper contract: what the Layer-1 inner loop consumes -# --------------------------------------------------------------------------- - -WRAPPER_TOP_FIELDS = [ - "F", "Isp", "Pc", "MR", "mdot_total", "mdot_O", "mdot_F", - "Cf_actual", "cstar_actual", "cstar_ideal", "eta_cstar", - "P_exit", "T_exit", "v_exit", -] -WRAPPER_DIAG_FIELDS = [ - "D32_O", "D32_F", "Cd_O", "Cd_F", "momentum_ratio_R", - "delta_p_feed_O", "delta_p_feed_F", -] - - -class TestWrapperParity: - """native_injector.evaluate() must match runner.evaluate() live.""" - - def test_wrapper_matches_python(self, native_injector_mod, rig): - for p_o, p_f in rig["points"]: - ref = rig["reference"][(p_o, p_f)] - nat = native_injector_mod.evaluate( - rig["config"], rig["runner"].cea_cache, p_o, p_f, PA_AMBIENT) - assert nat is not None, ( - f"native evaluate returned None at ({p_o / PSI_TO_PA:.1f}, " - f"{p_f / PSI_TO_PA:.1f}) psi — fell back to Python instead of solving" - ) - for key in WRAPPER_TOP_FIELDS: - _assert_close(f"({p_o / PSI_TO_PA:.0f},{p_f / PSI_TO_PA:.0f})psi {key}", - nat.get(key), ref.get(key)) - ref_diag = ref.get("diagnostics") or {} - nat_diag = nat.get("diagnostics") or {} - for key in WRAPPER_DIAG_FIELDS: - if ref_diag.get(key) is None: - continue - _assert_close(f"({p_o / PSI_TO_PA:.0f},{p_f / PSI_TO_PA:.0f})psi diag.{key}", - nat_diag.get(key), ref_diag.get(key)) - - def test_wrapper_stability_matches_python(self, native_injector_mod, rig): - for p_o, p_f in rig["points"]: - ref = rig["reference"][(p_o, p_f)] - nat = native_injector_mod.evaluate( - rig["config"], rig["runner"].cea_cache, p_o, p_f, PA_AMBIENT) - assert nat is not None - rs = ref.get("stability_results") or {} - ns = nat.get("stability_results") or {} - assert ns.get("stability_state") == rs.get("stability_state"), ( - f"stability_state: native={ns.get('stability_state')} " - f"python={rs.get('stability_state')}" - ) - _assert_close("stability_score", ns.get("stability_score"), rs.get("stability_score")) - for section in ("chugging", "acoustic"): - _assert_close( - f"{section}.stability_margin", - (ns.get(section) or {}).get("stability_margin"), - (rs.get(section) or {}).get("stability_margin"), - ) - - -# --------------------------------------------------------------------------- -# Level 2 — kernel struct: the C kernel's own numbers, no Python shim -# --------------------------------------------------------------------------- - - -@pytest.fixture(scope="module") -def struct_results(native_injector_mod, rig): - """Raw EdEvaluateResult per point, straight off the ctypes call.""" - ni = native_injector_mod - nat = ni._nat() - assert ni._ensure_cea(rig["runner"].cea_cache), "CEA tables failed to load into the native lib" - state = ni.build_state(rig["config"]) - out = {} - for p_o, p_f in rig["points"]: - rc, res = nat.evaluate(ctypes.byref(state), p_o, p_f, PA_AMBIENT) - assert rc == 0 and res.converged, f"ed_evaluate rc={rc} converged={res.converged}" - out[(p_o, p_f)] = res - return out - - -class TestKernelStructParity: - """EdEvaluateResult fields must match live Python — no wrapper overrides. - - Split into chamber vs thrust so a failure localizes the stale stage instead - of reading as 'native is broken'. - """ - - def test_struct_chamber_matches_python(self, rig, struct_results): - 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" - _assert_close(f"{label}.Pc", res.Pc, ref["Pc"]) - _assert_close(f"{label}.MR", res.MR, ref["MR"]) - _assert_close(f"{label}.mdot_total", res.mdot_total, ref["mdot_total"]) - _assert_close(f"{label}.eta_cstar", res.eta_cstar, - (ref.get("diagnostics") or {}).get("eta_cstar", ref.get("eta_cstar"))) - - def test_struct_thrust_matches_python(self, rig, struct_results): - """The C kernel's delivered F/Isp must be on the same physics basis as - production Python (RPA delivered thrust: F = zeta_n*Cf_vac*Pc*At - Pa*Ae). - - If this fails with native ~13-15% HIGH while test_struct_chamber_* passes, - ed_nozzle.c is still computing the retired momentum-method thrust (ideal-Tc - exhaust velocity, no combustion-efficiency penalty) that the RPA fix - removed from nozzle.py — i.e. EdEvaluateResult.F carries the pre-fix - inflated number and only the Python wrapper override hides it. Port the - Cf_vac formula to ed_evaluate.c/ed_nozzle.c; do not paper over this by - widening the tolerance. - """ - 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" - _assert_close(f"{label}.F", res.F, ref["F"]) - _assert_close(f"{label}.Isp", res.Isp, ref["Isp"]) - _assert_close(f"{label}.Cf_actual", res.Cf_actual, ref["Cf_actual"]) - - -# --------------------------------------------------------------------------- -# Physics invariant — the ceiling check that would have caught the thrust bug -# --------------------------------------------------------------------------- - - -class TestDeliveredIspInvariant: - """Delivered Isp must sit at/below eta_cstar * zeta_n * of the ideal — on - BOTH implementations. This is the thermodynamic invariant the original - momentum-method bug violated (model Isp >= the ideal equilibrium ceiling).""" - - def test_python_delivered_below_ceiling(self, rig): - g0 = 9.80665 - for p_o, p_f in rig["points"]: - ref = rig["reference"][(p_o, p_f)] - diag = ref.get("diagnostics") or {} - eta = float(diag.get("eta_cstar", ref.get("eta_cstar", np.nan))) - cstar_ideal = float(diag.get("cstar_ideal", ref.get("cstar_ideal", np.nan))) - cf_vac = rig["runner"].cea_cache.eval_cf_vac(ref["MR"], ref["Pc"], ref["eps"]) - isp_vac_ideal = cf_vac * cstar_ideal / g0 - # Ambient thrust <= vacuum thrust, so this bound holds a fortiori. - ceiling = eta * isp_vac_ideal - assert ref["Isp"] <= ceiling * (1.0 + 1e-6), ( - f"delivered Isp {ref['Isp']:.2f}s exceeds eta_cstar*Isp_vac_ideal " - f"{ceiling:.2f}s — an efficiency term has been dropped from the thrust path" - ) diff --git a/EngineDesign/tests/test_numba_ab_parity.py b/EngineDesign/tests/test_numba_ab_parity.py index 7f0055744..b95e74e13 100644 --- a/EngineDesign/tests/test_numba_ab_parity.py +++ b/EngineDesign/tests/test_numba_ab_parity.py @@ -1,38 +1,35 @@ -"""Live A/B parity for the Numba accelerator: Numba vs C, and Numba vs Python. - -Sibling of test_native_ab_parity.py, and deliberately shaped like it -- same -points, same tolerance, same opt-in gating -- so the two read as one story while -both backends exist. - -THREE COMPARISONS, each answering a different question: - - Numba vs C at 1e-12 -- the regression guard. These two implement the same - physics from the same inputs, so they agree to a few - ULP (measured: worst 9.7e-16 over 300 points). Any - loosening here means a real divergence, not noise. - Numba vs Python at 2e-3 -- the contract. Same RTOL as the C suite and for the - same reason: the accelerated chamber solve lands - within ~1e-3 of Python, so 2e-3 has headroom without - masking a physics bug. - Randomized sweep -- 200 fixed-seed points across the operating box, - which the C suite never had. Also asserts the - accelerated path does not bail where Python converges. - -BOTH CONFIGS ARE EXERCISED ON PURPOSE. configs/canonical/impinging.yaml has -ablative cooling ON and impinging_lox_ch4_8000N.yaml has it off; those are -different code paths through _cooling_evaluate, and the ablative one is what the -project's default configs actually take. - -SCOPE: this covers the pure-Numba surface (chamber + nozzle + thrust core). The -injector *diagnostics* dict is still assembled by a C call inside -make_native_signature_evaluate, so asserting on it here would be partly circular; -that coverage lands when the diagnostics are surfaced from Numba's own solve. +"""Live A/B parity for the Numba accelerator against the authoritative Python physics. + +Runs both implementations on the same inputs at test time and diffs them field by +field, so a drift on EITHER side fails regardless of which one moved. This is the +sole numeric guard on the accelerated path now that the C port is gone. + +TOLERANCE. RTOL is 1e-6, not the 2e-3 the retired C suite used. Measured agreement +on 120 randomized points across both configs is ~2.5e-9 -- the accelerated and +Python paths run the same physics and differ only by Brent's convergence +tolerance, so 1e-6 leaves ~400x headroom while still being three orders tighter +than the old contract. If a change pushes this above 1e-6, that is a physics +divergence, not rounding: fix it rather than widening the bound. + +THE REFERENCE MUST BE FORCED TO PYTHON. With the accelerator enabled, +runner.evaluate reaches chamber_solver._native_chamber_pc -> accel.chamber_solve +and closure.flows -> accel.solve, so an unguarded "Python" reference is largely +the same numba kernels and the comparison is self-referential (it reads as ~1e-15 +agreement, which is the tell). _python_only() below disables the accelerator for +the reference computation; without it this suite proves nothing. + +BOTH CONFIGS ARE EXERCISED ON PURPOSE: configs/canonical/impinging.yaml has +ablative cooling ON (the path the project's default configs take) and +impinging_lox_ch4_8000N.yaml has it off. Different routes through +kernels._cooling_evaluate. """ from __future__ import annotations import os +from contextlib import contextmanager from pathlib import Path +import numpy as np import pytest ROOT = Path(__file__).resolve().parents[1] @@ -41,36 +38,51 @@ pytestmark = pytest.mark.skipif( os.environ.get("ED_REQUIRE_ACCEL") != "1" and os.environ.get("ED_AB_PARITY") != "1", - reason="A/B parity runs in the parity CI job; set ED_AB_PARITY=1 to run locally", + reason="A/B parity runs in the accel-parity CI job; set ED_AB_PARITY=1 to run locally", ) POINTS_PSI = [(563.467, 567.644), (518.4, 550.6), (597.3, 584.7)] -RTOL = 2e-3 # Numba vs Python -- matches the C suite's target -RTOL_TIGHT = 1e-12 # Numba vs C -- same physics, same inputs; ULP-level or bust +RTOL = 1e-6 CONFIGS = [ - ("configs/canonical/impinging.yaml", True), # ablative ON (default configs) + ("configs/canonical/impinging.yaml", True), # ablative ON ("configs/impinging_lox_ch4_8000N.yaml", False), # ablative off ] -# Fields NumbaEvaluator.evaluate() produces; all are core physics, no diagnostics. -CORE_FIELDS = ["Pc", "F", "Isp", "MR", "cstar_actual", "gamma", - "Tc", "mdot_total", "v_exit", "Cf_actual"] +CORE_FIELDS = ["Pc", "F", "Isp", "MR", "cstar_actual", "eta_cstar", + "mdot_total", "mdot_O", "mdot_F", "Cf_actual", + "P_exit", "T_exit", "v_exit"] + +DIAG_FIELDS = ["D32_O", "D32_F", "Cd_O", "Cd_F", "momentum_ratio_R", + "delta_p_feed_O", "delta_p_feed_F", "delta_p_injector_O", + "delta_p_injector_F", "A_geom_O", "A_geom_F", "A_eff_O", "A_eff_F", + "turbulence_intensity_mix", "u_O", "u_F", "We_O", "We_F"] + + +@contextmanager +def _python_only(): + """Force the authoritative Python path for the reference computation.""" + from engine import accel + real = accel.enabled + accel.enabled = lambda: False + try: + yield + finally: + accel.enabled = real def _rel(got, want): return abs(float(got) - float(want)) / max(abs(float(want)), 1e-12) -def _assert_close(name, got, want, rtol): +def _assert_close(name, got, want, rtol=RTOL): assert got is not None and want is not None, f"{name}: missing value ({got} vs {want})" rel = _rel(got, want) - assert rel <= rtol, f"{name}: got={float(got):.10g} want={float(want):.10g} rel={rel:.3e} > {rtol:g}" + assert rel <= rtol, f"{name}: accel={float(got):.10g} python={float(want):.10g} rel={rel:.3e} > {rtol:g}" def _py_field(ref, key): - """Python's result dict keeps some fields top-level and some in diagnostics.""" if key in ref and ref[key] is not None: return ref[key] return (ref.get("diagnostics") or {}).get(key) @@ -80,207 +92,165 @@ def _py_field(ref, key): def _rig(cfg_rel): - """Config + C shim + Numba evaluator + live Python reference, built once.""" + """Config + runner + per-point pure-Python reference, built once.""" if cfg_rel in _RIGS: return _RIGS[cfg_rel] - if os.environ.get("ED_USE_NATIVE", "1") == "0": - pytest.skip("ED_USE_NATIVE=0 -- no C side to compare against") - try: - from engine.native.python import autobuild, ed_native, native_injector - ed_native.load(autobuild.ensure_lib()) - except Exception as exc: - if os.environ.get("ED_REQUIRE_ACCEL") == "1": - pytest.fail(f"ED_REQUIRE_ACCEL=1 but the C reference is unavailable: {exc}") - pytest.skip(f"C reference unavailable ({exc})") - if not native_injector.native_enabled(): - pytest.skip("native kernel not enabled") - - from engine.accel import kernels + from engine import accel from engine.core.runner import PintleEngineRunner from engine.pipeline.io import load_config + if not accel.available(): + if os.environ.get("ED_REQUIRE_ACCEL") == "1": + pytest.fail("ED_REQUIRE_ACCEL=1 but numba is unavailable") + pytest.skip("numba unavailable") + config = load_config(str(ROOT / cfg_rel)) - assert native_injector._can_handle_chamber(config), f"C cannot handle {cfg_rel}" + assert accel.can_handle_chamber(config), f"accelerator cannot handle {cfg_rel}" runner = PintleEngineRunner(config) - native_injector._ensure_cea(runner.cea_cache) - points = [(po * PSI_TO_PA, pf * PSI_TO_PA) for po, pf in POINTS_PSI] - rig = { - "config": config, "runner": runner, "cache": runner.cea_cache, - "ni": native_injector, "nb": kernels.NumbaEvaluator(config, runner.cea_cache), - "mod": kernels, "points": points, - "reference": {p: runner.evaluate(p[0], p[1], P_ambient=PA_AMBIENT, silent=True) - for p in points}, - } + with _python_only(): + reference = {p: runner.evaluate(p[0], p[1], P_ambient=PA_AMBIENT, silent=True) + for p in points} + rig = {"config": config, "runner": runner, "cache": runner.cea_cache, + "points": points, "reference": reference} _RIGS[cfg_rel] = rig return rig @pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).split("/")[-1]) -class TestNumbaMatchesC: - """The regression guard. Numba and C must agree to ULP, not to a tolerance.""" - - def test_core_fields(self, cfg_rel, ablative): - r = _rig(cfg_rel) - for p_o, p_f in r["points"]: - c = r["ni"].evaluate(r["config"], r["cache"], p_o, p_f, PA_AMBIENT) - n = r["nb"].evaluate(p_o, p_f, PA_AMBIENT) - assert c is not None and n is not None, f"a backend bailed at {p_o:.0f}/{p_f:.0f}" - for k in CORE_FIELDS: - if k in c and k in n: - _assert_close(f"{k}@{p_o:.0f}/{p_f:.0f}", n[k], c[k], RTOL_TIGHT) - - -@pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).split("/")[-1]) -class TestNumbaMatchesPython: +class TestAccelMatchesPython: """The contract: what the optimizer consumes must match the authoritative path.""" - def test_wrapper_core_fields(self, cfg_rel, ablative): + def test_wrapper_fields(self, cfg_rel, ablative): + from engine import accel r = _rig(cfg_rel) for p in r["points"]: ref = r["reference"][p] - n = r["nb"].evaluate(p[0], p[1], PA_AMBIENT) - assert n is not None, f"numba bailed where Python converged at {p}" + got = accel.evaluate(r["config"], r["cache"], p[0], p[1], PA_AMBIENT) + assert got is not None, f"accelerator bailed where Python converged at {p}" for k in CORE_FIELDS: want = _py_field(ref, k) if want: - _assert_close(f"{k}@{p[0]:.0f}/{p[1]:.0f}", n[k], want, RTOL) + _assert_close(f"{k}@{p[0]:.0f}/{p[1]:.0f}", got[k], want) + + def test_diagnostics(self, cfg_rel, ablative): + from engine import accel + r = _rig(cfg_rel) + for p in r["points"]: + pd = (r["reference"][p].get("diagnostics") or {}) + gd = accel.evaluate(r["config"], r["cache"], p[0], p[1], PA_AMBIENT)["diagnostics"] + for k in DIAG_FIELDS: + if pd.get(k) and k in gd: + _assert_close(f"diag[{k}]", gd[k], pd[k]) def test_kernel_level_raw_tuple(self, cfg_rel, ablative): - """Kernel level: the raw evaluate_core tuple, with no wrapper in the way. + """Kernel level: the raw evaluate_core tuple, no wrapper in the way. - The C suite keeps this level because a wrapper override once hid a kernel - computing retired momentum-method thrust. Numba has no ctypes struct, so - this is simply the returned tuple -- same property, less machinery. + The retired C suite kept this level because a wrapper override once hid a + kernel computing retired momentum-method thrust. The property is + backend-agnostic and worth keeping: a wrapper cannot paper over the kernel. """ + from engine.accel import kernels, params r = _rig(cfg_rel) - nb, mod = r["nb"], r["mod"] + P = params.extract_params(r["config"]) + arr = kernels.cea_arrays(r["cache"]) for p in r["points"]: ref = r["reference"][p] - raw = mod.evaluate_core(nb.P, *nb.cea, p[0], p[1], PA_AMBIENT) + raw = kernels.evaluate_core(P, *arr, p[0], p[1], PA_AMBIENT) assert raw[0], f"kernel did not converge at {p}" - _assert_close("kernel Pc", raw[1], _py_field(ref, "Pc"), RTOL) - _assert_close("kernel F", raw[2], _py_field(ref, "F"), RTOL) - _assert_close("kernel Isp", raw[3], _py_field(ref, "Isp"), RTOL) - _assert_close("kernel MR", raw[4], _py_field(ref, "MR"), RTOL) + _assert_close("kernel Pc", raw[1], _py_field(ref, "Pc")) + _assert_close("kernel F", raw[2], _py_field(ref, "F")) + _assert_close("kernel Isp", raw[3], _py_field(ref, "Isp")) + _assert_close("kernel MR", raw[4], _py_field(ref, "MR")) @pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).split("/")[-1]) class TestRandomizedSweep: """Breadth the three fixed points cannot give. Fixed seed, so failures repeat.""" - N = 200 + N = 60 - def test_sweep_matches_c(self, cfg_rel, ablative): - import numpy as np + def test_sweep(self, cfg_rel, ablative): + from engine import accel r = _rig(cfg_rel) rng = np.random.default_rng(20260904) - lo, hi = 3.0e6, 5.5e6 - matched = c_only = nb_only = 0 + matched = 0 worst = 0.0 for _ in range(self.N): - p_o = float(rng.uniform(lo, hi)); p_f = float(rng.uniform(lo, hi)) - c = r["ni"].evaluate(r["config"], r["cache"], p_o, p_f, PA_AMBIENT) - n = r["nb"].evaluate(p_o, p_f, PA_AMBIENT) - if c is None and n is None: + p_o = float(rng.uniform(3.0e6, 5.5e6)); p_f = float(rng.uniform(3.0e6, 5.5e6)) + got = accel.evaluate(r["config"], r["cache"], p_o, p_f, PA_AMBIENT) + with _python_only(): + try: + ref = r["runner"].evaluate(p_o, p_f, P_ambient=PA_AMBIENT, silent=True) + except Exception: + ref = None + py_ok = ref is not None and np.isfinite(ref.get("F", np.nan)) + if not py_ok: continue - # A convergence disagreement is a real divergence: same physics, same - # inputs, so one backend bailing where the other did not is a bug. - assert c is not None, f"C bailed where Numba converged at {p_o:.0f}/{p_f:.0f}" - assert n is not None, f"Numba bailed where C converged at {p_o:.0f}/{p_f:.0f}" + # Python converged, so the accelerated path must too: same physics, + # same inputs. A one-sided bail is a bug, not a tolerance question. + assert got is not None, f"accelerator bailed where Python converged at {p_o:.0f}/{p_f:.0f}" matched += 1 for k in CORE_FIELDS: - if k in c and k in n and c[k]: - worst = max(worst, _rel(n[k], c[k])) + want = _py_field(ref, k) + if want: + worst = max(worst, _rel(got[k], want)) assert matched > self.N // 2, f"only {matched}/{self.N} points converged" - assert worst <= RTOL_TIGHT, f"worst Numba-vs-C divergence {worst:.3e} over {matched} points" + assert worst <= RTOL, f"worst accel-vs-Python divergence {worst:.3e} over {matched} points" class TestCoolingIsActuallyApplied: - """Pins the Tc_ideal/Tc_effective distinction. + """Pins the Tc_ideal / Tc_effective distinction. - ed_evaluate.c returns Tc_ideal as `.Tc` but Tc_effective as `.Tc_effective`, - and native_injector.py:538 puts the EFFECTIVE one into the dict the optimizer - and comprehensive_stability_analysis consume. Returning the ideal value is a - silent ~0.8% error that lands in stability, not a crash -- so assert both that - the two differ and that the wrapper exposes the effective one. + evaluate_core returns the ideal Tc at index 7 and the cooling-adjusted one at + index 21; the wrapper must expose the EFFECTIVE value, because that is what + reaches comprehensive_stability_analysis. Returning the ideal one is a silent + ~0.8% error that lands in stability rather than crashing. """ def test_effective_tc_differs_and_is_reported(self): + from engine import accel + from engine.accel import kernels, params r = _rig("configs/canonical/impinging.yaml") - nb, mod = r["nb"], r["mod"] + P = params.extract_params(r["config"]) + arr = kernels.cea_arrays(r["cache"]) p_o, p_f = r["points"][0] - raw = mod.evaluate_core(nb.P, *nb.cea, p_o, p_f, PA_AMBIENT) + raw = kernels.evaluate_core(P, *arr, p_o, p_f, PA_AMBIENT) assert raw[0], "kernel did not converge" tc_ideal, tc_eff = float(raw[7]), float(raw[21]) assert tc_eff < tc_ideal - 1.0, ( f"cooling not applied: Tc_ideal={tc_ideal:.2f} Tc_effective={tc_eff:.2f}. " "If ablative is genuinely inactive for this config the test is vacuous." ) - reported = r["nb"].evaluate(p_o, p_f, PA_AMBIENT)["Tc"] + reported = accel.evaluate(r["config"], r["cache"], p_o, p_f, PA_AMBIENT)["Tc"] assert _rel(reported, tc_eff) < 1e-12, ( f"wrapper reported Tc={reported:.4f}, expected the EFFECTIVE {tc_eff:.4f} " f"(not the ideal {tc_ideal:.4f})" ) - def test_matches_c_effective_tc(self): - r = _rig("configs/canonical/impinging.yaml") - p_o, p_f = r["points"][0] - c = r["ni"].evaluate(r["config"], r["cache"], p_o, p_f, PA_AMBIENT) - n = r["nb"].evaluate(p_o, p_f, PA_AMBIENT) - _assert_close("Tc (effective)", n["Tc"], c["Tc"], RTOL_TIGHT) +@pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).split("/")[-1]) +class TestDeliveredIspInvariant: + """Delivered Isp must sit at/below eta_cstar * the ideal ceiling. -class TestDiagnosticsMatchC: - """The injector diagnostics dict, assembled from Numba's own solve. - - This used to come from a second solve in C (native_injector._nat().injector_solve - + _result_to_diag). Numba's injector_solve already computed every value; they - were simply discarded. Assembling them here is what removes the last C call - from the accelerated evaluate path. + Physics invariant, not a cross-implementation diff: this is what the original + momentum-method thrust bug violated (model Isp >= the ideal equilibrium + ceiling). Carried over from the retired C parity suite; it depends on no + backend, so it outlives both. """ - # C also emits these four; no reader for any of them exists anywhere, and they - # are impinging spray quantities the accelerated path never needs. - # - # A_eff_O/F and turbulence_intensity_mix were briefly on this list and that was - # WRONG -- the suite caught it. A_eff is asserted present (not merely derivable - # from Cd) by test_flow_capacity_effective_area, and turbulence_intensity_mix - # reaches chamber_solver.py:180 via *closure* diagnostics, i.e. the accel.solve - # path rather than the accel.evaluate path the first analysis examined. Both are - # now produced. Treat every addition here with that in mind. - KNOWN_ABSENT = {"J", "TMR", "theta", "feed_orifice_coupling_iterations"} - - @pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).split("/")[-1]) - def test_fields_match_and_nothing_new_is_missing(self, cfg_rel, ablative): - import math - from engine.accel import diagnostics, params + def test_delivered_below_ceiling(self, cfg_rel, ablative): + g0 = 9.80665 r = _rig(cfg_rel) - ni, kernels = r["ni"], r["mod"] - - P = params.extract_params(r["config"]) - arr = kernels.cea_arrays(r["cache"]) - state = ni.build_state(r["config"]) - - for p_o, p_f in r["points"]: - core = kernels.evaluate_core(P, *arr, p_o, p_f, PA_AMBIENT) - assert core[0], f"kernel did not converge at {p_o:.0f}/{p_f:.0f}" - Pc = core[1] - got = diagnostics.build_diag(P, kernels.injector_solve(P, p_o, p_f, Pc)) - rc, ir = ni._nat().injector_solve(state, p_o, p_f, Pc) - assert rc == 0, "C injector solve failed" - want = ni._result_to_diag(r["config"], ir) - - # A newly-missing field means a consumer could silently get None. - newly_absent = (set(want) - set(got)) - self.KNOWN_ABSENT - assert not newly_absent, ( - f"fields C provides but Numba dropped: {sorted(newly_absent)}. " - "Either produce them or justify the omission in KNOWN_ABSENT." + for p in r["points"]: + ref = r["reference"][p] + diag = ref.get("diagnostics") or {} + eta = float(diag.get("eta_cstar", ref.get("eta_cstar", np.nan))) + cstar_ideal = float(diag.get("cstar_ideal", ref.get("cstar_ideal", np.nan))) + cf_vac = r["cache"].eval_cf_vac(ref["MR"], ref["Pc"], ref["eps"]) + ceiling = eta * cf_vac * cstar_ideal / g0 + # Ambient thrust <= vacuum thrust, so this bound holds a fortiori. + assert ref["Isp"] <= ceiling * (1.0 + 1e-6), ( + f"delivered Isp {ref['Isp']:.2f}s exceeds eta_cstar*Isp_vac_ideal " + f"{ceiling:.2f}s — an efficiency term has been dropped from the thrust path" ) - - for k in sorted(set(want) & set(got)): - a, b = got[k], want[k] - if isinstance(b, bool) or not isinstance(b, (int, float)): - assert a == b, f"{k}: numba={a!r} C={b!r}" - elif b and math.isfinite(float(b)): - _assert_close(f"diag[{k}]", a, b, RTOL_TIGHT) From 4561b11de2c2375fcf795f9a0245330721f12b45 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 02:30:39 -0700 Subject: [PATCH 10/20] Drop the ED_ACCEL=c dispatch branch now that the parity suite no longer needs it --- EngineDesign/engine/accel/__init__.py | 76 +++++++-------------------- 1 file changed, 20 insertions(+), 56 deletions(-) diff --git a/EngineDesign/engine/accel/__init__.py b/EngineDesign/engine/accel/__init__.py index 6b18b321a..e8a81dfe3 100644 --- a/EngineDesign/engine/accel/__init__.py +++ b/EngineDesign/engine/accel/__init__.py @@ -1,13 +1,13 @@ """Numba-backed physics accelerator for the Layer-1 optimizer inner loop. -Replaces the hand-written C port at engine/native. During the migration both -backends exist and must be *simultaneously* callable -- the parity suite, the -benchmark and CI all compare them -- so backend selection lives here rather than -being baked into either implementation. - -Public surface mirrors native_injector's, so call sites change an import and -nothing else. Every entry point returns None rather than raising when it cannot -handle a config, because every caller already treats None as "use Python". +Replaced the hand-written C port that used to live at engine/native (deleted once +this reached parity and then overtook it). Every entry point returns None rather +than raising when it cannot handle a config, because every caller treats None as +"fall back to the authoritative Python path". + +Set ED_ACCEL=off to disable the accelerator entirely; everything then runs on the +Python physics, which stays authoritative and is what the parity suite diffs +against. """ from __future__ import annotations @@ -18,22 +18,6 @@ "chug_margin_fast"] -def _c_backend(): - """native_injector when ED_ACCEL=c, else None. - - Lets a single run route the whole accelerated surface through the C port, so - the parity suite and CI can exercise both backends without either - implementation knowing the other exists. Deleted with the C tree. - """ - if os.environ.get("ED_ACCEL") != "c": - return None - try: - from engine.native.python import native_injector - except Exception: - return None - return native_injector - - def available() -> bool: """False (never raises) when numba is absent, so a missing dep degrades to Python.""" try: @@ -44,29 +28,24 @@ def available() -> bool: def enabled() -> bool: - mode = os.environ.get("ED_ACCEL", "numba") - if mode == "off": + if os.environ.get("ED_ACCEL", "numba") == "off": return False - if os.environ.get("ED_USE_NATIVE") == "0": # honour the historical switch + if os.environ.get("ED_USE_NATIVE") == "0": # historical switch, still honoured return False - if mode == "c": - ni = _c_backend() - return bool(ni and ni.native_enabled()) return available() def require() -> bool: """Strict mode: a genuine accelerator failure raises instead of falling back. - Honours ED_REQUIRE_NATIVE too, so the existing CI parity job keeps its contract - while both backends coexist. + Without it a broken accelerator is invisible -- every caller falls back to + Python and the suite passes green on the wrong path. """ - return (os.environ.get("ED_REQUIRE_ACCEL") == "1" - or os.environ.get("ED_REQUIRE_NATIVE") == "1") + return os.environ.get("ED_REQUIRE_ACCEL") == "1" def can_handle(config) -> bool: - """Mirrors native_injector._can_handle.""" + """Impinging only; regen-coupled feed loss is not ported.""" inj = getattr(config, "injector", None) if inj is None or inj.type != "impinging": return False @@ -77,11 +56,11 @@ def can_handle(config) -> bool: def can_handle_chamber(config) -> bool: - """Mirrors native_injector._can_handle_chamber. + """Adds the chamber-solve gates on top of can_handle(). No ablative gate: ablative IS ported (kernels._cooling_evaluate). No graphite - gate either -- graphite never enters the chamber residual, exactly as the C - kernel treats it. + gate either -- graphite never enters the chamber residual; it lives in the + burn/recession path and chamber_solver.py references it zero times. """ if not can_handle(config): return False @@ -97,11 +76,7 @@ def can_handle_chamber(config) -> bool: def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): """Single-call chamber + nozzle + thrust + stability. None => caller falls back. - Signature matches native_injector.evaluate so it drops into the same slot. """ - _ni = _c_backend() - if _ni is not None: - return _ni.evaluate(config, cache, P_tank_O, P_tank_F, P_ambient) from engine.accel import diagnostics as _diag from engine.accel import kernels as _k from engine.accel import params as _p @@ -157,7 +132,7 @@ def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): def solve(config, P_tank_O, P_tank_F, Pc): """Injector mass flows at a given Pc -> (mdot_O, mdot_F, diagnostics), or None. - Mirrors native_injector.solve. This sits on the FALLBACK path: closure.flows + Sits on the FALLBACK path: closure.flows calls it on every residual iteration of the Python chamber solve, so it runs far more often than evaluate() does. @@ -167,9 +142,6 @@ def solve(config, P_tank_O, P_tank_F, Pc): (_apply_x_to_worker_config_inplace), so a config-keyed cache would serve stale geometry. """ - _ni = _c_backend() - if _ni is not None: - return _ni.solve(config, P_tank_O, P_tank_F, Pc) from engine.accel import diagnostics as _diag from engine.accel import kernels as _k from engine.accel import params as _p @@ -189,17 +161,12 @@ def solve(config, P_tank_O, P_tank_F, Pc): def chamber_solve(config, cache, P_tank_O, P_tank_F): """Whole chamber residual loop -> (Pc, diagnostics), or None. - Mirrors native_injector.chamber_solve. The only consumer - (chamber_solver._native_chamber_pc) reads element 0, so the second element is - a plain dict here rather than the C path's ctypes struct. + The only consumer (chamber_solver._native_chamber_pc) reads element 0. Shares evaluate_core's Brent solve instead of duplicating it. That computes a little more than Pc (nozzle/thrust), which is deliberate: a second, subtly different root-find is exactly how the two paths would drift apart. """ - _ni = _c_backend() - if _ni is not None: - return _ni.chamber_solve(config, cache, P_tank_O, P_tank_F) from engine.accel import kernels as _k from engine.accel import params as _p @@ -230,7 +197,7 @@ def warmup(): @njit(cache=True) persists compiled code, but each worker process still deserializes it on first call -- un-warmed, that lands inside the first CMA generation and skews it. Call this in the parent AND in the pool's worker - initialiser, which is where the C path called autobuild.prewarm(). + initialiser. Never raises: a warmup failure must not block optimization, exactly as the C prewarm didn't. @@ -256,8 +223,5 @@ def chug_margin_fast(streams, chamber, **kw): Python against 4.2 us in C, and acoustic.fast_acoustic has no loop to compile. A 6 us difference does not justify a kernel, so that path stays pure Python. """ - _ni = _c_backend() - if _ni is not None: - return _ni.chug_margin_fast(streams, chamber, **kw) from engine.accel import stability as _stab return _stab.chug_margin_fast(streams, chamber, **kw) From f76e66732bc1b9ed153ca32781bc2d37d60ba612 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 02:42:11 -0700 Subject: [PATCH 11/20] Delete the C physics port; numba is the accelerator --- .github/workflows/engine-design-ci.yml | 89 +- EngineDesign/README.md | 54 +- EngineDesign/backend/main.py | 2 +- EngineDesign/engine/core/chamber_solver.py | 12 +- EngineDesign/engine/core/closure.py | 2 +- EngineDesign/engine/core/nozzle.py | 2 +- EngineDesign/engine/native/.gitignore | 3 - EngineDesign/engine/native/CMakeLists.txt | 145 -- .../engine/native/CPORT_COMPLETION_PLAN.md | 331 --- EngineDesign/engine/native/README.md | 287 --- EngineDesign/engine/native/__init__.py | 7 - .../engine/native/bench/bench_evaluate.c | 124 - EngineDesign/engine/native/include/ed_abi.h | 24 - EngineDesign/engine/native/include/ed_cea.h | 76 - .../engine/native/include/ed_chamber.h | 72 - .../engine/native/include/ed_combustion.h | 49 - .../engine/native/include/ed_cooling.h | 45 - .../engine/native/include/ed_discharge.h | 29 - .../engine/native/include/ed_evaluate.h | 71 - .../engine/native/include/ed_feed_loss.h | 21 - .../engine/native/include/ed_injector.h | 54 - .../engine/native/include/ed_nozzle.h | 71 - .../engine/native/include/ed_phys_const.h | 42 - .../engine/native/include/ed_root_find.h | 42 - EngineDesign/engine/native/include/ed_spray.h | 31 - .../engine/native/include/ed_stability.h | 87 - EngineDesign/engine/native/include/ed_state.h | 249 -- EngineDesign/engine/native/include/ed_types.h | 79 - .../engine/native/include/ed_workspace.h | 52 - EngineDesign/engine/native/python/__init__.py | 15 - .../engine/native/python/autobuild.py | 134 -- .../engine/native/python/bench_compare.py | 100 - .../engine/native/python/ed_native.py | 302 --- .../engine/native/python/ed_state_builder.py | 111 - .../engine/native/python/native_injector.py | 578 ----- EngineDesign/engine/native/src/ed_abi.c | 15 - EngineDesign/engine/native/src/ed_cea.c | 215 -- EngineDesign/engine/native/src/ed_chamber.c | 157 -- .../engine/native/src/ed_combustion_eff.c | 9 - .../engine/native/src/ed_combustion_physics.c | 192 -- EngineDesign/engine/native/src/ed_cooling.c | 198 -- EngineDesign/engine/native/src/ed_discharge.c | 52 - EngineDesign/engine/native/src/ed_evaluate.c | 131 -- EngineDesign/engine/native/src/ed_feed_loss.c | 33 - .../engine/native/src/ed_injector_coaxial.c | 9 - .../engine/native/src/ed_injector_impinging.c | 253 --- .../engine/native/src/ed_injector_pintle.c | 9 - EngineDesign/engine/native/src/ed_nozzle.c | 139 -- EngineDesign/engine/native/src/ed_root_find.c | 102 - EngineDesign/engine/native/src/ed_spray.c | 65 - EngineDesign/engine/native/src/ed_stability.c | 14 - .../engine/native/src/ed_stability_modes.c | 158 -- EngineDesign/engine/native/src/ed_workspace.c | 9 - .../engine/native/tests/ed_test_util.h | 62 - .../native/tests/golden/cea_samples.json | 950 -------- .../engine/native/tests/golden/cea_tables.bin | Bin 1887428 -> 0 bytes .../tests/golden/component_samples.json | 2008 ----------------- .../native/tests/golden/golden_impinging.json | 824 ------- .../tests/golden/injector_impinging.json | 587 ----- .../native/tests/golden/nozzle_golden.json | 140 -- .../native/tests/golden/nozzle_oracle.json | 453 ---- .../native/tests/golden/residual_samples.json | 697 ------ .../native/tests/golden/state_impinging.json | 92 - .../engine/native/tests/test_cea_interp.c | 80 - .../engine/native/tests/test_chamber_golden.c | 50 - .../engine/native/tests/test_feed_discharge.c | 121 - .../native/tests/test_injector_golden.c | 154 -- .../engine/native/tests/test_nozzle_golden.c | 87 - .../native/tests/test_residual_golden.c | 154 -- .../engine/native/tests/test_root_find.c | 56 - .../native/tools/bench_evaluate_paths.py | 83 - .../native/tools/capture_nozzle_oracle.py | 162 -- .../native/tools/check_fast_eval_parity.py | 107 - .../engine/native/tools/export_cea_tables.py | 137 -- .../native/tools/export_component_golden.py | 115 - .../native/tools/export_golden_vectors.py | 100 - .../native/tools/export_injector_golden.py | 128 -- .../native/tools/export_nozzle_golden.py | 98 - .../native/tools/export_residual_golden.py | 178 -- .../engine/native/tools/state_from_yaml.c | 28 - .../scripts/bench_layer1_native_vs_python.py | 192 -- EngineDesign/scripts/bench_layer1_numba.py | 40 +- .../scripts/thrust_model_comparison.py | 4 +- EngineDesign/tests/test_numba_ab_parity.py | 2 +- 84 files changed, 76 insertions(+), 12965 deletions(-) delete mode 100644 EngineDesign/engine/native/.gitignore delete mode 100644 EngineDesign/engine/native/CMakeLists.txt delete mode 100644 EngineDesign/engine/native/CPORT_COMPLETION_PLAN.md delete mode 100644 EngineDesign/engine/native/README.md delete mode 100644 EngineDesign/engine/native/__init__.py delete mode 100644 EngineDesign/engine/native/bench/bench_evaluate.c delete mode 100644 EngineDesign/engine/native/include/ed_abi.h delete mode 100644 EngineDesign/engine/native/include/ed_cea.h delete mode 100644 EngineDesign/engine/native/include/ed_chamber.h delete mode 100644 EngineDesign/engine/native/include/ed_combustion.h delete mode 100644 EngineDesign/engine/native/include/ed_cooling.h delete mode 100644 EngineDesign/engine/native/include/ed_discharge.h delete mode 100644 EngineDesign/engine/native/include/ed_evaluate.h delete mode 100644 EngineDesign/engine/native/include/ed_feed_loss.h delete mode 100644 EngineDesign/engine/native/include/ed_injector.h delete mode 100644 EngineDesign/engine/native/include/ed_nozzle.h delete mode 100644 EngineDesign/engine/native/include/ed_phys_const.h delete mode 100644 EngineDesign/engine/native/include/ed_root_find.h delete mode 100644 EngineDesign/engine/native/include/ed_spray.h delete mode 100644 EngineDesign/engine/native/include/ed_stability.h delete mode 100644 EngineDesign/engine/native/include/ed_state.h delete mode 100644 EngineDesign/engine/native/include/ed_types.h delete mode 100644 EngineDesign/engine/native/include/ed_workspace.h delete mode 100644 EngineDesign/engine/native/python/__init__.py delete mode 100644 EngineDesign/engine/native/python/autobuild.py delete mode 100644 EngineDesign/engine/native/python/bench_compare.py delete mode 100644 EngineDesign/engine/native/python/ed_native.py delete mode 100644 EngineDesign/engine/native/python/ed_state_builder.py delete mode 100644 EngineDesign/engine/native/python/native_injector.py delete mode 100644 EngineDesign/engine/native/src/ed_abi.c delete mode 100644 EngineDesign/engine/native/src/ed_cea.c delete mode 100644 EngineDesign/engine/native/src/ed_chamber.c delete mode 100644 EngineDesign/engine/native/src/ed_combustion_eff.c delete mode 100644 EngineDesign/engine/native/src/ed_combustion_physics.c delete mode 100644 EngineDesign/engine/native/src/ed_cooling.c delete mode 100644 EngineDesign/engine/native/src/ed_discharge.c delete mode 100644 EngineDesign/engine/native/src/ed_evaluate.c delete mode 100644 EngineDesign/engine/native/src/ed_feed_loss.c delete mode 100644 EngineDesign/engine/native/src/ed_injector_coaxial.c delete mode 100644 EngineDesign/engine/native/src/ed_injector_impinging.c delete mode 100644 EngineDesign/engine/native/src/ed_injector_pintle.c delete mode 100644 EngineDesign/engine/native/src/ed_nozzle.c delete mode 100644 EngineDesign/engine/native/src/ed_root_find.c delete mode 100644 EngineDesign/engine/native/src/ed_spray.c delete mode 100644 EngineDesign/engine/native/src/ed_stability.c delete mode 100644 EngineDesign/engine/native/src/ed_stability_modes.c delete mode 100644 EngineDesign/engine/native/src/ed_workspace.c delete mode 100644 EngineDesign/engine/native/tests/ed_test_util.h delete mode 100644 EngineDesign/engine/native/tests/golden/cea_samples.json delete mode 100644 EngineDesign/engine/native/tests/golden/cea_tables.bin delete mode 100644 EngineDesign/engine/native/tests/golden/component_samples.json delete mode 100644 EngineDesign/engine/native/tests/golden/golden_impinging.json delete mode 100644 EngineDesign/engine/native/tests/golden/injector_impinging.json delete mode 100644 EngineDesign/engine/native/tests/golden/nozzle_golden.json delete mode 100644 EngineDesign/engine/native/tests/golden/nozzle_oracle.json delete mode 100644 EngineDesign/engine/native/tests/golden/residual_samples.json delete mode 100644 EngineDesign/engine/native/tests/golden/state_impinging.json delete mode 100644 EngineDesign/engine/native/tests/test_cea_interp.c delete mode 100644 EngineDesign/engine/native/tests/test_chamber_golden.c delete mode 100644 EngineDesign/engine/native/tests/test_feed_discharge.c delete mode 100644 EngineDesign/engine/native/tests/test_injector_golden.c delete mode 100644 EngineDesign/engine/native/tests/test_nozzle_golden.c delete mode 100644 EngineDesign/engine/native/tests/test_residual_golden.c delete mode 100644 EngineDesign/engine/native/tests/test_root_find.c delete mode 100644 EngineDesign/engine/native/tools/bench_evaluate_paths.py delete mode 100644 EngineDesign/engine/native/tools/capture_nozzle_oracle.py delete mode 100644 EngineDesign/engine/native/tools/check_fast_eval_parity.py delete mode 100644 EngineDesign/engine/native/tools/export_cea_tables.py delete mode 100644 EngineDesign/engine/native/tools/export_component_golden.py delete mode 100644 EngineDesign/engine/native/tools/export_golden_vectors.py delete mode 100644 EngineDesign/engine/native/tools/export_injector_golden.py delete mode 100644 EngineDesign/engine/native/tools/export_nozzle_golden.py delete mode 100644 EngineDesign/engine/native/tools/export_residual_golden.py delete mode 100644 EngineDesign/engine/native/tools/state_from_yaml.c delete mode 100644 EngineDesign/scripts/bench_layer1_native_vs_python.py diff --git a/.github/workflows/engine-design-ci.yml b/.github/workflows/engine-design-ci.yml index 068cab105..4a02f4f9d 100644 --- a/.github/workflows/engine-design-ci.yml +++ b/.github/workflows/engine-design-ci.yml @@ -140,7 +140,7 @@ jobs: continue-on-error: true # Browser E2E (Playwright). Playwright's webServer auto-boots the FastAPI - # backend (ED_USE_NATIVE=0 -> pure Python, so no C build here) and Vite, so + # backend (ED_ACCEL=off -> pure Python) and Vite, so # this job just needs Python deps, node deps, and Chromium. The guard that # matters is connect-no-loop.spec.ts: it asserts idle /api/config/load traffic # settles instead of the ~7 req/s bootstrap fetch loop that hung the UI. tsc @@ -218,48 +218,19 @@ jobs: if-no-files-found: warn retention-days: 7 - # Native C physics kernel: compile libed_physics and run its golden-vector / - # unit tests via CTest. Fully self-contained — golden vectors are committed - # (engine/native/tests/golden/), so no rocketcea/Python is involved. ubuntu - # ships gcc + cmake. ED_NATIVE_ARCH=OFF drops -march=native for a portable, - # runner-independent build; chamber_golden self-skips (exit 77) until that - # stage lands (CTest reports it skipped, not failed). - native-kernel: - name: Native kernel (C golden/unit tests) - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Configure + build (portable — no -march=native) - working-directory: EngineDesign/engine/native - run: cmake -S . -B build -DED_NATIVE_ARCH=OFF && cmake --build build -j - - - name: Run CTest suite - working-directory: EngineDesign/engine/native - run: ctest --test-dir build --output-on-failure - - # Accelerator parity. The Layer-1 inner loop runs on engine/accel (numba); the - # C port in engine/native is the outgoing implementation and is kept here as a - # numeric oracle until it is deleted. The matrix runs the production impinging - # tests through BOTH backends so neither can rot while both exist. + # Accelerator parity: the numba Layer-1 accelerator vs the authoritative Python + # physics, diffed live on identical inputs. # # CRITICAL: every accelerated entry point returns None on any failure and the - # caller silently falls back to Python (chamber_solver._native_chamber_pc, + # caller silently falls back to Python (chamber_solver._accel_chamber_pc, # closure.flows), so `pytest` alone can be a FALSE GREEN — everything runs on - # Python and passes. The pre-flight positively proves the selected backend is - # real, and ED_REQUIRE_ACCEL=1 turns a genuine accelerator failure into a raise - # rather than a quiet fallback. + # Python and passes. The pre-flight positively proves the accelerator is real, + # and ED_REQUIRE_ACCEL=1 turns a genuine failure into a raise, not a quiet + # fallback. accel-parity: - name: Accelerator parity (ED_ACCEL=${{ matrix.backend }}) + name: Accelerator parity (numba vs Python) runs-on: ubuntu-latest timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - backend: [numba, c] steps: - name: Checkout code @@ -286,61 +257,41 @@ jobs: - name: Verify committed CEA cache is present run: test -f output/cache/cea_cache_LOX_CH4_3D.npz - # Positive proof the selected backend is real, not a silent Python fallback. - # The C library is built in BOTH legs: the numba parity suite diffs numba - # against C on identical inputs, so it needs the oracle present regardless of - # which backend production is routed through. - - name: Pre-flight — backend is genuinely available - env: - ED_ACCEL: ${{ matrix.backend }} + # Positive proof the accelerator is real, not a silent Python fallback. + - name: Pre-flight — accelerator is genuinely available run: | python -c " - from engine.native.python import autobuild, ed_native, native_injector - p = autobuild.ensure_lib(verbose=True) - ed_native.load(p) - assert native_injector.available(), 'native_injector.available() is False' - print('C oracle built + ctypes-loaded OK:', p) import numba from engine import accel assert accel.available(), 'accel.available() is False (numba missing?)' - assert accel.enabled(), 'accel.enabled() is False for ED_ACCEL=${{ matrix.backend }}' + assert accel.enabled(), 'accel.enabled() is False' print('accel enabled, numba', numba.__version__) " # ED_REQUIRE_ACCEL=1 makes a genuine accelerator failure RAISE instead of # falling back, so this step cannot pass on the Python path and report a false # green. It proves the backend RAN; the numeric comparison is the next step. - - name: Run impinging tests through ED_ACCEL=${{ matrix.backend }} (strict) + - name: Run impinging tests through the accelerator (strict) env: - ED_ACCEL: ${{ matrix.backend }} + ED_ACCEL: 'numba' ED_REQUIRE_ACCEL: '1' - ED_REQUIRE_NATIVE: '1' run: | python -m pytest \ tests/test_layer1_impinging_vector.py \ tests/test_flow_capacity_effective_area.py \ --timeout=180 --timeout-method=thread -q - # Live A/B numeric parity, run once (it is backend-independent: it calls the - # numba kernels and the C kernel directly and diffs them, plus both against - # the authoritative Python physics). Unlike the golden-vector C tests — frozen - # snapshots that go stale when the PYTHON side changes — a live comparison - # fails no matter which side drifted. - # - # test_accel_params_match_native_state asserts the pure-Python config->scalar - # extraction reproduces the C EdEngineState EXACTLY; it is deleted with the C - # tree, having done its job. - - name: Live A/B parity — numba vs C vs Python on identical inputs (strict) - if: matrix.backend == 'numba' + # Live A/B numeric parity: run the accelerator and the authoritative Python + # physics on the same inputs and diff them field by field. A live comparison + # fails no matter which side drifted — unlike a frozen snapshot, which goes + # stale silently when the Python side changes. + - name: Live A/B parity — numba vs Python on identical inputs (strict) env: ED_REQUIRE_ACCEL: '1' - ED_REQUIRE_NATIVE: '1' ED_AB_PARITY: '1' run: | python -m pytest \ tests/test_numba_ab_parity.py \ - tests/test_native_ab_parity.py \ - tests/test_accel_params_match_native_state.py \ --timeout=300 --timeout-method=thread -v # Aggregate status, mirroring daq-server-ci.yml's build-summary. @@ -352,7 +303,6 @@ jobs: - backend-smoke - frontend - frontend-e2e - - native-kernel - accel-parity if: always() @@ -368,5 +318,4 @@ jobs: echo "| Backend smoke | ${{ needs.backend-smoke.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Frontend build | ${{ needs.frontend.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Frontend E2E (Playwright) | ${{ needs.frontend-e2e.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| Native kernel (C tests) | ${{ needs.native-kernel.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| Accelerator parity (numba + C) | ${{ needs.accel-parity.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Accelerator parity (numba vs Python) | ${{ needs.accel-parity.result }} |" >> $GITHUB_STEP_SUMMARY diff --git a/EngineDesign/README.md b/EngineDesign/README.md index 24b5396e9..6d0a86ed9 100644 --- a/EngineDesign/README.md +++ b/EngineDesign/README.md @@ -1,6 +1,6 @@ # Liquid Rocket Engine Design Pipeline -A comprehensive physics-based simulation and **multi-layer optimization pipeline** for liquid bipropellant rocket engines. The propellants and the injector type are **whatever you put in the config** — the whole point is to evaluate and compare different engine designs, not to model one fixed engine. Presets ship for **LOX/RP‑1** and **LOX/CH₄ (methalox)** with **pintle** or **impinging** injectors. Takes tank pressures as input and solves for chamber pressure, mass flow rates, thrust, and all performance parameters, accelerated by a native C physics kernel. +A comprehensive physics-based simulation and **multi-layer optimization pipeline** for liquid bipropellant rocket engines. The propellants and the injector type are **whatever you put in the config** — the whole point is to evaluate and compare different engine designs, not to model one fixed engine. Presets ship for **LOX/RP‑1** and **LOX/CH₄ (methalox)** with **pintle** or **impinging** injectors. Takes tank pressures as input and solves for chamber pressure, mass flow rates, thrust, and all performance parameters, accelerated by a Numba‑compiled physics kernel. ## Overview @@ -10,37 +10,42 @@ A comprehensive physics-based simulation and **multi-layer optimization pipeline - Full flow path simulation: tank → feed system → injector → combustion → nozzle → thrust - **Injector modes:** pintle (`injector.type: pintle`) or twin-jet **impinging** (`injector.type: impinging`); see `docs/optimizer_readme.md` (Injector types) and `configs/canonical/impinging.yaml` - **Propellants:** LOX/RP‑1 and **LOX/CH₄ (methalox)** via propellant presets; canonical seeds in `configs/canonical/` -- **Native C physics kernel** (`engine/native/`): the chamber solve + stability hot path runs in C, making `evaluate()` ~**88× faster** with machine‑precision parity — see [Native physics kernel](#native-c-physics-kernel-performance) +- **Numba physics accelerator** (`engine/accel/`): the chamber solve + nozzle + stability hot path is JIT‑compiled, making a Layer‑1 candidate ~**120× faster** — see [Numba physics accelerator](#numba-physics-accelerator-performance) - Multi-layer optimization for complete engine design (geometry, pressure curves, thermal protection) - Time-varying analysis with ablative recession tracking - Stability analysis (chugging, acoustic, feed-system coupling) - Flight simulation validation via RocketPy integration -## Native C physics kernel (performance) +## Numba physics accelerator (performance) The evaluation hot path — chamber‑pressure solve (injector → CEA → combustion -efficiency → ablative cooling → Brent root‑find) plus the chug/acoustic stability -sweep — is implemented as a standalone **C11 library under `engine/native/`** and -wired into the live path. It is an **opt‑in accelerator with automatic Python -fallback**, not a rewrite: the Python physics remains the reference implementation -and is used whenever native is disabled or a config isn't covered. - -- **Enable:** the FastAPI backend sets `ED_USE_NATIVE=1` automatically at startup - (and prebuilds the library), so the **frontend optimizer uses it out of the box**. - For CLI/scripts, `export ED_USE_NATIVE=1`. Set `ED_USE_NATIVE=0` for pure Python. -- **Auto‑build:** on first use the library is compiled with CMake into an - arch‑tagged directory — no manual build step. Requires a C compiler + CMake. -- **Parity & safety:** a one‑time self‑check compares the native result against - Python and falls back on any mismatch. Measured agreement: chamber Pc ~5e‑10, - CEA/stability ~1e‑16. A full `runner.evaluate()` matches Python to ~5e‑10. -- **Speed:** chamber solve ~400× faster; full `evaluate()` ~88× (≈68 ms → ≈0.8 ms), - which is what makes the 15000‑eval Layer‑1 optimizer runs finish in seconds. +efficiency → ablative cooling → Brent root‑find), the nozzle/thrust step, and the +chug stability sweep — is JIT‑compiled with **Numba** under `engine/accel/`. It is +an **accelerator with automatic Python fallback**, not a rewrite: the Python +physics remains the reference implementation and runs whenever the accelerator is +disabled or a config isn't covered. + +- **Enable:** on by default. Set `ED_ACCEL=off` for pure Python. `numba` is a + declared dependency (`requirements-base.txt`); if it is missing the accelerator + reports itself unavailable and everything runs on Python rather than failing. +- **No build step:** kernels compile on first use and cache to `__pycache__` + (`@njit(cache=True)`). `accel.warmup()` front‑loads that in the parent process + and in each Layer‑1 pool worker, since the cache is not shared memory. +- **Parity:** enforced ahead of time by `tests/test_numba_ab_parity.py`, which + runs the accelerated and Python paths live on identical inputs and diffs them + field by field. There is **no runtime self‑check**. Measured agreement is + ~2.5e‑9 (Brent convergence tolerance, not rounding); the suite asserts 1e‑6. +- **Speed:** ~120× per Layer‑1 candidate (≈327 ms → ≈2.7 ms), which is what makes + the 15000‑eval Layer‑1 optimizer runs finish in seconds. The chug stability + sweep is ~53× (1537 µs → 29 µs); roughly a quarter of that came from + vectorising the pure‑Python sweep, which speeds up uncovered configs too. - **Coverage today:** impinging injector + ablative cooling + advanced efficiency. - Pintle/coaxial, film/regen‑coupled cooling, and the nozzle/thrust step still run - in Python (the native path falls back automatically for those). + Pintle/coaxial and film/regen‑coupled cooling still run in Python (the + accelerator returns `None` and the caller falls back automatically). -See `engine/native/README.md` for build details, the staged port plan, and the -parity/benchmark methodology. +This replaced a hand‑written C11 port that lived at `engine/native/`. The Numba +path reached numeric parity with it, then measured faster (the C build, its +ctypes marshalling and golden‑vector suite are gone with it). ## Architecture @@ -162,7 +167,7 @@ EngineDesign/ │ │ ├── analysis.py │ │ └── coupling.py │ │ -│ ├── native/ # Native C physics kernel (opt-in accelerator) +│ ├── accel/ # Numba physics accelerator (JIT hot path) │ │ ├── README.md # Build, staged port plan, parity/benchmarks │ │ ├── CMakeLists.txt # C11 build (auto-built on first use) │ │ ├── include/ # Public headers (ed_*.h) @@ -485,7 +490,6 @@ See the `docs/` folder for additional documentation: - `docs/CONFIG_SYSTEM.md` - Config model: two canonical configs, propellant presets, in-memory switch, burn-time sync - `docs/flight_simulation.md` - `/simulate` endpoint, tank-capacity resolution, and propellant regimes - `docs/flight_altitude_optimization.md` - Minimum-fuel burn-time optimization for a target apogee -- `engine/native/README.md` - Native C physics kernel: build, staged port plan, and parity/benchmark methodology **Control System Documentation:** - `docs/control/README.md` - Control system overview diff --git a/EngineDesign/backend/main.py b/EngineDesign/backend/main.py index 8405c8a0f..24dd4b26a 100644 --- a/EngineDesign/backend/main.py +++ b/EngineDesign/backend/main.py @@ -23,7 +23,7 @@ # and before the Layer-1 ProcessPool spawns. Opt out with ED_ACCEL=off. # # Correction to what this comment used to claim: there is NO runtime self-check -# against Python (see engine/accel/__init__.py and the note in native_injector). +# against Python (see engine/accel/__init__.py). # Equivalence is enforced ahead of time by tests/test_numba_ab_parity.py, which # diffs the accelerated and Python paths live on the same inputs. try: diff --git a/EngineDesign/engine/core/chamber_solver.py b/EngineDesign/engine/core/chamber_solver.py index 01d7e2c07..a83e11188 100644 --- a/EngineDesign/engine/core/chamber_solver.py +++ b/EngineDesign/engine/core/chamber_solver.py @@ -4,7 +4,7 @@ evaluation path (frontend solves, time-varying, flight, Layer-1 finalization). The native C kernel does not hook in here: it accelerates only the Layer-1 inner loop, through a single ``ed_evaluate`` call in -``engine/native/python/native_injector.evaluate``. Keeping native out of this solver +``engine.accel.evaluate``. Keeping the accelerator out of this solver is deliberate — it leaves the general path simple, authoritative, and free of any runtime native/Python switch. """ @@ -229,14 +229,14 @@ def residual(self, Pc: float, P_tank_O: float, P_tank_F: float) -> float: return float(residual) - def _native_chamber_pc(self, P_tank_O: float, P_tank_F: float): + def _accel_chamber_pc(self, P_tank_O: float, P_tank_F: float): """Solve Pc with the native C kernel (whole residual loop + Brent) when native is enabled and can handle this config; otherwise return None so the Python Brent solver runs. Pure capability routing — no runtime parity self-check or process latch. Parity is enforced by the golden test suite and the load-time ABI assert in - ed_native.py. A solve that raises or returns a non-finite Pc falls back to the + the accelerator. A solve that raises or returns a non-finite Pc falls back to the Python solver for that call. """ from engine import accel @@ -331,9 +331,9 @@ def residual_func(Pc): # single-call ed_evaluate seam and fall back to runner.evaluate -> here, so # keeping it native keeps that fallback fast (a pure-Python Brent solve here is # ~100x slower). Any failure -> Python Brent below. - _native_pc = self._native_chamber_pc(P_tank_O, P_tank_F) - if _native_pc is not None: - Pc = _native_pc + _accel_pc = self._accel_chamber_pc(P_tank_O, P_tank_F) + if _accel_pc is not None: + Pc = _accel_pc success = True skip_solve = True residual_min, residual_max = -1.0, 1.0 diff --git a/EngineDesign/engine/core/closure.py b/EngineDesign/engine/core/closure.py index 95db9dab0..7071c3b0d 100644 --- a/EngineDesign/engine/core/closure.py +++ b/EngineDesign/engine/core/closure.py @@ -40,7 +40,7 @@ def _try_native_flows( disabled or can't handle this config (caller falls back to the Python model). Parity is enforced by the A/B suite, not a runtime self-check (capability - dispatch). Strict mode (``ED_REQUIRE_ACCEL=1``/``ED_REQUIRE_NATIVE=1``, the CI + dispatch). Strict mode (``ED_REQUIRE_ACCEL=1``, the CI parity job) makes a *genuine* accelerator failure raise instead of silently falling back to Python, which would report a false green. A config the kernel simply doesn't handle (``solve`` returns None) still falls back quietly. diff --git a/EngineDesign/engine/core/nozzle.py b/EngineDesign/engine/core/nozzle.py index d2bb1bb72..0a6dee574 100644 --- a/EngineDesign/engine/core/nozzle.py +++ b/EngineDesign/engine/core/nozzle.py @@ -14,7 +14,7 @@ ~(1 - zeta_c*zeta_n). The exit state computed here (M_exit, P_exit, T_exit, v_exit) is frozen-isentropic and REPORTING-ONLY — thrust does not depend on it. -The native C kernel (engine/native/src/ed_evaluate.c) computes the SAME delivered +The numba accelerator (engine/accel/kernels.py) computes the SAME delivered formula from the same tables for the Layer-1 inner loop; parity is enforced live by tests/test_native_ab_parity.py. """ diff --git a/EngineDesign/engine/native/.gitignore b/EngineDesign/engine/native/.gitignore deleted file mode 100644 index 469d4bafb..000000000 --- a/EngineDesign/engine/native/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# CMake build trees (any arch suffix). Golden data under tests/golden is kept. -build/ -build_*/ diff --git a/EngineDesign/engine/native/CMakeLists.txt b/EngineDesign/engine/native/CMakeLists.txt deleted file mode 100644 index 554b4cfed..000000000 --- a/EngineDesign/engine/native/CMakeLists.txt +++ /dev/null @@ -1,145 +0,0 @@ -cmake_minimum_required(VERSION 3.16) -project(ed_physics C) - -# --------------------------------------------------------------------------- -# STAR EngineDesign native physics kernel (parallel implementation). -# Builds libed_physics (static + shared), unit/golden tests, and the benchmark. -# No dependency on the Python package; tables/golden vectors are produced offline -# by engine/native/tools/*.py and read from tests/golden/. -# --------------------------------------------------------------------------- - -set(CMAKE_C_STANDARD 11) -set(CMAKE_C_STANDARD_REQUIRED ON) -if(NOT CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) -endif() - -option(ED_NATIVE_ARCH "Enable -march=native (non-portable)" ON) -option(ED_ENABLE_LTO "Enable link-time optimization in Release" ON) -option(ED_FAST_MATH "Enable -ffast-math (documented parity tradeoff)" OFF) - -# Hot-path optimization + warning flags, compiler-aware so the same tree builds -# on macOS/Linux (GCC/Clang) and Windows (MSVC). NOTE: -ffast-math / /fp:fast -# break strict IEEE (no NaN propagation, reassociation). The CEA NaN-corner -# fallback and residual NaN guards depend on NaN semantics, so fast-math is OFF -# by default; enable only for isolated nozzle/stability arithmetic and document -# any parity delta in README.md. -if(MSVC) - set(ED_OPT_FLAGS /O2) - set(ED_WARN_FLAGS /W3) - if(ED_FAST_MATH) - list(APPEND ED_OPT_FLAGS /fp:fast) - endif() - # -march=native has no MSVC analogue we rely on; ED_NATIVE_ARCH is a no-op here. -else() - set(ED_OPT_FLAGS -O3) - set(ED_WARN_FLAGS -Wall -Wextra) - if(ED_NATIVE_ARCH) - include(CheckCCompilerFlag) - check_c_compiler_flag("-march=native" ED_HAS_MARCH_NATIVE) - if(ED_HAS_MARCH_NATIVE) - list(APPEND ED_OPT_FLAGS -march=native) - endif() - endif() - if(ED_FAST_MATH) - list(APPEND ED_OPT_FLAGS -ffast-math) - endif() -endif() - -set(ED_SOURCES - src/ed_abi.c - src/ed_workspace.c - src/ed_cea.c - src/ed_root_find.c - src/ed_feed_loss.c - src/ed_discharge.c - src/ed_spray.c - src/ed_combustion_eff.c - src/ed_combustion_physics.c - src/ed_injector_pintle.c - src/ed_injector_impinging.c - src/ed_injector_coaxial.c - src/ed_chamber.c - src/ed_cooling.c - src/ed_nozzle.c - src/ed_evaluate.c - src/ed_stability.c - src/ed_stability_modes.c -) - -add_library(ed_physics_obj OBJECT ${ED_SOURCES}) -target_include_directories(ed_physics_obj PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) -target_compile_options(ed_physics_obj PRIVATE ${ED_WARN_FLAGS} - $<$:${ED_OPT_FLAGS}>) - -add_library(ed_physics STATIC $) -add_library(ed_physics_shared SHARED $) -set_target_properties(ed_physics_shared PROPERTIES OUTPUT_NAME ed_physics) -# Export all symbols from the DLL on Windows (MSVC hides them by default), so the -# ctypes shim resolves ed_* without per-function __declspec annotations. -set_target_properties(ed_physics_shared PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) -foreach(t ed_physics ed_physics_shared) - target_include_directories(${t} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) - # libm is a separate library only on UNIX; on Windows math lives in the CRT. - if(NOT WIN32) - target_link_libraries(${t} PUBLIC m) - endif() -endforeach() - -if(ED_ENABLE_LTO) - include(CheckIPOSupported) - check_ipo_supported(RESULT ED_IPO_OK OUTPUT _ipo_msg) - if(ED_IPO_OK) - set_property(TARGET ed_physics_obj ed_physics ed_physics_shared - PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE ON) - endif() -endif() - -# Golden data location (produced by tools/export_*.py). Passed to tests. -set(ED_GOLDEN_DIR "${CMAKE_CURRENT_SOURCE_DIR}/tests/golden") - -# ---- Tests ---------------------------------------------------------------- -enable_testing() - -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) - -add_executable(test_cea_interp tests/test_cea_interp.c) -target_link_libraries(test_cea_interp PRIVATE ed_physics) -target_compile_definitions(test_cea_interp PRIVATE ED_GOLDEN_DIR="${ED_GOLDEN_DIR}") -add_test(NAME cea_interp COMMAND test_cea_interp) - -add_executable(test_feed_discharge tests/test_feed_discharge.c) -target_link_libraries(test_feed_discharge PRIVATE ed_physics) -target_compile_definitions(test_feed_discharge PRIVATE ED_GOLDEN_DIR="${ED_GOLDEN_DIR}") -add_test(NAME feed_discharge COMMAND test_feed_discharge) - -add_executable(test_residual_golden tests/test_residual_golden.c) -target_link_libraries(test_residual_golden PRIVATE ed_physics) -target_compile_definitions(test_residual_golden PRIVATE ED_GOLDEN_DIR="${ED_GOLDEN_DIR}") -add_test(NAME residual_golden COMMAND test_residual_golden) - -add_executable(test_injector_golden tests/test_injector_golden.c) -target_link_libraries(test_injector_golden PRIVATE ed_physics) -target_compile_definitions(test_injector_golden PRIVATE ED_GOLDEN_DIR="${ED_GOLDEN_DIR}") -add_test(NAME injector_golden COMMAND test_injector_golden) - -add_executable(test_nozzle_golden tests/test_nozzle_golden.c) -target_link_libraries(test_nozzle_golden PRIVATE ed_physics) -target_compile_definitions(test_nozzle_golden PRIVATE ED_GOLDEN_DIR="${ED_GOLDEN_DIR}") -add_test(NAME nozzle_golden COMMAND test_nozzle_golden) - -# Golden chamber/evaluate/stability tests are wired but skip (exit 77) until the -# corresponding physics stages land; they assert ED_ERR_NOT_IMPLEMENTED today. -add_executable(test_chamber_golden tests/test_chamber_golden.c) -target_link_libraries(test_chamber_golden PRIVATE ed_physics) -target_compile_definitions(test_chamber_golden PRIVATE ED_GOLDEN_DIR="${ED_GOLDEN_DIR}") -add_test(NAME chamber_golden COMMAND test_chamber_golden) -set_tests_properties(chamber_golden PROPERTIES SKIP_RETURN_CODE 77) - -# ---- Benchmark ------------------------------------------------------------ -add_executable(bench_evaluate bench/bench_evaluate.c) -target_link_libraries(bench_evaluate PRIVATE ed_physics) -target_compile_options(bench_evaluate PRIVATE $<$:${ED_OPT_FLAGS}>) -target_compile_definitions(bench_evaluate PRIVATE ED_GOLDEN_DIR="${ED_GOLDEN_DIR}") diff --git a/EngineDesign/engine/native/CPORT_COMPLETION_PLAN.md b/EngineDesign/engine/native/CPORT_COMPLETION_PLAN.md deleted file mode 100644 index c07ba1c3e..000000000 --- a/EngineDesign/engine/native/CPORT_COMPLETION_PLAN.md +++ /dev/null @@ -1,331 +0,0 @@ -# Finishing the C port — completion plan - -Goal: make the Layer-1 optimizer evaluate each CMA-ES candidate with a **single native -`ed_evaluate` call** instead of Python orchestration that crosses the ctypes boundary ~5× per -sample, and retire the `ED_USE_NATIVE` gating so C is simply the path (with Python kept only as a -fallback/parity oracle). Written to be executed surgically without shifting physics or breaking the -GUI flow. - ---- - -## Progress log - -- **RPA thrust port + live A/B parity — DONE (2026-07-18).** `ed_evaluate.c` now computes - DELIVERED thrust on the same RPA basis as `nozzle.py` (`F = zeta_n*Cf_vac*Pc*At - Pa*Ae`; - `Cf_vac` added to the C CEA tables, .bin format v2, v1 still loads). The frozen nozzle - (`ed_nozzle_solve`) is retained for the display-only exit state; its momentum-method F/Isp - fields are legacy and unused. `native_injector.evaluate()` consumes the C result directly - (no Python-side thrust override). Parity is enforced LIVE — both implementations run on the - same inputs — by `tests/test_native_ab_parity.py` (wrapper level + raw EdEvaluateResult - level), wired into the CI `native-parity` job. Historical entries below describe pre-RPA - state (momentum-method oracle, shifting-vs-frozen deltas) and are kept as history; - `tools/check_evaluate_parity.py` and its frozen `nozzle_oracle.json` are superseded. -- **Phase 0 — DONE (2026-06-26).** Python parity oracle captured - (`tests/golden/nozzle_oracle.json`, pintle + impinging × 3 pressure points, shifting + frozen - branches). Baseline: **2.73 ms / Python evaluate**. Tool: `tools/capture_nozzle_oracle.py`. - - **Key finding:** shifting equilibrium moves F/Isp by **up to 0.96% (9.6e-3)** — ~10× the 1e-3 - parity target. So a frozen nozzle cannot match the *shifting* oracle; it matches the *frozen* - oracle exactly. This drove the Phase 1 split below. -- **Phase 1a — DONE (2026-06-26).** Frozen C nozzle implemented + golden-tested. - - `include/ed_nozzle.h`, `src/ed_nozzle.c` (faithful port of `nozzle.py::calculate_thrust` - frozen path + `mach_solver.py` supersonic Newton). - - `tools/export_nozzle_golden.py` → `tests/golden/nozzle_golden.json`; `tests/test_nozzle_golden.c` - wired into CMake. **Passes at rtol 1e-7** (6/6 samples); full existing suite still green. -- **Phase 1b — DEFERRED.** Shifting-equilibrium in C (see Phase 1b below). Decision: frozen now, - shifting later. **Constraint (user):** no physics may be lost — the Python shifting-equilibrium - path stays intact and remains the AUTHORITATIVE nozzle for Layer-1 finalization and every reported - number. The frozen C nozzle is the fast inner-loop ranking kernel only. -- **Phase 5 + 6 — DONE (2026-06-26).** Native is now the DEFAULT path (no env var needed). Single - source of truth `native_injector.native_enabled()` (ON unless `ED_USE_NATIVE=0`; re-reads env each - call so the escape hatch works at runtime; caches only the lib-load probe). Replaced all scattered - `os.environ.get("ED_USE_NATIVE")` checks (closure prewarm, chamber_solver, stability/analysis, - layer1 fast-eval). **Removed the per-call Python residual guard** in chamber_solver (the dominant - per-candidate Python cost) — deleted outright, no debug flag remains; runtime verification is - superseded by the live A/B suite `tests/test_native_ab_parity.py` (CI parity job), with - `ED_USE_NATIVE=0` / `ED_LAYER1_NATIVE_EVAL=0` as the manual escape hatches. Phase 6: marked - nozzle/chamber_solver/closure as the authoritative/fallback-oracle paths in their docstrings (no - behavior change). Two bugs found & fixed while validating: (1) `_ensure_cea` keyed by `id(cache)` - but the native lib has ONE tables buffer → a freed cache's id reuse silently served stale CEA - tables in multi-config processes; now tracked by a token stored on the cache object. (2) my initial - `native_enabled()` cached the env read, defeating the anchor test's runtime `ED_USE_NATIVE=0` pin. - Archived 3 dead debug scripts (reproduce_failure / reproduce_blowdown / reproduce_masking) to - `archive/scrap_files/`. **No physics file is removable** — pintle runs entirely on the Python path, - which is also the impinging fallback + the authoritative finalization/flight/time-series path. - Full pytest suite at baseline (51 pre-existing fails / 342 pass, no NEW failures); suite 4× faster - (35s→8s); pintle verified working (Python fallback, F=7454 N); impinging native parity 2.6e-15. -- **Phase 3 + 4 — DONE (2026-06-26).** `native_injector.evaluate()` returns a runner-compatible - result dict (physics from `ed_evaluate`; full diagnostics via an injector solve at the converged Pc - + `_result_to_diag`, so D32/delta_p_feed/etc. match; stability via the same - `comprehensive_stability_analysis` the Python path uses). Wired into `_eval_candidate` - ([layer1:~1873]) native-first with Python fallback, gated by `_native_fast_eval_enabled()` - (`ED_USE_NATIVE=1`, GUI default; `ED_LAYER1_NATIVE_EVAL=0` forces Python). Validation - (`tools/check_fast_eval_parity.py`): native fast path == Python frozen path to **2.6e-15** across - physics, diagnostics AND stability (score/acoustic/chug/feed). **Measured 3.4× per-candidate - speedup** (903 → 266 µs). Layer-1 pytest green with native ON and OFF; pintle falls back to Python; - finalization replay stays Python (full shifting). Subtlety fixed: stability uses the cooling-adjusted - effective Tc (`r.Tc_effective`), not the ideal Tc the nozzle expands from. -- **Phase 2 — DONE (2026-06-26).** `ed_evaluate.c` body implemented: `ed_chamber_solve` → - `ed_cea_eval(MR,Pc,Pa,eps)` → `ed_nozzle_solve` → flatten into `EdEvaluateResult` (every field - Layer-1 reads). Validated through the **real ctypes path** (`build_state` → `EdNative.evaluate`) - against the frozen oracle: `tools/check_evaluate_parity.py`, impinging × 3 points, **worst rel - 1.1e-15** (machine precision — native chamber is bit-identical to Python, nozzle is frozen-exact). - Full C ctest suite still green. NOTE: validation is the Python parity harness (it exercises the - exact production `build_state` path); a pure-C `test_evaluate_golden.c` is optional/deferred since - it would be strictly weaker. `build_state` is impinging-only today, so pintle still uses the Python - path (Phase 4 preserves that fallback). - ---- - -## 0. Current state (verified, 2026-06-26) - -**Already in C** (called today via `native_injector`, with `ED_USE_NATIVE` defaulted to `1`): -- Chamber fixed-point solve + Brent — `ed_chamber_solve` (the whole residual loop runs in C) -- Injector flows — `ed_injector_solve` -- CEA lookups — `ed_cea_eval` -- Stability chug sweep + acoustic — `ed_chug_margin_fast`, `ed_fast_acoustic` - -**Still Python, per sample** (`engine/core/runner.py::PintleEngineRunner.evaluate`): -- Orchestration: builds the call sequence, re-packs `EdEngineState` each call (`build_state`) -- **Nozzle → thrust → Isp** (`engine/core/nozzle.py::calculate_thrust`) — *not ported* -- Per-call **Python residual validation** of every native Pc (`chamber_solver.py:263`) -- One-time **parity self-checks** + Python-injector fallback (`closure.py`, `chamber_solver.py`) -- Objective assembly (`layer1_static_optimization.py::_compute_objective_value`) — stays Python (cheap) - -**Two stubs block the single-call path:** -- `engine/native/src/ed_nozzle.c` — `ed_nozzle_stage()` returns `"deferred"`; nozzle physics unwritten in C. -- `engine/native/src/ed_evaluate.c` — calls `ed_chamber_solve`, then `return ED_ERR_NOT_IMPLEMENTED` - (comment: "Nozzle expansion + thrust/Isp assembly lands with the chamber port"). - -**API is already frozen** — `engine/native/include/ed_evaluate.h` fully specifies `EdEvaluateResult` -(every field Layer-1 consumes) and the `ed_evaluate` / `ed_evaluate_batch` signatures. `ed_native.py` -already binds `ed_evaluate` (argtypes/restype at ~line 221). So this is **implementation + wiring**, -not redesign. - -### About `ED_USE_NATIVE` (the "bs that keeps popping up") -It is **already on for every GUI run**: `backend/main.py:28` does -`os.environ.setdefault("ED_USE_NATIVE", "1")` before the optimizer imports and before the Layer-1 -`ProcessPool` spawns, so workers inherit it. It was built as an opt-*out* kill switch + a cautious -per-call/one-time parity guard during the port. Now that the kernels are trusted, the gating and the -per-call Python validation are pure overhead and noise. Phase 5 removes them and makes C unconditional -(Python only if the library genuinely fails to load). - ---- - -## 1. Guardrails (do these before touching anything) - -1. **Lock a parity oracle.** For a fixed set of configs — at minimum `configs/canonical/pintle.yaml` - and `configs/canonical/impinging.yaml`, plus `tests/golden/anchor_A_config_ethalox_pintle.yaml` — - record the full `runner.evaluate()` result dict (Python path, `ED_USE_NATIVE=0`) at several - `(P_O, P_F)` points. This is the ground truth every later phase is checked against. -2. **Parity tolerance, not bit-equality.** C↔Python will differ at the ULP level. Use the tolerance - already in the codebase: `rtol = 1e-3` on `mdot`/`Pc` (see `closure.py::_NATIVE_RTOL`), and add - `rtol = 1e-3` on `F`, `Isp`, `Cf`, `T_exit`. Document that bit-identical is **not** a goal. -3. **Existing native golden tests must stay green** the whole time: - `engine/native/tests/test_chamber_golden.c`, `test_injector_golden.c`, `test_residual_golden.c`, - `test_cea_interp.c`. Add new golden tests in the same harness (Phases 1–2). -4. **Capture a full Layer-1 run** (best config + objective trace + wall-clock) on one canonical - problem now, to compare end-to-end after wiring (Phase 4). Use `bench_compare.py` for timing. -5. **Injector-type coverage.** Native covers pintle + impinging; `native_injector._can_handle_*` - returns False for unsupported types (e.g. coaxial). Every phase must preserve the Python fallback - for types the C path can't handle — do not assume all configs go native. - ---- - -## 2. Phase 1a — Frozen nozzle in C (`ed_nozzle.c`) — DONE - -Ported the FROZEN path of `engine/core/nozzle.py::calculate_thrust` (use_shifting_equilibrium=False) -+ `mach_solver.py` supersonic Newton. Scope decision came from Phase 0 (shifting eq is a ~1% term; -see Progress log). - -- `include/ed_nozzle.h` — flat `EdNozzleInputs`/`EdNozzleResult` + `ed_nozzle_solve`. -- `src/ed_nozzle.c` — area-Mach Newton (tol 1e-10, identical to Python), isentropic exit state, - momentum+pressure thrust, throat conditions. `ed_nozzle_stage()` now returns `"frozen"`. -- `tools/export_nozzle_golden.py` records CEA-derived inputs + frozen outputs → - `tests/golden/nozzle_golden.json`. `tests/test_nozzle_golden.c` asserts **rtol 1e-7** (kernel is - the same formulas on the same thermo, so parity is near machine precision). -- `engine/core/nozzle.py` untouched — it remains the oracle AND the authoritative shifting-eq nozzle. - -Done checklist: -- [x] `ed_nozzle.h` added; `ed_nozzle.c` in the existing `ED_SOURCES` list (no CMake source edit needed). -- [x] `test_nozzle_golden` wired + passing (6/6, rtol 1e-7); existing suite green (root_find, cea_interp, - feed_discharge, residual_golden, injector_golden). - -## 2b. Phase 1b — Shifting equilibrium in C (`ed_nozzle`) — DEFERRED - -Brings the inner-loop nozzle from ~1% (frozen) to <1e-3 vs the *shifting* oracle. Only do this if the -inner-loop ranking bias is shown to matter; the Python shifting path already guarantees correct final -numbers, so this is an optimization-quality refinement, not a correctness fix. - -- Port `reaction_chemistry.py::calculate_shifting_equilibrium_properties` + - `calculate_shifting_equilibrium_gamma` + `calculate_frozen_gamma_from_composition`, and the 20-iter - loop in `nozzle.py:432-500`. These do iterative CEA re-evaluations — reuse `ed_cea_eval`. -- Watch the empirical branches (`α≈0.15-0.25`, `Da/(1+Da)`, the CEA-failed fallback). Capture a - dedicated shifting golden from the `shifting` branch already in `nozzle_oracle.json`. -- **Hard constraint:** do not delete or weaken the Python shifting path. It stays authoritative for - Layer-1 finalization regardless of whether 1b lands. - ---- - -## 3. Phase 2 — Implement `ed_evaluate.c` body — DONE - -(See Progress log. Implemented as below; cooling fields taken from chamber diag, no separate -`ed_cooling` call needed since Layer-1 disables ablative/graphite for the static eval.) - -Fill in the assembly between `ed_chamber_solve` and the result: - -1. Call `ed_chamber_solve(state, cea, P_tank_O, P_tank_F, ws, &chamber_diag)` (already implemented). -2. Pull thermo + flow from `EdChamberDiagnostics` (`ed_chamber.h:26`): `Pc, mdot_O, mdot_F, MR, - gamma, R, Tc, cstar_actual/ideal, eta_cstar, Cd_O, Cd_F, momentum_ratio_R, SMD, - delta_P_injector_*, A_geom_*`. -3. Call `ed_nozzle_solve(...)` (Phase 1) for `F, Isp, v_exit, P_exit/throat, T_exit/throat, - Cf_actual/ideal`. -4. Populate **every** `EdEvaluateResult` field (`ed_evaluate.h:20-46`), set `converged = 1`, return - `ED_OK`. On any sub-step failure, return the sub-step's status and leave `converged = 0` (Python - fallback will catch `None`). -5. Cooling fields (`cooling_efficiency`, `Tc_effective`): Layer-1 typically runs with ablative/ - graphite cooling **disabled** for the static eval (`layer1_static_optimization.py:3369-3372`). - Mirror that — if cooling is off, set `cooling_efficiency = 1.0`, `Tc_effective = Tc`. Only port - `ed_cooling` into the evaluate path if a Layer-1 sample actually needs it (it does not today). -6. Leave `ed_evaluate_batch` as a thin loop over `ed_evaluate` for now (optional speedup later). - -Golden test: `engine/native/tests/test_evaluate_golden.c` comparing the full struct to the Python -`runner.evaluate()` oracle for pintle + impinging at several pressures, `rtol 1e-3`. - -Surgical checklist: -- [ ] `ed_evaluate.c` returns `ED_OK` with a fully-populated struct for pintle + impinging. -- [ ] Field-by-field mapping documented inline (chamber-diag field → result field). -- [ ] `test_evaluate_golden.c` passes. - ---- - -## 4. Phase 3 — Python wrapper (`native_injector.evaluate`) - -Add a `evaluate()` to `engine/native/python/native_injector.py` mirroring the existing -`chamber_solve()` shape: - -``` -def evaluate(config, cache, P_O_Pa, P_F_Pa, P_ambient_Pa, Pc_guess_Pa=0.0): - if not _can_handle_chamber(config): return None # preserves type fallback - nat = _nat() - if not _ensure_cea(cache): return None - st = build_state(config) - rc, res = nat.evaluate(st, P_O_Pa, P_F_Pa, P_ambient_Pa, Pc_guess_Pa) # EdEvaluateResult - if rc != 0 or not res.converged: return None - return _result_to_runner_dict(res) # match runner.evaluate keys -``` - -- Add `EdNative.evaluate(...)` in `ed_native.py` that allocates an `EdEvaluateResult`, calls the - already-bound `ed_evaluate`, returns `(rc, struct)`. Reuse the existing `self._ws_buf` / - `self._tables_buf` workspace buffers (already allocated once per process). -- `_result_to_runner_dict` must produce **exactly** the keys Layer-1 reads downstream — verified - against `runner.py`: top-level `F, Isp, Pc, MR, v_exit, P_exit, P_throat, T_exit, T_throat, - Cf_actual, Cf_ideal` and a `diagnostics` sub-dict with `cstar_actual, mdot_O, mdot_F, MR, gamma, - R, Tc, momentum_ratio_R, Cd_O, Cd_F, P_injector_*, delta_p_*`. Cross-check against - `_compute_objective_value` and `_layer1_*` consumers so nothing reads a missing key. -- **Process safety:** each `ProcessPool` worker imports its own `native_injector` and holds its own - `EdNative` instance + workspace — no shared mutable state across workers. Confirm `_nat()` is - per-process (it is, module-global in the worker). - -Surgical checklist: -- [ ] `native_injector.evaluate` returns a dict byte-compatible with `runner.evaluate` consumers, or `None`. -- [ ] Parity test (Python): `native_injector.evaluate` vs `runner.evaluate` within tolerance on the oracle set. - ---- - -## 5. Phase 4 — Wire into the Layer-1 hot path - -In `engine/optimizer/layers/layer1_static_optimization.py::_eval_candidate` (~line 1874), replace the -unconditional `_worker_runner.evaluate(...)` with: - -``` -result = None -if _worker_native_ok: # resolved once per worker (see Phase 5) - result = native_injector.evaluate(_worker_runner.config, _worker_runner.cea_cache, - P_O_Pa, P_F_Pa, _worker_constants['P_ambient']) -if result is None: # unsupported type / non-converged → Python - result = _worker_runner.evaluate(P_O_Pa, P_F_Pa, - P_ambient=_worker_constants['P_ambient'], silent=True) -``` - -- **Everything downstream is unchanged** — `_compute_objective_value`, thrust/MR extraction, the - impinging momentum hinge all keep reading `result[...]`. That is the whole point of matching keys - in Phase 3. -- **Stability:** if Layer-1 scores stability per sample, route it natively too — `ed_stability.h` - already takes a `const EdEvaluateResult*` (`ed_stability.h:80`), so feed the struct straight into - `ed_chug_margin_fast` / `ed_fast_acoustic` instead of re-deriving inputs in Python - (`stability/analysis.py:483-500`). If stability is only scored at finalization (not per sample), - leave it for a follow-up. -- **Finalization replay** (`layer1_static_optimization.py:3376`, the high-fidelity re-eval of the - winning candidate) should stay on the **Python** `runner.evaluate` so the final reported numbers go - through the full-fidelity path including cooling. Only the inner search loop goes native. - -Validation: rerun the captured Layer-1 problem; assert the best config, feasibility, and objective -trace match the pre-change run within tolerance, and record the speedup via `bench_compare.py`. - -Surgical checklist: -- [ ] `_eval_candidate` native-first with Python fallback; downstream untouched. -- [ ] End-to-end Layer-1 parity (best config + objective) within tolerance. -- [ ] Speedup measured and recorded. - ---- - -## 6. Phase 5 — Retire the `ED_USE_NATIVE` gating and per-call guards - -Now make C the path, not a toggle: - -1. **Resolve native availability ONCE** at process start (library loads + passes a single golden - parity self-check). Store a module-level `NATIVE_AVAILABLE: bool`. This replaces: - - `closure.py` per-process `_NATIVE_OK` one-time self-check (keep the *idea*, run it once at - startup, not lazily on first flow). - - `chamber_solver.py` per-call Python residual guard (`_native_chamber_resolve` / line 263) — - **delete the per-call residual**; trust the kernel that passed the startup golden. Keep a single - `assert`-style parity check behind a debug env var for developers, off by default. -2. **Remove `os.environ.get("ED_USE_NATIVE")` branches** from the hot path - (`closure.py:24,87`, `chamber_solver.py:243`, `stability/analysis.py:487,497`). Replace each with - `if NATIVE_AVAILABLE:`. Keep **one** opt-out for developers: honor `ED_USE_NATIVE=0` only at the - single startup resolution point (so `NATIVE_AVAILABLE=False` forces Python everywhere) — that's the - debugging escape hatch, not a per-call branch. -3. `backend/main.py:28` `setdefault(..., "1")` can stay (harmless) or be dropped once gating is gone; - leaving it documents intent. The autobuild prewarm stays. - -Surgical checklist: -- [ ] Single startup native-resolution; no per-call `ED_USE_NATIVE` checks remain in hot code. -- [ ] Per-call Python residual guard removed; golden + Layer-1 parity still green. -- [ ] `ED_USE_NATIVE=0` still forces a clean all-Python run (dev fallback intact). - ---- - -## 7. Phase 6 — Demote Python physics to fallback-only - -Not deletion — these stay as the fallback + oracle, just off the hot path: -- `engine/core/runner.py::evaluate` — fallback for unsupported types + finalization replay. -- `engine/core/chamber_solver.py` (scipy residual/Brent) — fallback only. -- `engine/core/closure.py` + `engine/core/injectors/{impinging,coaxial,pintle}.py` — Python injector - models, fallback only; coaxial stays Python until/unless `ed_injector_solve` covers it. -- `engine/core/nozzle.py`, `engine/pipeline/cea_cache.py::eval` — fallback/oracle. - -Cleanup: -- [ ] Ensure no per-sample Python `import` inside the native path (move imports to module top or a - once-per-worker init). -- [ ] Mark each fallback module with a one-line docstring note: "fallback/parity oracle; hot path is - `ed_evaluate`." -- [ ] Confirm coaxial (and any other non-native type) still optimizes correctly via fallback. - ---- - -## 8. Risk register - -| Risk | Mitigation | -|------|-----------| -| C↔Python numeric drift changes optimizer trajectory | Gate on `rtol 1e-3` golden parity per field; compare full Layer-1 best config before/after | -| Missing/renamed result-dict key breaks `_compute_objective_value` | Phase 3 cross-checks every consumed key against `runner.py` + `layer1_*`; parity test catches it | -| Coaxial / unsupported injector silently goes wrong | `_can_handle_*` returns None → Python fallback preserved and explicitly tested | -| ProcessPool worker state races | Per-process `EdNative` + workspace; no shared mutable buffers; verify in a multi-worker run | -| Cooling fidelity lost in inner loop | Layer-1 inner eval already disables ablative/graphite; finalization replay stays Python full-fidelity | -| Nozzle exit-pressure solve diverges on extreme samples | Reuse `ed_root_find`; on non-convergence return error → Python fallback for that sample | - -## 9. Suggested order & checkpoints -1. Phase 0 guardrails (oracle + baseline timing). -2. Phase 1 nozzle (green golden) → 3. Phase 2 evaluate (green golden) → 4. Phase 3 wrapper (Python parity). -5. Phase 4 wiring (end-to-end parity + speedup number) — **stop and verify here; this delivers the win.** -6. Phase 5 de-gating, then 7. Phase 6 demotion — cleanup, lower risk, do last. diff --git a/EngineDesign/engine/native/README.md b/EngineDesign/engine/native/README.md deleted file mode 100644 index 7102367a1..000000000 --- a/EngineDesign/engine/native/README.md +++ /dev/null @@ -1,287 +0,0 @@ -# `engine/native` — Native C11 physics kernel (parallel implementation) - -A clean-room C11 port of the STAR EngineDesign hot path -(`ChamberSolver.solve → nozzle → comprehensive_stability_analysis`), built **next -to** the Python package. It links against **no** Python at runtime. The chamber -solve + per-eval stability physics are now **wired into the live path behind the -opt-in `ED_USE_NATIVE=1` flag** (set automatically by the FastAPI backend), with -automatic Python fallback; the Python physics remains the reference -implementation. End-to-end `runner.evaluate()` is ~88× faster with documented -numerical parity. The nozzle/thrust step and Layer-1 batching are the remaining -work (Stage 4 onward). - -> **Status: Stages 1–3 complete and verified. The native chamber solve is wired -> into production behind `ED_USE_NATIVE=1` with auto-build on startup.** The whole -> chamber residual loop — impinging injector, CEA, combustion efficiency -> (L*/kinetics/mixing/turbulence), ablative cooling_eff, and the Brent root-find — -> now runs in C. Measured against the live Python implementation: -> `ed_chamber_solve` matches `ChamberSolver.solve` to **~5e-10 on Pc** and is -> **~400× faster**. Stage 5 ported the per-eval stability physics (the chug -> complex-impedance sweep + the 1L/1T acoustic growth rates) to C, and the -> display-only ablative heat-flux profile is skipped on the optimizer (`silent`) -> path. A full `runner.evaluate()` is now **~68× faster end-to-end** (68 ms → -> 1.0 ms) at ~5e-10 parity. What's left is Python *orchestration* — logging -> f-strings, ctypes call overhead, result-dict assembly, and the nozzle's CEA/ -> shifting-equilibrium calls (Stage 4, deferred) — **not** the chamber/stability -> physics, which is native. - -## Cross-platform - -Builds and runs on **macOS, Linux, and Windows**. The CMake flags are -compiler-aware (GCC/Clang `-O3 [-march=native] [-flto]`; MSVC `/O2`), `libm` is -linked only on UNIX, and the benchmark clock uses `QueryPerformanceCounter` on -Windows / `clock_gettime` elsewhere. The macOS arch note below is **macOS-only** -and is handled automatically — it has no effect on Windows or Linux users. - ---- - -## Why staged - -The chamber residual is not a small function. For the canonical configs it pulls -in `combustion_physics.calculate_combustion_efficiency_advanced` (~1.4k lines of -finite-rate chemistry / gasification-SMD / mixing), `reaction_chemistry`, and the -ablative + graphite cooling models, then nozzle expansion, then the full -chug/acoustic/feed stability stack — together ~6–10k lines of intricate, tightly -coupled numerics that must match Python to **Pc rtol 1e-4**. Emitting all of that -in one pass without the ability to iterate against Python parity would produce -code that silently fails the golden gate. So this delivers **correct, tested -layers** and grows upward, exactly as the spec's suggested implementation order -prescribes (`ed_types/ed_state/ed_cea/ed_root_find + unit tests` first). - -## What is implemented and verified now - -| Component | Source | Parity vs Python | Speed | -|---|---|---|---| -| CEA trilinear interp + clamp | `src/ed_cea.c` ↔ `cea_cache.py` | **max rel err 3.98e-16** (79 samples incl. clamp edges) | **91.7 ns** / 6-property eval (~15 ns/table; target <100 ns) | -| Brent/bisection root find | `src/ed_root_find.c` ↔ `scipy.brentq` | analytic roots to 1e-9; bracket/endpoint cases | **41.7 ns** / solve | -| Feed-system loss | `src/ed_feed_loss.c` ↔ `feed_loss.py` | exact (60 samples, all `phi_type`) | — | -| Injector discharge Cd | `src/ed_discharge.c` ↔ `discharge.py` | exact (61 samples, geom/P/T/clamp paths) | — | -| Spray (J, TMR, We, Oh, SMD Ingebo/Lefebvre, x*) | `src/ed_spray.c` ↔ `spray.py` | exact | — | -| **Impinging injector solve** | `src/ed_injector_impinging.c` ↔ `injectors/impinging.py` | **rtol 1e-6** (24 samples: mdot, Cd, momentum R, jet areas, SMD, We, θ, x*) | feed-orifice fixed point in C | -| **Combustion efficiency** (η_L*/kinetics/mixing/turbulence) | `src/ed_combustion_physics.c` ↔ `combustion_physics.py` | **~3e-16** (180 residual checks) | — | -| **Ablative cooling_eff** | `src/ed_cooling.c` ↔ `_evaluate_cooling_models` + hot-wall flux + ablative response | **~3e-16** | — | -| **Chamber solve** (whole residual loop + Brent) | `src/ed_chamber.c` ↔ `ChamberSolver.solve` | **Pc ~5e-10**; `runner.evaluate()` ~5e-10 | **~400×** vs Python solve | -| **Chug stability** (200-pt complex Nyquist sweep) | `src/ed_stability_modes.c` ↔ `chug.py::chug_margin_fast` | **~3e-16** (gain margin, f_chug) | dominant stability cost | -| **Acoustic stability** (1L+1T growth rates) | `src/ed_stability_modes.c` ↔ `acoustic.py::fast_acoustic` | **0** (bit-identical) | per-eval stability now fully native | -| **Chamber post-proc tail** (display-only ablative profile gated on `silent`) | `chamber_solver.py` | bit-identical scalars; display path keeps the profile | biggest tail cost removed | -| **Python orchestration (Tier 1)** | silent-path logging level (`runner.py`); `np.clip`→scalar in `cea_cache.py` | exact (CEA golden diff = 0) | `evaluate()` now **~88×** end-to-end (68 ms → 0.77 ms) | -| Flat POD config snapshot | `include/ed_state.h` + `ed_state_patch()` | O(1) hot-field patch | — | -| ctypes shim | `python/ed_native.py` | drives the above; **112× vs Python `CEACache.eval`** through ctypes | — | - -Measured on this machine (Apple Silicon, Apple clang 17, `-O3 -flto`, -Release). `CEACache.eval` in Python is ~99 µs/call — dominated by dict + numpy -churn — versus 0.88 µs through the ctypes-bound C kernel and ~0.09 µs native; this -is the dict-elimination win the spec calls out, shown on the one kernel ported so -far. - -## Directory layout - -``` -engine/native/ -├── include/ ed_types,ed_state,ed_cea,ed_workspace,ed_root_find, -│ ed_feed_loss,ed_discharge,ed_chamber,ed_evaluate,ed_stability,ed_abi -├── src/ implemented: ed_cea, ed_root_find, ed_feed_loss, ed_discharge, -│ ed_workspace, ed_abi; staged stubs: ed_chamber, ed_evaluate, -│ ed_stability(+_modes), ed_nozzle, ed_cooling, ed_spray, -│ ed_combustion_eff/_physics, ed_injector_{pintle,impinging,coaxial} -├── tests/ test_root_find, test_cea_interp, test_feed_discharge (PASS); -│ test_chamber_golden (SKIP=77 until physics lands); golden/*.json,*.bin -├── bench/ bench_evaluate.c (per-kernel ns/call; full path auto-enables) -├── tools/ export_cea_tables.py, export_component_golden.py, -│ export_golden_vectors.py, state_from_yaml.c -└── python/ __init__.py, ed_native.py, ed_state_builder.py, bench_compare.py -``` - -## Build - -**macOS / Linux:** - -```bash -cd engine/native -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build -j -ctest --test-dir build --output-on-failure -./build/bench_evaluate -``` - -Flags: `-O3` (Release) with `-march=native` (`-DED_NATIVE_ARCH=OFF` for portable -`-O2`-class builds) and `-flto` (`-DED_ENABLE_LTO=OFF` to disable). `-ffast-math` -is **OFF by default** (`-DED_FAST_MATH=ON` to enable): the CEA NaN-corner fallback -and residual NaN guards depend on IEEE NaN semantics, so fast-math would change -behavior — only enable it for isolated nozzle/stability arithmetic and document -any parity delta. - -**Windows (MSVC):** - -```bat -cd engine\native -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build --config Release -ctest --test-dir build -C Release --output-on-failure -build\Release\bench_evaluate.exe -``` - -The auto-build path (below) shells out to whatever CMake generator/toolchain is -installed (MSVC or MinGW) and produces `ed_physics.dll`; no Windows-specific -steps are required. - -**macOS arch note — auto-handled, macOS-only:** a ctypes library must match the -Python interpreter's architecture, and on Apple Silicon the system can run an -x86_64 (Rosetta) Python alongside arm64 toolchains. The **auto-build builds with -the running interpreter's architecture**, so this is resolved automatically and -needs no manual flag. This note does **not** apply to Windows or Linux. For a -manual arch-matched build if ever needed: - -```bash -cmake -S . -B build_arm -DCMAKE_OSX_ARCHITECTURES=arm64 -DED_NATIVE_ARCH=OFF -cmake --build build_arm -j --target ed_physics_shared -``` - -## Export tables, golden vectors, and a state snapshot - -All exporters read the existing `engine/` package **read-only** and write only -under `engine/native/`. Run from the repo root (`EngineDesign/`): - -```bash -# CEA tables (.bin) + reference eval samples (.json) for the C parity test -python engine/native/tools/export_cea_tables.py --config configs/canonical/impinging.yaml --out engine/native/tests/golden - -# Leaf-physics parity samples (feed loss, discharge) -python engine/native/tools/export_component_golden.py --out engine/native/tests/golden - -# Runner-level golden vectors (Pc, F, MR, injector + stability margins) for the -# future chamber/evaluate/stability golden test -python engine/native/tools/export_golden_vectors.py --config configs/canonical/impinging.yaml \ - --out engine/native/tests/golden/golden_impinging.json - -# Flat EdEngineState field mapping (JSON today; packed .bin lands with ed_evaluate) -python engine/native/python/ed_state_builder.py --config configs/canonical/impinging.yaml -``` - -## Run tests & benchmark - -```bash -ctest --test-dir build --output-on-failure # root_find, cea_interp, feed_discharge PASS; chamber_golden SKIP -./build/bench_evaluate # per-kernel ns/call -python engine/native/python/bench_compare.py # Python vs C (CEA today; combined path when implemented) -``` - -## Production wiring (LIVE — one seam: the Layer-1 inner loop) - -Native acceleration exists for **one purpose**: speeding up the Layer-1 optimizer's -per-candidate evaluation. It is wired in at **exactly one place** and nowhere else. - -- **The single seam:** the Layer-1 inner loop - (`engine/optimizer/layers/layer1_static_optimization.py`) calls - `native_injector.evaluate()` per candidate — a single `ed_evaluate` C call (chamber - residual loop + Brent + nozzle) plus native-accelerated stability. On any - unsupported config or non-converged solve it returns `None` and the worker falls - back to the full Python `runner.evaluate()` for that candidate. -- **The general path stays pure Python.** `runner.evaluate()`, `chamber_solver.solve`, - `closure.flows`, and `nozzle.py` have **no native branch** — they are the - authoritative implementation used by frontend solves, time-varying, flight, and - Layer-1 finalization. This is deliberate: native never spills past the one seam, so - the general path has no native/Python switch to reason about and nothing in Python - becomes barren. -- **Enable / disable:** `ED_USE_NATIVE=1` (default) turns the seam on; `ED_USE_NATIVE=0` - makes Layer-1 run entirely in Python. The FastAPI backend (`backend/main.py`) and the - Layer-1 parent both `ensure_lib()` once before spawning the worker pool, so workers - load a ready library instead of racing to compile. The lib otherwise auto-builds - lazily on first use (`ed_native.load` → `autobuild.ensure_lib`); no manual `cmake`. -- **Config compatibility:** the seam engages only for **impinging** injectors with - **ablative-only** cooling and the advanced efficiency model (`_can_handle_chamber`). - Pintle/coaxial or film/regen-coupled designs return `None` → full Python. This is - capability routing, not a safeguard: the C for those paths simply isn't written. -- **Thrust (RPA, exact):** `ed_evaluate` computes DELIVERED thrust on the same RPA basis - as `nozzle.py` — `F = zeta_n*Cf_vac*Pc*At - Pa*Ae`, with `Cf_vac` in the native CEA - tables (format v2) — so inner-loop F/Isp match finalization exactly. The frozen - exit-state kernel (`ed_nozzle_solve`) supplies display-only exit/throat properties; - its legacy momentum-method F fields are unused (see ed_nozzle.h). -- **CEA tables:** the live `CEACache` is dumped to a temp `.bin` (format v2, incl. - `Cf_vac`), loaded into the native lib once per process (so the C path uses exactly - the runtime grid), and the temp file is **deleted immediately after the load**. -- **Parity:** there is **no per-call runtime self-check**. Native↔Python parity is - enforced by (a) the golden C test suite (`engine/native/tests`), (b) the LIVE A/B - suite `tests/test_native_ab_parity.py` — both implementations run on the same inputs - in the CI parity job, at the wrapper level and the raw `EdEvaluateResult` level — - and (c) the load-time ABI assert below. The seam trusts the native result at - runtime; a raise / non-finite `Pc` falls back to Python for that call - (`ED_REQUIRE_NATIVE=1` makes machinery failures raise instead). -- **Layout-drift guard (the one structural safeguard):** `ed_native.py` asserts - `ctypes.sizeof(EdEngineState) == ed_sizeof_engine_state()` at load, so any - `ed_state.h` change that isn't mirrored fails loudly at import (→ native disabled for - the process) instead of corrupting inputs. This is an assertion on a real invariant, - not a trust flag — it can't be silently flipped on. - -## Remaining integration (later stages) - -1. **`runner._evaluate_native()`** opt-in once `ed_evaluate` (Stages 3–4) lands — - replaces the whole chamber→nozzle path, not just the injector, behind the same - `ED_USE_NATIVE` flag with Python fallback. -2. **Layer 1 worker uses native** — the CMA-ES worker calls `ed_evaluate_batch` - with one frozen `EdEngineState` + per-thread `EdWorkspace`, patching hot fields - via `ed_state_patch()`. - -That full switch-over is gated on `bench_compare.py` reporting **≥10×** on the -combined `evaluate + stability` path and the golden tests passing at the documented -tolerances (Pc 1e-4, F/mdot 1e-3, margins 1e-2). - -## Staged plan - -Each stage is independently golden-tested before the next begins. - -- **Stage 1 — foundation (DONE):** types, flat state, CEA interp, Brent, feed - loss, discharge, workspace, build system, export tooling, ctypes shim, parity - tests. *(this commit)* -- **Stage 2 — impinging injector + spray + wiring (DONE):** ported - `injectors/impinging.py` and `spray.py` (SMD Ingebo/Lefebvre), golden-tested - (mdot, Cd, momentum ratio R, jet areas, SMD), and wired into `closure.flows` - behind `ED_USE_NATIVE=1` with auto-build + self-check. *(this commit)* - Pintle/coaxial solves are the next sub-step before Stage 3. -- **Stage 3 — combustion efficiency + cooling + chamber solve (DONE):** ported - `combustion_physics.calculate_combustion_efficiency_advanced` (η_L*, η_kinetics, - η_mixing, η_turbulence) and the ablative `cooling_eff` chain (hot-wall flux + - `compute_ablative_response`), golden-tested to ~3e-16, then wired the whole - residual + Brent into `ed_chamber_solve` and into `ChamberSolver.solve`. Pc parity - ~5e-10 (≪ 1e-4 target); ~400× faster chamber solve, ~38× faster `evaluate()`. - `reaction_chemistry` progress is diagnostics-only (runs after the root-find, does - not affect Pc) and is deferred. *(this commit)* -- **Stage 5 — stability (chug DONE):** ported `chug.py::chug_margin_fast` — the - 200-pt complex-impedance Nyquist sweep that dominates per-eval stability cost — to - C (`ed_chug_margin_fast`), parity ~3e-16, and wired it into - `compute_physical_stability` behind `ED_USE_NATIVE`. `evaluate()` is now ~50×. - *(this commit)*. Remaining (small): `acoustic.fast_acoustic` and the feed-system - margins. -- **Stage 4 — nozzle + `ed_evaluate`:** `nozzle.py` thrust/Isp/exit conditions → - full `EdEvaluateResult`. NOTE: parity requires porting the shifting-equilibrium - path (`reaction_chemistry.calculate_shifting_equilibrium_properties`), which moves - F by ~0.4% (above the 1e-3 target) — so this stage carries the reaction-chemistry - port. Currently the nozzle (calculate_thrust) still runs in Python (~10% of eval). -- **Chamber post-processing tail (~30% of the native eval):** `ChamberSolver.solve` - still rebuilds the diagnostics dict in Python (re-runs `_evaluate_cooling_models` - + `reaction_chemistry` progress) after the native root-find. Trimming this (have - the native solve return cooling/progress) is the other remaining lever. -- **Stage 6 — optimize to ≥10×:** profile the combined path; batch API; confirm - zero hot-loop allocation; `bench_compare.py` 10× gate. - -## Known gaps vs Python (current) - -- `ed_chamber_solve` / `ed_evaluate` / `ed_stability_analyze` return - `ED_ERR_NOT_IMPLEMENTED` (Stages 2–5). APIs and result structs are frozen. -- Cd P/T corrections, `phi_type ∈ {sqrtP, logP}`, and clamp branches are ported - and tested even though the canonical impinging config does not exercise them - (it uses `phi_type=none`, geometry-Cd, corrections off). -- Standalone C `state_from_yaml` defers to `python/ed_state_builder.py` so config - resolution (presets/defaults/derived geometry) has a single source of truth. -- Deliberate non-goals (unchanged): time-varying solver, flight sim, Layer 2–4, - `stability/report.py` rich payload, calling RocketCEA from C. - -## Parity tolerances (asserted / planned) - -| Quantity | Tolerance | Where | -|---|---|---| -| CEA properties | rel 1e-9 (achieves ~1e-16) | `test_cea_interp` (now) | -| feed Δp, Cd | rel 1e-9 (exact) | `test_feed_discharge` (now) | -| Pc | rtol 1e-4 | `test_chamber_golden` (Stage 3) | -| F, mdot_* | rtol 1e-3 | Stage 4 | -| stability margins | rtol 1e-2 | Stage 5 | diff --git a/EngineDesign/engine/native/__init__.py b/EngineDesign/engine/native/__init__.py deleted file mode 100644 index ce85a8df6..000000000 --- a/EngineDesign/engine/native/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Native physics kernel package marker. - -Makes engine.native.python importable from the engine package. Importing this -module has no side effects and does NOT build or load the native library; that -happens lazily in engine.native.python.autobuild when the native path is first -requested (ED_USE_NATIVE=1). -""" diff --git a/EngineDesign/engine/native/bench/bench_evaluate.c b/EngineDesign/engine/native/bench/bench_evaluate.c deleted file mode 100644 index 0c507bff2..000000000 --- a/EngineDesign/engine/native/bench/bench_evaluate.c +++ /dev/null @@ -1,124 +0,0 @@ -/* bench_evaluate.c - Standalone timing harness. - * - * Final target (per spec): load state_impinging.bin + cea_tables.bin, warm up - * 1000 iters, time 100k (ed_evaluate + ed_stability_analyze) calls, print median - * ns/eval and the implied speedup vs the Python baseline. - * - * STAGE 1: ed_evaluate/ed_stability are not yet implemented, so this harness - * times the kernels that ARE on the hot path and already ported — the CEA - * trilinear lookup (target <100 ns) and a representative bracketed Brent solve — - * to validate the measurement rig and establish per-component baselines. The full - * combined-path timing turns on automatically once ed_evaluate returns ED_OK. - */ -#include "ed_cea.h" -#include "ed_root_find.h" -#include "ed_evaluate.h" -#include -#include -#include - -#ifndef ED_GOLDEN_DIR -#define ED_GOLDEN_DIR "." -#endif - -/* Portable monotonic high-resolution clock (Windows QPC / POSIX clock_gettime). */ -#if defined(_WIN32) -#include -static double now_ns(void) { - static LARGE_INTEGER freq; - if (freq.QuadPart == 0) QueryPerformanceFrequency(&freq); - LARGE_INTEGER c; QueryPerformanceCounter(&c); - return (double)c.QuadPart * 1e9 / (double)freq.QuadPart; -} -#else -#include -static double now_ns(void) { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (double)ts.tv_sec * 1e9 + (double)ts.tv_nsec; -} -#endif - -static int cmp_d(const void *a, const void *b) { - double x = *(const double *)a, y = *(const double *)b; - return (x > y) - (x < y); -} - -/* A monotone residual standing in for the chamber supply-demand balance, used to - * exercise the Brent solver at a realistic ~10-30 iteration count. */ -static double demo_residual(double Pc, void *ctx) { - double k = *(double *)ctx; - return 6.0 - k * Pc * 1e-6 - 0.3 * Pc * 1e-6; /* root near a few MPa */ -} - -int main(void) { - EdCeaTables cea; - if (ed_cea_load(ED_GOLDEN_DIR "/cea_tables.bin", &cea) != ED_OK) { - fprintf(stderr, "bench: cannot load cea_tables.bin\n"); - return 1; - } - - const int N = 100000, WARM = 1000, REPS = 20; - double *samp = (double *)malloc(sizeof(double) * REPS); - - /* Per-call latency is below clock_gettime granularity, so each rep times the - * whole N-iteration batch and divides; we report the median rep. */ - - /* ---- CEA lookup ---- */ - EdCeaResult r; - double acc = 0.0; - for (int i = 0; i < WARM; ++i) ed_cea_eval(&cea, 2.8, 4.0e6, 101325.0, 6.0, &r); - for (int rep = 0; rep < REPS; ++rep) { - double t0 = now_ns(); - for (int i = 0; i < N; ++i) { - double MR = 2.5 + (double)(i % 100) / 100.0 * 1.5; - double Pc = 1.5e6 + (double)(i % 137) / 137.0 * 6.0e6; - double eps = 4.0 + (double)(i % 53) / 53.0 * 10.0; - ed_cea_eval(&cea, MR, Pc, 101325.0, eps, &r); - acc += r.cstar_ideal; - } - samp[rep] = (now_ns() - t0) / N; - } - qsort(samp, REPS, sizeof(double), cmp_d); - printf("ed_cea_eval : median %6.1f ns/call (sink=%.3g)\n", samp[REPS / 2], acc); - - /* ---- Brent solve ---- */ - ed_root_opts opt = { .xtol = 1.0, .rtol = 1e-9, .max_iter = 100 }; - long iters = 0; acc = 0.0; - for (int rep = 0; rep < REPS; ++rep) { - double t0 = now_ns(); - for (int i = 0; i < N; ++i) { - double k = 1.0 + (double)(i % 100) / 100.0; - ed_root_result rr; - ed_brentq(demo_residual, &k, 1.0e5, 8.0e6, &opt, &rr); - if (rep == 0) iters += rr.iterations; - acc += rr.root; - } - samp[rep] = (now_ns() - t0) / N; - } - qsort(samp, REPS, sizeof(double), cmp_d); - printf("ed_brentq (residual): median %6.1f ns/call avg %.1f iters (sink=%.3g)\n", - samp[REPS / 2], (double)iters / N, acc); - - /* ---- Full combined path (auto-enables when implemented) ---- */ - EdEngineState st; EdWorkspace ws; ed_workspace_reset(&ws); - EdEvaluateResult ev; - ed_status_t rc = ed_evaluate(&st, &cea, 5e6, 5e6, 101325.0, 0.0, &ws, &ev); - if (rc == ED_ERR_NOT_IMPLEMENTED) { - printf("ed_evaluate+stability: pending (combustion/cooling/nozzle/stability stage)\n"); - } else { - for (int i = 0; i < WARM; ++i) ed_evaluate(&st, &cea, 5e6, 5e6, 101325.0, 0.0, &ws, &ev); - for (int rep = 0; rep < REPS; ++rep) { - double t0 = now_ns(); - for (int i = 0; i < N; ++i) - ed_evaluate(&st, &cea, 5e6, 5e6, 101325.0, 0.0, &ws, &ev); - samp[rep] = (now_ns() - t0) / N; - } - qsort(samp, REPS, sizeof(double), cmp_d); - printf("ed_evaluate : median %6.1f ns/call\n", samp[REPS / 2]); - } - - free(samp); - ed_cea_free(&cea); - return 0; -} diff --git a/EngineDesign/engine/native/include/ed_abi.h b/EngineDesign/engine/native/include/ed_abi.h deleted file mode 100644 index a6e1454ff..000000000 --- a/EngineDesign/engine/native/include/ed_abi.h +++ /dev/null @@ -1,24 +0,0 @@ -/* ed_abi.h - sizeof helpers so the ctypes shim can allocate opaque buffers for - * the POD structs without mirroring their full (evolving) layout in Python. - * Flat double-only result structs are mirrored directly in Python instead. */ -#ifndef ED_ABI_H -#define ED_ABI_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -size_t ed_sizeof_engine_state(void); -size_t ed_sizeof_cea_tables(void); -size_t ed_sizeof_workspace(void); -size_t ed_sizeof_evaluate_result(void); -size_t ed_sizeof_stability_result(void); -size_t ed_sizeof_chamber_diagnostics(void); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_ABI_H */ diff --git a/EngineDesign/engine/native/include/ed_cea.h b/EngineDesign/engine/native/include/ed_cea.h deleted file mode 100644 index e757284f4..000000000 --- a/EngineDesign/engine/native/include/ed_cea.h +++ /dev/null @@ -1,76 +0,0 @@ -/* ed_cea.h - Preloaded CEA property tables + hand-rolled trilinear interpolation. - * - * Mirrors engine/pipeline/cea_cache.py: CEACache.eval() + _trilinear_interpolate(). - * Tables are produced offline by engine/native/python/export_cea_tables.py and - * loaded from a flat .bin (see ed_cea_load). No RocketCEA at runtime. - */ -#ifndef ED_CEA_H -#define ED_CEA_H - -#include "ed_types.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* Flattened C-order tables: index(i_pc,i_mr,i_eps) = (i_pc*n_mr + i_mr)*n_eps + i_eps. - * Stored as double for bit-comparable parity with the float64 Python cache. */ -typedef struct { - const double *Pc_grid; /* length n_pc, ascending */ - const double *MR_grid; /* length n_mr, ascending */ - const double *eps_grid; /* length n_eps, ascending */ - const double *cstar_table; - const double *Cf_table; - const double *Tc_table; - const double *gamma_table; - const double *R_table; - const double *M_table; - /* Vacuum thrust coefficient (RPA delivered-thrust basis). NULL when loaded - * from a version-1 .bin (pre-Cf_vac); ed_cea_eval then reports NaN and - * ed_evaluate refuses to compute delivered thrust (callers fall back). */ - const double *Cf_vac_table; - size_t n_pc, n_mr, n_eps; - - /* Clamp bounds == grid endpoints (matches CEACache.{Pc,MR,eps}_{min,max}). */ - double Pc_min, Pc_max; - double MR_min, MR_max; - double eps_min, eps_max; - - /* Backing storage when loaded from file (NULL for caller-provided tables). */ - void *_owned; -} EdCeaTables; - -/* Output of one CEA lookup (matches CEACache.eval() dict keys). - * MIRRORED in ed_native.py (EdCeaResult ctypes.Structure) — keep in sync. */ -typedef struct { - double cstar_ideal; - double Cf_ideal; - double Tc; - double gamma; - double R; - double M; - double Cf_vac; /* vacuum thrust coefficient; NaN if the table lacks it (v1 .bin) */ -} EdCeaResult; - -/* Evaluate CEA properties at (MR, Pc, eps). Pa is accepted for API parity with - * the Python signature but, like the Python cache, does not affect 3D lookups. - * Clamps inputs to grid bounds, then trilinear-interpolates each table. */ -ed_status_t ed_cea_eval(const EdCeaTables *t, - double MR, double Pc, double Pa, double eps, - EdCeaResult *out); - -/* Single-table trilinear interpolation (exposed for unit tests). Inputs must be - * pre-clamped to [grid0, gridN-1] exactly as eval() does. */ -double ed_cea_trilinear(const EdCeaTables *t, const double *table, - double Pc, double MR, double eps); - -/* Load tables from a .bin produced by export_cea_tables.py. Allocates backing - * storage (offline/setup only, never in the hot loop). Free with ed_cea_free. */ -ed_status_t ed_cea_load(const char *path, EdCeaTables *out); -void ed_cea_free(EdCeaTables *t); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_CEA_H */ diff --git a/EngineDesign/engine/native/include/ed_chamber.h b/EngineDesign/engine/native/include/ed_chamber.h deleted file mode 100644 index 391f01646..000000000 --- a/EngineDesign/engine/native/include/ed_chamber.h +++ /dev/null @@ -1,72 +0,0 @@ -/* ed_chamber.h - Module 1: chamber-pressure solve. - * - * Port target: ChamberSolver.solve()/residual() (engine/core/chamber_solver.py) - * via closure.flows() -> injector.solve(), cea_cache.eval(), eta_cstar(), cooling. - * - * STATUS: API frozen. The residual chain depends on the combustion-physics and - * cooling ports (engine/native/README.md "Staged plan"); until those land, - * ed_chamber_solve returns ED_ERR_NOT_IMPLEMENTED. The foundational pieces it - * will compose (CEA, Brent, feed-loss, discharge) are implemented and tested. - */ -#ifndef ED_CHAMBER_H -#define ED_CHAMBER_H - -#include "ed_types.h" -#include "ed_state.h" -#include "ed_cea.h" -#include "ed_workspace.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* Flat diagnostics produced by the chamber solve and consumed downstream by the - * nozzle/evaluate and stability kernels. Mirrors the subset of ChamberSolver.solve - * diagnostics dict that Layer 1 / nozzle / stability actually read. */ -typedef struct EdChamberDiagnostics { - double Pc; /* Pa */ - double mdot_O; /* kg/s */ - double mdot_F; /* kg/s */ - double mdot_total; /* kg/s */ - double MR; - double cstar_ideal; /* m/s */ - double cstar_actual; /* m/s */ - double eta_cstar; - double cooling_efficiency; - double Tc; /* K (effective, post-cooling) */ - double Tc_ideal; /* K */ - double gamma; - double R; /* J/(kg.K) */ - double M; /* kg/kmol */ - - /* Injector diagnostics needed for Layer-1 penalties. */ - double momentum_ratio_R; - double delta_P_injector_O; /* Pa */ - double delta_P_injector_F; /* Pa */ - double A_geom_O; /* m^2 */ - double A_geom_F; /* m^2 */ - double SMD; /* m (Sauter mean diameter, max of branches) */ - double Cd_O; - double Cd_F; - double u_O; /* m/s injection velocity */ - double u_F; - - int converged; - int residual_iters; -} EdChamberDiagnostics; - -/* Solve supply(Pc) == demand(Pc). Pc_guess_Pa = 0 selects the auto midpoint. - * Allocation-free; uses ws for the residual history / warm-start bracket. */ -ed_status_t ed_chamber_solve(const EdEngineState *state, - const EdCeaTables *cea, - double P_tank_O_Pa, - double P_tank_F_Pa, - double Pc_guess_Pa, - EdWorkspace *ws, - EdChamberDiagnostics *out); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_CHAMBER_H */ diff --git a/EngineDesign/engine/native/include/ed_combustion.h b/EngineDesign/engine/native/include/ed_combustion.h deleted file mode 100644 index 20d5f4729..000000000 --- a/EngineDesign/engine/native/include/ed_combustion.h +++ /dev/null @@ -1,49 +0,0 @@ -/* ed_combustion.h - Combustion efficiency (eta_c*). - * - * Port of combustion_eff.eta_cstar -> combustion_physics. - * calculate_combustion_efficiency_advanced and its sub-models - * (compute_combustion_state, calculate_eta_Lstar/gasification, residence, - * reaction time, Damkohler, mixing, turbulence). - */ -#ifndef ED_COMBUSTION_H -#define ED_COMBUSTION_H - -#include "ed_types.h" -#include "ed_state.h" - -#ifdef __cplusplus -extern "C" { -#endif - -typedef struct { - double eta_total; - double eta_Lstar; - double eta_kinetics; - double eta_mixing; /* Rupe momentum-ratio mixing efficiency */ - double Da; - double tau_res; - double tau_chem; -} EdEtaResult; - -/* Inputs gathered by the chamber residual (advanced_params in Python). D32_O/D32_F - * are per-stream Sauter means in metres (<=0 == absent). fuel_latent_heat is L_eff - * and fuel_T_star_cap_K is the interface cap; cp_l/T_inj/rho_l/mu use the same - * Python defaults (2000, 293, 800, 7e-5). Returns eta components or an error if a - * value is non-finite (mirrors the residual returning NaN). */ -ed_status_t ed_combustion_efficiency_advanced( - const EdCombustionEff *cfg, - double Lstar, double Pc, double Tc, double cstar_ideal, - double gamma, double R, double MR, - double Ac, double At, double Dinj, double m_dot_total, - double u_fuel, double u_lox, - double D32_O, double D32_F, - double momentum_ratio_R, double R_opt, - double fuel_latent_heat, - double fuel_T_star_cap_K, - EdEtaResult *out); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_COMBUSTION_H */ diff --git a/EngineDesign/engine/native/include/ed_cooling.h b/EngineDesign/engine/native/include/ed_cooling.h deleted file mode 100644 index ce99e54c9..000000000 --- a/EngineDesign/engine/native/include/ed_cooling.h +++ /dev/null @@ -1,45 +0,0 @@ -/* ed_cooling.h - Cooling efficiency factor used by the chamber residual. - * - * Port of ChamberSolver._evaluate_cooling_models + _compute_cooling_efficiency - * for the ablative path (the one active in the canonical configs), plus the - * regen_cooling.estimate_hot_wall_heat_flux and ablative_cooling.compute_ablative_response - * it calls. Film/regen/graphite contributions to cooling_eff are not active in the - * canonical configs; when their enable flags are set the port returns - * ED_ERR_NOT_IMPLEMENTED so callers fall back rather than silently mismatch. - */ -#ifndef ED_COOLING_H -#define ED_COOLING_H - -#include "ed_types.h" -#include "ed_state.h" - -#ifdef __cplusplus -extern "C" { -#endif - -typedef struct { - double cooling_eff; - double heat_removed; /* W (ablative cooling_power) */ - double effective_Tc; /* K (Tc_ideal - delta_T_abl) */ - double q_total; /* W/m^2 incident hot-wall flux */ - double wetted_area; /* m^2 */ -} EdCoolingResult; - -/* Huzel combustion-gas viscosity [Pa.s]. */ -double ed_gas_viscosity_huzel(double T_K, double M_kg_kmol); - -/* Chamber wetted surface area [m^2] (frustum model from _get_chamber_geometry). */ -double ed_chamber_wetted_area(const EdGeometry *g); - -/* cooling_eff (and diagnostics) for one residual evaluation. Tc/gamma/R/M are the - * ideal CEA values. Returns cooling_eff=1 when coupling/ablative are off. */ -ed_status_t ed_cooling_evaluate(const EdEngineState *s, - double Pc, double mdot_O, double mdot_F, - double Tc, double gamma, double R, double M, - EdCoolingResult *out); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_COOLING_H */ diff --git a/EngineDesign/engine/native/include/ed_discharge.h b/EngineDesign/engine/native/include/ed_discharge.h deleted file mode 100644 index 42a717ba9..000000000 --- a/EngineDesign/engine/native/include/ed_discharge.h +++ /dev/null @@ -1,29 +0,0 @@ -/* ed_discharge.h - Injector discharge coefficient model. Port of discharge.py. */ -#ifndef ED_DISCHARGE_H -#define ED_DISCHARGE_H - -#include "ed_types.h" -#include "ed_state.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* Geometry-based asymptotic Cd at high Re (cd_inf_from_orifice_diameter). - * Returns config Cd_inf when use_geometry_cd is false or d_hyd invalid. */ -double ed_cd_inf_from_orifice_diameter(double d_hyd_m, const EdDischarge *c); - -/* Cd(Re) = Cd_inf,eff - a_Re/sqrt(Re), with optional P/T corrections, clamped - * to [Cd_min, Cd_inf,eff]. Pass NAN for P_inlet/T_inlet to skip a correction. - * Port of discharge.cd_from_re. */ -double ed_cd_from_re(double Re, const EdDischarge *c, - double P_inlet, double T_inlet, double d_hyd_m); - -/* Re = rho*u*d_hyd/mu (calculate_reynolds_number; returns 1e6 if mu<=0). */ -double ed_reynolds(double rho, double u, double d_hyd, double mu); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_DISCHARGE_H */ diff --git a/EngineDesign/engine/native/include/ed_evaluate.h b/EngineDesign/engine/native/include/ed_evaluate.h deleted file mode 100644 index 4355afafd..000000000 --- a/EngineDesign/engine/native/include/ed_evaluate.h +++ /dev/null @@ -1,71 +0,0 @@ -/* ed_evaluate.h - Module 2: full evaluate (chamber solve -> nozzle -> Isp/thrust). - * - * Port target: runner._evaluate_internal() + nozzle.py. Flat result, no dicts. - * STATUS: API frozen; implementation gated on the chamber/nozzle ports (README - * "Staged plan"). Returns ED_ERR_NOT_IMPLEMENTED until then. - */ -#ifndef ED_EVALUATE_H -#define ED_EVALUATE_H - -#include "ed_types.h" -#include "ed_state.h" -#include "ed_cea.h" -#include "ed_workspace.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* Mirrors the runner.evaluate() keys consumed by Layer 1. */ -typedef struct EdEvaluateResult { - /* Chamber / flow */ - double Pc, mdot_O, mdot_F, mdot_total, MR; - /* Performance */ - double F; /* thrust, N */ - double Isp; /* s */ - double v_exit; /* m/s */ - double P_exit; /* Pa */ - double P_throat; /* Pa */ - double T_exit; /* K */ - double T_throat; /* K */ - /* Thermo */ - double Tc, gamma, R; - double cstar_actual, cstar_ideal, eta_cstar; - double Cf_actual, Cf_ideal; - double eps, A_throat, A_exit; - double Cd_O, Cd_F; - /* Injector diagnostics for Layer-1 penalties */ - double momentum_ratio_R; - double delta_P_injector_O, delta_P_injector_F; - double A_geom_O, A_geom_F; - double SMD; - /* Cooling summary (only what objective/stability need) */ - double cooling_efficiency; - double Tc_effective; - int converged; -} EdEvaluateResult; - -ed_status_t ed_evaluate(const EdEngineState *state, - const EdCeaTables *cea, - double P_tank_O_Pa, - double P_tank_F_Pa, - double P_ambient_Pa, - double Pc_guess_Pa, - EdWorkspace *ws, - EdEvaluateResult *out); - -/* Stretch batch API: one state + workspace, many tank-pressure pairs. */ -ed_status_t ed_evaluate_batch(const EdEngineState *state, - const EdCeaTables *cea, - size_t n, - const double *P_tank_O_Pa, - const double *P_tank_F_Pa, - double P_ambient_Pa, - EdWorkspace *ws, - EdEvaluateResult *out); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_EVALUATE_H */ diff --git a/EngineDesign/engine/native/include/ed_feed_loss.h b/EngineDesign/engine/native/include/ed_feed_loss.h deleted file mode 100644 index 566ccfd72..000000000 --- a/EngineDesign/engine/native/include/ed_feed_loss.h +++ /dev/null @@ -1,21 +0,0 @@ -/* ed_feed_loss.h - Feed-system pressure loss. Port of feed_loss.delta_p_feed. */ -#ifndef ED_FEED_LOSS_H -#define ED_FEED_LOSS_H - -#include "ed_types.h" -#include "ed_state.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* Delta_p_feed = K_eff(P) * (rho/2) * (mdot/(rho*A))^2, clamped to >= 0. - * A = pi*(d_inlet/2)^2 when d_inlet>0, else A_hydraulic. Returns NAN on invalid - * geometry/inputs (the Python version raises; callers treat NAN as failure). */ -double ed_delta_p_feed(double mdot, double rho, const EdFeed *cfg, double P_tank); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_FEED_LOSS_H */ diff --git a/EngineDesign/engine/native/include/ed_injector.h b/EngineDesign/engine/native/include/ed_injector.h deleted file mode 100644 index 8df15d28b..000000000 --- a/EngineDesign/engine/native/include/ed_injector.h +++ /dev/null @@ -1,54 +0,0 @@ -/* ed_injector.h - Injector flow solve (closure.flows -> injector.solve). - * - * ed_injector_solve dispatches on state->injector.type. Stage 2 implements the - * impinging doublet (engine/core/injectors/impinging.py); pintle/coaxial return - * ED_ERR_NOT_IMPLEMENTED. Returns mdot_O/mdot_F plus the diagnostics the chamber - * residual and Layer-1 penalties consume. - */ -#ifndef ED_INJECTOR_H -#define ED_INJECTOR_H - -#include "ed_types.h" -#include "ed_state.h" - -#ifdef __cplusplus -extern "C" { -#endif - -typedef struct { - double mdot_O, mdot_F; - double Cd_O, Cd_F; - double A_geom_O, A_geom_F; /* n_elements * pi*(d_jet/2)^2 */ - double A_eff_O, A_eff_F; /* Cd * A_geom */ - double u_O, u_F; /* bulk velocity through jets */ - double v_O_bulk, v_F_bulk; /* mdot/(rho*n*A_jet) */ - double momentum_ratio_R; - double J, TMR, theta; /* momentum flux ratio, thrust-mom ratio, spray angle */ - double We_O, We_F; - double D32_O, D32_F; /* SMD */ - double x_star; - double u_rel; - double P_injector_O, P_injector_F; - double delta_p_injector_O, delta_p_injector_F; - double delta_p_feed_O, delta_p_feed_F; - double turbulence_intensity_mix; - double MR; - int constraints_satisfied; - int iterations; /* outer spray-constraint iterations */ - int feed_orifice_coupling_iters; /* last inner fixed-point iters */ -} EdInjectorResult; - -/* Per-branch impinging diagnostics for golden checks/dispatch use. */ -ed_status_t ed_injector_solve(const EdEngineState *state, - double P_tank_O, double P_tank_F, double Pc, - EdInjectorResult *out); - -ed_status_t ed_injector_impinging_solve(const EdEngineState *state, - double P_tank_O, double P_tank_F, double Pc, - EdInjectorResult *out); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_INJECTOR_H */ diff --git a/EngineDesign/engine/native/include/ed_nozzle.h b/EngineDesign/engine/native/include/ed_nozzle.h deleted file mode 100644 index c64cfcc76..000000000 --- a/EngineDesign/engine/native/include/ed_nozzle.h +++ /dev/null @@ -1,71 +0,0 @@ -/* ed_nozzle.h - frozen-gas EXIT-STATE kernel (exit Mach + isentropic exit props). - * - * Port of the supersonic isentropic core of nozzle.py (area-Mach Newton from - * 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. - */ -#ifndef ED_NOZZLE_H -#define ED_NOZZLE_H - -#include "ed_types.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* Inputs the nozzle consumes. The thermo (Cf_ideal, gamma, R, Tc) is the CEA - * result at (MR, Pc, Pa, eps) — i.e. exactly what nozzle.py reads from - * cea_cache.eval() before the isentropic expansion. */ -typedef struct EdNozzleInputs { - double Pc; /* chamber pressure [Pa] */ - double mdot_total; /* total mass flow [kg/s] */ - 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 nozzle_efficiency; /* scales Cf_theoretical only (frozen F is independent) */ - double Cf_ideal; /* CEA ideal thrust coefficient */ - double gamma; /* chamber gamma (>1) */ - double R; /* gas constant [J/(kg.K)] */ - double Tc; /* chamber temperature [K] */ -} EdNozzleInputs; - -/* Flat result. Mirrors the nozzle keys runner.evaluate() forwards to Layer 1. */ -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] */ - double T_exit; /* [K] */ - double v_exit; /* [m/s] */ - double M_exit; /* exit Mach (>1) */ - double P_throat; /* [Pa] */ - double T_throat; /* [K] */ - double Isp; /* [s] */ - int converged; -} EdNozzleResult; - -/* Solve the frozen nozzle. Returns ED_OK on success; ED_ERR_INVALID_ARG on bad - * geometry/thermo, ED_ERR_NO_CONVERGE if the exit-Mach Newton fails. */ -ed_status_t ed_nozzle_solve(const EdNozzleInputs *in, EdNozzleResult *out); - -/* Build tag (kept for symbol-stability with the former placeholder TU). */ -const char *ed_nozzle_stage(void); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_NOZZLE_H */ diff --git a/EngineDesign/engine/native/include/ed_phys_const.h b/EngineDesign/engine/native/include/ed_phys_const.h deleted file mode 100644 index df37404d9..000000000 --- a/EngineDesign/engine/native/include/ed_phys_const.h +++ /dev/null @@ -1,42 +0,0 @@ -/* ed_phys_const.h - Physical constants mirrored from the engine pipeline constants - * modules. Values copied verbatim so the C residual matches Python bit-for-bit. */ -#ifndef ED_PHYS_CONST_H -#define ED_PHYS_CONST_H - -/* physics_constants.py */ -#define ED_PRANDTL_DEFAULT 0.8 -#define ED_D_M_REF 5e-5 -#define ED_D_M_T_REF 1500.0 -#define ED_D_M_P_REF 2.5e6 -#define ED_U_SLIP_CAP 50.0 -#define ED_D_MIN_GASIFICATION 1e-6 - -/* combustion_physics.py hardcoded model params / defaults */ -#define ED_CS_C_L 0.1 /* near-field length coeff */ -#define ED_CS_C_U 0.5 /* RMS velocity contribution */ -#define ED_CS_U_RMS_CAP 200.0 -#define ED_GAS_MU_DEFAULT 7e-5 /* eta_Lstar default mu */ -#define ED_GAS_RHO_L_DEFAULT 800.0 /* liquid fuel density default */ -#define ED_GAS_CP_L_DEFAULT 2000.0 /* fuel_props.get("specific_heat", 2000) */ -#define ED_GAS_T_INJ_DEFAULT 293.0 /* fuel_props.get("temperature", 293) */ -#define ED_GAS_CP_G_DEFAULT 2200.0 -#define ED_MIX_C_MU 0.09 -#define ED_MIX_DM_REF 2.0e-5 /* mixing molecular diffusivity ref */ -#define ED_MIX_DM_T_REF 300.0 -#define ED_MIX_DM_P_REF 101325.0 - -/* constants.py (thermal) */ -#define ED_NU_LAMINAR 4.36 -#define ED_NU_TURB_COEF 0.023 -#define ED_NU_TURB_RE_EXP 0.8 -#define ED_NU_TURB_PR_EXP 0.4 -#define ED_RECOVERY_FACTOR_DEF 0.94 -#define ED_STEFAN_BOLTZMANN 5.670374419e-8 -#define ED_MIN_DENS_KG_M3 0.01 -#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 -#define ED_HUZEL_COEFF 46.6e-10 - -#endif /* ED_PHYS_CONST_H */ diff --git a/EngineDesign/engine/native/include/ed_root_find.h b/EngineDesign/engine/native/include/ed_root_find.h deleted file mode 100644 index cef1cb512..000000000 --- a/EngineDesign/engine/native/include/ed_root_find.h +++ /dev/null @@ -1,42 +0,0 @@ -/* ed_root_find.h - Allocation-free bracketed root finder (Brent's method). - * - * Replaces scipy.optimize.brentq in the chamber-pressure solve. Supports an - * optional warm-start bracket and records the iteration count. The callback is a - * plain function pointer + void* context so the residual stays inlinable and - * heap-free. - */ -#ifndef ED_ROOT_FIND_H -#define ED_ROOT_FIND_H - -#include "ed_types.h" - -#ifdef __cplusplus -extern "C" { -#endif - -typedef double (*ed_root_fn)(double x, void *ctx); - -typedef struct { - double xtol; /* absolute tolerance on x (brentq xtol) */ - double rtol; /* relative tolerance on x (brentq rtol) */ - int max_iter; /* iteration cap */ -} ed_root_opts; - -typedef struct { - double root; - double f_root; - int iterations; - int converged; /* 1 if converged within tolerance */ -} ed_root_result; - -/* Brent's method on [a, b]; requires f(a) and f(b) to have opposite signs. - * Returns ED_ERR_NO_BRACKET if not, ED_ERR_NONFINITE on non-finite evaluations. - * Matches scipy brentq defaults: xtol=2e-12, rtol=4*eps when opts==NULL. */ -ed_status_t ed_brentq(ed_root_fn f, void *ctx, double a, double b, - const ed_root_opts *opts, ed_root_result *out); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_ROOT_FIND_H */ diff --git a/EngineDesign/engine/native/include/ed_spray.h b/EngineDesign/engine/native/include/ed_spray.h deleted file mode 100644 index 719be8586..000000000 --- a/EngineDesign/engine/native/include/ed_spray.h +++ /dev/null @@ -1,31 +0,0 @@ -/* ed_spray.h - Spray/mixing correlations. Port of engine/core/spray.py. */ -#ifndef ED_SPRAY_H -#define ED_SPRAY_H - -#include "ed_types.h" -#include "ed_state.h" - -#ifdef __cplusplus -extern "C" { -#endif - -double ed_momentum_flux_ratio(double rho_O, double u_O, double rho_F, double u_F); -double ed_thrust_momentum_ratio(double J, double MR); -double ed_spray_angle_from_J(double J, double k, double n); -double ed_spray_angle_from_TMR(double TMR); -double ed_weber_number(double rho, double u, double d_char, double sigma); -double ed_ohnesorge_number(double mu, double rho, double sigma, double d_or); -double ed_smd_lefebvre(double d_or, double We, double Oh, double C, double m, double p); -double ed_smd_impinging_ingebo(double d_jet, double u_rel, double rho_liq, - double mu_liq, double sigma, double rho_gas, double C); -double ed_tau_evap(double D32, double K); -double ed_xstar(double U_rel, double tau_evap); - -/* Returns 1 if We_O>=We_min, We_F>=We_min, and (x* 1/G treated as +inf */ - double inertance; /* 1/m */ - double resistance; /* Pa.s/kg */ - double tau_conv; /* s */ - double reg_Z_hf; /* Pa.s/kg (0 => regulator impedance contributes nothing) */ - double reg_corner_hz; - int reg_enabled; -} EdChugStream; - -typedef struct { - double gain_margin; - double f_chug_hz; - double phase_margin_deg; - int stable; -} EdChugResult; - -/* K_c = chamber gain (cstar / A_t); theta_c = chamber residence time constant. */ -ed_status_t ed_chug_margin_fast(const EdChugStream *streams, int n_streams, - double K_c, double theta_c, - double f_lo, double f_hi, EdChugResult *out); - -/* Fast acoustic check (1L + 1T modes). Port of acoustic.py::fast_acoustic. */ -typedef struct { - double alpha_max; /* max growth rate [1/s]; NaN if no modes */ - double f_1L, f_1T; /* Hz */ - int limiting; /* 0 = 1L, 1 = 1T, -1 = none */ - int stable; /* alpha_max < 0 */ -} EdAcousticResult; - -ed_status_t ed_fast_acoustic(double D_ch, double L_ch, double gamma, double a_sound, - double nu_g, double mach_ne, double n, double tau_sens, - EdAcousticResult *out); - -typedef struct EdStabilityResult { - ed_stability_state_t stability_state; - double stability_score; - int is_stable; - - /* Chugging (low-frequency feed-coupled). */ - double chug_frequency; /* Hz */ - double chug_margin; - double tau_residence; /* s */ - double Lstar; /* m */ - - /* Acoustic (chamber modes). */ - double acoustic_margin; - int acoustic_mode_count; - double acoustic_limiting_freq; /* Hz */ - - /* Feed-system coupling. */ - double feed_margin; - double feed_frequency; /* Hz */ -} EdStabilityResult; - -ed_status_t ed_stability_analyze(const EdEngineState *state, - const EdChamberDiagnostics *chamber, - const EdEvaluateResult *eval, - EdStabilityResult *out); - -#ifdef __cplusplus -} -#endif - -#endif /* ED_STABILITY_H */ diff --git a/EngineDesign/engine/native/include/ed_state.h b/EngineDesign/engine/native/include/ed_state.h deleted file mode 100644 index 6111ed6ad..000000000 --- a/EngineDesign/engine/native/include/ed_state.h +++ /dev/null @@ -1,249 +0,0 @@ -/* ed_state.h - Flat POD snapshot of one engine configuration. - * - * EdEngineState is a frozen, allocation-free snapshot of everything needed to - * evaluate a single design point. It is produced offline (state_from_yaml / - * ed_state_builder.py) from a PintleEngineConfig and never references Python. - * - * Hot Layer-1 fields (throat area, L*, injector dims, tank pressures) are grouped - * so they can be patched in O(1) with ed_state_patch() without re-parsing YAML. - * - * NOTE (Stage 1): Field coverage is complete enough for the foundational kernels - * (CEA, root-find, feed-loss, discharge, geometry). Fields consumed only by the - * not-yet-ported combustion/cooling/stability physics are present so that the - * struct layout is stable for future stages; see engine/native/README.md. - */ -#ifndef ED_STATE_H -#define ED_STATE_H - -#include "ed_types.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* Mirrors DischargeConfig (engine/pipeline/config_schemas + discharge.py). */ -typedef struct { - double Cd_inf; - double a_Re; - double Cd_min; - uint8_t use_geometry_cd; - double d_ref_m; - double d_min_m; - double cd_small_hole_exponent; - double cd_large_hole_log_gain; - double cd_inf_max; - double cd_inf_min_geom; - uint8_t use_pressure_correction; - double P_ref; - double a_P; - uint8_t use_temperature_correction; - double T_ref; - double a_T; -} EdDischarge; - -/* Mirrors FeedSystemConfig used by feed_loss.delta_p_feed. */ -typedef struct { - double d_inlet; /* <=0 => use A_hydraulic */ - double A_hydraulic; /* m^2 */ - double K0; - double K1; - ed_phi_type_t phi_type; -} EdFeed; - -/* Bulk fluid properties for one propellant branch (engine.fluids[...]). */ -typedef struct { - double density; /* kg/m^3 */ - double viscosity; /* Pa.s */ - double surface_tension; /* N/m */ - double specific_heat; /* J/(kg.K) */ - double thermal_conductivity;/* W/(m.K) */ - double temperature; /* K */ - double bulk_modulus; /* Pa (feed-system acoustics) */ - /* Fuel-only evaporation props (see _get_fuel_props); 0 if unused. */ - double boiling_point; /* K */ - double latent_heat; /* J/kg */ - double molecular_weight; /* g/mol */ - double Pc_ref; /* Pa */ -} EdFluid; - -/* Impinging-doublet geometry (per branch). */ -typedef struct { - int n_elements; - double d_jet; /* m */ - double impingement_angle; /* deg */ - double spacing; /* m */ -} EdImpingingBranch; - -/* Injector geometry, tagged union over injector_type. Only the active arm is - * meaningful. (Pintle/coaxial fields are placeholders for later stages.) */ -typedef struct { - ed_injector_type_t type; - /* impinging */ - EdImpingingBranch imp_O; - EdImpingingBranch imp_F; - /* pintle (placeholder) */ - double pintle_d_tip; - double pintle_annular_gap; - int pintle_n_orifices; - double pintle_d_orifice; - /* coaxial (placeholder) */ - double coax_d_core; - double coax_d_port; - double coax_annulus_gap; -} EdInjector; - -typedef enum { ED_EFF_CONSTANT = 0, ED_EFF_LINEAR = 1, ED_EFF_EXPONENTIAL = 2 } ed_eff_model_t; - -/* Combustion efficiency config (CombustionEfficiencyConfig). */ -typedef struct { - ed_eff_model_t model; /* constant | linear | exponential */ - double efficiency; /* combustion.efficiency scalar baseline */ - double C; /* exponential/linear/constant model coeff */ - double K; - double mixture_efficiency_floor; - double cooling_efficiency_floor; - double turbulence_efficiency_floor; - uint8_t use_advanced_model; - uint8_t use_finite_rate_chemistry; - uint8_t use_shifting_equilibrium; - uint8_t use_cooling_coupling; - uint8_t use_turbulence_coupling; - double Pc_gate; - double tau_ref; - double tau_ref_P; - double tau_ref_T; - double n_pressure; - double T_star_fuel_cap_K; - double A0_hydrocarbon, Ea_hydrocarbon, n_pre_hydrocarbon; - uint8_t has_tau_Tc_floor; /* tau_Tc_floor_K present? */ - double tau_Tc_floor; /* K */ - /* Rupe momentum-ratio mixing model (replaces k-e mixing + retired eta_turbulence). */ - double Em_peak; /* peak mixing efficiency at the balanced momentum ratio */ - double mixing_sigma; /* log-Gaussian width in ln(R) space */ - double R_opt; /* momentum-ratio optimum override; <=0 => derive from angles */ -} EdCombustionEff; - -/* Cooling enable flags + params the chamber residual reads via - * _evaluate_cooling_models (the ablative path for the canonical config). */ -typedef struct { - uint8_t regen_enabled; - uint8_t film_enabled; - uint8_t ablative_enabled; - uint8_t graphite_enabled; - uint8_t use_cooling_coupling; - - /* 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_prandtl; - double gas_turbulence_intensity; - double recovery_factor; /* resolved (null -> 0.94) */ - double radiation_emissivity_hot; - double radiation_view_factor; - double regen_chamber_inner_diameter; - - /* ablative response (compute_ablative_response) */ - double ablative_coverage_fraction; - double ablative_surface_temperature_limit; - double ablative_material_density; - double ablative_heat_of_ablation; - double ablative_specific_heat; - double ablative_pyrolysis_temperature; - uint8_t ablative_use_physics_based_blowing; - double ablative_blowing_efficiency; - double ablative_blowing_coefficient; - double ablative_blowing_min_reduction_factor; - double ablative_turbulence_reference_intensity; - double ablative_turbulence_sensitivity; - double ablative_turbulence_exponent; - double ablative_turbulence_max_multiplier; - double ablative_surface_emissivity; - double ablative_ambient_temperature; - double ablative_radiative_sink_minimum_threshold; - double ablative_radiative_sink_fallback_temperature; - - double cooling_efficiency_floor; -} EdCooling; - -/* Spray / SMD config (spray.* block). */ -typedef enum { ED_SMD_LEFEBVRE = 0, ED_SMD_INGEBO = 1 } ed_smd_model_t; -typedef enum { ED_SPRAYANG_J = 0, ED_SPRAYANG_TMR = 1 } ed_spray_angle_model_t; - -typedef struct { - ed_smd_model_t smd_model; - double smd_C; /* lefebvre C */ - double smd_m; /* lefebvre Weber exponent */ - double smd_p; /* lefebvre Ohnesorge exponent */ - double smd_C_ingebo; /* ingebo prefactor */ - double smd_we_corr_max; /* lefebvre We cap; <=0 => none */ - double chamber_gas_R; /* J/(kg.K) for rho_gas */ - double chamber_gas_T; /* K */ - ed_spray_angle_model_t spray_angle_model; - double spray_angle_k; - double spray_angle_n; - double we_min; - double evap_K; /* tau_evap = K * D32^2 */ - double evap_x_star_limit; /* m */ - uint8_t evap_use_constraint; -} EdSpray; - -/* Chamber + nozzle geometry (ChamberGeometry). These are the primary hot fields - * mutated by Layer-1 optimization. */ -typedef struct { - double A_throat; /* m^2 */ - double A_exit; /* m^2 */ - double volume; /* m^3 */ - double Lstar; /* m (override; <=0 => V/At) */ - double length; /* m */ - double length_cylindrical; - double length_contraction; - double chamber_diameter; /* m */ - double exit_diameter; /* m */ - double expansion_ratio; - double nozzle_efficiency; - double Cf; - double design_pressure; /* Pa */ -} EdGeometry; - -/* Brent solver settings (SolverConfig) + injector closure loop. */ -typedef struct { - double Pc_min_bound; /* Pa */ - double Pc_max_bound; /* Pa */ - double tolerance; /* xtol */ - int max_iterations; - int closure_max_iterations; /* solver.closure.max_iterations */ - double closure_Cd_reduction_factor;/* solver.closure.Cd_reduction_factor */ -} EdSolver; - -/* Top-level frozen snapshot. POD; safe to memcpy and patch. */ -typedef struct { - EdInjector injector; - EdFeed feed_O; - EdFeed feed_F; - EdDischarge discharge_O; - EdDischarge discharge_F; - EdFluid fluid_O; - EdFluid fluid_F; - EdCombustionEff comb; - EdCooling cooling; - EdSpray spray; - EdGeometry geom; - EdSolver solver; - double P_ambient; /* Pa (from elevation), may be overridden per-call */ -} EdEngineState; - -/* O(1) patch of the hottest Layer-1 fields. Pass NAN to leave a field unchanged. */ -static inline void ed_state_patch(EdEngineState *s, - double A_throat, double A_exit, - double Lstar, double volume) { - if (isfinite(A_throat)) s->geom.A_throat = A_throat; - if (isfinite(A_exit)) s->geom.A_exit = A_exit; - if (isfinite(Lstar)) s->geom.Lstar = Lstar; - if (isfinite(volume)) s->geom.volume = volume; -} - -#ifdef __cplusplus -} -#endif - -#endif /* ED_STATE_H */ diff --git a/EngineDesign/engine/native/include/ed_types.h b/EngineDesign/engine/native/include/ed_types.h deleted file mode 100644 index af085ae37..000000000 --- a/EngineDesign/engine/native/include/ed_types.h +++ /dev/null @@ -1,79 +0,0 @@ -/* ed_types.h - Common scalar types, enums, status codes, and small math helpers - * - * Part of the STAR EngineDesign native physics kernel (parallel implementation). - * This is a clean-room C11 port of the Python hot path under engine/. It does NOT - * link against or import any Python code at runtime. - */ -#ifndef ED_TYPES_H -#define ED_TYPES_H - -#include -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* Return / status codes. 0 == success; negative == failure. */ -typedef enum { - ED_OK = 0, - ED_ERR_INVALID_ARG = -1, - ED_ERR_NONFINITE = -2, - ED_ERR_NO_BRACKET = -3, - ED_ERR_NO_CONVERGE = -4, - ED_ERR_OUT_OF_RANGE = -5, - ED_ERR_NOT_IMPLEMENTED = -6, - ED_ERR_IO = -7 -} ed_status_t; - -/* Injector dispatch tags (matches injector.type strings in config). */ -typedef enum { - ED_INJ_PINTLE = 0, - ED_INJ_IMPINGING = 1, - ED_INJ_COAXIAL = 2 -} ed_injector_type_t; - -/* Feed-loss pressure-dependence model (matches FeedSystemConfig.phi_type). */ -typedef enum { - ED_PHI_NONE = 0, - ED_PHI_SQRTP = 1, - ED_PHI_LOGP = 2 -} ed_phi_type_t; - -/* Stability state enum (matches stability analysis "stability_state"). */ -typedef enum { - ED_STAB_STABLE = 0, - ED_STAB_MARGINAL = 1, - ED_STAB_UNSTABLE = 2 -} ed_stability_state_t; - -/* Physical constants used across the kernel (match engine/pipeline/constants.py). */ -#define ED_G0 9.80665 -#define ED_PI 3.14159265358979323846 -#define ED_P_SEA_LEVEL 101325.0 - -/* Branch-free clamp for doubles. Matches numpy.clip semantics for scalar a<=b. */ -static inline double ed_clip(double x, double lo, double hi) { - if (x < lo) return lo; - if (x > hi) return hi; - return x; -} - -static inline double ed_min(double a, double b) { return a < b ? a : b; } -static inline double ed_max(double a, double b) { return a > b ? a : b; } - -static inline int ed_isfinite(double x) { return isfinite(x); } - -/* numpy.sign for the bracket-sign test in the root finder. */ -static inline int ed_sign(double x) { - if (x > 0.0) return 1; - if (x < 0.0) return -1; - return 0; -} - -#ifdef __cplusplus -} -#endif - -#endif /* ED_TYPES_H */ diff --git a/EngineDesign/engine/native/include/ed_workspace.h b/EngineDesign/engine/native/include/ed_workspace.h deleted file mode 100644 index 5eb6f44a2..000000000 --- a/EngineDesign/engine/native/include/ed_workspace.h +++ /dev/null @@ -1,52 +0,0 @@ -/* ed_workspace.h - Reusable scratch buffers, sized once, reused across evals. - * - * The hot path performs zero malloc/free. Any temporary arrays needed by the - * residual loop, cooling segment profiles, or stability mode sweeps live here and - * are owned by the caller for the lifetime of a worker thread. - */ -#ifndef ED_WORKSPACE_H -#define ED_WORKSPACE_H - -#include "ed_types.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* Fixed upper bounds keep the workspace a flat POD with no heap pointers. - * Sized for the canonical configs (<=20 cooling segments, <=8 acoustic modes). */ -#define ED_WS_MAX_SEGMENTS 64 -#define ED_WS_MAX_MODES 16 - -typedef struct { - /* Root-finder convergence history (bench/diagnostics only). */ - double residual_hist[256]; - int residual_count; - - /* Cooling heat-flux profile scratch (ablative/graphite, later stages). */ - double seg_x[ED_WS_MAX_SEGMENTS]; - double seg_q[ED_WS_MAX_SEGMENTS]; - - /* Acoustic mode scratch (stability, later stages). */ - double mode_freq[ED_WS_MAX_MODES]; - double mode_margin[ED_WS_MAX_MODES]; - - /* Warm-start cache for the Pc bracket across consecutive evaluations. */ - double last_Pc; - double last_bracket_lo; - double last_bracket_hi; -} EdWorkspace; - -/* Zero-initialize a workspace. No allocation. */ -static inline void ed_workspace_reset(EdWorkspace *ws) { - ws->residual_count = 0; - ws->last_Pc = 0.0; - ws->last_bracket_lo = 0.0; - ws->last_bracket_hi = 0.0; -} - -#ifdef __cplusplus -} -#endif - -#endif /* ED_WORKSPACE_H */ diff --git a/EngineDesign/engine/native/python/__init__.py b/EngineDesign/engine/native/python/__init__.py deleted file mode 100644 index 7a41a7729..000000000 --- a/EngineDesign/engine/native/python/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Future integration shim for the native physics kernel. - -This package is intentionally NOT imported by any production code path (runner.py, -Layer 1, routers, frontend). It exists so a later PR can opt in via the env var -ED_USE_NATIVE=1. Importing it never triggers native loading by itself; call -ed_native.load() explicitly. - -See engine/native/README.md ("Future integration plan"). -""" - -import os - -ED_USE_NATIVE = os.environ.get("ED_USE_NATIVE", "0") == "1" - -__all__ = ["ED_USE_NATIVE"] diff --git a/EngineDesign/engine/native/python/autobuild.py b/EngineDesign/engine/native/python/autobuild.py deleted file mode 100644 index 583d3472a..000000000 --- a/EngineDesign/engine/native/python/autobuild.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Auto-build the native library on demand, cross-platform (macOS/Linux/Windows). - -The first time the native path is used, this configures and builds libed_physics -with CMake into an arch-tagged build dir. Building with the *running interpreter's* -architecture means the ctypes load always matches (this is what removes the macOS -Rosetta arch caveat automatically — no manual flag needed). On Windows it shells -out to whatever CMake generator/toolchain is installed (MSVC or MinGW). - -Rebuilds only when the library is missing or older than any header/source/CMake -file, so steady-state startup is a couple of stat() calls. -""" -from __future__ import annotations - -import os -import platform -import subprocess -import sys -import threading - -_LOCK = threading.Lock() -_CACHED_LIB: str | None = None - -NATIVE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def _system() -> str: - return platform.system() # 'Darwin' | 'Linux' | 'Windows' - - -def _lib_names() -> list[str]: - s = _system() - if s == "Darwin": - return ["libed_physics.dylib"] - if s == "Windows": - return ["ed_physics.dll", "libed_physics.dll"] - return ["libed_physics.so"] - - -def _build_dir() -> str: - # Arch-tag so an x86_64 (Rosetta) and arm64 build never clash on macOS. - tag = f"{_system().lower()}_{platform.machine().lower()}" - return os.path.join(NATIVE_DIR, f"build_auto_{tag}") - - -def _find_lib(build_dir: str) -> str | None: - # Single-config (Make/Ninja): build_dir/lib*. Multi-config (MSVC): build_dir/Release/*. - search_dirs = [build_dir, os.path.join(build_dir, "Release"), - os.path.join(build_dir, "RelWithDebInfo")] - for d in search_dirs: - for name in _lib_names(): - p = os.path.join(d, name) - if os.path.exists(p): - return p - return None - - -def _newest_source_mtime() -> float: - newest = 0.0 - for sub in ("include", "src"): - d = os.path.join(NATIVE_DIR, sub) - for root, _, files in os.walk(d): - for fn in files: - if fn.endswith((".c", ".h")): - newest = max(newest, os.path.getmtime(os.path.join(root, fn))) - cml = os.path.join(NATIVE_DIR, "CMakeLists.txt") - if os.path.exists(cml): - newest = max(newest, os.path.getmtime(cml)) - return newest - - -def _configure_args(build_dir: str) -> list[str]: - args = ["cmake", "-S", NATIVE_DIR, "-B", build_dir, "-DCMAKE_BUILD_TYPE=Release"] - if _system() == "Darwin": - # Match the interpreter arch exactly; disable -march=native for that cross. - args += [f"-DCMAKE_OSX_ARCHITECTURES={platform.machine()}", "-DED_NATIVE_ARCH=OFF"] - return args - - -def ensure_lib(force: bool = False, verbose: bool = False) -> str: - """Return a path to a current, arch-matched libed_physics, building if needed. - - Raises RuntimeError if CMake is unavailable or the build fails. - """ - global _CACHED_LIB - with _LOCK: - if _CACHED_LIB and not force and os.path.exists(_CACHED_LIB): - return _CACHED_LIB - - build_dir = _build_dir() - lib = _find_lib(build_dir) - needs_build = force or lib is None - if lib is not None and not force: - if os.path.getmtime(lib) < _newest_source_mtime(): - needs_build = True - - if needs_build: - out = None if verbose else subprocess.DEVNULL - try: - subprocess.run(_configure_args(build_dir), check=True, stdout=out, stderr=out) - subprocess.run(["cmake", "--build", build_dir, "--config", "Release", - "--target", "ed_physics_shared", "-j"], - check=True, stdout=out, stderr=out) - except FileNotFoundError as e: - raise RuntimeError("CMake not found on PATH; cannot auto-build native kernel") from e - except subprocess.CalledProcessError as e: - raise RuntimeError(f"Native build failed (see cmake output): {e}") from e - lib = _find_lib(build_dir) - - if lib is None: - raise RuntimeError(f"Native library not found after build in {build_dir}") - _CACHED_LIB = lib - return lib - - -def _safe_ensure() -> None: - try: - ensure_lib() - except Exception: # noqa: BLE001 - prewarm must never crash startup - pass - - -def prewarm() -> "threading.Thread": - """Kick off the build in a background daemon thread (non-blocking startup). - - The first native call blocks on the same lock, so it transparently waits for - this to finish if it is still running. - """ - t = threading.Thread(target=_safe_ensure, name="ed-native-prewarm", daemon=True) - t.start() - return t - - -if __name__ == "__main__": - print(ensure_lib(force="--force" in sys.argv, verbose=True)) diff --git a/EngineDesign/engine/native/python/bench_compare.py b/EngineDesign/engine/native/python/bench_compare.py deleted file mode 100644 index 593344964..000000000 --- a/EngineDesign/engine/native/python/bench_compare.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python3 -"""Side-by-side Python vs C timing/parity. NOT wired into production. - -Final target (per spec): time runner.evaluate(silent=True) vs ed_evaluate+ -ed_stability over the same inputs; exit 1 if the C path is <10x on the combined -evaluate+stability path. - -Stage 1: ed_evaluate is not yet implemented, so the combined comparison reports -SKIP (exit 77). What IS compared now is the CEA lookup overlap — the one hot-path -kernel ported so far — both for numerical parity and for the Python-vs-C speed -ratio, which validates the comparison methodology end to end. - - python engine/native/python/bench_compare.py \ - --config configs/canonical/impinging.yaml -""" -from __future__ import annotations - -import argparse -import os -import sys -import time - -import numpy as np - -ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) -if ROOT not in sys.path: - sys.path.insert(0, ROOT) - -from engine.pipeline.io import load_config # noqa: E402 -from engine.pipeline.cea_cache import CEACache # noqa: E402 - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import ed_native # noqa: E402 - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--config", default="configs/canonical/impinging.yaml") - ap.add_argument("--tables", default=os.path.join(ROOT, "engine/native/tests/golden/cea_tables.bin")) - ap.add_argument("--n", type=int, default=100000) - args = ap.parse_args() - - if not os.path.exists(args.tables): - print(f"[skip] missing {args.tables}; run tools/export_cea_tables.py first") - return 77 - - cfg = load_config(args.config) - cache = CEACache(cfg.combustion.cea) - - nat = ed_native.load() - nat.cea_load(args.tables) - - rng = np.random.default_rng(1) - MRs = rng.uniform(cache.MR_min, cache.MR_max, args.n) - Pcs = rng.uniform(cache.Pc_min, cache.Pc_max, args.n) - Eps = rng.uniform(cache.eps_min, cache.eps_max, args.n) - - # ---- parity spot check ---- - max_rel = 0.0 - for i in range(0, args.n, max(args.n // 200, 1)): - py = cache.eval(float(MRs[i]), float(Pcs[i]), 101325.0, float(Eps[i])) - c = nat.cea_eval(float(MRs[i]), float(Pcs[i]), float(Eps[i])) - for key, cv in (("cstar_ideal", c.cstar_ideal), ("Tc", c.Tc), - ("gamma", c.gamma), ("R", c.R)): - pv = py[key] - if abs(pv) > 0: - max_rel = max(max_rel, abs(cv - pv) / abs(pv)) - print(f"CEA parity: max relative error over spot-check = {max_rel:.2e}") - if max_rel > 1e-6: - print("[FAIL] CEA parity exceeds 1e-6") - return 1 - - # ---- timing ---- - t0 = time.perf_counter() - for i in range(args.n): - cache.eval(float(MRs[i]), float(Pcs[i]), 101325.0, float(Eps[i])) - t_py = time.perf_counter() - t0 - - t0 = time.perf_counter() - for i in range(args.n): - nat.cea_eval(float(MRs[i]), float(Pcs[i]), float(Eps[i])) - t_c = time.perf_counter() - t0 - - print(f"CEA eval Python: {t_py/args.n*1e9:8.1f} ns/call") - print(f"CEA eval C(ctypes): {t_c/args.n*1e9:8.1f} ns/call (incl. ctypes overhead)") - print(f"CEA eval speedup: {t_py/t_c:.1f}x (ctypes-bound; native-native is far higher — see bench_evaluate)") - - # ---- combined evaluate+stability path ---- - rc, _ = nat.evaluate(ed_native.C.create_string_buffer(nat.lib.ed_sizeof_engine_state()), - 5e6, 5e6) - if rc == ed_native.ED_ERR_NOT_IMPLEMENTED: - print("[skip] ed_evaluate+stability not yet implemented; combined 10x gate deferred.") - return 77 - - print("[todo] combined evaluate+stability comparison") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/EngineDesign/engine/native/python/ed_native.py b/EngineDesign/engine/native/python/ed_native.py deleted file mode 100644 index 343712190..000000000 --- a/EngineDesign/engine/native/python/ed_native.py +++ /dev/null @@ -1,302 +0,0 @@ -"""ctypes bindings for libed_physics. - -Loads an arch-matched library (auto-built on demand via autobuild.ensure_lib), -mirrors the POD structs from include/ed_state.h, and binds the implemented C API. -A sizeof self-check (ctypes vs ed_sizeof_*) runs at load so any layout drift fails -loudly instead of silently reading garbage. -""" -from __future__ import annotations - -import ctypes as C -import os - -ED_OK = 0 -ED_ERR_NOT_IMPLEMENTED = -6 - -_d = C.c_double -_i = C.c_int -_u8 = C.c_uint8 - - -# --- POD struct mirrors of include/ed_state.h (field order MUST match) -------- -class EdDischarge(C.Structure): - _fields_ = [ - ("Cd_inf", _d), ("a_Re", _d), ("Cd_min", _d), ("use_geometry_cd", _u8), - ("d_ref_m", _d), ("d_min_m", _d), ("cd_small_hole_exponent", _d), - ("cd_large_hole_log_gain", _d), ("cd_inf_max", _d), ("cd_inf_min_geom", _d), - ("use_pressure_correction", _u8), ("P_ref", _d), ("a_P", _d), - ("use_temperature_correction", _u8), ("T_ref", _d), ("a_T", _d), - ] - - -class EdFeed(C.Structure): - _fields_ = [("d_inlet", _d), ("A_hydraulic", _d), ("K0", _d), ("K1", _d), ("phi_type", _i)] - - -class EdFluid(C.Structure): - _fields_ = [(n, _d) for n in ( - "density", "viscosity", "surface_tension", "specific_heat", - "thermal_conductivity", "temperature", "bulk_modulus", - "boiling_point", "latent_heat", "molecular_weight", "Pc_ref")] - - -class EdImpingingBranch(C.Structure): - _fields_ = [("n_elements", _i), ("d_jet", _d), ("impingement_angle", _d), ("spacing", _d)] - - -class EdInjector(C.Structure): - _fields_ = [ - ("type", _i), ("imp_O", EdImpingingBranch), ("imp_F", EdImpingingBranch), - ("pintle_d_tip", _d), ("pintle_annular_gap", _d), ("pintle_n_orifices", _i), - ("pintle_d_orifice", _d), ("coax_d_core", _d), ("coax_d_port", _d), - ("coax_annulus_gap", _d), - ] - - -class EdCombustionEff(C.Structure): - _fields_ = [ - ("model", _i), - ("efficiency", _d), ("C", _d), ("K", _d), ("mixture_efficiency_floor", _d), - ("cooling_efficiency_floor", _d), ("turbulence_efficiency_floor", _d), - ("use_advanced_model", _u8), ("use_finite_rate_chemistry", _u8), - ("use_shifting_equilibrium", _u8), ("use_cooling_coupling", _u8), - ("use_turbulence_coupling", _u8), ("Pc_gate", _d), ("tau_ref", _d), - ("tau_ref_P", _d), ("tau_ref_T", _d), ("n_pressure", _d), - ("T_star_fuel_cap_K", _d), ("A0_hydrocarbon", _d), ("Ea_hydrocarbon", _d), - ("n_pre_hydrocarbon", _d), - ("has_tau_Tc_floor", _u8), ("tau_Tc_floor", _d), - # Rupe momentum-ratio mixing model - ("Em_peak", _d), ("mixing_sigma", _d), ("R_opt", _d), - ] - - -class EdCooling(C.Structure): - _fields_ = [ - ("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_prandtl", _d), ("gas_turbulence_intensity", _d), - ("recovery_factor", _d), ("radiation_emissivity_hot", _d), - ("radiation_view_factor", _d), ("regen_chamber_inner_diameter", _d), - ("ablative_coverage_fraction", _d), ("ablative_surface_temperature_limit", _d), - ("ablative_material_density", _d), ("ablative_heat_of_ablation", _d), - ("ablative_specific_heat", _d), ("ablative_pyrolysis_temperature", _d), - ("ablative_use_physics_based_blowing", _u8), - ("ablative_blowing_efficiency", _d), ("ablative_blowing_coefficient", _d), - ("ablative_blowing_min_reduction_factor", _d), - ("ablative_turbulence_reference_intensity", _d), - ("ablative_turbulence_sensitivity", _d), ("ablative_turbulence_exponent", _d), - ("ablative_turbulence_max_multiplier", _d), ("ablative_surface_emissivity", _d), - ("ablative_ambient_temperature", _d), - ("ablative_radiative_sink_minimum_threshold", _d), - ("ablative_radiative_sink_fallback_temperature", _d), - ("cooling_efficiency_floor", _d), - ] - - -class EdSpray(C.Structure): - _fields_ = [ - ("smd_model", _i), ("smd_C", _d), ("smd_m", _d), ("smd_p", _d), - ("smd_C_ingebo", _d), ("smd_we_corr_max", _d), ("chamber_gas_R", _d), - ("chamber_gas_T", _d), ("spray_angle_model", _i), ("spray_angle_k", _d), - ("spray_angle_n", _d), ("we_min", _d), ("evap_K", _d), - ("evap_x_star_limit", _d), ("evap_use_constraint", _u8), - ] - - -class EdGeometry(C.Structure): - _fields_ = [(n, _d) for n in ( - "A_throat", "A_exit", "volume", "Lstar", "length", "length_cylindrical", - "length_contraction", "chamber_diameter", "exit_diameter", "expansion_ratio", - "nozzle_efficiency", "Cf", "design_pressure")] - - -class EdSolver(C.Structure): - _fields_ = [ - ("Pc_min_bound", _d), ("Pc_max_bound", _d), ("tolerance", _d), - ("max_iterations", _i), ("closure_max_iterations", _i), - ("closure_Cd_reduction_factor", _d), - ] - - -class EdEngineState(C.Structure): - _fields_ = [ - ("injector", EdInjector), ("feed_O", EdFeed), ("feed_F", EdFeed), - ("discharge_O", EdDischarge), ("discharge_F", EdDischarge), - ("fluid_O", EdFluid), ("fluid_F", EdFluid), ("comb", EdCombustionEff), - ("cooling", EdCooling), ("spray", EdSpray), ("geom", EdGeometry), - ("solver", EdSolver), ("P_ambient", _d), - ] - - -class EdInjectorResult(C.Structure): - _doubles = ( - "mdot_O", "mdot_F", "Cd_O", "Cd_F", "A_geom_O", "A_geom_F", "A_eff_O", - "A_eff_F", "u_O", "u_F", "v_O_bulk", "v_F_bulk", "momentum_ratio_R", - "J", "TMR", "theta", "We_O", "We_F", "D32_O", "D32_F", "x_star", "u_rel", - "P_injector_O", "P_injector_F", "delta_p_injector_O", "delta_p_injector_F", - "delta_p_feed_O", "delta_p_feed_F", "turbulence_intensity_mix", "MR", - ) - _fields_ = [(n, _d) for n in _doubles] + [ - ("constraints_satisfied", _i), ("iterations", _i), - ("feed_orifice_coupling_iters", _i)] - - -class EdCeaResult(C.Structure): - # Mirror of ed_cea.h EdCeaResult — keep field order/count in sync. - _fields_ = [(n, _d) for n in ("cstar_ideal", "Cf_ideal", "Tc", "gamma", "R", "M", "Cf_vac")] - - -class EdChugStream(C.Structure): - _fields_ = [ - ("G_inj", _d), ("inertance", _d), ("resistance", _d), ("tau_conv", _d), - ("reg_Z_hf", _d), ("reg_corner_hz", _d), ("reg_enabled", _i), - ] - - -class EdChugResult(C.Structure): - _fields_ = [("gain_margin", _d), ("f_chug_hz", _d), - ("phase_margin_deg", _d), ("stable", _i)] - - -class EdAcousticResult(C.Structure): - _fields_ = [("alpha_max", _d), ("f_1L", _d), ("f_1T", _d), - ("limiting", _i), ("stable", _i)] - - -class EdChamberDiagnostics(C.Structure): - _doubles = ( - "Pc", "mdot_O", "mdot_F", "mdot_total", "MR", "cstar_ideal", "cstar_actual", - "eta_cstar", "cooling_efficiency", "Tc", "Tc_ideal", "gamma", "R", "M", - "momentum_ratio_R", "delta_P_injector_O", "delta_P_injector_F", - "A_geom_O", "A_geom_F", "SMD", "Cd_O", "Cd_F", "u_O", "u_F", - ) - _fields_ = [(n, _d) for n in _doubles] + [ - ("converged", _i), ("residual_iters", _i)] - - -class EdEvaluateResult(C.Structure): - _doubles = ( - "Pc", "mdot_O", "mdot_F", "mdot_total", "MR", "F", "Isp", "v_exit", - "P_exit", "P_throat", "T_exit", "T_throat", "Tc", "gamma", "R", - "cstar_actual", "cstar_ideal", "eta_cstar", "Cf_actual", "Cf_ideal", - "eps", "A_throat", "A_exit", "Cd_O", "Cd_F", "momentum_ratio_R", - "delta_P_injector_O", "delta_P_injector_F", "A_geom_O", "A_geom_F", - "SMD", "cooling_efficiency", "Tc_effective", - ) - _fields_ = [(n, _d) for n in _doubles] + [("converged", _i)] - - -class EdNative: - def __init__(self, lib_path: str | None = None): - if lib_path is None: - from . import autobuild # local import to keep ctypes import side-effect-free - lib_path = os.environ.get("ED_NATIVE_LIB") or autobuild.ensure_lib() - self.lib = C.CDLL(lib_path) - self.lib_path = lib_path - L = self.lib - - for fn in ("ed_sizeof_cea_tables", "ed_sizeof_engine_state", - "ed_sizeof_workspace", "ed_sizeof_evaluate_result"): - getattr(L, fn).restype = C.c_size_t - - # Layout drift guard: ctypes mirror must match the C struct byte-for-byte. - c_size = L.ed_sizeof_engine_state() - if C.sizeof(EdEngineState) != c_size: - raise RuntimeError( - f"EdEngineState layout mismatch: ctypes={C.sizeof(EdEngineState)} " - f"C={c_size}. Update ed_native.py struct mirror to match ed_state.h.") - - L.ed_cea_load.argtypes = [C.c_char_p, C.c_void_p]; L.ed_cea_load.restype = _i - L.ed_cea_free.argtypes = [C.c_void_p] - L.ed_cea_eval.argtypes = [C.c_void_p, _d, _d, _d, _d, C.POINTER(EdCeaResult)] - L.ed_cea_eval.restype = _i - - L.ed_delta_p_feed.argtypes = [_d, _d, C.POINTER(EdFeed), _d]; L.ed_delta_p_feed.restype = _d - L.ed_cd_inf_from_orifice_diameter.argtypes = [_d, C.POINTER(EdDischarge)] - L.ed_cd_inf_from_orifice_diameter.restype = _d - L.ed_cd_from_re.argtypes = [_d, C.POINTER(EdDischarge), _d, _d, _d]; L.ed_cd_from_re.restype = _d - - L.ed_injector_solve.argtypes = [C.POINTER(EdEngineState), _d, _d, _d, - C.POINTER(EdInjectorResult)] - L.ed_injector_solve.restype = _i - - L.ed_evaluate.argtypes = [C.c_void_p, C.c_void_p, _d, _d, _d, _d, C.c_void_p, - C.POINTER(EdEvaluateResult)] - L.ed_evaluate.restype = _i - - L.ed_chamber_solve.argtypes = [C.POINTER(EdEngineState), C.c_void_p, _d, _d, _d, - C.c_void_p, C.POINTER(EdChamberDiagnostics)] - L.ed_chamber_solve.restype = _i - - L.ed_chug_margin_fast.argtypes = [C.POINTER(EdChugStream), _i, _d, _d, _d, _d, - C.POINTER(EdChugResult)] - L.ed_chug_margin_fast.restype = _i - - L.ed_fast_acoustic.argtypes = [_d, _d, _d, _d, _d, _d, _d, _d, - C.POINTER(EdAcousticResult)] - L.ed_fast_acoustic.restype = _i - - self._tables_buf = C.create_string_buffer(L.ed_sizeof_cea_tables()) - self._ws_buf = C.create_string_buffer(L.ed_sizeof_workspace()) - - # --- chug fast-tier -------------------------------------------------- - def chug_margin_fast(self, streams, K_c, theta_c, f_lo=2.0, f_hi=2000.0): - n = len(streams) - arr = (EdChugStream * n)(*streams) - out = EdChugResult() - rc = self.lib.ed_chug_margin_fast(arr, n, float(K_c), float(theta_c), - float(f_lo), float(f_hi), C.byref(out)) - return rc, out - - def fast_acoustic(self, D_ch, L_ch, gamma, a_sound, nu_g, mach_ne, n, tau_sens): - out = EdAcousticResult() - rc = self.lib.ed_fast_acoustic(float(D_ch), float(L_ch), float(gamma), - float(a_sound), float(nu_g), float(mach_ne), - float(n), float(tau_sens), C.byref(out)) - return rc, out - - # --- chamber solve --------------------------------------------------- - def chamber_solve(self, state, P_O, P_F, Pc_guess=0.0): - out = EdChamberDiagnostics() - rc = self.lib.ed_chamber_solve(C.byref(state), self._tables_buf, - float(P_O), float(P_F), float(Pc_guess), - self._ws_buf, C.byref(out)) - return rc, out - - # --- CEA ------------------------------------------------------------- - def cea_load(self, bin_path: str) -> None: - if self.lib.ed_cea_load(bin_path.encode(), self._tables_buf) != ED_OK: - raise RuntimeError("ed_cea_load failed") - - def cea_eval(self, MR: float, Pc: float, eps: float, Pa: float = 101325.0) -> EdCeaResult: - out = EdCeaResult() - if self.lib.ed_cea_eval(self._tables_buf, MR, Pc, Pa, eps, C.byref(out)) != ED_OK: - raise RuntimeError("ed_cea_eval failed") - return out - - def cea_free(self) -> None: - self.lib.ed_cea_free(self._tables_buf) - - # --- injector -------------------------------------------------------- - def injector_solve(self, state: EdEngineState, P_O: float, P_F: float, Pc: float): - out = EdInjectorResult() - rc = self.lib.ed_injector_solve(C.byref(state), P_O, P_F, Pc, C.byref(out)) - return rc, out - - def evaluate(self, state_buf, P_O: float, P_F: float, P_amb: float = 101325.0): - out = EdEvaluateResult() - rc = self.lib.ed_evaluate(state_buf, self._tables_buf, P_O, P_F, P_amb, - 0.0, self._ws_buf, C.byref(out)) - return rc, out - - -_INSTANCE: EdNative | None = None - - -def load(lib_path: str | None = None) -> EdNative: - """Process-wide singleton (auto-builds + loads on first call).""" - global _INSTANCE - if _INSTANCE is None or lib_path is not None: - _INSTANCE = EdNative(lib_path) - return _INSTANCE diff --git a/EngineDesign/engine/native/python/ed_state_builder.py b/EngineDesign/engine/native/python/ed_state_builder.py deleted file mode 100644 index a429129ba..000000000 --- a/EngineDesign/engine/native/python/ed_state_builder.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Convert a PintleEngineConfig into the flat field set backing EdEngineState. - -NOT imported by runner.py. This is the future "config -> binary snapshot" shim. - -Stage 1 implements the *mapping* (config -> flat Python dict whose keys mirror -ed_state.h) and a JSON dump, which is what the parity tooling needs today. Emitting -the packed binary EdEngineState requires a ctypes Structure mirror of the full -nested layout in ed_state.h; that mirror is added together with ed_evaluate (so the -two never drift), at which point build_state_bin() is filled in. See README. -""" -from __future__ import annotations - -import json -import os -import sys - -ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) -if ROOT not in sys.path: - sys.path.insert(0, ROOT) - -PHI = {"none": 0, "sqrtP": 1, "logP": 2} -INJ = {"pintle": 0, "impinging": 1, "coaxial": 2} - - -def _feed(c): - return { - "d_inlet": float(getattr(c, "d_inlet", 0.0) or 0.0), - "A_hydraulic": float(getattr(c, "A_hydraulic", 0.0) or 0.0), - "K0": float(c.K0), "K1": float(c.K1), "phi_type": PHI[c.phi_type], - } - - -def _disc(c): - return {k: (int(getattr(c, k)) if k.startswith("use_") else float(getattr(c, k))) - for k in ("Cd_inf", "a_Re", "Cd_min", "use_geometry_cd", "d_ref_m", - "d_min_m", "cd_small_hole_exponent", "cd_large_hole_log_gain", - "cd_inf_max", "cd_inf_min_geom", "use_pressure_correction", - "P_ref", "a_P", "use_temperature_correction", "T_ref", "a_T")} - - -def build_state_dict(cfg) -> dict: - """Flatten the parts of a PintleEngineConfig needed for one evaluation.""" - from engine.pipeline.config_schemas import ensure_chamber_geometry - cg = ensure_chamber_geometry(cfg) - inj = cfg.injector - - state = { - "injector_type": INJ[inj.type], - "feed_O": _feed(cfg.feed_system["oxidizer"]), - "feed_F": _feed(cfg.feed_system["fuel"]), - "discharge_O": _disc(cfg.discharge["oxidizer"]), - "discharge_F": _disc(cfg.discharge["fuel"]), - "geom": { - "A_throat": float(cg.A_throat), "A_exit": float(cg.A_exit), - "volume": float(cg.volume), - "Lstar": float(cg.Lstar) if cg.Lstar else 0.0, - "length": float(cg.length), - "length_cylindrical": float(cg.length_cylindrical or 0.0), - "length_contraction": float(cg.length_contraction or 0.0), - "chamber_diameter": float(cg.chamber_diameter or 0.0), - "exit_diameter": float(cg.exit_diameter or 0.0), - "expansion_ratio": float(cg.expansion_ratio), - "nozzle_efficiency": float(cg.nozzle_efficiency), - "Cf": float(cg.Cf or 0.0), - "design_pressure": float(cg.design_pressure or 0.0), - }, - "solver": { - "Pc_min_bound": float(cfg.solver.Pc_bounds[0]), - "Pc_max_bound": float(cfg.solver.Pc_bounds[1]), - "tolerance": float(cfg.solver.tolerance), - "max_iterations": int(cfg.solver.max_iterations), - }, - "cooling": { - "regen_enabled": int(bool(cfg.regen_cooling and cfg.regen_cooling.enabled)), - "film_enabled": int(bool(cfg.film_cooling and cfg.film_cooling.enabled)), - "ablative_enabled": int(bool(cfg.ablative_cooling and cfg.ablative_cooling.enabled)), - "graphite_enabled": int(bool(getattr(cfg, "graphite_insert", None) and cfg.graphite_insert.enabled)), - }, - } - if inj.type == "impinging": - for branch, key in ((inj.geometry.oxidizer, "imp_O"), (inj.geometry.fuel, "imp_F")): - state[key] = { - "n_elements": int(branch.n_elements), "d_jet": float(branch.d_jet), - "impingement_angle": float(branch.impingement_angle), - "spacing": float(branch.spacing), - } - return state - - -def build_state_bin(cfg, path: str) -> None: # pragma: no cover - staged - raise NotImplementedError( - "Binary EdEngineState emission lands with the ed_evaluate port " - "(needs the ctypes EdEngineState mirror of ed_state.h). Use build_state_dict() " - "for the flat field mapping today." - ) - - -if __name__ == "__main__": - import argparse - from engine.pipeline.io import load_config - - ap = argparse.ArgumentParser() - ap.add_argument("--config", default="configs/canonical/impinging.yaml") - ap.add_argument("--out", default="engine/native/tests/golden/state_impinging.json") - args = ap.parse_args() - cfg = load_config(args.config) - d = build_state_dict(cfg) - os.makedirs(os.path.dirname(args.out), exist_ok=True) - with open(args.out, "w") as f: - json.dump(d, f, indent=1) - print(f"[ok] wrote {args.out}") diff --git a/EngineDesign/engine/native/python/native_injector.py b/EngineDesign/engine/native/python/native_injector.py deleted file mode 100644 index 2e399971d..000000000 --- a/EngineDesign/engine/native/python/native_injector.py +++ /dev/null @@ -1,578 +0,0 @@ -"""Native injector fast-path used by engine.core.closure.flows when ED_USE_NATIVE=1. - -Builds an EdEngineState from a PintleEngineConfig, calls the C impinging solver, -and returns (mdot_O, mdot_F, diagnostics) matching the Python solve's contract for -the keys the chamber residual / Layer 1 / stability consume. - -Safety: - * Only the impinging injector with regen-coupled feed loss disabled is handled; - anything else returns None so the caller uses Python. - * There is no runtime parity self-check (capability-dispatch architecture): - native<->Python parity is enforced by the golden C tests and the live A/B - suite (tests/test_native_ab_parity.py, run in the CI parity job). Genuine - native-machinery failures raise under ED_REQUIRE_NATIVE=1 and fall back to - Python (logged once) otherwise. -""" -from __future__ import annotations - -import logging -import math -import os - -_logger = logging.getLogger(__name__) - -# One-time fallback warning so a broken native path in a long optimizer run is -# visible in the log without spamming once per candidate. -_FALLBACK_WARNED = False - - -def _warn_fallback_once(stage: str, exc: Exception) -> None: - global _FALLBACK_WARNED - if not _FALLBACK_WARNED: - _FALLBACK_WARNED = True - _logger.warning( - "Native evaluate %s failed (%s: %s); falling back to Python for such " - "calls. (Logged once; set ED_REQUIRE_NATIVE=1 to raise instead.)", - stage, type(exc).__name__, exc) - -_NAT = None -_PHI = {"none": 0, "sqrtP": 1, "logP": 2} -_INJ = {"pintle": 0, "impinging": 1, "coaxial": 2} - - -_NATIVE_LIB_OK = None - -# ed_combustion_physics.c implements the Rupe mixing model (eta_turbulence retired) at -# bit-parity with Python (verified 2026-06-28, worst reldiff 5e-16 across 600-900 psi), -# so the native fast path is on by default. - - -def native_enabled() -> bool: - """Single source of truth for whether the native kernel is used at runtime. - - ON by default; ``ED_USE_NATIVE=0`` forces it OFF (debugging / parity escape - hatch), and a library that cannot be built or loaded also disables it - (graceful fallback to Python — never a hard failure). - - The ``ED_USE_NATIVE`` env var is re-read every call so the escape hatch works - even after the lib has loaded (tests pin it at runtime). Only the expensive - library-load probe is cached. - - This decides *whether native is wired in at all*. Per-config capability is a - SEPARATE decision made by ``_can_handle*`` — pintle and other unsupported - configs always fall back to the Python path regardless of this flag. - """ - if os.environ.get("ED_USE_NATIVE", "1") == "0": - return False - global _NATIVE_LIB_OK - if _NATIVE_LIB_OK is None: - try: - _nat() # build/load the library once; cache the instance - _NATIVE_LIB_OK = True - except Exception: - _NATIVE_LIB_OK = False - return _NATIVE_LIB_OK - - -def available() -> bool: - """Backwards-compatible alias for :func:`native_enabled`.""" - return native_enabled() - - -def require_native() -> bool: - """Strict mode (ED_REQUIRE_NATIVE=1): a *genuine* native failure (library - won't load, solver errors, returns rc!=0 / non-converged) raises instead of - returning None to fall back to Python. - - This exists for the CI parity job: without it, a broken or missing native - build makes every call silently fall back to Python, so the parity tests pass - on the Python path and report a FALSE GREEN. Configs the native kernel simply - doesn't cover (`_can_handle*` False) still fall back quietly — that's "not - applicable", not a failure — so default (non-strict) runs are unaffected. - """ - return os.environ.get("ED_REQUIRE_NATIVE", "0") == "1" - - -def _nat(): - global _NAT - if _NAT is None: - from . import ed_native - _NAT = ed_native.load() - return _NAT - - -def _can_handle(config) -> bool: - inj = getattr(config, "injector", None) - if inj is None or inj.type != "impinging": - return False - regen = getattr(config, "regen_cooling", None) - if regen is not None and getattr(regen, "enabled", False): - return False # regen-coupled feed loss not ported yet - return True - - -def _fill_discharge(dst, c): - dst.Cd_inf = float(c.Cd_inf); dst.a_Re = float(c.a_Re); dst.Cd_min = float(c.Cd_min) - dst.use_geometry_cd = int(bool(c.use_geometry_cd)) - dst.d_ref_m = float(c.d_ref_m); dst.d_min_m = float(c.d_min_m) - dst.cd_small_hole_exponent = float(c.cd_small_hole_exponent) - dst.cd_large_hole_log_gain = float(c.cd_large_hole_log_gain) - dst.cd_inf_max = float(c.cd_inf_max); dst.cd_inf_min_geom = float(c.cd_inf_min_geom) - dst.use_pressure_correction = int(bool(c.use_pressure_correction)) - dst.P_ref = float(c.P_ref); dst.a_P = float(c.a_P) - dst.use_temperature_correction = int(bool(c.use_temperature_correction)) - dst.T_ref = float(c.T_ref); dst.a_T = float(c.a_T) - - -def _fill_feed(dst, c): - dst.d_inlet = float(getattr(c, "d_inlet", 0.0) or 0.0) - dst.A_hydraulic = float(getattr(c, "A_hydraulic", 0.0) or 0.0) - dst.K0 = float(c.K0); dst.K1 = float(c.K1); dst.phi_type = _PHI[c.phi_type] - - -_EFF_MODEL = {"constant": 0, "linear": 1, "exponential": 2} - - -def _fill_fluid(dst, f): - dst.density = float(f.density); dst.viscosity = float(f.viscosity) - dst.surface_tension = float(f.surface_tension) - dst.temperature = float(getattr(f, "temperature", 0.0) or 0.0) - dst.latent_heat = float(getattr(f, "latent_heat", 300e3) or 300e3) - - -def _fill_comb(dst, eff): - dst.model = _EFF_MODEL.get(eff.model, 2) - dst.C = float(eff.C); dst.K = float(eff.K) - dst.cooling_efficiency_floor = float(eff.cooling_efficiency_floor) - dst.use_cooling_coupling = int(bool(eff.use_cooling_coupling)) - dst.tau_ref = float(eff.tau_ref); dst.tau_ref_P = float(eff.tau_ref_P) - dst.tau_ref_T = float(eff.tau_ref_T); dst.n_pressure = float(eff.n_pressure) - dst.T_star_fuel_cap_K = float(getattr(eff, "T_star_fuel_cap_K", 1000.0)) - floor = getattr(eff, "tau_Tc_floor_K", None) - dst.has_tau_Tc_floor = int(floor is not None) - dst.tau_Tc_floor = float(floor or 0.0) - # Rupe momentum-ratio mixing model (R_opt<=0 => derive from impingement angles in C) - dst.Em_peak = float(getattr(eff, "Em_peak", 0.96)) - dst.mixing_sigma = float(getattr(eff, "mixing_sigma", 1.5)) - _ropt = getattr(eff, "R_opt", None) - dst.R_opt = float(_ropt) if _ropt is not None else 0.0 - - -def _fill_cooling(dst, cfg): - rg, ab, eff = cfg.regen_cooling, cfg.ablative_cooling, cfg.combustion.efficiency - fc = getattr(cfg, "film_cooling", None) - dst.regen_enabled = int(bool(rg and rg.enabled)) - dst.film_enabled = int(bool(fc and fc.enabled)) - dst.ablative_enabled = int(bool(ab and ab.enabled)) - dst.graphite_enabled = int(bool(getattr(cfg, "graphite_insert", None) and cfg.graphite_insert.enabled)) - dst.use_cooling_coupling = int(bool(eff.use_cooling_coupling)) - dst.cooling_efficiency_floor = float(eff.cooling_efficiency_floor) - 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_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 - dst.radiation_emissivity_hot = float(rg.radiation_emissivity_hot) - dst.radiation_view_factor = float(rg.radiation_view_factor) - dst.regen_chamber_inner_diameter = float(rg.chamber_inner_diameter or 0.0) - if ab is not None: - dst.ablative_coverage_fraction = float(ab.coverage_fraction) - dst.ablative_surface_temperature_limit = float(ab.surface_temperature_limit) - dst.ablative_material_density = float(ab.material_density) - dst.ablative_heat_of_ablation = float(ab.heat_of_ablation) - dst.ablative_specific_heat = float(ab.specific_heat) - dst.ablative_pyrolysis_temperature = float(ab.pyrolysis_temperature) - dst.ablative_use_physics_based_blowing = int(bool(ab.use_physics_based_blowing)) - dst.ablative_blowing_efficiency = float(ab.blowing_efficiency) - dst.ablative_blowing_coefficient = float(ab.blowing_coefficient) - dst.ablative_blowing_min_reduction_factor = float(ab.blowing_min_reduction_factor) - dst.ablative_turbulence_reference_intensity = float(ab.turbulence_reference_intensity) - dst.ablative_turbulence_sensitivity = float(ab.turbulence_sensitivity) - dst.ablative_turbulence_exponent = float(ab.turbulence_exponent) - dst.ablative_turbulence_max_multiplier = float(ab.turbulence_max_multiplier) - dst.ablative_surface_emissivity = float(ab.surface_emissivity) - dst.ablative_ambient_temperature = float(ab.ambient_temperature) - dst.ablative_radiative_sink_minimum_threshold = float(ab.radiative_sink_minimum_threshold) - dst.ablative_radiative_sink_fallback_temperature = float(ab.radiative_sink_fallback_temperature) - - -def _fill_geom(dst, cg): - dst.A_throat = float(cg.A_throat); dst.A_exit = float(cg.A_exit); dst.volume = float(cg.volume) - dst.Lstar = float(cg.Lstar) if cg.Lstar else 0.0 - dst.length = float(cg.length) - dst.length_cylindrical = float(cg.length_cylindrical or 0.0) - dst.length_contraction = float(cg.length_contraction or 0.0) - dst.chamber_diameter = float(cg.chamber_diameter or 0.0) - dst.exit_diameter = float(cg.exit_diameter or 0.0) - dst.expansion_ratio = float(cg.expansion_ratio) - dst.nozzle_efficiency = float(cg.nozzle_efficiency) - dst.Cf = float(cg.Cf or 0.0) - dst.design_pressure = float(cg.design_pressure or 0.0) - - -def _fill_imp(dst, b): - dst.n_elements = int(b.n_elements); dst.d_jet = float(b.d_jet) - dst.impingement_angle = float(b.impingement_angle) - dst.spacing = float(getattr(b, "spacing", 0.0) or 0.0) - - -def build_state(config): - from . import ed_native - st = ed_native.EdEngineState() - g = config.injector.geometry - sp = config.spray - st.injector.type = _INJ[config.injector.type] - _fill_imp(st.injector.imp_O, g.oxidizer) - _fill_imp(st.injector.imp_F, g.fuel) - _fill_discharge(st.discharge_O, config.discharge["oxidizer"]) - _fill_discharge(st.discharge_F, config.discharge["fuel"]) - _fill_feed(st.feed_O, config.feed_system["oxidizer"]) - _fill_feed(st.feed_F, config.feed_system["fuel"]) - _fill_fluid(st.fluid_O, config.fluids["oxidizer"]) - _fill_fluid(st.fluid_F, config.fluids["fuel"]) - # Impinging atomization is always Ingebo (see impinging.py); ignore stale lefebvre in YAML. - st.spray.smd_model = 1 - st.spray.smd_C = float(sp.smd.C); st.spray.smd_m = float(sp.smd.m); st.spray.smd_p = float(sp.smd.p) - st.spray.smd_C_ingebo = float(sp.smd.C_ingebo) - wcm = getattr(sp.smd, "we_corr_max", None) - st.spray.smd_we_corr_max = float(wcm) if wcm else 0.0 - st.spray.chamber_gas_R = float(sp.smd.chamber_gas_R) - st.spray.chamber_gas_T = float(sp.smd.chamber_gas_T) - st.spray.spray_angle_model = 0 if sp.spray_angle.model == "J" else 1 - st.spray.spray_angle_k = float(sp.spray_angle.k); st.spray.spray_angle_n = float(sp.spray_angle.n) - st.spray.we_min = float(sp.weber.get("We_min", 15.0)) - st.spray.evap_K = float(sp.evaporation.K) - st.spray.evap_x_star_limit = float(sp.evaporation.x_star_limit) - st.spray.evap_use_constraint = int(bool(sp.evaporation.use_constraint)) - st.solver.closure_max_iterations = int(config.solver.closure.max_iterations) - st.solver.closure_Cd_reduction_factor = float(config.solver.closure.Cd_reduction_factor) - st.solver.Pc_min_bound = float(config.solver.Pc_bounds[0]) - st.solver.Pc_max_bound = float(config.solver.Pc_bounds[1]) - st.solver.tolerance = float(config.solver.tolerance) - st.solver.max_iterations = int(config.solver.max_iterations) - _fill_comb(st.comb, config.combustion.efficiency) - _fill_cooling(st.cooling, config) - from engine.pipeline.config_schemas import ensure_chamber_geometry - _fill_geom(st.geom, ensure_chamber_geometry(config)) - return st - - -def _result_to_diag(config, r): - """Map EdInjectorResult -> the diagnostics dict the Python solve returns.""" - g = config.injector.geometry - djo, djf = float(g.oxidizer.d_jet), float(g.fuel.d_jet) - rho_O = float(config.fluids["oxidizer"].density) - rho_F = float(config.fluids["fuel"].density) - n_O = max(1, int(g.oxidizer.n_elements)) - n_F = max(1, int(g.fuel.n_elements)) - mdot_bn_O = r.Cd_O * r.A_geom_O * math.sqrt(2.0 * rho_O * r.delta_p_injector_O) if r.delta_p_injector_O > 0 else 0.0 - mdot_bn_F = r.Cd_F * r.A_geom_F * math.sqrt(2.0 * rho_F * r.delta_p_injector_F) if r.delta_p_injector_F > 0 else 0.0 - diag = { - "injector_type": "impinging", - "iterations": int(r.iterations), - "constraints_satisfied": bool(r.constraints_satisfied), - "feed_orifice_coupling_iterations": int(r.feed_orifice_coupling_iters), - "J": r.J, "TMR": r.TMR, "theta": r.theta, - "We_O": r.We_O, "We_F": r.We_F, - "D32_O": r.D32_O, "D32_F": r.D32_F, - "x_star": r.x_star, "u_rel": r.u_rel, "V_rel": r.u_rel, - "u_O": r.u_O, "u_F": r.u_F, - "turbulence_intensity_mix": r.turbulence_intensity_mix, - "Cd_O": r.Cd_O, "Cd_F": r.Cd_F, - "P_injector_O": r.P_injector_O, "P_injector_F": r.P_injector_F, - "delta_p_injector_O": r.delta_p_injector_O, "delta_p_injector_F": r.delta_p_injector_F, - "delta_p_feed_O": r.delta_p_feed_O, "delta_p_feed_F": r.delta_p_feed_F, - "mdot_from_bernoulli_O": mdot_bn_O, "mdot_from_bernoulli_F": mdot_bn_F, - "A_geom_O": r.A_geom_O, "A_geom_F": r.A_geom_F, - "A_eff_O": r.A_eff_O, "A_eff_F": r.A_eff_F, - "A_jet_O": math.pi * (djo / 2.0) ** 2, "A_jet_F": math.pi * (djf / 2.0) ** 2, - "d_jet_O": djo, "d_jet_F": djf, - "momentum_ratio_n_elements_O": n_O, "momentum_ratio_n_elements_F": n_F, - "rho_O_momentum": rho_O, "rho_F_momentum": rho_F, - "MR": r.MR, - } - if math.isfinite(r.v_O_bulk): - diag["v_O_bulk"] = r.v_O_bulk - if math.isfinite(r.v_F_bulk): - diag["v_F_bulk"] = r.v_F_bulk - if math.isfinite(r.momentum_ratio_R) and r.momentum_ratio_R > 0: - diag["momentum_ratio_R"] = r.momentum_ratio_R - # Same included-angle convention as impinging.py: separation = θ_O + θ_F (deg). - imp_sep = float(g.oxidizer.impingement_angle + g.fuel.impingement_angle) - diag["impingement_angle_deg"] = max(1.0, min(179.0, imp_sep)) - return diag - - -def solve(config, P_tank_O, P_tank_F, Pc): - """Return (mdot_O, mdot_F, diagnostics) via the C kernel, or None to fall back.""" - if not _can_handle(config): - return None - try: - nat = _nat() - st = build_state(config) - rc, r = nat.injector_solve(st, float(P_tank_O), float(P_tank_F), float(Pc)) - if rc != 0: - if require_native(): - raise RuntimeError(f"native injector_solve returned rc={rc}") - return None - return float(r.mdot_O), float(r.mdot_F), _result_to_diag(config, r) - except Exception: - if require_native(): - raise - return None - - -# --- chamber solve (Stage 3): whole residual loop in C --------------------- -# Token of the CEACache whose tables are currently resident in the native lib's -# single buffer (None = none loaded). See _ensure_cea for why this is not id()-keyed. -_CEA_RESIDENT_TOKEN = None - - -def _can_handle_chamber(config) -> bool: - """Native chamber solve covers impinging + ablative-only cooling (film/regen - off), matching the ported cooling_eff path. Anything else -> Python.""" - if not _can_handle(config): - return False - fc = getattr(config, "film_cooling", None) - if fc is not None and getattr(fc, "enabled", False): - return False - eff = config.combustion.efficiency - if not getattr(eff, "use_advanced_model", True): - return False - return True - - -def _ensure_cea(cache) -> bool: - """Load the live CEACache into the native lib's (single) resident tables buffer. - - The native library holds ONE table set at a time, so we reload whenever the - requested cache differs from the one currently resident. Identity is tracked by - a token stored ON the cache object — immune to id() reuse after GC. The old - id(cache)-keyed flag silently served a previously-loaded config's tables when a - freed cache's id was reused (multi-config processes: tests, GUI config switches). - Returns False if the cache isn't a supported 3D grid. - """ - global _CEA_RESIDENT_TOKEN - nat = _nat() - token = getattr(cache, "_ed_native_token", None) - if token is None: - token = object() # unique sentinel; lives and dies with this cache - try: - cache._ed_native_token = token - except Exception: - token = None # cache rejects attributes -> always reload (safe, slower) - if token is not None and _CEA_RESIDENT_TOKEN is token: - return True - if not getattr(cache, "use_3d", False): - return False - import os, struct, tempfile, numpy as np - Pc = np.ascontiguousarray(cache.Pc_grid, dtype=np.float64) - MR = np.ascontiguousarray(cache.MR_grid, dtype=np.float64) - eps = np.ascontiguousarray(cache.eps_grid, dtype=np.float64) - # Cf_vac table (format v2): the RPA delivered-thrust basis ed_evaluate needs. - # Caches built before the Cf_vac column exist get a per-gridpoint isentropic - # fallback (same physics as cea_cache._isentropic_cf_vac, so C-interpolated - # values stay consistent with what Python would serve for that cache). - cf_vac = getattr(cache, "Cf_vac_table", None) - if cf_vac is None: - from engine.pipeline.cea_cache import _isentropic_cf_vac - gamma_t = np.asarray(cache.gamma_table, dtype=np.float64) - cf_vac = np.empty_like(gamma_t) - for k in range(gamma_t.shape[2]): - e_k = float(eps[k]) - for i in range(gamma_t.shape[0]): - for j in range(gamma_t.shape[1]): - cf_vac[i, j, k] = _isentropic_cf_vac(gamma_t[i, j, k], e_k) - tables = [cache.cstar_table, cache.Cf_table, cache.Tc_table, - cache.gamma_table, cache.R_table, cache.M_table, cf_vac] - with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: - path = f.name - f.write(b"EDCA"); f.write(struct.pack(" 0.0) - cstreams.append(ed_native.EdChugStream( - G_inj=float(st.G_inj()), inertance=float(st.inertance()), - resistance=float(st.resistance()), tau_conv=float(st.tau_conv), - reg_Z_hf=float(getattr(reg, "Z_hf", 0.0)), - reg_corner_hz=float(getattr(reg, "corner_hz", 3.0)), - reg_enabled=int(enabled))) - rc, out = nat.chug_margin_fast(cstreams, chamber.K_c(), chamber.theta_c(), f_lo, f_hi) - if rc != 0: - return None - return { - "gain_margin": float(out.gain_margin), - "stable": bool(out.stable), - "f_chug_hz": float(out.f_chug_hz), - "phase_margin_deg": float(out.phase_margin_deg), - "margin": float(out.gain_margin), - } - except Exception: - return None - - -def fast_acoustic(D_ch, L_ch, gas, *, n, tau_sens): - """Native port of acoustic.fast_acoustic. Returns a dict matching Python, or None.""" - if not available(): - return None - try: - nat = _nat() - rc, out = nat.fast_acoustic(D_ch, L_ch, gas.gamma, gas.a_sound, gas.nu_g, - gas.mach_nozzle_entrance, n, tau_sens) - if rc != 0: - return None - lim = {0: "1L", 1: "1T"}.get(out.limiting, None) - return { - "alpha_max": float(out.alpha_max), "limiting_mode": lim, - "stable": bool(out.stable), "f_1L": float(out.f_1L), "f_1T": float(out.f_1T), - } - except Exception: - return None - - -def chamber_solve(config, cache, P_tank_O, P_tank_F): - """Return (Pc, diagnostics_struct) via the native chamber solve, or None.""" - if not _can_handle_chamber(config): - return None - try: - nat = _nat() - if not _ensure_cea(cache): - if require_native(): - raise RuntimeError("native CEA cache load failed (unsupported/ non-3D grid)") - return None - st = build_state(config) - rc, d = nat.chamber_solve(st, float(P_tank_O), float(P_tank_F)) - if rc != 0 or not d.converged: - if require_native(): - raise RuntimeError(f"native chamber_solve rc={rc} converged={bool(d.converged)}") - return None - return float(d.Pc), d - except Exception: - if require_native(): - raise - return None - - -def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): - """Native chamber solve + native RPA delivered thrust + Python stability. - - Returns a dict compatible with the keys engine.optimizer Layer-1 reads from - runner.evaluate() — or None to fall back to the full Python path (unsupported - injector type, non-3D CEA cache, or non-converged solve). - - The whole physics chain runs in C: chamber residual loop + the SAME RPA - delivered-thrust formula engine.core.nozzle.calculate_thrust uses - (F = zeta_n*Cf_vac*Pc*At - Pa*Ae, ed_evaluate.c), on the SAME Cf_vac table - (dumped from this cache, format v2). So the inner loop ranks on the exact - number finalization reports — no frozen-nozzle bias, and no Python-side - thrust override. Parity is enforced live by tests/test_native_ab_parity.py - at both the wrapper and raw-struct level. Stability reuses - comprehensive_stability_analysis (native kernels under the hood). - """ - if not _can_handle_chamber(config): - return None - try: - import ctypes as C - nat = _nat() - if not _ensure_cea(cache): - return None - st = build_state(config) # geometry changes per candidate, so rebuild each call - rc, r = nat.evaluate(C.byref(st), float(P_tank_O), float(P_tank_F), float(P_ambient)) - if rc != 0 or not r.converged: - # Per-candidate physics outcome (degenerate CMA samples legitimately - # fail to converge) — quiet Python fallback, never a strict-mode raise. - return None - # Full injector diagnostics at the converged Pc — same construction the - # production Python path feeds to stability (D32, delta_p_feed, Cd, A_geom, - # momentum_ratio_R, u_O/F, ...), so per-candidate stability is unchanged. - rci, ir = nat.injector_solve(st, float(P_tank_O), float(P_tank_F), float(r.Pc)) - if rci != 0: - return None - except Exception as exc: - # Genuine native-machinery failure (lib load/build, ctypes layout, table - # dump) — NOT a physics outcome. Strict mode (ED_REQUIRE_NATIVE=1, the CI - # parity job) surfaces it instead of passing green on the Python fallback; - # default runs fall back quietly but leave one log line so a long - # optimizer run silently running 100% Python is diagnosable. - if require_native(): - raise - _warn_fallback_once("physics", exc) - return None - - diagnostics = _result_to_diag(config, ir) - diagnostics.update({ - "mdot_O": r.mdot_O, "mdot_F": r.mdot_F, "mdot_total": r.mdot_total, - "Pc": r.Pc, "MR": r.MR, - "cstar_ideal": r.cstar_ideal, "cstar_actual": r.cstar_actual, "eta_cstar": r.eta_cstar, - # Stability uses the cooling-adjusted effective Tc (= runner's diagnostics["Tc"]), - # not the ideal Tc the nozzle expands from. - "gamma": r.gamma, "R": r.R, "Tc": r.Tc_effective, "SMD": r.SMD, - }) - - try: - from engine.pipeline.stability.analysis import comprehensive_stability_analysis - stability_results = comprehensive_stability_analysis( - config=config, Pc=r.Pc, MR=r.MR, mdot_total=r.mdot_total, - cstar=r.cstar_actual, gamma=r.gamma, R=r.R, Tc=r.Tc_effective, diagnostics=diagnostics) - except Exception as exc: - # Do NOT proceed with empty stability: missing margins read as worst-case - # downstream, silently biasing the ranking of native-evaluated candidates - # vs Python-evaluated ones. Fall back to the full Python path for this - # call instead (strict mode raises). - if require_native(): - raise - _warn_fallback_once("stability", exc) - return None - - # F/Isp/Cf_actual come straight from the C result — ed_evaluate.c computes the - # RPA delivered thrust from the tables' Cf_vac (and errors out rather than - # serving the retired momentum-method number, so a stale table set lands in - # the Python fallback above, never here). - if r.F != r.F: # NaN guard - return None - - return { - # Mirrors the runner.evaluate keys Layer-1 consumes (success is inferred - # from finite F/Pc by the objective, exactly as for runner.evaluate). - "Pc": r.Pc, "mdot_O": r.mdot_O, "mdot_F": r.mdot_F, "mdot_total": r.mdot_total, - "MR": r.MR, "F": r.F, "Isp": r.Isp, "v_exit": r.v_exit, - "P_exit": r.P_exit, "P_throat": r.P_throat, "T_exit": r.T_exit, "T_throat": r.T_throat, - "Tc": r.Tc_effective, "eps": r.eps, "A_throat": r.A_throat, "A_exit": r.A_exit, - "cstar_actual": r.cstar_actual, "cstar_ideal": r.cstar_ideal, "eta_cstar": r.eta_cstar, - "gamma": r.gamma, "R": r.R, - "Cf": r.Cf_actual, "Cf_actual": r.Cf_actual, "Cf_ideal": r.Cf_ideal, - "Cd_O": r.Cd_O, "Cd_F": r.Cd_F, "A_geom_O": r.A_geom_O, "A_geom_F": r.A_geom_F, - "stability": stability_results, "stability_results": stability_results, - "diagnostics": diagnostics, - "P_ambient": float(P_ambient), - "native_fast_eval": True, - } diff --git a/EngineDesign/engine/native/src/ed_abi.c b/EngineDesign/engine/native/src/ed_abi.c deleted file mode 100644 index a754feba9..000000000 --- a/EngineDesign/engine/native/src/ed_abi.c +++ /dev/null @@ -1,15 +0,0 @@ -/* ed_abi.c - struct sizes for the ctypes shim (offline/setup only). */ -#include "ed_abi.h" -#include "ed_state.h" -#include "ed_cea.h" -#include "ed_workspace.h" -#include "ed_evaluate.h" -#include "ed_stability.h" -#include "ed_chamber.h" - -size_t ed_sizeof_engine_state(void) { return sizeof(EdEngineState); } -size_t ed_sizeof_cea_tables(void) { return sizeof(EdCeaTables); } -size_t ed_sizeof_workspace(void) { return sizeof(EdWorkspace); } -size_t ed_sizeof_evaluate_result(void) { return sizeof(EdEvaluateResult); } -size_t ed_sizeof_stability_result(void) { return sizeof(EdStabilityResult); } -size_t ed_sizeof_chamber_diagnostics(void){ return sizeof(EdChamberDiagnostics); } diff --git a/EngineDesign/engine/native/src/ed_cea.c b/EngineDesign/engine/native/src/ed_cea.c deleted file mode 100644 index c3b52e8c0..000000000 --- a/EngineDesign/engine/native/src/ed_cea.c +++ /dev/null @@ -1,215 +0,0 @@ -/* ed_cea.c - CEA table interpolation, faithful port of CEACache (cea_cache.py). - * - * Parity notes vs Python: - * - Index lookup replicates np.searchsorted(grid, x, side='left') followed by - * np.clip(i, 1, n-1), so boundary weights (0 at low edge, 1 at high edge) match. - * - The 8-corner trilinear formula is byte-for-byte the same expression order. - * - If any corner is NaN, falls back to the f000 (lower) corner, like Python. - * - eval() clamps (Pc,MR,eps) to grid bounds first (CEACache.eval clamp). - */ -#include "ed_cea.h" - -#include -#include -#include - -/* First index i in [0,n] with g[i] >= x (np.searchsorted side='left'). */ -static size_t searchsorted_left(const double *g, size_t n, double x) { - size_t lo = 0, hi = n; - while (lo < hi) { - size_t mid = lo + ((hi - lo) >> 1); - if (g[mid] < x) lo = mid + 1; - else hi = mid; - } - return lo; -} - -static size_t clamp_idx(size_t i, size_t n) { - /* np.clip(i, 1, n-1) */ - if (i < 1) return 1; - if (i > n - 1) return n - 1; - return i; -} - -double ed_cea_trilinear(const EdCeaTables *t, const double *table, - double Pc, double MR, double eps) { - const size_t n_mr = t->n_mr, n_eps = t->n_eps; - - size_t i_pc = clamp_idx(searchsorted_left(t->Pc_grid, t->n_pc, Pc), t->n_pc); - size_t i_mr = clamp_idx(searchsorted_left(t->MR_grid, t->n_mr, MR), t->n_mr); - size_t i_eps = clamp_idx(searchsorted_left(t->eps_grid, t->n_eps, eps), t->n_eps); - - const double Pc0 = t->Pc_grid[i_pc - 1], Pc1 = t->Pc_grid[i_pc]; - const double MR0 = t->MR_grid[i_mr - 1], MR1 = t->MR_grid[i_mr]; - const double e0 = t->eps_grid[i_eps - 1], e1 = t->eps_grid[i_eps]; - - /* index(i,j,k) = (i*n_mr + j)*n_eps + k */ -#define IDX(i, j, k) ((((size_t)(i) * n_mr + (size_t)(j)) * n_eps) + (size_t)(k)) - const double f000 = table[IDX(i_pc - 1, i_mr - 1, i_eps - 1)]; - const double f001 = table[IDX(i_pc - 1, i_mr - 1, i_eps )]; - const double f010 = table[IDX(i_pc - 1, i_mr, i_eps - 1)]; - const double f011 = table[IDX(i_pc - 1, i_mr, i_eps )]; - const double f100 = table[IDX(i_pc, i_mr - 1, i_eps - 1)]; - const double f101 = table[IDX(i_pc, i_mr - 1, i_eps )]; - const double f110 = table[IDX(i_pc, i_mr, i_eps - 1)]; - const double f111 = table[IDX(i_pc, i_mr, i_eps )]; -#undef IDX - - if (isnan(f000) || isnan(f001) || isnan(f010) || isnan(f011) || - isnan(f100) || isnan(f101) || isnan(f110) || isnan(f111)) { - return f000; /* nearest-neighbour fallback (Python returns the f000 corner) */ - } - - const double wx = (Pc1 != Pc0) ? (Pc - Pc0) / (Pc1 - Pc0) : 0.0; - const double wy = (MR1 != MR0) ? (MR - MR0) / (MR1 - MR0) : 0.0; - const double wz = (e1 != e0 ) ? (eps - e0) / (e1 - e0 ) : 0.0; - - return f000 * (1 - wx) * (1 - wy) * (1 - wz) + - f100 * wx * (1 - wy) * (1 - wz) + - f010 * (1 - wx) * wy * (1 - wz) + - f110 * wx * wy * (1 - wz) + - f001 * (1 - wx) * (1 - wy) * wz + - f101 * wx * (1 - wy) * wz + - f011 * (1 - wx) * wy * wz + - f111 * wx * wy * wz; -} - -/* Shared stencil: the 8 flat corner offsets + trilinear weights for a point. - * All six property tables share grids, so the index/weight work is done once. */ -typedef struct { - size_t off[8]; - double w[8]; - size_t base; /* f000 offset, used for the NaN-corner fallback */ -} ed_stencil; - -static ed_stencil cea_stencil(const EdCeaTables *t, double Pc, double MR, double eps) { - const size_t n_mr = t->n_mr, n_eps = t->n_eps; - size_t i_pc = clamp_idx(searchsorted_left(t->Pc_grid, t->n_pc, Pc), t->n_pc); - size_t i_mr = clamp_idx(searchsorted_left(t->MR_grid, t->n_mr, MR), t->n_mr); - size_t i_eps = clamp_idx(searchsorted_left(t->eps_grid, t->n_eps, eps), t->n_eps); - - const double Pc0 = t->Pc_grid[i_pc - 1], Pc1 = t->Pc_grid[i_pc]; - const double MR0 = t->MR_grid[i_mr - 1], MR1 = t->MR_grid[i_mr]; - const double e0 = t->eps_grid[i_eps - 1], e1 = t->eps_grid[i_eps]; - const double wx = (Pc1 != Pc0) ? (Pc - Pc0) / (Pc1 - Pc0) : 0.0; - const double wy = (MR1 != MR0) ? (MR - MR0) / (MR1 - MR0) : 0.0; - const double wz = (e1 != e0 ) ? (eps - e0) / (e1 - e0 ) : 0.0; - -#define OFF(i, j, k) ((((size_t)(i) * n_mr + (size_t)(j)) * n_eps) + (size_t)(k)) - ed_stencil s; - s.base = OFF(i_pc - 1, i_mr - 1, i_eps - 1); - s.off[0] = OFF(i_pc - 1, i_mr - 1, i_eps - 1); s.w[0] = (1-wx)*(1-wy)*(1-wz); - s.off[1] = OFF(i_pc, i_mr - 1, i_eps - 1); s.w[1] = wx *(1-wy)*(1-wz); - s.off[2] = OFF(i_pc - 1, i_mr, i_eps - 1); s.w[2] = (1-wx)*wy *(1-wz); - s.off[3] = OFF(i_pc, i_mr, i_eps - 1); s.w[3] = wx *wy *(1-wz); - s.off[4] = OFF(i_pc - 1, i_mr - 1, i_eps ); s.w[4] = (1-wx)*(1-wy)*wz; - s.off[5] = OFF(i_pc, i_mr - 1, i_eps ); s.w[5] = wx *(1-wy)*wz; - s.off[6] = OFF(i_pc - 1, i_mr, i_eps ); s.w[6] = (1-wx)*wy *wz; - s.off[7] = OFF(i_pc, i_mr, i_eps ); s.w[7] = wx *wy *wz; -#undef OFF - return s; -} - -static double cea_apply(const ed_stencil *s, const double *table) { - const double f0 = table[s->off[0]], f1 = table[s->off[1]], - f2 = table[s->off[2]], f3 = table[s->off[3]], - f4 = table[s->off[4]], f5 = table[s->off[5]], - f6 = table[s->off[6]], f7 = table[s->off[7]]; - if (isnan(f0) || isnan(f1) || isnan(f2) || isnan(f3) || - isnan(f4) || isnan(f5) || isnan(f6) || isnan(f7)) { - return table[s->base]; /* matches Python f000 fallback */ - } - return f0*s->w[0] + f1*s->w[1] + f2*s->w[2] + f3*s->w[3] + - f4*s->w[4] + f5*s->w[5] + f6*s->w[6] + f7*s->w[7]; -} - -ed_status_t ed_cea_eval(const EdCeaTables *t, - double MR, double Pc, double Pa, double eps, - EdCeaResult *out) { - (void)Pa; /* accepted for API parity; unused in 3D lookup, as in Python */ - if (!t || !out) return ED_ERR_INVALID_ARG; - if (!isfinite(MR) || !isfinite(Pc) || !isfinite(eps)) return ED_ERR_NONFINITE; - - const double Pc_c = ed_clip(Pc, t->Pc_min, t->Pc_max); - const double MR_c = ed_clip(MR, t->MR_min, t->MR_max); - const double eps_c = ed_clip(eps, t->eps_min, t->eps_max); - - const ed_stencil s = cea_stencil(t, Pc_c, MR_c, eps_c); - out->cstar_ideal = cea_apply(&s, t->cstar_table); - out->Cf_ideal = cea_apply(&s, t->Cf_table); - out->Tc = cea_apply(&s, t->Tc_table); - out->gamma = cea_apply(&s, t->gamma_table); - out->R = cea_apply(&s, t->R_table); - out->M = cea_apply(&s, t->M_table); - /* NaN (not an error) when the table set predates Cf_vac: chamber-only - * consumers keep working; delivered-thrust consumers must check. */ - out->Cf_vac = t->Cf_vac_table ? cea_apply(&s, t->Cf_vac_table) : (double)NAN; - return ED_OK; -} - -/* ---- .bin loader (offline/setup only) --------------------------------------- - * Layout (little-endian, matches export_cea_tables.py / native_injector._ensure_cea): - * char magic[4] = "EDCA" - * int32 version = 1 | 2 - * int32 n_pc, n_mr, n_eps - * double Pc_grid[n_pc], MR_grid[n_mr], eps_grid[n_eps] - * double cstar, Cf, Tc, gamma, R, M (each n_pc*n_mr*n_eps, C-order) - * double Cf_vac (version >= 2 only) - * Version 1 (pre-Cf_vac) still loads: Cf_vac_table is left NULL and - * ed_cea_eval reports Cf_vac = NaN for it. - */ -ed_status_t ed_cea_load(const char *path, EdCeaTables *out) { - if (!path || !out) return ED_ERR_INVALID_ARG; - FILE *f = fopen(path, "rb"); - if (!f) return ED_ERR_IO; - - ed_status_t rc = ED_ERR_IO; - char magic[4]; - int32_t version = 0, n_pc = 0, n_mr = 0, n_eps = 0; - if (fread(magic, 1, 4, f) != 4 || memcmp(magic, "EDCA", 4) != 0) goto done; - if (fread(&version, sizeof version, 1, f) != 1 || - (version != 1 && version != 2)) goto done; - if (fread(&n_pc, sizeof n_pc, 1, f) != 1) goto done; - if (fread(&n_mr, sizeof n_mr, 1, f) != 1) goto done; - if (fread(&n_eps, sizeof n_eps, 1, f) != 1) goto done; - if (n_pc <= 0 || n_mr <= 0 || n_eps <= 0) goto done; - - const int n_tables = (version >= 2) ? 7 : 6; - const size_t ng = (size_t)n_pc + (size_t)n_mr + (size_t)n_eps; - const size_t nt = (size_t)n_pc * (size_t)n_mr * (size_t)n_eps; - const size_t total = ng + (size_t)n_tables * nt; - double *buf = (double *)malloc(total * sizeof(double)); - if (!buf) { rc = ED_ERR_IO; goto done; } - if (fread(buf, sizeof(double), total, f) != total) { free(buf); goto done; } - - memset(out, 0, sizeof(*out)); - out->_owned = buf; - out->n_pc = (size_t)n_pc; out->n_mr = (size_t)n_mr; out->n_eps = (size_t)n_eps; - double *p = buf; - out->Pc_grid = p; p += n_pc; - out->MR_grid = p; p += n_mr; - out->eps_grid = p; p += n_eps; - out->cstar_table = p; p += nt; - out->Cf_table = p; p += nt; - out->Tc_table = p; p += nt; - out->gamma_table = p; p += nt; - out->R_table = p; p += nt; - out->M_table = p; p += nt; - out->Cf_vac_table = (version >= 2) ? p : NULL; - - out->Pc_min = out->Pc_grid[0]; out->Pc_max = out->Pc_grid[n_pc - 1]; - out->MR_min = out->MR_grid[0]; out->MR_max = out->MR_grid[n_mr - 1]; - out->eps_min = out->eps_grid[0]; out->eps_max = out->eps_grid[n_eps - 1]; - rc = ED_OK; - -done: - fclose(f); - return rc; -} - -void ed_cea_free(EdCeaTables *t) { - if (t && t->_owned) { - free(t->_owned); - t->_owned = NULL; - } -} diff --git a/EngineDesign/engine/native/src/ed_chamber.c b/EngineDesign/engine/native/src/ed_chamber.c deleted file mode 100644 index 98b052e76..000000000 --- a/EngineDesign/engine/native/src/ed_chamber.c +++ /dev/null @@ -1,157 +0,0 @@ -/* ed_chamber.c - Module 1: chamber-pressure solve. - * - * Faithful port of ChamberSolver.residual()/solve() (engine/core/chamber_solver.py). - * The residual composes the native injector solve (closure.flows), CEA eval, - * eta_cstar (combustion_physics), and the ablative cooling_eff, then balances - * supply (injector) against demand (Pc*At/cstar_actual). Brent replaces brentq. - * Allocation-free in the residual loop. - */ -#include "ed_chamber.h" -#include "ed_injector.h" -#include "ed_combustion.h" -#include "ed_cooling.h" -#include "ed_root_find.h" - -#include -#include - -typedef struct { - const EdEngineState *st; - const EdCeaTables *cea; - double P_O, P_F; - /* last-evaluated diagnostics (filled on the final residual call) */ - EdChamberDiagnostics *diag; - int fill; -} ed_chamber_ctx; - -static double infer_Lstar(const EdGeometry *g) { - if (g->Lstar > 0) return g->Lstar; - if (g->A_throat > 0) return g->volume / g->A_throat; - return 0.0; -} - -/* residual = mdot_supply - mdot_demand; NaN on any invalid sub-result (mirrors - * the Python residual returning np.nan, which the solver treats as out-of-domain). */ -static double chamber_residual(double Pc, void *vctx) { - ed_chamber_ctx *c = (ed_chamber_ctx *)vctx; - const EdEngineState *st = c->st; - const EdGeometry *g = &st->geom; - - if (!(isfinite(Pc) && Pc > 0)) return NAN; - - EdInjectorResult inj; - if (ed_injector_solve(st, c->P_O, c->P_F, Pc, &inj) != ED_OK) return NAN; - const double mdot_O = inj.mdot_O, mdot_F = inj.mdot_F; - if (!(isfinite(mdot_O) && isfinite(mdot_F)) || mdot_F <= 0.0) return NAN; - const double mdot_supply = mdot_O + mdot_F; - const double MR = mdot_O / mdot_F; - - EdCeaResult cea; - if (ed_cea_eval(c->cea, MR, Pc, ED_P_SEA_LEVEL, g->expansion_ratio, &cea) != ED_OK) return NAN; - if (!(cea.cstar_ideal > 0 && isfinite(cea.cstar_ideal))) return NAN; - - const double Lstar = infer_Lstar(g); - 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; - if (st->comb.R_opt > 0.0) { - R_opt = st->comb.R_opt; - } else { - const double sO = sin(st->injector.imp_O.impingement_angle * ED_PI / 180.0); - const double sF = sin(st->injector.imp_F.impingement_angle * ED_PI / 180.0); - R_opt = (sO > 0.0 && sF > 0.0) ? sqrt(sF / sO) : 1.0; - } - - EdEtaResult eta; - if (ed_combustion_efficiency_advanced( - &st->comb, Lstar, Pc, cea.Tc, cea.cstar_ideal, cea.gamma, cea.R, MR, - Ac, g->A_throat, Dinj, mdot_supply, inj.u_F, inj.u_O, - inj.D32_O, inj.D32_F, inj.momentum_ratio_R, R_opt, - 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; - if (!(isfinite(eta_final) && eta_final > 0.0 && eta_final <= 1.0)) return NAN; - - const double cstar_actual = eta_final * cea.cstar_ideal; - const double mdot_demand = Pc * g->A_throat / cstar_actual; - const double residual = mdot_supply - mdot_demand; - if (!isfinite(residual)) return NAN; - - if (c->fill && c->diag) { - EdChamberDiagnostics *d = c->diag; - d->Pc = Pc; d->mdot_O = mdot_O; d->mdot_F = mdot_F; d->mdot_total = mdot_supply; - d->MR = MR; d->cstar_ideal = cea.cstar_ideal; d->cstar_actual = cstar_actual; - d->eta_cstar = eta_final; d->cooling_efficiency = cool.cooling_eff; - d->Tc = cool.effective_Tc; d->Tc_ideal = cea.Tc; d->gamma = cea.gamma; d->R = cea.R; d->M = cea.M; - d->momentum_ratio_R = inj.momentum_ratio_R; - d->delta_P_injector_O = inj.delta_p_injector_O; - d->delta_P_injector_F = inj.delta_p_injector_F; - d->A_geom_O = inj.A_geom_O; d->A_geom_F = inj.A_geom_F; - d->SMD = ed_max(inj.D32_O, inj.D32_F); - d->Cd_O = inj.Cd_O; d->Cd_F = inj.Cd_F; d->u_O = inj.u_O; d->u_F = inj.u_F; - } - return residual; -} - -ed_status_t ed_chamber_solve(const EdEngineState *state, - const EdCeaTables *cea, - double P_tank_O_Pa, - double P_tank_F_Pa, - double Pc_guess_Pa, - EdWorkspace *ws, - EdChamberDiagnostics *out) { - (void)Pc_guess_Pa; (void)ws; - if (!state || !cea || !out) return ED_ERR_INVALID_ARG; - memset(out, 0, sizeof(*out)); - - /* Bounds: ChamberSolver.solve(). 15% feed-loss margin below min tank pressure. */ - double Pc_min = 100000.0; - double Pc_max = ed_min(P_tank_O_Pa, P_tank_F_Pa) * (1.0 - 0.15); - Pc_min = ed_max(Pc_min, state->solver.Pc_min_bound); - Pc_max = ed_min(Pc_max, state->solver.Pc_max_bound); - if (Pc_max <= Pc_min) return ED_ERR_NO_BRACKET; - - ed_chamber_ctx ctx = { state, cea, P_tank_O_Pa, P_tank_F_Pa, NULL, 0 }; - - const double r_min = chamber_residual(Pc_min, &ctx); - const double r_max = chamber_residual(Pc_max, &ctx); - if (!isfinite(r_min) || !isfinite(r_max)) return ED_ERR_NONFINITE; - - double Pc; - int iters = 0; - if (ed_sign(r_min) == ed_sign(r_max)) { - /* Python: supply>demand everywhere but within 0.1 kg/s of balance -> accept Pc_max. */ - if (r_min > 0 && r_max > 0 && r_max < 0.1) { - Pc = Pc_max; - } else { - return ED_ERR_NO_BRACKET; /* infeasible point; Python raises -> caller falls back */ - } - } else { - ed_root_opts opt = { - .xtol = state->solver.tolerance, - .rtol = state->solver.tolerance * 1e-3, - .max_iter = state->solver.max_iterations > 0 ? state->solver.max_iterations : 100, - }; - ed_root_result rr; - ed_status_t rc = ed_brentq(chamber_residual, &ctx, Pc_min, Pc_max, &opt, &rr); - if (rc != ED_OK) return rc; - Pc = rr.root; - iters = rr.iterations; - } - - /* Final pass to populate diagnostics at the solved Pc. */ - ctx.diag = out; ctx.fill = 1; - const double rfinal = chamber_residual(Pc, &ctx); - if (!isfinite(rfinal)) return ED_ERR_NONFINITE; - out->converged = 1; - out->residual_iters = iters; - return ED_OK; -} diff --git a/EngineDesign/engine/native/src/ed_combustion_eff.c b/EngineDesign/engine/native/src/ed_combustion_eff.c deleted file mode 100644 index 9356c4d28..000000000 --- a/EngineDesign/engine/native/src/ed_combustion_eff.c +++ /dev/null @@ -1,9 +0,0 @@ -/* ed_combustion_eff.c - STAGE: deferred port. See engine/native/README.md "Staged plan". - * Placeholder translation unit reserved so the source layout and build graph - * match the target architecture. The corresponding Python physics has been read - * and mapped; implementation lands in a follow-up stage with golden parity tests. - */ -#include "ed_types.h" - -/* Internal version tag keeps this a non-empty, ISO-C-valid translation unit. */ -const char *ed_combustion_eff_stage(void) { return "deferred"; } diff --git a/EngineDesign/engine/native/src/ed_combustion_physics.c b/EngineDesign/engine/native/src/ed_combustion_physics.c deleted file mode 100644 index 8f7f1d27a..000000000 --- a/EngineDesign/engine/native/src/ed_combustion_physics.c +++ /dev/null @@ -1,192 +0,0 @@ -/* ed_combustion_physics.c - faithful port of engine/pipeline/combustion_physics.py - * (the eta_c* advanced model: L*, kinetics, mixing, turbulence). - * - * Parity is to machine precision: every expression, default, and clamp matches the - * Python source. Diagnostics-only branches (Spalding, debug logging, warnings) are - * omitted since they do not affect the returned values. - */ -#include "ed_combustion.h" -#include "ed_phys_const.h" - -#include - -/* compute_combustion_state: shared geometric/velocity state. */ -typedef struct { - double rho_ch, U_bulk, G_throat, tau_res, L_mix, U_rms, dU, U_mix; - int ok; -} ed_comb_state; - -static ed_comb_state combustion_state(double Pc, double Tc, double R, double Ac, - double At, double Lstar, double m_dot_total, - double Dinj, double u_fuel, double u_lox) { - ed_comb_state s; s.ok = 0; - if (R <= 0 || Tc <= 0 || Ac <= 0 || At <= 0 || Dinj <= 0 || Lstar <= 0 || m_dot_total <= 0) - return s; - s.rho_ch = Pc / (R * Tc); - s.U_bulk = m_dot_total / (s.rho_ch * Ac); - s.G_throat = m_dot_total / At; - s.tau_res = (Lstar * s.rho_ch) / s.G_throat; - s.L_mix = ED_CS_C_L * Dinj; - const double uf = u_fuel, uo = u_lox; - s.U_rms = sqrt(0.5 * (uf * uf + uo * uo)); - if (!isfinite(s.U_rms) || s.U_rms < 0 || s.U_rms > ED_CS_U_RMS_CAP) return s; - s.dU = fabs(uf - uo); - s.U_mix = sqrt(s.dU * s.dU + ED_CS_C_U * s.U_rms * s.U_rms); - if (!isfinite(s.U_mix) || s.U_mix <= 0) return s; - s.ok = 1; - return s; -} - -/* calculate_gasification_efficiency -> eta_vap. */ -static double gasification_eta(double Tc, double Pc, double tau_res, double SMD, - double L_eff, double cp_g, double rho_g, - double U_slip, double T_star_cap) { - const double rho_l = ED_GAS_RHO_L_DEFAULT, cp_l = ED_GAS_CP_L_DEFAULT; - const double T_inj = ED_GAS_T_INJ_DEFAULT, mu_g = ED_GAS_MU_DEFAULT, Pr = ED_PRANDTL_DEFAULT; - - const double D = ed_max(SMD, ED_D_MIN_GASIFICATION); - const double D_sq = D * D; - - const double dT_safe = ed_max(200.0, 0.10 * Tc); - const double T_star_upper = ed_min(T_star_cap, Tc - dT_safe); - const double T_star_lower = T_inj + 50.0; - const double T_star = ed_clip(T_star_upper, T_star_lower, Tc - dT_safe); - - const double k_g = mu_g * cp_g / Pr; - const double D_m = ED_D_M_REF * pow(Tc / ED_D_M_T_REF, 1.75) * (ED_D_M_P_REF / ed_max(Pc, 1e3)); - - double U_slip_capped = ed_min(fabs(U_slip), ED_U_SLIP_CAP); - U_slip_capped = ed_max(U_slip_capped, 0.1); - - const double Re = rho_g * U_slip_capped * D / ed_max(mu_g, 1e-10); - const double Sc = mu_g / (rho_g * ed_max(D_m, 1e-12)); - const double Nu = 2.0 + 0.6 * sqrt(ed_max(Re, 0.0)) * pow(Pr, 1.0 / 3.0); - const double Sh = 2.0 + 0.6 * sqrt(ed_max(Re, 0.0)) * pow(Sc, 1.0 / 3.0); - - const double dT_initial = Tc - T_inj; - const double dT_final = Tc - T_star; - double tau_heat; - if (dT_final <= 0 || dT_initial <= dT_final) { - tau_heat = 1e-9; - } else { - tau_heat = (rho_l * cp_l * D_sq) / (6.0 * Nu * k_g) * log(dT_initial / dT_final); - } - - const double energy_available = cp_g * (Tc - T_star); - const double energy_required = energy_available + L_eff; - const double Phi = ed_clip(energy_available / ed_max(energy_required, 1e-6), 1e-6, 1.0); - - const double denom = 6.0 * rho_g * D_m * Sh * Phi; - const double tau_gasify = (denom <= 0) ? INFINITY : (rho_l * D_sq) / denom; - - const double tau_heat_safe = ed_max(tau_heat, 1e-12); - const double tau_gasify_safe = ed_max(tau_gasify, 1e-12); - const double tau_vap = 1.0 / (1.0 / tau_heat_safe + 1.0 / tau_gasify_safe); - - if (tau_vap <= 0 || !isfinite(tau_vap)) return 1.0; - return 1.0 - exp(-tau_res / tau_vap); -} - -/* calculate_reaction_time_scale */ -static double reaction_time_scale(double Pc, double Tc, double MR, - const EdCombustionEff *cfg) { - double Ea_norm = (MR < 1.5) ? 12.0 : (MR > 3.0 ? 8.0 : 10.0); - const double pressure_factor = pow(cfg->tau_ref_P / ed_max(Pc, 1e5), cfg->n_pressure); - double Tc_eff = Tc; - if (cfg->has_tau_Tc_floor && isfinite(cfg->tau_Tc_floor) && cfg->tau_Tc_floor > 0) - Tc_eff = ed_max(Tc_eff, cfg->tau_Tc_floor); - double exp_arg = Ea_norm * (cfg->tau_ref_T / ed_max(Tc_eff, 1000.0) - 1.0); - exp_arg = ed_clip(exp_arg, -20.0, 20.0); - return cfg->tau_ref * pressure_factor * exp(exp_arg); -} - -/* calculate_rupe_mixing_efficiency: eta_mix = Em_peak * exp(-(ln(R/R_opt))^2/(2 sigma^2)). - * Momentum-ratio mixing efficiency (Rupe/SP-8089); replaces the k-e near-field model - * and the retired eta_turbulence step. Returns <0 to signal an invalid momentum ratio. - * - * INTENTIONAL C-vs-Python divergence on invalid R: Python falls back to - * eta_mix = Em_peak (its lenient branch exists for pintle/coaxial, which have no - * impinging momentum ratio), whereas here an invalid R propagates as - * ED_ERR_INVALID_ARG -> NaN residual -> the native solve fails and that call - * falls back to Python. This is deliberate strictness, not a physics - * disagreement: the C path only runs for impinging configs, whose injector solve - * always produces a valid R — an invalid R here means something upstream is - * broken, and refusing (worst case: a slower Python answer) beats guessing. */ -static double rupe_mixing_eta(double momentum_ratio_R, double R_opt, - double Em_peak, double sigma) { - if (!(momentum_ratio_R > 0.0 && isfinite(momentum_ratio_R))) return -1.0; - const double Ro = (R_opt > 0.0 && isfinite(R_opt)) ? R_opt : 1.0; - const double sig = (sigma > 0.0 && isfinite(sigma)) ? sigma : 1.5; - const double z = log(momentum_ratio_R / Ro); - return Em_peak * exp(-(z * z) / (2.0 * sig * sig)); -} - -/* _mass_flux_weighted_d32_um (returns metres). do/df <=0 == absent. */ -static double smd_weighted(double do_m, double df_m, double MR) { - const int o_ok = (do_m > 0 && isfinite(do_m)); - const int f_ok = (df_m > 0 && isfinite(df_m)); - if (o_ok && f_ok) { - if (!(isfinite(MR) && MR > 0)) return 0.5 * (do_m + df_m); - const double w_o = MR / (1.0 + MR), w_f = 1.0 / (1.0 + MR); - return w_o * do_m + w_f * df_m; - } - if (o_ok) return do_m; - if (f_ok) return df_m; - return -1.0; /* signals "no valid SMD" -> caller errors */ -} - -ed_status_t ed_combustion_efficiency_advanced( - const EdCombustionEff *cfg, - double Lstar, double Pc, double Tc, double cstar_ideal, - double gamma, double R, double MR, - double Ac, double At, double Dinj, double m_dot_total, - double u_fuel, double u_lox, - double D32_O, double D32_F, - double momentum_ratio_R, double R_opt, - double fuel_latent_heat, - double fuel_T_star_cap_K, - EdEtaResult *out) { - (void)cstar_ideal; - if (!cfg || !out) return ED_ERR_INVALID_ARG; - - const ed_comb_state st = combustion_state(Pc, Tc, R, Ac, At, Lstar, m_dot_total, - Dinj, u_fuel, u_lox); - if (!st.ok) return ED_ERR_NONFINITE; - - const double cp_g = (gamma > 1.0) ? gamma * R / (gamma - 1.0) : ED_GAS_CP_G_DEFAULT; - - /* 1. eta_Lstar */ - double eta_Lstar; - if (cfg->model == ED_EFF_CONSTANT) { - eta_Lstar = 1.0 - cfg->C; - } else if (cfg->model == ED_EFF_LINEAR) { - eta_Lstar = ed_clip(1.0 - cfg->C * (1.0 - Lstar / 1.0), 0.0, 1.0); - } else { /* exponential */ - const double SMD = smd_weighted(D32_O, D32_F, MR); - if (SMD <= 0) return ED_ERR_INVALID_ARG; - const double U_slip = ed_max(st.U_bulk, st.U_mix); - eta_Lstar = gasification_eta(Tc, Pc, st.tau_res, SMD, fuel_latent_heat, - cp_g, st.rho_ch, U_slip, fuel_T_star_cap_K); - } - - /* 2. eta_kinetics (Da from geometric tau_res and chemical tau_chem) */ - const double tau_chem = reaction_time_scale(Pc, Tc, MR, cfg); - const double Da = (tau_chem <= 0) ? INFINITY : st.tau_res / tau_chem; - const double eta_kinetics = 1.0 - exp(-sqrt(Da)); - - /* 3. eta_mixing (Rupe momentum-ratio model; turbulence folded in, no separate term) */ - const double eta_mixing = rupe_mixing_eta(momentum_ratio_R, R_opt, - cfg->Em_peak, cfg->mixing_sigma); - if (eta_mixing < 0.0) return ED_ERR_INVALID_ARG; - - out->eta_Lstar = eta_Lstar; - out->eta_kinetics = eta_kinetics; - out->eta_mixing = eta_mixing; - out->eta_total = eta_Lstar * eta_kinetics * eta_mixing; - out->Da = Da; - out->tau_res = st.tau_res; - out->tau_chem = tau_chem; - - if (!isfinite(out->eta_total)) return ED_ERR_NONFINITE; - return ED_OK; -} diff --git a/EngineDesign/engine/native/src/ed_cooling.c b/EngineDesign/engine/native/src/ed_cooling.c deleted file mode 100644 index 1d6a53091..000000000 --- a/EngineDesign/engine/native/src/ed_cooling.c +++ /dev/null @@ -1,198 +0,0 @@ -/* ed_cooling.c - ablative cooling_eff, faithful port of the chamber cooling path. - * - * Chain: _get_chamber_geometry (wetted area) -> gas state + Huzel viscosity -> - * estimate_hot_wall_heat_flux -> compute_ablative_response -> effective_Tc -> - * _compute_cooling_efficiency. Matches Python expression-for-expression. - */ -#include "ed_cooling.h" -#include "ed_phys_const.h" - -#include -#include - -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; -} - -double ed_chamber_wetted_area(const EdGeometry *g) { - double diameter = g->chamber_diameter; - if (diameter <= 0) diameter = 0.08; /* matches Python final fallback */ - if (diameter < 1e-6) diameter = 1e-6; - const double area_cross = ED_PI * (diameter * 0.5) * (diameter * 0.5); - const double circumference = ED_PI * diameter; - - /* Frustum model when both sub-lengths are present (canonical), else cylinder. */ - if (g->length_cylindrical > 0 && g->length_contraction > 0) { - const double area_cyl = circumference * g->length_cylindrical; - const double r1 = diameter * 0.5; - const double A_throat = (g->A_throat > 0) ? g->A_throat : area_cross / 3.0; - const double r2 = sqrt(A_throat / ED_PI); - const double slant = sqrt((r1 - r2) * (r1 - r2) + g->length_contraction * g->length_contraction); - return area_cyl + ED_PI * (r1 + r2) * slant; - } - return circumference * g->length; -} - -/* 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, - double *q_total, double *q_conv, double *q_rad) { - const EdCooling *c = &s->cooling; - const double d = c->regen_chamber_inner_diameter; /* config.chamber_inner_diameter */ - const double A_cross = ED_PI * d * d / 4.0; - const double rho_g = ed_max(Pc / (R * ed_max(Tc, 1.0)), ED_MIN_DENS_KG_M3); - const double V_g = mdot_total / (rho_g * A_cross); - - 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); - 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; - - const double Taw = Tc * c->recovery_factor; - const double dT = ed_max(Taw - wall_T, 0.0); - *q_conv = h_g * dT; - double qr = c->radiation_emissivity_hot * c->radiation_view_factor * ED_STEFAN_BOLTZMANN - * (Tc * Tc * Tc * Tc - wall_T * wall_T * wall_T * wall_T); - *q_rad = ed_max(qr, 0.0); - *q_total = *q_conv + *q_rad; -} - -/* compute_ablative_response -> heat_removed (cooling_power). */ -static double ablative_heat_removed(const EdCooling *c, double surface_T, double area, - double ti, double q_conv, double q_rad, - double gas_mdot) { - if (!c->ablative_enabled || area <= 0) return 0.0; - - double turb = 1.0; - if (ti > 0 && c->ablative_turbulence_reference_intensity > 0) { - const double ratio = pow(ti / c->ablative_turbulence_reference_intensity, - c->ablative_turbulence_exponent); - turb = 1.0 + c->ablative_turbulence_sensitivity * ratio; - } - turb = ed_clip(turb, 1.0, c->ablative_turbulence_max_multiplier); - - const int below_pyro = surface_T < c->ablative_pyrolysis_temperature; - int use_physics = 0; - double convective_reduction = 1.0; - if (below_pyro) { - convective_reduction = 1.0; - } else if (c->ablative_use_physics_based_blowing && gas_mdot > 0) { - use_physics = 1; - } else { - convective_reduction = 1.0 - ed_clip(c->ablative_blowing_efficiency, 0.0, 1.0); - } - - const double T_sink = (c->ablative_ambient_temperature < c->ablative_radiative_sink_minimum_threshold) - ? c->ablative_radiative_sink_fallback_temperature - : c->ablative_ambient_temperature; - double radiative_relief = c->ablative_surface_emissivity * ED_STEFAN_BOLTZMANN - * (surface_T * surface_T * surface_T * surface_T - - T_sink * T_sink * T_sink * T_sink); - radiative_relief = ed_max(radiative_relief, 0.0); - - const double q_conv_incident = q_conv, q_rad_incident = q_rad; - - if (use_physics) { - const double q_conv_prov = q_conv_incident * turb; - const double q_total_prov = ed_max(q_conv_prov + q_rad_incident - radiative_relief, 0.0); - if (q_total_prov > 0) { - const double dT_pyro = ed_max(surface_T - c->ablative_pyrolysis_temperature, 0.0); - const double energy_per_mass = c->ablative_heat_of_ablation + c->ablative_specific_heat * dT_pyro; - if (energy_per_mass > 0) { - const double mass_flux_prov = q_total_prov / ed_max(energy_per_mass, ED_EPS_SMALL); - const double m_dot_pyro = mass_flux_prov * area; - const double B = m_dot_pyro / ed_max(gas_mdot, ED_EPS_SMALL); - const double blow = 1.0 / (1.0 + c->ablative_blowing_coefficient * B); - convective_reduction = ed_max(blow, c->ablative_blowing_min_reduction_factor); - } else { - convective_reduction = 1.0; - } - } else { - convective_reduction = 1.0; - } - } - - const double q_conv_eff = q_conv_incident * turb * convective_reduction; - const double effective_heat_flux = ed_max(q_conv_eff + q_rad_incident - radiative_relief, 0.0); - - if (below_pyro || effective_heat_flux <= 0) return 0.0; - const double dT_pyro = ed_max(surface_T - c->ablative_pyrolysis_temperature, 0.0); - const double energy_per_mass = c->ablative_heat_of_ablation + c->ablative_specific_heat * dT_pyro; - if (energy_per_mass <= 0) return 0.0; - return effective_heat_flux * area; /* cooling_power == heat_removed */ -} - -ed_status_t ed_cooling_evaluate(const EdEngineState *s, - double Pc, double mdot_O, double mdot_F, - double Tc, double gamma, double R, double M, - EdCoolingResult *out) { - if (!s || !out) return ED_ERR_INVALID_ARG; - memset(out, 0, sizeof(*out)); - out->cooling_eff = 1.0; - out->effective_Tc = Tc; - - const double mdot_total = mdot_O + mdot_F; - if (mdot_total <= 0) return ED_OK; - - const EdCooling *c = &s->cooling; - /* Film/regen/graphite contributions to cooling_eff aren't active in the - * canonical configs; refuse rather than silently mismatch if enabled. */ - if (c->film_enabled || c->regen_enabled) return ED_ERR_NOT_IMPLEMENTED; - if (!c->use_cooling_coupling || !c->ablative_enabled) return ED_OK; - - const EdGeometry *g = &s->geom; - double diameter = g->chamber_diameter; - if (diameter <= 0) diameter = 0.08; - if (diameter < 1e-6) diameter = 1e-6; - const double area_cross = ED_PI * (diameter * 0.5) * (diameter * 0.5); - const double rho_g = ed_max(Pc / (R * ed_max(Tc, 1.0)), 1e-6); - const double velocity_g = mdot_total / (rho_g * area_cross); - - const double mu_g = (M > 0 && Tc > 0) ? ed_gas_viscosity_huzel(Tc, M) : c->hot_gas_viscosity; - const double Re_g = rho_g * velocity_g * diameter / ed_max(mu_g, 1e-8); - - double ti = 0.05; - if (Re_g > 0) ti = ed_clip(0.16 * pow(Re_g, -0.125), 0.02, 0.25); - ti = ed_max(ti, ed_clip(c->gas_turbulence_intensity, 0.0, 0.5)); - - /* hot-wall flux uses Tc_ideal (film disabled => effective_Tc == Tc here) */ - double q_total, q_conv, q_rad; - hot_wall_flux(s, Pc, Tc, gamma, R, M, mdot_total, - c->ablative_surface_temperature_limit, &q_total, &q_conv, &q_rad); - - const double abl_area = ed_chamber_wetted_area(g) * ed_clip(c->ablative_coverage_fraction, 0.0, 1.0); - const double heat_removed = ablative_heat_removed(c, c->ablative_surface_temperature_limit, - abl_area, ti, q_conv, q_rad, mdot_total); - - /* effective_Tc reduction (feeds the cooling_eff denominator only) */ - double effective_Tc = Tc; - const double cp = gamma * R / ed_max(gamma - 1.0, ED_EPS_SMALL); - if (heat_removed > 0 && mdot_total > 0) { - const double delta_T = heat_removed / ed_max(mdot_total * cp, ED_EPS_SMALL); - effective_Tc = ed_max(effective_Tc - delta_T, 1.0); - } - - /* _compute_cooling_efficiency */ - double cooling_eff = 1.0; - if (heat_removed > 0) { - const double available = mdot_total * cp * ed_max(effective_Tc, 1.0); - if (available > 0) { - const double factor = 1.0 - heat_removed / available; - cooling_eff = ed_clip(factor, c->cooling_efficiency_floor, 1.0); - } - } - - out->cooling_eff = cooling_eff; - out->heat_removed = heat_removed; - out->effective_Tc = effective_Tc; - out->q_total = q_total; - out->wetted_area = abl_area; - return ED_OK; -} diff --git a/EngineDesign/engine/native/src/ed_discharge.c b/EngineDesign/engine/native/src/ed_discharge.c deleted file mode 100644 index 067db604c..000000000 --- a/EngineDesign/engine/native/src/ed_discharge.c +++ /dev/null @@ -1,52 +0,0 @@ -/* ed_discharge.c - Port of discharge.py (Cd models). */ -#include "ed_discharge.h" - -double ed_cd_inf_from_orifice_diameter(double d_hyd_m, const EdDischarge *c) { - if (!c) return NAN; - if (!c->use_geometry_cd) return c->Cd_inf; - if (!(isfinite(d_hyd_m)) || d_hyd_m <= 0.0) return c->Cd_inf; - - const double d_min = c->d_min_m; - const double d_ref = c->d_ref_m; - const double cd_base = c->Cd_inf; - const double exp_small = c->cd_small_hole_exponent; - const double log_gain = c->cd_large_hole_log_gain; - const double cd_max = c->cd_inf_max; - const double cd_floor = c->cd_inf_min_geom; - - const double d = ed_max(d_min, d_hyd_m); - if (d_ref <= 0.0) return ed_clip(cd_base, cd_floor, cd_max); - - const double ratio = d / d_ref; - double cd_geom; - if (ratio < 1.0) { - cd_geom = cd_base * pow(ratio, ed_max(0.0, exp_small)); - } else { - cd_geom = cd_base + log_gain * log(ratio); - } - return ed_clip(cd_geom, cd_floor, cd_max); -} - -double ed_cd_from_re(double Re, const EdDischarge *c, - double P_inlet, double T_inlet, double d_hyd_m) { - if (!c) return NAN; - const double cd_inf_eff = ed_cd_inf_from_orifice_diameter(d_hyd_m, c); - - if (Re <= 0.0) return c->Cd_min; - - double Cd = cd_inf_eff - c->a_Re / sqrt(ed_max(Re, 1e-6)); - - if (c->use_pressure_correction && isfinite(P_inlet) && c->P_ref > 0.0) { - Cd *= 1.0 + c->a_P * (P_inlet / c->P_ref - 1.0); - } - if (c->use_temperature_correction && isfinite(T_inlet) && c->T_ref > 0.0) { - Cd *= 1.0 + c->a_T * (T_inlet / c->T_ref - 1.0); - } - - return ed_clip(Cd, c->Cd_min, cd_inf_eff); -} - -double ed_reynolds(double rho, double u, double d_hyd, double mu) { - if (mu <= 0.0) return 1e6; - return (rho * u * d_hyd) / mu; -} diff --git a/EngineDesign/engine/native/src/ed_evaluate.c b/EngineDesign/engine/native/src/ed_evaluate.c deleted file mode 100644 index 3c57e38f3..000000000 --- a/EngineDesign/engine/native/src/ed_evaluate.c +++ /dev/null @@ -1,131 +0,0 @@ -/* ed_evaluate.c - Module 2 entry point (chamber solve -> nozzle -> Isp/thrust). - * - * Mirrors runner._evaluate_internal(): solve the chamber, evaluate CEA at the - * converged (MR, Pc, eps), then compute DELIVERED thrust on the same RPA basis - * as engine/core/nozzle.py::calculate_thrust: - * - * F(Pa) = zeta_n * Cf_vac * Pc * At - Pa * Ae - * - * zeta_c (eta_c*) rides in through the efficiency-reduced Pc from the chamber - * solve; zeta_n = nozzle_efficiency. Cf_vac is CEA's shifting-equilibrium - * vacuum thrust coefficient from the tables (see thrust_efficiency_bug_analysis - * .md — the old momentum-method F expanded from the ideal Tc and over-predicted - * by ~(1 - zeta_c*zeta_n)). The frozen nozzle (ed_nozzle_solve) still runs to - * provide the display-only exit state (v_exit, P/T_exit, throat conditions), - * matching what nozzle.py reports; its momentum-method F is NOT used. */ -#include "ed_evaluate.h" -#include "ed_chamber.h" -#include "ed_nozzle.h" -#include - -ed_status_t ed_evaluate(const EdEngineState *state, - const EdCeaTables *cea, - double P_tank_O_Pa, - double P_tank_F_Pa, - double P_ambient_Pa, - double Pc_guess_Pa, - EdWorkspace *ws, - EdEvaluateResult *out) { - if (!state || !out) return ED_ERR_INVALID_ARG; - memset(out, 0, sizeof(*out)); - - EdChamberDiagnostics ch; - ed_status_t rc = ed_chamber_solve(state, cea, P_tank_O_Pa, P_tank_F_Pa, - Pc_guess_Pa, ws, &ch); - if (rc != ED_OK) return rc; - - const EdGeometry *g = &state->geom; - const double eps = g->expansion_ratio; - - /* Nozzle thermo: CEA at the converged operating point (mirrors nozzle.py:229 - * cea_cache.eval(MR, Pc, Pa, eps)). Pa is ignored by the 3D lookup, so this - * reproduces the same grid point the chamber used. */ - EdCeaResult cr; - rc = ed_cea_eval(cea, ch.MR, ch.Pc, P_ambient_Pa, eps, &cr); - if (rc != ED_OK) return rc; - - EdNozzleInputs nin; - nin.Pc = ch.Pc; - nin.mdot_total = ch.mdot_total; - nin.A_throat = g->A_throat; - nin.A_exit = g->A_exit; - nin.eps = eps; - nin.Pa = P_ambient_Pa; - nin.nozzle_efficiency = g->nozzle_efficiency; - nin.Cf_ideal = cr.Cf_ideal; - nin.gamma = cr.gamma; - nin.R = cr.R; - nin.Tc = cr.Tc; - - EdNozzleResult nz; - rc = ed_nozzle_solve(&nin, &nz); - if (rc != ED_OK) return rc; - - /* Delivered thrust — RPA basis, identical to nozzle.py::calculate_thrust. - * Requires Cf_vac in the tables (version-2 .bin); a v1 table set yields - * NaN here and we refuse rather than fall back to the retired momentum - * method, so callers (native_injector) drop to the Python path. */ - if (!isfinite(cr.Cf_vac)) return ED_ERR_NONFINITE; - const double F_delivered = g->nozzle_efficiency * cr.Cf_vac * ch.Pc * g->A_throat - - P_ambient_Pa * g->A_exit; - if (!isfinite(F_delivered)) return ED_ERR_NONFINITE; - - /* Chamber / flow */ - out->Pc = ch.Pc; - out->mdot_O = ch.mdot_O; - out->mdot_F = ch.mdot_F; - out->mdot_total = ch.mdot_total; - out->MR = ch.MR; - /* Performance: delivered F/Isp/Cf (RPA); exit state from the frozen nozzle - * (display-only, mirrors nozzle.py's reported exit state). */ - out->F = F_delivered; - out->Isp = F_delivered / (ch.mdot_total * ED_G0); - out->v_exit = nz.v_exit; - out->P_exit = nz.P_exit; - out->P_throat = nz.P_throat; - out->T_exit = nz.T_exit; - out->T_throat = nz.T_throat; - /* Thermo (ideal chamber values; nozzle expansion uses the same CEA point) */ - out->Tc = ch.Tc_ideal; - out->gamma = ch.gamma; - out->R = ch.R; - out->cstar_actual = ch.cstar_actual; - out->cstar_ideal = ch.cstar_ideal; - out->eta_cstar = ch.eta_cstar; - out->Cf_actual = F_delivered / (ch.Pc * g->A_throat); - out->Cf_ideal = nz.Cf_ideal; - out->eps = eps; - out->A_throat = g->A_throat; - out->A_exit = g->A_exit; - out->Cd_O = ch.Cd_O; - out->Cd_F = ch.Cd_F; - /* Injector diagnostics for Layer-1 penalties */ - out->momentum_ratio_R = ch.momentum_ratio_R; - out->delta_P_injector_O = ch.delta_P_injector_O; - out->delta_P_injector_F = ch.delta_P_injector_F; - out->A_geom_O = ch.A_geom_O; - out->A_geom_F = ch.A_geom_F; - out->SMD = ch.SMD; - /* Cooling summary */ - out->cooling_efficiency = ch.cooling_efficiency; - out->Tc_effective = ch.Tc; - out->converged = (ch.converged && nz.converged) ? 1 : 0; - return ED_OK; -} - -ed_status_t ed_evaluate_batch(const EdEngineState *state, - const EdCeaTables *cea, - size_t n, - const double *P_tank_O_Pa, - const double *P_tank_F_Pa, - double P_ambient_Pa, - EdWorkspace *ws, - EdEvaluateResult *out) { - if (!P_tank_O_Pa || !P_tank_F_Pa || !out) return ED_ERR_INVALID_ARG; - ed_status_t last = ED_OK; - for (size_t i = 0; i < n; ++i) { - last = ed_evaluate(state, cea, P_tank_O_Pa[i], P_tank_F_Pa[i], - P_ambient_Pa, 0.0, ws, &out[i]); - } - return last; -} diff --git a/EngineDesign/engine/native/src/ed_feed_loss.c b/EngineDesign/engine/native/src/ed_feed_loss.c deleted file mode 100644 index 2e541d800..000000000 --- a/EngineDesign/engine/native/src/ed_feed_loss.c +++ /dev/null @@ -1,33 +0,0 @@ -/* ed_feed_loss.c - Port of feed_loss.delta_p_feed (engine/pipeline/feed_loss.py). - * - * Delta_p = K_eff(P) * (rho/2) * v^2, v = mdot/(rho*A), clamped to >= 0. - * Where Python raises ValueError (bad area/rho/mdot), this returns NAN so the - * caller can treat the design point as invalid (matches residual NaN handling). - */ -#include "ed_feed_loss.h" - -double ed_delta_p_feed(double mdot, double rho, const EdFeed *cfg, double P_tank) { - if (!cfg) return NAN; - - double K_eff; - switch (cfg->phi_type) { - case ED_PHI_NONE: K_eff = cfg->K0; break; - case ED_PHI_SQRTP: K_eff = cfg->K0 + cfg->K1 * sqrt(ed_max(0.0, P_tank)); break; - case ED_PHI_LOGP: K_eff = cfg->K0 + cfg->K1 * log(P_tank); break; - default: return NAN; - } - - double A; - if (cfg->d_inlet > 0.0) { - A = ED_PI * (cfg->d_inlet * 0.5) * (cfg->d_inlet * 0.5); - } else { - A = cfg->A_hydraulic; - } - - if (!(A > 0.0) || !(rho > 0.0) || mdot < 0.0) return NAN; - - const double v = mdot / (rho * A); - double dp = K_eff * (rho * 0.5) * v * v; - if (dp < 0.0) dp = 0.0; - return dp; -} diff --git a/EngineDesign/engine/native/src/ed_injector_coaxial.c b/EngineDesign/engine/native/src/ed_injector_coaxial.c deleted file mode 100644 index bc5e73390..000000000 --- a/EngineDesign/engine/native/src/ed_injector_coaxial.c +++ /dev/null @@ -1,9 +0,0 @@ -/* ed_injector_coaxial.c - STAGE: deferred port. See engine/native/README.md "Staged plan". - * Placeholder translation unit reserved so the source layout and build graph - * match the target architecture. The corresponding Python physics has been read - * and mapped; implementation lands in a follow-up stage with golden parity tests. - */ -#include "ed_types.h" - -/* Internal version tag keeps this a non-empty, ISO-C-valid translation unit. */ -const char *ed_injector_coaxial_stage(void) { return "deferred"; } diff --git a/EngineDesign/engine/native/src/ed_injector_impinging.c b/EngineDesign/engine/native/src/ed_injector_impinging.c deleted file mode 100644 index f868648fd..000000000 --- a/EngineDesign/engine/native/src/ed_injector_impinging.c +++ /dev/null @@ -1,253 +0,0 @@ -/* ed_injector_impinging.c - Port of ImpingingInjector.solve (impinging.py). - * - * Structure mirrors Python exactly: - * outer spray-constraint loop (closure.max_iterations), each pass runs a - * feed-loss <-> inlet-pressure <-> Bernoulli fixed point (under-relaxed, 0.35), - * whose inner Cd<->Bernoulli sub-iteration solves mdot = Cd(Re(mdot)) A sqrt(2 rho dP). - * Converged mdot is a fixed point, so matching the same tolerances reproduces the - * Python result to ~1e-9 (well within the 1e-3 parity target). - * - * Regen-coupled feed loss (config.regen_cooling.enabled) adds delta_p_regen_channels, - * not yet ported -> returns ED_ERR_NOT_IMPLEMENTED so callers fall back to Python. - */ -#include "ed_injector.h" -#include "ed_spray.h" -#include "ed_feed_loss.h" -#include "ed_discharge.h" -#include - -#define FP_TOL 1e-6 -#define FP_MAX_ITER 150 -#define CD_INNER_MAX 120 -#define CD_INNER_TOL 1e-12 -#define FP_RELAX 0.35 - -/* Inner: for a fixed inlet head dP_inj, iterate mdot = Cd(Re(mdot)) A sqrt(2 rho dP). */ -static double bern_mdot_with_cd(double mdot_seed, double dP_inj, double Pi_inj, - double rho, double area, double d_hyd, double mu, - const EdDischarge *disc, double Tin, double cd_cap, - double *cd_out) { - if (dP_inj <= 0.0) { - double cd0 = ed_min(ed_cd_from_re(0.0, disc, Pi_inj, Tin, d_hyd), cd_cap); - *cd_out = cd0; - return 0.0; - } - double cd_lo = ed_min(ed_cd_from_re(0.0, disc, Pi_inj, Tin, d_hyd), cd_cap); - double m = (mdot_seed > 1e-18) ? mdot_seed : cd_lo * area * sqrt(2.0 * rho * dP_inj); - double cd = cd_lo; - - for (int cin = 1; cin <= CD_INNER_MAX; ++cin) { - double m_was = m; - double u_loc = (area > 0.0) ? m / (rho * area) : 0.0; - double Re_loc = ed_reynolds(rho, u_loc, d_hyd, mu); - cd = ed_min(ed_cd_from_re(Re_loc, disc, Pi_inj, Tin, d_hyd), cd_cap); - m = cd * area * sqrt(2.0 * rho * dP_inj); - double inn_rel = fabs(m - m_was) / ed_max(fabs(m_was), 1e-18); - if (inn_rel < CD_INNER_TOL) break; - } - *cd_out = cd; - return m; -} - -ed_status_t ed_injector_impinging_solve(const EdEngineState *st, - double P_tank_O, double P_tank_F, double Pc, - EdInjectorResult *out) { - if (!st || !out) return ED_ERR_INVALID_ARG; - if (st->cooling.regen_enabled) return ED_ERR_NOT_IMPLEMENTED; - - memset(out, 0, sizeof(*out)); - const EdInjector *inj = &st->injector; - const EdDischarge *dO = &st->discharge_O, *dF = &st->discharge_F; - const EdFeed *fO = &st->feed_O, *fF = &st->feed_F; - const EdSpray *sp = &st->spray; - - const double rho_O = st->fluid_O.density, mu_O = st->fluid_O.viscosity, sig_O = st->fluid_O.surface_tension; - const double rho_F = st->fluid_F.density, mu_F = st->fluid_F.viscosity, sig_F = st->fluid_F.surface_tension; - const double T_O = st->fluid_O.temperature, T_F = st->fluid_F.temperature; - - const double djo = inj->imp_O.d_jet, djf = inj->imp_F.d_jet; - const int nO = inj->imp_O.n_elements, nF = inj->imp_F.n_elements; - const double A_O = nO * ED_PI * (djo * 0.5) * (djo * 0.5); - const double A_F = nF * ED_PI * (djf * 0.5) * (djf * 0.5); - - const int max_iter = st->solver.closure_max_iterations; - const double Cd_reduction = st->solver.closure_Cd_reduction_factor; - double Cd_O_eff = ed_cd_inf_from_orifice_diameter(djo, dO); - double Cd_F_eff = ed_cd_inf_from_orifice_diameter(djf, dF); - - double imp_sep = ed_clip((double)inj->imp_O.impingement_angle + inj->imp_F.impingement_angle, 1.0, 179.0); - double imp_angle = imp_sep * ED_PI / 180.0; - - double mdot_O = 0.1, mdot_F = 0.1; - double Cd_O = 0.0, Cd_F = 0.0; - double Pi_O = P_tank_O, Pi_F = P_tank_F; - double dpf_O = 0.0, dpf_F = 0.0, dpi_O = 0.0, dpi_F = 0.0; - int fp_last = 0; - double We_O = 0, We_F = 0, D32_O = 0, D32_F = 0, J = 0, TMR = 0, theta = 0, x_star = 0, u_rel = 0; - double ti_mix = 0.0; - int constraints_ok = 0, iters_done = 0; - - for (int iteration = 0; iteration < max_iter; ++iteration) { - iters_done = iteration + 1; - /* ---- feed-orifice fixed point ---- */ - double mo = mdot_O, mf = mdot_F; - Pi_O = P_tank_O; Pi_F = P_tank_F; - dpi_O = dpi_F = dpf_O = dpf_F = 0.0; - Cd_O = Cd_F = 0.0; - int fp_it = 0; - - for (fp_it = 1; fp_it <= FP_MAX_ITER; ++fp_it) { - double mo_prev = mo, mf_prev = mf; - - dpf_O = ed_delta_p_feed(mo, rho_O, fO, P_tank_O); - dpf_F = ed_delta_p_feed(mf, rho_F, fF, P_tank_F); - Pi_O = P_tank_O - dpf_O; Pi_F = P_tank_F - dpf_F; - dpi_O = ed_max(0.0, Pi_O - Pc); dpi_F = ed_max(0.0, Pi_F - Pc); - - double mo_new, mf_new, cdo, cdf; - if (Pi_O < Pc) { mo_new = 0.0; cdo = ed_min(ed_cd_from_re(0.0, dO, Pi_O, T_O, djo), Cd_O_eff); } - else mo_new = bern_mdot_with_cd(mo, dpi_O, Pi_O, rho_O, A_O, djo, mu_O, dO, T_O, Cd_O_eff, &cdo); - if (Pi_F < Pc) { mf_new = 0.0; cdf = ed_min(ed_cd_from_re(0.0, dF, Pi_F, T_F, djf), Cd_F_eff); } - else mf_new = bern_mdot_with_cd(mf, dpi_F, Pi_F, rho_F, A_F, djf, mu_F, dF, T_F, Cd_F_eff, &cdf); - - double w = FP_RELAX; - mo = mo_prev + w * (mo_new - mo_prev); - mf = mf_prev + w * (mf_new - mf_prev); - - /* recompute consistent heads at the relaxed iterate */ - dpf_O = ed_delta_p_feed(mo, rho_O, fO, P_tank_O); - dpf_F = ed_delta_p_feed(mf, rho_F, fF, P_tank_F); - Pi_O = P_tank_O - dpf_O; Pi_F = P_tank_F - dpf_F; - dpi_O = ed_max(0.0, Pi_O - Pc); dpi_F = ed_max(0.0, Pi_F - Pc); - - if (Pi_O < Pc) { - Cd_O = ed_min(ed_cd_from_re(0.0, dO, Pi_O, T_O, djo), Cd_O_eff); - } else { - double u_o2 = (A_O > 0.0) ? mo / (rho_O * A_O) : 0.0; - double Re_o2 = ed_reynolds(rho_O, u_o2, djo, mu_O); - Cd_O = ed_min(ed_cd_from_re(Re_o2, dO, Pi_O, T_O, djo), Cd_O_eff); - } - if (Pi_F < Pc) { - Cd_F = ed_min(ed_cd_from_re(0.0, dF, Pi_F, T_F, djf), Cd_F_eff); - } else { - double u_f2 = (A_F > 0.0) ? mf / (rho_F * A_F) : 0.0; - double Re_f2 = ed_reynolds(rho_F, u_f2, djf, mu_F); - Cd_F = ed_min(ed_cd_from_re(Re_f2, dF, Pi_F, T_F, djf), Cd_F_eff); - } - - double den_o = ed_max(ed_max(fabs(mo_prev), fabs(mo)), 1e-18); - double den_f = ed_max(ed_max(fabs(mf_prev), fabs(mf)), 1e-18); - double rel_o = fabs(mo - mo_prev) / den_o; - double rel_f = fabs(mf - mf_prev) / den_f; - if (rel_o < FP_TOL && rel_f < FP_TOL) break; - } - fp_last = fp_it > FP_MAX_ITER ? FP_MAX_ITER : fp_it; - mdot_O = mo; mdot_F = mf; - - /* ---- spray diagnostics ---- */ - double u_O = (A_O > 0.0) ? mdot_O / (rho_O * A_O) : 0.0; - double u_F = (A_F > 0.0) ? mdot_F / (rho_F * A_F) : 0.0; - u_rel = sqrt(u_O * u_O + u_F * u_F - 2.0 * u_O * u_F * cos(imp_angle)); - - /* turbulence mix intensity (for downstream models) */ - { - double Re_Ol = ed_reynolds(rho_O, u_O, djo, mu_O); - double Re_Fl = ed_reynolds(rho_F, u_F, djf, mu_F); - double ti_O = (Re_Ol > 0.0) ? 0.16 * pow(Re_Ol, -0.125) : 0.1; - double ti_F = (Re_Fl > 0.0) ? 0.16 * pow(Re_Fl, -0.125) : 0.1; - ti_O = ed_clip(ti_O, 0.02, 0.3); ti_F = ed_clip(ti_F, 0.02, 0.3); - double vtot = ed_max(u_O + u_F, 1e-6); - ti_mix = ed_clip((ti_O * u_O + ti_F * u_F) / vtot, 0.02, 0.35); - } - - double rho_gas = ed_max(Pc / (sp->chamber_gas_R * sp->chamber_gas_T), 1e-6); - J = ed_momentum_flux_ratio(rho_O, u_O, rho_F, u_F); - double MR = (mdot_F > 0.0) ? mdot_O / mdot_F : INFINITY; - TMR = ed_thrust_momentum_ratio(J, MR); - theta = (sp->spray_angle_model == ED_SPRAYANG_J) - ? ed_spray_angle_from_J(J, sp->spray_angle_k, sp->spray_angle_n) - : ed_spray_angle_from_TMR(TMR); - - double Oh_O = ed_ohnesorge_number(mu_O, rho_O, sig_O, djo); - double Oh_F = ed_ohnesorge_number(mu_F, rho_F, sig_F, djf); - - if (sp->smd_model == ED_SMD_INGEBO) { - We_O = ed_weber_number(rho_gas, u_rel, djo, sig_O); - We_F = ed_weber_number(rho_gas, u_rel, djf, sig_F); - D32_O = ed_smd_impinging_ingebo(djo, u_rel, rho_O, mu_O, sig_O, rho_gas, sp->smd_C_ingebo); - D32_F = ed_smd_impinging_ingebo(djf, u_rel, rho_F, mu_F, sig_F, rho_gas, sp->smd_C_ingebo); - } else { - double alpha = 0.35; - double ue_O = sqrt(ed_max(u_O, 0.0) * ed_max(u_O, 0.0) + (alpha * ed_max(u_rel, 0.0)) * (alpha * ed_max(u_rel, 0.0))); - double ue_F = sqrt(ed_max(u_F, 0.0) * ed_max(u_F, 0.0) + (alpha * ed_max(u_rel, 0.0)) * (alpha * ed_max(u_rel, 0.0))); - We_O = ed_weber_number(rho_O, ue_O, djo, sig_O); - We_F = ed_weber_number(rho_F, ue_F, djf, sig_F); - double weO = We_O, weF = We_F; - if (sp->smd_we_corr_max > 0.0 && isfinite(sp->smd_we_corr_max)) { - weO = ed_min(We_O, sp->smd_we_corr_max); - weF = ed_min(We_F, sp->smd_we_corr_max); - } - D32_O = ed_smd_lefebvre(djo, weO, Oh_O, sp->smd_C, sp->smd_m, sp->smd_p); - D32_F = ed_smd_lefebvre(djf, weF, Oh_F, sp->smd_C, sp->smd_m, sp->smd_p); - } - - double te_O = ed_tau_evap(D32_O, sp->evap_K); - double te_F = ed_tau_evap(D32_F, sp->evap_K); - x_star = ed_max(ed_xstar(u_rel, te_O), ed_xstar(u_rel, te_F)); - - constraints_ok = ed_check_spray_constraints(We_O, We_F, x_star, sp); - out->u_O = u_O; out->u_F = u_F; - if (constraints_ok) break; - - Cd_O_eff *= Cd_reduction; Cd_F_eff *= Cd_reduction; - Cd_O_eff = ed_max(Cd_O_eff, dO->Cd_min); - Cd_F_eff = ed_max(Cd_F_eff, dF->Cd_min); - } - - /* ---- final momentum-balance metric (bulk velocities through jets) ---- */ - double A_jet_O = ED_PI * (djo * 0.5) * (djo * 0.5); - double A_jet_F = ED_PI * (djf * 0.5) * (djf * 0.5); - int n_O = nO < 1 ? 1 : nO, n_F = nF < 1 ? 1 : nF; - double denom_O = rho_O * (double)n_O * A_jet_O; - double denom_F = rho_F * (double)n_F * A_jet_F; - double v_O_bulk = (denom_O > 0.0) ? mdot_O / denom_O : NAN; - double v_F_bulk = (denom_F > 0.0) ? mdot_F / denom_F : NAN; - double mom_R = NAN; - if (rho_O > 0.0 && rho_F > 0.0 && isfinite(v_O_bulk) && isfinite(v_F_bulk) && v_F_bulk != 0.0) { - double num = rho_O * v_O_bulk * v_O_bulk, den = rho_F * v_F_bulk * v_F_bulk; - if (den > 0.0 && num >= 0.0) mom_R = sqrt(num / den); - } - - out->mdot_O = mdot_O; out->mdot_F = mdot_F; - out->Cd_O = Cd_O; out->Cd_F = Cd_F; - out->A_geom_O = A_O; out->A_geom_F = A_F; - out->A_eff_O = Cd_O * A_O; out->A_eff_F = Cd_F * A_F; - out->v_O_bulk = v_O_bulk; out->v_F_bulk = v_F_bulk; - out->momentum_ratio_R = mom_R; - out->J = J; out->TMR = TMR; out->theta = theta; - out->We_O = We_O; out->We_F = We_F; - out->D32_O = D32_O; out->D32_F = D32_F; - out->x_star = x_star; out->u_rel = u_rel; - out->P_injector_O = Pi_O; out->P_injector_F = Pi_F; - out->delta_p_injector_O = dpi_O; out->delta_p_injector_F = dpi_F; - out->delta_p_feed_O = dpf_O; out->delta_p_feed_F = dpf_F; - out->turbulence_intensity_mix = ti_mix; - out->MR = (mdot_F > 0.0) ? mdot_O / mdot_F : 0.0; - out->constraints_satisfied = constraints_ok; - out->iterations = iters_done; - out->feed_orifice_coupling_iters = fp_last; - return ED_OK; -} - -ed_status_t ed_injector_solve(const EdEngineState *st, - double P_tank_O, double P_tank_F, double Pc, - EdInjectorResult *out) { - if (!st || !out) return ED_ERR_INVALID_ARG; - switch (st->injector.type) { - case ED_INJ_IMPINGING: - return ed_injector_impinging_solve(st, P_tank_O, P_tank_F, Pc, out); - default: - memset(out, 0, sizeof(*out)); - return ED_ERR_NOT_IMPLEMENTED; /* pintle/coaxial: later stage */ - } -} diff --git a/EngineDesign/engine/native/src/ed_injector_pintle.c b/EngineDesign/engine/native/src/ed_injector_pintle.c deleted file mode 100644 index 63480fa13..000000000 --- a/EngineDesign/engine/native/src/ed_injector_pintle.c +++ /dev/null @@ -1,9 +0,0 @@ -/* ed_injector_pintle.c - STAGE: deferred port. See engine/native/README.md "Staged plan". - * Placeholder translation unit reserved so the source layout and build graph - * match the target architecture. The corresponding Python physics has been read - * and mapped; implementation lands in a follow-up stage with golden parity tests. - */ -#include "ed_types.h" - -/* Internal version tag keeps this a non-empty, ISO-C-valid translation unit. */ -const char *ed_injector_pintle_stage(void) { return "deferred"; } diff --git a/EngineDesign/engine/native/src/ed_nozzle.c b/EngineDesign/engine/native/src/ed_nozzle.c deleted file mode 100644 index f313d39e7..000000000 --- a/EngineDesign/engine/native/src/ed_nozzle.c +++ /dev/null @@ -1,139 +0,0 @@ -/* ed_nozzle.c - Frozen-gas EXIT-STATE kernel: exit Mach (Newton on the area-Mach - * relation) + isentropic exit/throat state. Port of mach_solver.py - * (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. - */ -#include "ed_nozzle.h" - -#include - -/* A/A* = (1/M) * [ (2/(g+1)) * (1 + (g-1)/2 * M^2) ]^((g+1)/(2(g-1))) */ -static double area_mach_ratio(double M, double gamma) { - double term = (2.0 / (gamma + 1.0)) * (1.0 + (gamma - 1.0) / 2.0 * M * M); - double exponent = (gamma + 1.0) / (2.0 * (gamma - 1.0)); - return (1.0 / M) * pow(term, exponent); -} - -/* d(A/A*)/dM = (A/A*) * 2(M^2 - 1) / ( M (2 + (g-1) M^2) ) (stable simplified form) */ -static double area_mach_derivative(double M, double gamma, double A_Astar) { - double numerator = 2.0 * (M * M - 1.0); - double denominator = M * (2.0 + (gamma - 1.0) * M * M); - return A_Astar * (numerator / denominator); -} - -/* Supersonic initial guess — mirrors mach_solver.estimate_initial_mach. */ -static double estimate_initial_mach(double eps, double gamma) { - double M_guess; - if (eps > 10.0) { - double p = (gamma - 1.0) / 2.0; - double prefactor = pow((gamma + 1.0) / (gamma - 1.0), (gamma + 1.0) / 4.0); - M_guess = prefactor * pow(eps, p); - } else if (eps > 1.5) { - M_guess = 1.0 + sqrt(2.0 * (eps - 1.0) / (gamma + 1.0)); - } else { - M_guess = 1.0 + 0.5 * (eps - 1.0); - } - return ed_max(M_guess, 1.0 + 1e-6); -} - -/* Supersonic Newton solve of A/A*(M) = eps. Mirrors solve_mach_from_area_ratio. */ -static int solve_exit_mach(double eps, double gamma, double *M_out) { - const double tol = 1e-10; - const int max_iter = 50; - double M = estimate_initial_mach(eps, gamma); - double error = INFINITY; - - for (int i = 0; i < max_iter; ++i) { - double A_Astar = area_mach_ratio(M, gamma); - error = A_Astar - eps; - if (fabs(error) < tol) { - *M_out = M; - return 1; - } - double dA_dM = area_mach_derivative(M, gamma, A_Astar); - if (fabs(dA_dM) < 1e-12) { - /* Derivative too small: supersonic branch is increasing in M. */ - if (error > 0) M *= 0.99; else M *= 1.01; - } else { - double step = error / dA_dM; - step = ed_clip(step, -0.5 * M, 0.5 * M); - M = M - step; - } - if (M <= 1.0) M = 1.0 + 1e-6; - } - - /* Match Python's relaxed final convergence check (tol * 10). */ - double A_final = area_mach_ratio(M, gamma); - *M_out = M; - return fabs(A_final - eps) < tol * 10.0; -} - -ed_status_t ed_nozzle_solve(const EdNozzleInputs *in, EdNozzleResult *out) { - if (!in || !out) return ED_ERR_INVALID_ARG; - - const double Pc = in->Pc; - const double mdot = in->mdot_total; - const double gamma = in->gamma; - 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) || - !(gamma > 1.0) || !(R > 0.0) || !(Tc > 0.0) || !(Pc > 0.0) || !(mdot > 0.0)) { - return ED_ERR_INVALID_ARG; - } - - double M_exit = 0.0; - if (!solve_exit_mach(eps, gamma, &M_exit) || M_exit <= 1.0) { - return ED_ERR_NO_CONVERGE; - } - - /* Isentropic exit state from chamber gamma (frozen). */ - double factor = 1.0 + (gamma - 1.0) / 2.0 * M_exit * M_exit; - double P_exit = Pc * pow(factor, -gamma / (gamma - 1.0)); - double T_exit = Tc / factor; - if (!ed_isfinite(P_exit) || P_exit < 0.0 || !ed_isfinite(T_exit) || T_exit <= 0.0) { - return ED_ERR_NONFINITE; - } - - double a_exit = sqrt(gamma * R * T_exit); - 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; - out->T_exit = T_exit; - out->v_exit = v_exit; - 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; -} - -const char *ed_nozzle_stage(void) { return "frozen"; } diff --git a/EngineDesign/engine/native/src/ed_root_find.c b/EngineDesign/engine/native/src/ed_root_find.c deleted file mode 100644 index 81150ebaf..000000000 --- a/EngineDesign/engine/native/src/ed_root_find.c +++ /dev/null @@ -1,102 +0,0 @@ -/* ed_root_find.c - Brent's method, allocation-free. - * - * Replaces scipy.optimize.brentq in the chamber solve. Brent converges to the - * same root (defined by the residual) within tolerance, so the solved Pc matches - * Python to far better than the 1e-4 parity target regardless of solver internals. - * - * Classic Brent (Numerical Recipes / scipy zeros.c structure): inverse quadratic - * interpolation with secant and bisection fallback, guaranteed bracketing. - */ -#include "ed_root_find.h" - -#include - -ed_status_t ed_brentq(ed_root_fn f, void *ctx, double a, double b, - const ed_root_opts *opts, ed_root_result *out) { - if (!f || !out) return ED_ERR_INVALID_ARG; - - double xtol = 2e-12, rtol = 4.0 * DBL_EPSILON; - int max_iter = 100; - if (opts) { - if (opts->xtol > 0) xtol = opts->xtol; - if (opts->rtol > 0) rtol = opts->rtol; - if (opts->max_iter > 0) max_iter = opts->max_iter; - } - - double fa = f(a, ctx); - double fb = f(b, ctx); - out->iterations = 0; - out->converged = 0; - - if (!isfinite(fa) || !isfinite(fb)) { - out->root = a; out->f_root = fa; - return ED_ERR_NONFINITE; - } - /* Exact hit at an endpoint. */ - if (fa == 0.0) { out->root = a; out->f_root = 0.0; out->converged = 1; return ED_OK; } - if (fb == 0.0) { out->root = b; out->f_root = 0.0; out->converged = 1; return ED_OK; } - if (ed_sign(fa) == ed_sign(fb)) { - out->root = a; out->f_root = fa; - return ED_ERR_NO_BRACKET; - } - - double c = a, fc = fa, d = b - a, e = d; - - for (int iter = 0; iter < max_iter; ++iter) { - out->iterations = iter + 1; - - if (ed_sign(fb) == ed_sign(fc)) { - c = a; fc = fa; d = b - a; e = d; - } - if (fabs(fc) < fabs(fb)) { - a = b; b = c; c = a; - fa = fb; fb = fc; fc = fa; - } - - const double tol = 2.0 * rtol * fabs(b) + 0.5 * xtol; /* scipy: convergence band */ - const double m = 0.5 * (c - b); - - if (fb == 0.0 || fabs(m) <= tol) { - out->root = b; out->f_root = fb; out->converged = 1; - return ED_OK; - } - - if (fabs(e) < tol || fabs(fa) <= fabs(fb)) { - /* Bisection */ - d = m; e = m; - } else { - double s = fb / fa, p, q; - if (a == c) { - /* Secant */ - p = 2.0 * m * s; - q = 1.0 - s; - } else { - /* Inverse quadratic interpolation */ - const double qa = fa / fc, r = fb / fc; - p = s * (2.0 * m * qa * (qa - r) - (b - a) * (r - 1.0)); - q = (qa - 1.0) * (r - 1.0) * (s - 1.0); - } - if (p > 0.0) q = -q; - else p = -p; - - if (2.0 * p < ed_min(3.0 * m * q - fabs(tol * q), fabs(e * q))) { - e = d; d = p / q; - } else { - d = m; e = m; /* fall back to bisection */ - } - } - - a = b; fa = fb; - if (fabs(d) > tol) b += d; - else b += (m > 0.0 ? tol : -tol); - - fb = f(b, ctx); - if (!isfinite(fb)) { - out->root = b; out->f_root = fb; - return ED_ERR_NONFINITE; - } - } - - out->root = b; out->f_root = fb; - return ED_ERR_NO_CONVERGE; -} diff --git a/EngineDesign/engine/native/src/ed_spray.c b/EngineDesign/engine/native/src/ed_spray.c deleted file mode 100644 index acef7c999..000000000 --- a/EngineDesign/engine/native/src/ed_spray.c +++ /dev/null @@ -1,65 +0,0 @@ -/* ed_spray.c - Port of engine/core/spray.py (formulas used by the impinging solve). - * Each function mirrors its Python counterpart including degenerate-input returns. */ -#include "ed_spray.h" - -double ed_momentum_flux_ratio(double rho_O, double u_O, double rho_F, double u_F) { - if (u_F == 0.0) return (u_O > 0.0) ? INFINITY : 0.0; - return (rho_O * u_O * u_O) / (rho_F * u_F * u_F); -} - -double ed_thrust_momentum_ratio(double J, double MR) { - return (MR > 0.0) ? J / (1.0 + MR) : J; -} - -double ed_spray_angle_from_J(double J, double k, double n) { - if (J <= 0.0) return 0.0; - double theta = 2.0 * atan(k * pow(J, n)); - return ed_clip(theta, 0.0, ED_PI / 2.0); -} - -double ed_spray_angle_from_TMR(double TMR) { - if (TMR <= 0.0) return 0.0; - double cos_theta = 1.0 / (1.0 + pow(TMR, 0.75)); - cos_theta = ed_clip(cos_theta, -1.0, 1.0); - return acos(cos_theta); -} - -double ed_weber_number(double rho, double u, double d_char, double sigma) { - if (sigma <= 0.0) return INFINITY; - return (rho * u * u * d_char) / sigma; -} - -double ed_ohnesorge_number(double mu, double rho, double sigma, double d_or) { - if (rho <= 0.0 || sigma <= 0.0 || d_or <= 0.0) return 0.0; - double sqrt_arg = rho * sigma * d_or; - return (sqrt_arg > 0.0) ? mu / sqrt(ed_max(sqrt_arg, 1e-12)) : 0.0; -} - -double ed_smd_lefebvre(double d_or, double We, double Oh, double C, double m, double p) { - if (We <= 0.0 || d_or <= 0.0) return d_or; - return C * d_or * pow(We, -m) * pow(1.0 + Oh, p); -} - -double ed_smd_impinging_ingebo(double d_jet, double u_rel, double rho_liq, - double mu_liq, double sigma, double rho_gas, double C) { - if (d_jet <= 0.0 || u_rel <= 0.0 || sigma <= 0.0 || rho_gas <= 0.0 || - rho_liq <= 0.0 || mu_liq <= 0.0 || C <= 0.0) { - return d_jet; - } - double We_g = rho_gas * u_rel * u_rel * d_jet / sigma; - double Re_l = rho_liq * u_rel * d_jet / mu_liq; - double product = We_g * Re_l; - if (product <= 0.0) return d_jet; - return C * d_jet * pow(product, -0.25); -} - -double ed_tau_evap(double D32, double K) { return K * D32 * D32; } - -double ed_xstar(double U_rel, double tau_evap) { return U_rel * tau_evap; } - -int ed_check_spray_constraints(double We_O, double We_F, double x_star, const EdSpray *s) { - if (We_O < s->we_min) return 0; - if (We_F < s->we_min) return 0; - if (s->evap_use_constraint && x_star >= s->evap_x_star_limit) return 0; - return 1; -} diff --git a/EngineDesign/engine/native/src/ed_stability.c b/EngineDesign/engine/native/src/ed_stability.c deleted file mode 100644 index dc0268f16..000000000 --- a/EngineDesign/engine/native/src/ed_stability.c +++ /dev/null @@ -1,14 +0,0 @@ -/* ed_stability.c - Module 3 entry point. Gated on the stability port (README). */ -#include "ed_stability.h" -#include - -ed_status_t ed_stability_analyze(const EdEngineState *state, - const EdChamberDiagnostics *chamber, - const EdEvaluateResult *eval, - EdStabilityResult *out) { - (void)state; (void)chamber; (void)eval; - if (!out) return ED_ERR_INVALID_ARG; - memset(out, 0, sizeof(*out)); - out->stability_state = ED_STAB_MARGINAL; - return ED_ERR_NOT_IMPLEMENTED; -} diff --git a/EngineDesign/engine/native/src/ed_stability_modes.c b/EngineDesign/engine/native/src/ed_stability_modes.c deleted file mode 100644 index 7ea8681ef..000000000 --- a/EngineDesign/engine/native/src/ed_stability_modes.c +++ /dev/null @@ -1,158 +0,0 @@ -/* ed_stability_modes.c - chug fast-tier margin (Stage 5a). - * - * Faithful port of stability/chug.py::chug_margin_fast: the 200-pt log-spaced - * frequency sweep of the open-loop L(iw) = Y_ch(s) * sum_k exp(-s*tau_k)/Z_feed_k(s), - * np.unwrap of the phase, and Nyquist phase-crossover gain-margin detection. - */ -#include "ed_stability.h" -#include -#include - -#define ED_CHUG_N 200 - -/* np.unwrap(p, discont=pi) over an array, in place. */ -static void unwrap_pi(double *p, int n) { - const double twopi = 2.0 * ED_PI; - double cum = 0.0; - double prev = p[0]; - for (int i = 1; i < n; ++i) { - double cur = p[i]; - double dd = cur - prev; - /* floor-mod to [0, 2pi): mod(dd+pi, 2pi) - pi */ - double m = (dd + ED_PI); - m = m - twopi * floor(m / twopi); - double ddmod = m - ED_PI; - if (ddmod == -ED_PI && dd > 0.0) ddmod = ED_PI; - double corr = ddmod - dd; - if (fabs(dd) < ED_PI) corr = 0.0; - cum += corr; - prev = cur; - p[i] = cur + cum; - } -} - -static double complex z_feed(const EdChugStream *s, double complex sv) { - double complex zr = 0.0; - if (s->reg_enabled && s->reg_Z_hf > 0.0) { - double wc = 2.0 * ED_PI * (s->reg_corner_hz > 1e-6 ? s->reg_corner_hz : 1e-6); - zr = s->reg_Z_hf * (sv / wc) / (1.0 + sv / wc); - } - double complex inv_G = (s->G_inj > 0.0) ? (1.0 / s->G_inj) : INFINITY; - return zr + s->inertance * sv + s->resistance + inv_G; -} - -ed_status_t ed_chug_margin_fast(const EdChugStream *streams, int n_streams, - double K_c, double theta_c, - double f_lo, double f_hi, EdChugResult *out) { - if (!streams || !out || n_streams <= 0) return ED_ERR_INVALID_ARG; - - double omega[ED_CHUG_N], phase[ED_CHUG_N], mag[ED_CHUG_N]; - const double l0 = log10(f_lo), l1 = log10(f_hi); - for (int i = 0; i < ED_CHUG_N; ++i) { - double f = pow(10.0, l0 + (l1 - l0) * (double)i / (double)(ED_CHUG_N - 1)); - double w = 2.0 * ED_PI * f; - omega[i] = w; - double complex sv = I * w; - double complex acc = 0.0; - for (int k = 0; k < n_streams; ++k) { - double complex Zf = z_feed(&streams[k], sv); - if (Zf == 0.0) continue; - acc += cexp(-sv * streams[k].tau_conv) / Zf; - } - double complex Y = K_c / (theta_c * sv + 1.0); - double complex L = Y * acc; - phase[i] = carg(L); - mag[i] = cabs(L); - } - unwrap_pi(phase, ED_CHUG_N); - - const double target = -ED_PI; - double gm_best = INFINITY, f_pc = NAN; - for (int i = 0; i < ED_CHUG_N - 1; ++i) { - double gi = phase[i] - target, gi1 = phase[i + 1] - target; - if (gi == 0.0 || gi * gi1 < 0.0) { - double frac = (gi - gi1) != 0.0 ? gi / (gi - gi1) : 0.0; - double w_c = omega[i] + frac * (omega[i + 1] - omega[i]); - double mag_c = mag[i] + frac * (mag[i + 1] - mag[i]); - double gm = mag_c > 0.0 ? 1.0 / mag_c : INFINITY; - if (gm < gm_best) { gm_best = gm; f_pc = w_c / (2.0 * ED_PI); } - } - } - - double pm_deg = NAN; - for (int i = 0; i < ED_CHUG_N - 1; ++i) { - double hi = mag[i] - 1.0, hi1 = mag[i + 1] - 1.0; - if (hi == 0.0 || hi * hi1 < 0.0) { - double frac = (hi - hi1) != 0.0 ? hi / (hi - hi1) : 0.0; - double ph_c = phase[i] + frac * (phase[i + 1] - phase[i]); - pm_deg = (ph_c - target) * 180.0 / ED_PI; - break; - } - } - - if (!isfinite(gm_best)) { - double mmax = mag[0]; - for (int i = 1; i < ED_CHUG_N; ++i) if (mag[i] > mmax) mmax = mag[i]; - gm_best = 1.0 / ed_max(mmax, 1e-12); - if (mmax < 1.0) gm_best = ed_max(gm_best, 1.0); - } - - out->gain_margin = gm_best; - out->f_chug_hz = f_pc; - out->phase_margin_deg = pm_deg; - out->stable = gm_best > 1.0; - return ED_OK; -} - -/* ---- fast acoustic (1L + 1T): port of acoustic.py::fast_acoustic ---------- */ -#define ED_TRANSVERSE_1T 1.84118 /* J'_1 first zero */ -#define ED_OVERLAP_1L 0.70 -#define ED_OVERLAP_1T 0.40 - -/* mode_growth_rate alpha = driving - damping_total (damping_budget). */ -static double mode_alpha(double f, double overlap, double D_ch, double L_ch, - double gamma, double nu_g, double mach_ne, - double n, double tau_sens) { - const double omega = 2.0 * ED_PI * f; - const double drive = 0.5 * omega * (gamma - 1.0) * overlap * (n * sin(omega * tau_sens)); - double a_noz = (f > 0 && L_ch > 0 && mach_ne > 0) ? ED_PI * f * (gamma - 1.0) * mach_ne : 0.0; - double a_vis = 0.0; - if (f > 0 && D_ch > 0 && nu_g > 0) { - double delta = sqrt(nu_g / (ED_PI * f)); - a_vis = 2.0 * ED_PI * f * (delta / D_ch) * 4.0; - } - const double a_inj = 0.02 * ED_PI * f; - const double a_2ph = 0.03 * ED_PI * f * 1.0; - return drive - (a_noz + a_vis + a_inj + a_2ph); -} - -ed_status_t ed_fast_acoustic(double D_ch, double L_ch, double gamma, double a_sound, - double nu_g, double mach_ne, double n, double tau_sens, - EdAcousticResult *out) { - if (!out) return ED_ERR_INVALID_ARG; - const double f_1L = (a_sound > 0 && L_ch > 0) ? a_sound / (4.0 * L_ch) : 0.0; - const double f_1T = (a_sound > 0 && D_ch > 0) ? ED_TRANSVERSE_1T * a_sound / (ED_PI * D_ch) : 0.0; - out->f_1L = f_1L; - out->f_1T = f_1T; - - int have = 0; - double best = -INFINITY; int lim = -1; - if (f_1L > 0) { - double a = mode_alpha(f_1L, ED_OVERLAP_1L, D_ch, L_ch, gamma, nu_g, mach_ne, n, tau_sens); - if (a > best) { best = a; lim = 0; } - have = 1; - } - if (f_1T > 0) { - double a = mode_alpha(f_1T, ED_OVERLAP_1T, D_ch, L_ch, gamma, nu_g, mach_ne, n, tau_sens); - if (a > best) { best = a; lim = 1; } - have = 1; - } - if (!have) { - out->alpha_max = NAN; out->limiting = -1; out->stable = 1; - return ED_OK; - } - out->alpha_max = best; - out->limiting = lim; - out->stable = best < 0.0; - return ED_OK; -} diff --git a/EngineDesign/engine/native/src/ed_workspace.c b/EngineDesign/engine/native/src/ed_workspace.c deleted file mode 100644 index 30e3dba24..000000000 --- a/EngineDesign/engine/native/src/ed_workspace.c +++ /dev/null @@ -1,9 +0,0 @@ -/* ed_workspace.c - Workspace lifecycle. The struct is POD and stack-allocatable; - * this TU exists so callers can take the address of a reset routine and to host - * any future non-inline workspace helpers. */ -#include "ed_workspace.h" - -/* Out-of-line mirror of ed_workspace_reset for ABI/ctypes consumers. */ -void ed_workspace_init(EdWorkspace *ws) { - ed_workspace_reset(ws); -} diff --git a/EngineDesign/engine/native/tests/ed_test_util.h b/EngineDesign/engine/native/tests/ed_test_util.h deleted file mode 100644 index 037f63277..000000000 --- a/EngineDesign/engine/native/tests/ed_test_util.h +++ /dev/null @@ -1,62 +0,0 @@ -/* ed_test_util.h - Minimal, dependency-free helpers for the C tests: - * file slurp + flat-JSON number extraction + tolerance checks. Header-only. - * - * The golden JSON files are arrays of FLAT objects (no nesting), so a key occurs - * at most once per object and values are plain numbers — a substring scan is - * sufficient and avoids pulling in a JSON dependency. - */ -#ifndef ED_TEST_UTIL_H -#define ED_TEST_UTIL_H - -#include -#include -#include -#include - -static char *edt_slurp(const char *path, size_t *len_out) { - FILE *f = fopen(path, "rb"); - if (!f) return NULL; - fseek(f, 0, SEEK_END); - long n = ftell(f); - fseek(f, 0, SEEK_SET); - char *buf = (char *)malloc((size_t)n + 1); - if (!buf) { fclose(f); return NULL; } - size_t rd = fread(buf, 1, (size_t)n, f); - fclose(f); - buf[rd] = '\0'; - if (len_out) *len_out = rd; - return buf; -} - -/* Find "key" within [obj, obj_end) and parse the number after its ':'. */ -static int edt_find_double(const char *obj, const char *obj_end, - const char *key, double *out) { - char pat[64]; - snprintf(pat, sizeof pat, "\"%s\"", key); - const char *p = strstr(obj, pat); - if (!p || p >= obj_end) return 0; - p = strchr(p, ':'); - if (!p || p >= obj_end) return 0; - p++; - *out = strtod(p, NULL); - return 1; -} - -/* Iterate flat objects in a JSON array. Returns pointer to next object start - * (at '{') or NULL. *end is set to the object's closing '}'. */ -static const char *edt_next_object(const char *p, const char **end) { - p = strchr(p, '{'); - if (!p) return NULL; - const char *e = strchr(p, '}'); - if (!e) return NULL; - *end = e; - return p; -} - -static int edt_close(double got, double want, double rtol, double atol) { - if (isnan(got) && isnan(want)) return 1; - double tol = atol + rtol * fabs(want); - return fabs(got - want) <= tol; -} - -#endif /* ED_TEST_UTIL_H */ diff --git a/EngineDesign/engine/native/tests/golden/cea_samples.json b/EngineDesign/engine/native/tests/golden/cea_samples.json deleted file mode 100644 index 8490f3c5e..000000000 --- a/EngineDesign/engine/native/tests/golden/cea_samples.json +++ /dev/null @@ -1,950 +0,0 @@ -[ - { - "MR": 2.8092048404409056, - "Pc": 3534066.717678023, - "Pa": 101325.0, - "eps": 12.771020030660075, - "cstar_ideal": 1859.3429562930646, - "Cf_ideal": 1.6743808389686212, - "Tc": 3320.386207445007, - "gamma": 1.1438704552551027, - "R": 423.35262140476726, - "M": 19.640081509666114 - }, - { - "MR": 3.617258407351754, - "Pc": 4128876.404815272, - "Pa": 101325.0, - "eps": 7.6609532065302295, - "cstar_ideal": 1810.467699160969, - "Cf_ideal": 1.5847635750089628, - "Tc": 3491.806579638209, - "gamma": 1.1264770633105243, - "R": 376.62942914486536, - "M": 22.076148256332537 - }, - { - "MR": 3.4769557564569418, - "Pc": 2493873.4848297066, - "Pa": 101325.0, - "eps": 11.400316484160834, - "cstar_ideal": 1812.4780650919793, - "Cf_ideal": 1.6658350757199354, - "Tc": 3419.067131145154, - "gamma": 1.1251713656786397, - "R": 385.2033702462469, - "M": 21.58478091231713 - }, - { - "MR": 4.095245157485887, - "Pc": 2985965.717036568, - "Pa": 101325.0, - "eps": 14.4376926701665, - "cstar_ideal": 1760.9728898304704, - "Cf_ideal": 1.7100787483857003, - "Tc": 3439.010600948607, - "gamma": 1.1234512102780507, - "R": 361.07083500032076, - "M": 23.027275512362582 - }, - { - "MR": 3.6010274155806705, - "Pc": 1767183.4847528967, - "Pa": 101325.0, - "eps": 8.860236327845941, - "cstar_ideal": 1794.2036428458935, - "Cf_ideal": 1.6151229612465823, - "Tc": 3377.3245553716674, - "gamma": 1.1226931283390698, - "R": 381.51114639074814, - "M": 21.7935212172087 - }, - { - "MR": 3.995663854789532, - "Pc": 6579627.999056177, - "Pa": 101325.0, - "eps": 7.591201504771234, - "cstar_ideal": 1785.8389081886332, - "Cf_ideal": 1.5824843809284672, - "Tc": 3550.9678669921677, - "gamma": 1.1269778093654406, - "R": 360.448084228944, - "M": 23.06712498982437 - }, - { - "MR": 3.7210706939941196, - "Pc": 2761079.64436389, - "Pa": 101325.0, - "eps": 4.897540264964289, - "cstar_ideal": 1792.778258832744, - "Cf_ideal": 1.4807697577577121, - "Tc": 3438.234894063834, - "gamma": 1.1241560473584111, - "R": 374.4784647240515, - "M": 22.202903464439764 - }, - { - "MR": 2.6878120819350855, - "Pc": 3720801.4796376424, - "Pa": 101325.0, - "eps": 9.11712469072256, - "cstar_ideal": 1858.798825821165, - "Cf_ideal": 1.6077426017879992, - "Tc": 3260.8246830345347, - "gamma": 1.1517107307708396, - "R": 433.34951003146153, - "M": 19.186977691270776 - }, - { - "MR": 2.879557850923388, - "Pc": 7526211.227398455, - "Pa": 101325.0, - "eps": 6.126238282184439, - "cstar_ideal": 1868.6184160466169, - "Cf_ideal": 1.533153966240889, - "Tc": 3426.166157036543, - "gamma": 1.1462796939864788, - "R": 414.96079745423515, - "M": 20.037086609517885 - }, - { - "MR": 2.6330443371189602, - "Pc": 1733318.0123594874, - "Pa": 101325.0, - "eps": 10.584248150314044, - "cstar_ideal": 1849.0716459357582, - "Cf_ideal": 1.6339044375861953, - "Tc": 3165.455329840113, - "gamma": 1.1484235641633007, - "R": 440.86945369569673, - "M": 18.859701291894076 - }, - { - "MR": 3.9385354278732025, - "Pc": 5812969.933549705, - "Pa": 101325.0, - "eps": 14.251871972495818, - "cstar_ideal": 1788.4949027404828, - "Cf_ideal": 1.7064730635629581, - "Tc": 3536.070328780356, - "gamma": 1.1266294036263103, - "R": 362.9509574139746, - "M": 22.90804752263028 - }, - { - "MR": 3.7046064499656364, - "Pc": 7884410.539146339, - "Pa": 101325.0, - "eps": 14.22271581732848, - "cstar_ideal": 1815.7606711789695, - "Cf_ideal": 1.7053298676085777, - "Tc": 3581.523320677156, - "gamma": 1.1287677576235766, - "R": 369.8894634908438, - "M": 22.47829397382455 - }, - { - "MR": 3.3831348163482353, - "Pc": 8501383.670142055, - "Pa": 101325.0, - "eps": 9.444867340867066, - "cstar_ideal": 1844.9079998098136, - "Cf_ideal": 1.627647032900192, - "Tc": 3574.4868811343736, - "gamma": 1.1320717447180648, - "R": 383.50633342840257, - "M": 21.68014226777262 - }, - { - "MR": 2.8927917284819773, - "Pc": 4614229.659798086, - "Pa": 101325.0, - "eps": 11.315428157394834, - "cstar_ideal": 1861.9039904339545, - "Cf_ideal": 1.6522335710543263, - "Tc": 3383.4485397284348, - "gamma": 1.1417806428854598, - "R": 415.87851835559945, - "M": 19.992588040047725 - }, - { - "MR": 2.9956036748406984, - "Pc": 8227632.054465912, - "Pa": 101325.0, - "eps": 6.827815928041877, - "cstar_ideal": 1867.5124496487097, - "Cf_ideal": 1.557669872252816, - "Tc": 3483.7147349524894, - "gamma": 1.1415547354617963, - "R": 406.1560597218382, - "M": 20.471237803913436 - }, - { - "MR": 3.011691007698576, - "Pc": 3070827.189143419, - "Pa": 101325.0, - "eps": 7.909911279387146, - "cstar_ideal": 1851.102999495267, - "Cf_ideal": 1.5903218979757763, - "Tc": 3378.8225303356753, - "gamma": 1.1343281448517835, - "R": 409.3881191262676, - "M": 20.30974348000126 - }, - { - "MR": 2.4090402006908374, - "Pc": 6028836.35279743, - "Pa": 101325.0, - "eps": 7.106209781676301, - "cstar_ideal": 1840.8896556140467, - "Cf_ideal": 1.5546337732136324, - "Tc": 3067.1521761859926, - "gamma": 1.1807860264124561, - "R": 460.88136189615307, - "M": 18.040831840044877 - }, - { - "MR": 2.522557841078302, - "Pc": 5934631.818051044, - "Pa": 101325.0, - "eps": 5.939589523093238, - "cstar_ideal": 1853.0223653886837, - "Cf_ideal": 1.5217690778393422, - "Tc": 3172.547647030745, - "gamma": 1.1696323631095833, - "R": 448.15641592978955, - "M": 18.55316141097793 - }, - { - "MR": 2.947899096995261, - "Pc": 4527094.487008944, - "Pa": 101325.0, - "eps": 5.652225751689709, - "cstar_ideal": 1860.1275641828495, - "Cf_ideal": 1.5169562892095356, - "Tc": 3402.3225619043646, - "gamma": 1.139227610719382, - "R": 411.9896871247815, - "M": 20.18131281582228 - }, - { - "MR": 2.792271953553783, - "Pc": 4794664.9226683555, - "Pa": 101325.0, - "eps": 9.240057405893111, - "cstar_ideal": 1863.2668357808989, - "Cf_ideal": 1.6128761109946503, - "Tc": 3340.949266580784, - "gamma": 1.1473390241883572, - "R": 423.5450167597385, - "M": 19.630974041545212 - }, - { - "MR": 2.8594182368751007, - "Pc": 3380522.1451843847, - "Pa": 101325.0, - "eps": 7.069738317951433, - "cstar_ideal": 1858.073708738993, - "Cf_ideal": 1.5644341945030358, - "Tc": 3337.837009318247, - "gamma": 1.140992856351618, - "R": 419.65550272537405, - "M": 19.813050015360794 - }, - { - "MR": 2.8690425824843357, - "Pc": 4862092.742394526, - "Pa": 101325.0, - "eps": 6.331769399866617, - "cstar_ideal": 1862.9879036795837, - "Cf_ideal": 1.5409142308019534, - "Tc": 3378.4626407946675, - "gamma": 1.1433548451919764, - "R": 417.4662644058022, - "M": 19.916963508032673 - }, - { - "MR": 3.2921350740114734, - "Pc": 2970090.6066459003, - "Pa": 101325.0, - "eps": 13.223309177136393, - "cstar_ideal": 1831.6256072344884, - "Cf_ideal": 1.6927653960691706, - "Tc": 3427.1142696108036, - "gamma": 1.1280660017487976, - "R": 393.26652067963005, - "M": 21.14230479222817 - }, - { - "MR": 2.724235062171063, - "Pc": 7897250.332073892, - "Pa": 101325.0, - "eps": 5.961293893297062, - "cstar_ideal": 1867.3274995393654, - "Cf_ideal": 1.5250741731249042, - "Tc": 3343.9785668821614, - "gamma": 1.1561525403407102, - "R": 427.64110365428803, - "M": 19.44275598487598 - }, - { - "MR": 3.7509563974870397, - "Pc": 5888963.230644521, - "Pa": 101325.0, - "eps": 6.30070538421468, - "cstar_ideal": 1805.7012739123486, - "Cf_ideal": 1.541174223431081, - "Tc": 3541.472946307374, - "gamma": 1.127303134479103, - "R": 369.574535757991, - "M": 22.497519307694883 - }, - { - "MR": 3.7677703580231916, - "Pc": 2994084.5562793, - "Pa": 101325.0, - "eps": 4.941289051852138, - "cstar_ideal": 1790.2485504227784, - "Cf_ideal": 1.4830349781634855, - "Tc": 3449.0259236094003, - "gamma": 1.1242750815384426, - "R": 372.2894864491577, - "M": 22.33337644793454 - }, - { - "MR": 3.5125021001725636, - "Pc": 5295746.648258684, - "Pa": 101325.0, - "eps": 10.979793823368032, - "cstar_ideal": 1824.7805308057252, - "Cf_ideal": 1.6577644255373656, - "Tc": 3521.627009305578, - "gamma": 1.128346055066565, - "R": 379.8609658360408, - "M": 21.888385599285957 - }, - { - "MR": 2.713873395644499, - "Pc": 2985315.9188516196, - "Pa": 101325.0, - "eps": 11.533052831033391, - "cstar_ideal": 1856.9216427932413, - "Cf_ideal": 1.6508416225476619, - "Tc": 3257.091894156683, - "gamma": 1.148016955603147, - "R": 431.8302249438178, - "M": 19.254440056801293 - }, - { - "MR": 2.5455689632516334, - "Pc": 8000588.80604901, - "Pa": 101325.0, - "eps": 8.71563819693991, - "cstar_ideal": 1856.6884754729206, - "Cf_ideal": 1.593702516450542, - "Tc": 3208.957115387639, - "gamma": 1.1705194070160858, - "R": 445.0248328685815, - "M": 18.683851382032913 - }, - { - "MR": 3.51310955171528, - "Pc": 3504844.0334809585, - "Pa": 101325.0, - "eps": 5.968591408221544, - "cstar_ideal": 1816.3332782903972, - "Cf_ideal": 1.5290260305894783, - "Tc": 3466.171723321466, - "gamma": 1.1264369411121873, - "R": 381.89021971540376, - "M": 21.772077308515616 - }, - { - "MR": 2.417481830031815, - "Pc": 2680343.667587624, - "Pa": 101325.0, - "eps": 13.570007466488173, - "cstar_ideal": 1838.2406246953549, - "Cf_ideal": 1.660864189930131, - "Tc": 3036.907715813326, - "gamma": 1.1716686664339242, - "R": 461.5563381455885, - "M": 18.01466991272276 - }, - { - "MR": 4.151093644315606, - "Pc": 4534338.745528819, - "Pa": 101325.0, - "eps": 8.166244442901787, - "cstar_ideal": 1764.5378457723073, - "Cf_ideal": 1.5983648288278212, - "Tc": 3492.6180428541625, - "gamma": 1.1252054911171492, - "R": 357.3617251780341, - "M": 23.266284880256975 - }, - { - "MR": 2.89670474628267, - "Pc": 8728832.873875534, - "Pa": 101325.0, - "eps": 4.6402286579242835, - "cstar_ideal": 1870.3450584398875, - "Cf_ideal": 1.4699264730274773, - "Tc": 3448.6439491053916, - "gamma": 1.1465000275481403, - "R": 413.07216267921484, - "M": 20.12853906280809 - }, - { - "MR": 3.135721017951339, - "Pc": 2349030.756227888, - "Pa": 101325.0, - "eps": 6.641584642418566, - "cstar_ideal": 1838.7263677004978, - "Cf_ideal": 1.5544806635388855, - "Tc": 3374.582629654947, - "gamma": 1.1295018105929786, - "R": 402.99661743524496, - "M": 20.631921190584347 - }, - { - "MR": 3.8040141414015816, - "Pc": 2630140.769244899, - "Pa": 101325.0, - "eps": 10.072560502377838, - "cstar_ideal": 1784.2798013259521, - "Cf_ideal": 1.6417853422760555, - "Tc": 3431.0049150513205, - "gamma": 1.1236141134847617, - "R": 371.58729340771686, - "M": 22.375664491004546 - }, - { - "MR": 3.0605894556930013, - "Pc": 5058253.771138854, - "Pa": 101325.0, - "eps": 7.667815763659144, - "cstar_ideal": 1857.132766943198, - "Cf_ideal": 1.5837155269163938, - "Tc": 3449.8381365408036, - "gamma": 1.1360447983414563, - "R": 403.98183307157575, - "M": 20.581423628526107 - }, - { - "MR": 2.908899013394181, - "Pc": 3254642.410441656, - "Pa": 101325.0, - "eps": 4.939194213334165, - "cstar_ideal": 1856.2550055385439, - "Cf_ideal": 1.4854535651553935, - "Tc": 3352.871430606601, - "gamma": 1.1384546464256111, - "R": 416.173365774025, - "M": 19.97874938783914 - }, - { - "MR": 3.2672645804052336, - "Pc": 8066743.156116434, - "Pa": 101325.0, - "eps": 14.419505426110234, - "cstar_ideal": 1852.6356342140796, - "Cf_ideal": 1.7043340999400693, - "Tc": 3551.5840214657965, - "gamma": 1.13364873693459, - "R": 389.7142839832886, - "M": 21.33487817859615 - }, - { - "MR": 2.449290699468814, - "Pc": 8342017.956020452, - "Pa": 101325.0, - "eps": 5.336769824339505, - "cstar_ideal": 1847.0601190399088, - "Cf_ideal": 1.4986993091854812, - "Tc": 3121.3667092552582, - "gamma": 1.179905967973163, - "R": 455.56972058129577, - "M": 18.251005668153873 - }, - { - "MR": 3.746125967614456, - "Pc": 8172165.936397559, - "Pa": 101325.0, - "eps": 5.847227799308719, - "cstar_ideal": 1812.7488827604893, - "Cf_ideal": 1.5237021196161968, - "Tc": 3586.5485101226714, - "gamma": 1.1287415090564057, - "R": 368.11907978611083, - "M": 22.586496621651087 - }, - { - "MR": 2.4, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1830.44592, - "Cf_ideal": 1.4336159411274627, - "Tc": 2966.6, - "gamma": 1.1627, - "R": 465.8745233372555, - "M": 17.847 - }, - { - "MR": 2.4, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1830.44592, - "Cf_ideal": 1.6768372063753618, - "Tc": 2966.6, - "gamma": 1.1627, - "R": 465.8745233372555, - "M": 17.847 - }, - { - "MR": 2.4, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1830.44592, - "Cf_ideal": 1.5539957144459469, - "Tc": 2966.6, - "gamma": 1.1627, - "R": 465.8745233372555, - "M": 17.847 - }, - { - "MR": 2.4, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1841.08344, - "Cf_ideal": 1.433674811476778, - "Tc": 3073.69, - "gamma": 1.1855, - "R": 461.27393165048545, - "M": 18.025 - }, - { - "MR": 2.4, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1841.08344, - "Cf_ideal": 1.6719222934059903, - "Tc": 3073.69, - "gamma": 1.1855, - "R": 461.27393165048545, - "M": 18.025 - }, - { - "MR": 2.4, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1841.08344, - "Cf_ideal": 1.5514957942115397, - "Tc": 3073.69, - "gamma": 1.1855, - "R": 461.27393165048545, - "M": 18.025 - }, - { - "MR": 2.4, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1833.9816, - "Cf_ideal": 1.4335604380333797, - "Tc": 2998.3, - "gamma": 1.1687, - "R": 464.5210692217442, - "M": 17.899 - }, - { - "MR": 2.4, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1833.9816, - "Cf_ideal": 1.6751446675700545, - "Tc": 2998.3, - "gamma": 1.1687, - "R": 464.5210692217442, - "M": 17.899 - }, - { - "MR": 2.4, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1833.9816, - "Cf_ideal": 1.55310119660565, - "Tc": 2998.3, - "gamma": 1.1687, - "R": 464.5210692217442, - "M": 17.899 - }, - { - "MR": 4.2, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1729.34376, - "Cf_ideal": 1.4275289680320573, - "Tc": 3289.44, - "gamma": 1.1186, - "R": 362.9184905281536, - "M": 22.91 - }, - { - "MR": 4.2, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1729.34376, - "Cf_ideal": 1.7189786914848535, - "Tc": 3289.44, - "gamma": 1.1186, - "R": 362.9184905281536, - "M": 22.91 - }, - { - "MR": 4.2, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1729.34376, - "Cf_ideal": 1.5659253274309113, - "Tc": 3289.44, - "gamma": 1.1186, - "R": 362.9184905281536, - "M": 22.91 - }, - { - "MR": 4.2, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1773.7836, - "Cf_ideal": 1.4281961075204113, - "Tc": 3582.99, - "gamma": 1.128, - "R": 352.65142376044446, - "M": 23.577 - }, - { - "MR": 4.2, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1773.7836, - "Cf_ideal": 1.7149816140288117, - "Tc": 3582.99, - "gamma": 1.128, - "R": 352.65142376044446, - "M": 23.577 - }, - { - "MR": 4.2, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1773.7836, - "Cf_ideal": 1.5647327253728784, - "Tc": 3582.99, - "gamma": 1.128, - "R": 352.65142376044446, - "M": 23.577 - }, - { - "MR": 4.2, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1740.6518400000002, - "Cf_ideal": 1.427692083263666, - "Tc": 3360.98, - "gamma": 1.121, - "R": 360.40150056350234, - "M": 23.07 - }, - { - "MR": 4.2, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1740.6518400000002, - "Cf_ideal": 1.7179873963155396, - "Tc": 3360.98, - "gamma": 1.121, - "R": 360.40150056350234, - "M": 23.07 - }, - { - "MR": 4.2, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1740.6518400000002, - "Cf_ideal": 1.565626220305991, - "Tc": 3360.98, - "gamma": 1.121, - "R": 360.40150056350234, - "M": 23.07 - }, - { - "MR": 2.672727272727273, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1842.88176, - "Cf_ideal": 1.4332692070610735, - "Tc": 3138.22, - "gamma": 1.1409, - "R": 439.36073863876555, - "M": 18.924 - }, - { - "MR": 2.672727272727273, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1842.88176, - "Cf_ideal": 1.6937657041804322, - "Tc": 3138.22, - "gamma": 1.1409, - "R": 439.36073863876555, - "M": 18.924 - }, - { - "MR": 2.672727272727273, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1842.88176, - "Cf_ideal": 1.5613564005741183, - "Tc": 3138.22, - "gamma": 1.1409, - "R": 439.36073863876555, - "M": 18.924 - }, - { - "MR": 2.672727272727273, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1866.2904, - "Cf_ideal": 1.433221649211883, - "Tc": 3318.24, - "gamma": 1.1611, - "R": 431.96501548212797, - "M": 19.248 - }, - { - "MR": 2.672727272727273, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1866.2904, - "Cf_ideal": 1.683535416800956, - "Tc": 3318.24, - "gamma": 1.1611, - "R": 431.96501548212797, - "M": 19.248 - }, - { - "MR": 2.672727272727273, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1866.2904, - "Cf_ideal": 1.5564001709453748, - "Tc": 3318.24, - "gamma": 1.1611, - "R": 431.96501548212797, - "M": 19.248 - }, - { - "MR": 2.672727272727273, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1849.9226400000002, - "Cf_ideal": 1.4333090542021891, - "Tc": 3187.41, - "gamma": 1.1457, - "R": 437.32708910162, - "M": 19.012 - }, - { - "MR": 2.672727272727273, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1849.9226400000002, - "Cf_ideal": 1.69075070780457, - "Tc": 3187.41, - "gamma": 1.1457, - "R": 437.32708910162, - "M": 19.012 - }, - { - "MR": 2.672727272727273, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1849.9226400000002, - "Cf_ideal": 1.559956754772854, - "Tc": 3187.41, - "gamma": 1.1457, - "R": 437.32708910162, - "M": 19.012 - }, - { - "MR": 3.327272727272727, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1806.36672, - "Cf_ideal": 1.4284027331573956, - "Tc": 3290.76, - "gamma": 1.1221, - "R": 396.79596344373385, - "M": 20.954 - }, - { - "MR": 3.327272727272727, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1806.36672, - "Cf_ideal": 1.717984709669064, - "Tc": 3290.76, - "gamma": 1.1221, - "R": 396.79596344373385, - "M": 20.954 - }, - { - "MR": 3.327272727272727, - "Pc": 1000000.0, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1806.36672, - "Cf_ideal": 1.5661164641788077, - "Tc": 3290.76, - "gamma": 1.1221, - "R": 396.79596344373385, - "M": 20.954 - }, - { - "MR": 3.327272727272727, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1850.2884000000001, - "Cf_ideal": 1.429889841055917, - "Tc": 3574.89, - "gamma": 1.1332, - "R": 386.0189710757231, - "M": 21.539 - }, - { - "MR": 3.327272727272727, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1850.2884000000001, - "Cf_ideal": 1.7121122243863, - "Tc": 3574.89, - "gamma": 1.1332, - "R": 386.0189710757231, - "M": 21.539 - }, - { - "MR": 3.327272727272727, - "Pc": 9000000.0, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1850.2884000000001, - "Cf_ideal": 1.5652996022850394, - "Tc": 3574.89, - "gamma": 1.1332, - "R": 386.0189710757231, - "M": 21.539 - }, - { - "MR": 3.327272727272727, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 4.0, - "cstar_ideal": 1817.7662400000002, - "Cf_ideal": 1.4287242877343778, - "Tc": 3360.95, - "gamma": 1.1249, - "R": 394.12507669700415, - "M": 21.096 - }, - { - "MR": 3.327272727272727, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 15.0, - "cstar_ideal": 1817.7662400000002, - "Cf_ideal": 1.7168288601453967, - "Tc": 3360.95, - "gamma": 1.1249, - "R": 394.12507669700415, - "M": 21.096 - }, - { - "MR": 3.327272727272727, - "Pc": 1727272.7272727273, - "Pa": 101325.0, - "eps": 7.0, - "cstar_ideal": 1817.7662400000002, - "Cf_ideal": 1.5659307165828231, - "Tc": 3360.95, - "gamma": 1.1249, - "R": 394.12507669700415, - "M": 21.096 - }, - { - "MR": 1.9, - "Pc": 0.0, - "Pa": 101325.0, - "eps": 2.0, - "cstar_ideal": 1830.44592, - "Cf_ideal": 1.4336159411274627, - "Tc": 2966.6, - "gamma": 1.1627, - "R": 465.8745233372555, - "M": 17.847 - }, - { - "MR": 4.7, - "Pc": 10000000.0, - "Pa": 101325.0, - "eps": 17.0, - "cstar_ideal": 1773.7836, - "Cf_ideal": 1.7149816140288117, - "Tc": 3582.99, - "gamma": 1.128, - "R": 352.65142376044446, - "M": 23.577 - }, - { - "MR": 3.0, - "Pc": 18000000.0, - "Pa": 101325.0, - "eps": 8.0, - "cstar_ideal": 1868.69832, - "Cf_ideal": 1.5897448140229384, - "Tc": 3495.07, - "gamma": 1.142, - "R": 405.4648696966741, - "M": 20.506 - } -] \ No newline at end of file diff --git a/EngineDesign/engine/native/tests/golden/cea_tables.bin b/EngineDesign/engine/native/tests/golden/cea_tables.bin deleted file mode 100644 index 726776f45a5367c83e9f4aa7adfcebacae03b0c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1887428 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

}Bx% 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Ð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

uWy1!`npshb26wobtZ(z+*Em?}a2h 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

wA$LnPsjs9@C^}TI)F<`sKB4HK=P4~VbCjs; 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 RntS|I&y}7O#GGv^{|A;{E;IlD diff --git a/EngineDesign/engine/native/tests/golden/component_samples.json b/EngineDesign/engine/native/tests/golden/component_samples.json deleted file mode 100644 index fc79ac699..000000000 --- a/EngineDesign/engine/native/tests/golden/component_samples.json +++ /dev/null @@ -1,2008 +0,0 @@ -{ - "feed": [ - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.0, - "rho": 1140.0, - "P_tank": 3000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.0, - "rho": 1140.0, - "P_tank": 4000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.0, - "rho": 1140.0, - "P_tank": 5000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.5, - "rho": 1140.0, - "P_tank": 3000000.0, - "expected": 10797.903842498665 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.5, - "rho": 1140.0, - "P_tank": 4000000.0, - "expected": 10797.903842498665 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.5, - "rho": 1140.0, - "P_tank": 5000000.0, - "expected": 10797.903842498665 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 1.5, - "rho": 1140.0, - "P_tank": 3000000.0, - "expected": 97181.13458248801 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 1.5, - "rho": 1140.0, - "P_tank": 4000000.0, - "expected": 97181.13458248801 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 1.5, - "rho": 1140.0, - "P_tank": 5000000.0, - "expected": 97181.13458248801 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 3.0, - "rho": 1140.0, - "P_tank": 3000000.0, - "expected": 388724.53832995205 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 3.0, - "rho": 1140.0, - "P_tank": 4000000.0, - "expected": 388724.53832995205 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 3.0, - "rho": 1140.0, - "P_tank": 5000000.0, - "expected": 388724.53832995205 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 5.0, - "rho": 1140.0, - "P_tank": 3000000.0, - "expected": 1079790.3842498667 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 5.0, - "rho": 1140.0, - "P_tank": 4000000.0, - "expected": 1079790.3842498667 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 5.0, - "rho": 1140.0, - "P_tank": 5000000.0, - "expected": 1079790.3842498667 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.0, - "rho": 810.0, - "P_tank": 3000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.0, - "rho": 810.0, - "P_tank": 4000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.0, - "rho": 810.0, - "P_tank": 5000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.5, - "rho": 810.0, - "P_tank": 3000000.0, - "expected": 60787.640961516234 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.5, - "rho": 810.0, - "P_tank": 4000000.0, - "expected": 60787.640961516234 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 0.5, - "rho": 810.0, - "P_tank": 5000000.0, - "expected": 60787.640961516234 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 1.5, - "rho": 810.0, - "P_tank": 3000000.0, - "expected": 547088.7686536461 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 1.5, - "rho": 810.0, - "P_tank": 4000000.0, - "expected": 547088.7686536461 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 1.5, - "rho": 810.0, - "P_tank": 5000000.0, - "expected": 547088.7686536461 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 3.0, - "rho": 810.0, - "P_tank": 3000000.0, - "expected": 2188355.0746145844 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 3.0, - "rho": 810.0, - "P_tank": 4000000.0, - "expected": 2188355.0746145844 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 3.0, - "rho": 810.0, - "P_tank": 5000000.0, - "expected": 2188355.0746145844 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 5.0, - "rho": 810.0, - "P_tank": 3000000.0, - "expected": 6078764.096151624 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 5.0, - "rho": 810.0, - "P_tank": 4000000.0, - "expected": 6078764.096151624 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0, - "mdot": 5.0, - "rho": 810.0, - "P_tank": 5000000.0, - "expected": 6078764.096151624 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 0.0, - "rho": 1140.0, - "P_tank": 3000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 0.0, - "rho": 1140.0, - "P_tank": 4000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 0.0, - "rho": 1140.0, - "P_tank": 5000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 0.5, - "rho": 1140.0, - "P_tank": 3000000.0, - "expected": 20149.16287772411 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 0.5, - "rho": 1140.0, - "P_tank": 4000000.0, - "expected": 21595.80768499733 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 0.5, - "rho": 1140.0, - "P_tank": 5000000.0, - "expected": 22870.327345665264 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 1.5, - "rho": 1140.0, - "P_tank": 3000000.0, - "expected": 181342.46589951706 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 1.5, - "rho": 1140.0, - "P_tank": 4000000.0, - "expected": 194362.26916497602 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 1.5, - "rho": 1140.0, - "P_tank": 5000000.0, - "expected": 205832.94611098745 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 3.0, - "rho": 1140.0, - "P_tank": 3000000.0, - "expected": 725369.8635980682 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 3.0, - "rho": 1140.0, - "P_tank": 4000000.0, - "expected": 777449.0766599041 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 3.0, - "rho": 1140.0, - "P_tank": 5000000.0, - "expected": 823331.7844439498 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 5.0, - "rho": 1140.0, - "P_tank": 3000000.0, - "expected": 2014916.2877724112 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 5.0, - "rho": 1140.0, - "P_tank": 4000000.0, - "expected": 2159580.7684997334 - }, - { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.001, - "phi_type": 1, - "mdot": 5.0, - "rho": 1140.0, - "P_tank": 5000000.0, - "expected": 2287032.734566527 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 0.0, - "rho": 810.0, - "P_tank": 3000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 0.0, - "rho": 810.0, - "P_tank": 4000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 0.0, - "rho": 810.0, - "P_tank": 5000000.0, - "expected": 0.0 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 0.5, - "rho": 810.0, - "P_tank": 3000000.0, - "expected": 287436.2271757752 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 0.5, - "rho": 810.0, - "P_tank": 4000000.0, - "expected": 291808.1058085911 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 0.5, - "rho": 810.0, - "P_tank": 5000000.0, - "expected": 295199.1983286326 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 1.5, - "rho": 810.0, - "P_tank": 3000000.0, - "expected": 2586926.044581977 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 1.5, - "rho": 810.0, - "P_tank": 4000000.0, - "expected": 2626272.9522773204 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 1.5, - "rho": 810.0, - "P_tank": 5000000.0, - "expected": 2656792.7849576934 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 3.0, - "rho": 810.0, - "P_tank": 3000000.0, - "expected": 10347704.178327909 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 3.0, - "rho": 810.0, - "P_tank": 4000000.0, - "expected": 10505091.809109282 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 3.0, - "rho": 810.0, - "P_tank": 5000000.0, - "expected": 10627171.139830774 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 5.0, - "rho": 810.0, - "P_tank": 3000000.0, - "expected": 28743622.717577524 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 5.0, - "rho": 810.0, - "P_tank": 4000000.0, - "expected": 29180810.580859117 - }, - { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.5, - "phi_type": 2, - "mdot": 5.0, - "rho": 810.0, - "P_tank": 5000000.0, - "expected": 29519919.832863264 - } - ], - "cd_inf": [ - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.0003, - "expected": 0.48 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.0004, - "expected": 0.48 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.001, - "expected": 0.5223303379776745 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.002, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.003, - "expected": 0.6060819766216224 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.006, - "expected": 0.6164791843300216 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.01, - "expected": 0.62 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "d_hyd": 0.0003, - "expected": 0.48 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "d_hyd": 0.0004, - "expected": 0.48 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "d_hyd": 0.001, - "expected": 0.5223303379776745 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "d_hyd": 0.002, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "d_hyd": 0.003, - "expected": 0.6060819766216224 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "d_hyd": 0.006, - "expected": 0.6164791843300216 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "d_hyd": 0.01, - "expected": 0.62 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 0, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.0003, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 0, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.0004, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 0, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.001, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 0, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.002, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 0, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.003, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 0, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.006, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 0, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "d_hyd": 0.01, - "expected": 0.6 - } - ], - "cd_from_re": [ - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 0.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.35 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 0.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.35 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 100.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.582 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 100.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.46199999999999997 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 5000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.5974544155877284 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 5000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.4774544155877284 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 50000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.5991950155281001 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 50000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.47919501552810007 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 500000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.5997454415587729 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 500000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.4797454415587728 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "Re": 0.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.35 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "Re": 0.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.35 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "Re": 100.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.582 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "Re": 100.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.46199999999999997 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "Re": 5000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.5974544155877284 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "Re": 5000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.4774544155877284 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "Re": 50000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.5991950155281001 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "Re": 50000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.47919501552810007 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "Re": 500000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.5997454415587729 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0, - "Re": 500000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.4797454415587728 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 1, - "P_ref": 5000000.0, - "a_P": 0.05, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 0.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.35 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 1, - "P_ref": 5000000.0, - "a_P": 0.05, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 0.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.35 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 1, - "P_ref": 5000000.0, - "a_P": 0.05, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 100.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.58782 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 1, - "P_ref": 5000000.0, - "a_P": 0.05, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 100.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.46662 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 1, - "P_ref": 5000000.0, - "a_P": 0.05, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 5000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 1, - "P_ref": 5000000.0, - "a_P": 0.05, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 5000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.48 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 1, - "P_ref": 5000000.0, - "a_P": 0.05, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 50000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 1, - "P_ref": 5000000.0, - "a_P": 0.05, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 50000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.48 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 1, - "P_ref": 5000000.0, - "a_P": 0.05, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 500000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 1, - "P_ref": 5000000.0, - "a_P": 0.05, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0, - "Re": 500000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.48 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 1, - "T_ref": 90.0, - "a_T": 0.03, - "Re": 0.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.35 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 1, - "T_ref": 90.0, - "a_T": 0.03, - "Re": 0.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.35 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 1, - "T_ref": 90.0, - "a_T": 0.03, - "Re": 100.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 1, - "T_ref": 90.0, - "a_T": 0.03, - "Re": 100.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.48 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 1, - "T_ref": 90.0, - "a_T": 0.03, - "Re": 5000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 1, - "T_ref": 90.0, - "a_T": 0.03, - "Re": 5000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.48 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 1, - "T_ref": 90.0, - "a_T": 0.03, - "Re": 50000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 1, - "T_ref": 90.0, - "a_T": 0.03, - "Re": 50000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.48 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 1, - "T_ref": 90.0, - "a_T": 0.03, - "Re": 500000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.002, - "expected": 0.6 - }, - { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 1, - "T_ref": 90.0, - "a_T": 0.03, - "Re": 500000.0, - "P_inlet": 6000000.0, - "T_inlet": 250.0, - "d_hyd": 0.0005, - "expected": 0.48 - } - ] -} \ No newline at end of file diff --git a/EngineDesign/engine/native/tests/golden/golden_impinging.json b/EngineDesign/engine/native/tests/golden/golden_impinging.json deleted file mode 100644 index f354d8890..000000000 --- a/EngineDesign/engine/native/tests/golden/golden_impinging.json +++ /dev/null @@ -1,824 +0,0 @@ -[ - { - "P_tank_O": 3309483.5007206397, - "P_tank_F": 3335358.5871420833, - "Pc": 2082692.755019595, - "mdot_O": 1.481931953271966, - "mdot_F": 0.8214389566624514, - "mdot_total": 2.3033709099344173, - "MR": 1.8040682649056863, - "F": 6107.440727798992, - "Isp": 270.3800895929381, - "v_exit": 2741.7805535939137, - "P_exit": 68591.15354373088, - "P_throat": 1187622.278328788, - "T_exit": 1556.2605873892112, - "Tc": 2981.316950412318, - "Cf_actual": 1.656394011058738, - "Cf_ideal": 1.4667798673398207, - "eps": 4.608954911214158, - "A_throat": 0.0017703959321061754, - "A_exit": 0.008159675026074325, - "cstar_actual": 1600.7803022144597, - "cstar_ideal": 1835.0419461618608, - "eta_cstar": 0.8723398969503785, - "gamma": 1.1707059398987925, - "gamma_exit": 1.2393065352693462, - "R": 464.08911327718874, - "R_exit": 499.8418384838424, - "Cd_F": 0.46426856249999987, - "A_geom_F": 6.283185307179586e-05, - "A_eff_F": 2.9170854104853863e-05, - "stability.stability_score": 0.8297410118395354, - "stability.is_stable": 1, - "stability.chugging.frequency": 100.259138015628, - "stability.chugging.frequency_residence": 231.39292248159666, - "stability.chugging.frequency_helmholtz": 1252.903951656863, - "stability.chugging.period": 0.0013474393396946775, - "stability.chugging.stability_index": 0.9, - "stability.chugging.stability_margin": 1.2999998638027748, - "stability.chugging.tau_residence": 0.0006878124939389768, - "stability.chugging.Lstar": 1.1010366919145165, - "stability.chugging.chug_gain_margin": 2.329833596615, - "stability.acoustic.sound_speed": 1272.7077126517377, - "stability.acoustic.modes.L1": 1767.6496009051912, - "stability.acoustic.modes.L2": 5302.948802715573, - "stability.acoustic.modes.L3": 8838.248004525956, - "stability.acoustic.modes.L4": 12373.547206336338, - "stability.acoustic.modes.L5": 15908.846408146721, - "stability.acoustic.modes.T1": 6352.141263089474, - "stability.acoustic.modes.T2": 10537.244555870906, - "stability.acoustic.modes.T3": 13219.54572567189, - "stability.acoustic.modes.T4": 14494.265825763297, - "stability.acoustic.modes.T5": 18393.67145835048, - "stability.acoustic.stability_margin": 1.223383455327791, - "stability.acoustic.alpha_max": -610.7506691437475, - "stability.feed_system.pogo_frequency": 286.7696673382022, - "stability.feed_system.surge_frequency": 573.5393346764045, - "stability.feed_system.water_hammer_pressure": 21643703.948546037, - "stability.feed_system.water_hammer_margin": 0.09622626330367563, - "stability.feed_system.stability_margin": 1.2999998638027748, - "stability.feed_system.sound_speed": 1147.078669352809, - "stability.Lstar": 1.1010366919145165, - "stability_results.stability_score": 0.8297410118395354, - "stability_results.is_stable": 1, - "stability_results.chugging.frequency": 100.259138015628, - "stability_results.chugging.frequency_residence": 231.39292248159666, - "stability_results.chugging.frequency_helmholtz": 1252.903951656863, - "stability_results.chugging.period": 0.0013474393396946775, - "stability_results.chugging.stability_index": 0.9, - "stability_results.chugging.stability_margin": 1.2999998638027748, - "stability_results.chugging.tau_residence": 0.0006878124939389768, - "stability_results.chugging.Lstar": 1.1010366919145165, - "stability_results.chugging.chug_gain_margin": 2.329833596615, - "stability_results.acoustic.sound_speed": 1272.7077126517377, - "stability_results.acoustic.modes.L1": 1767.6496009051912, - "stability_results.acoustic.modes.L2": 5302.948802715573, - "stability_results.acoustic.modes.L3": 8838.248004525956, - "stability_results.acoustic.modes.L4": 12373.547206336338, - "stability_results.acoustic.modes.L5": 15908.846408146721, - "stability_results.acoustic.modes.T1": 6352.141263089474, - "stability_results.acoustic.modes.T2": 10537.244555870906, - "stability_results.acoustic.modes.T3": 13219.54572567189, - "stability_results.acoustic.modes.T4": 14494.265825763297, - "stability_results.acoustic.modes.T5": 18393.67145835048, - "stability_results.acoustic.stability_margin": 1.223383455327791, - "stability_results.acoustic.alpha_max": -610.7506691437475, - "stability_results.feed_system.pogo_frequency": 286.7696673382022, - "stability_results.feed_system.surge_frequency": 573.5393346764045, - "stability_results.feed_system.water_hammer_pressure": 21643703.948546037, - "stability_results.feed_system.water_hammer_margin": 0.09622626330367563, - "stability_results.feed_system.stability_margin": 1.2999998638027748, - "stability_results.feed_system.sound_speed": 1147.078669352809, - "stability_results.Lstar": 1.1010366919145165, - "pressure_profile.P_throat": 2082692.755019595, - "chamber_intrinsics.A_throat": 0.0017703959321061754, - "injector_pressure.P_injector_F": 3020887.010603859, - "injector_pressure.delta_p_injector_F": 938194.2555842642, - "injector_pressure.delta_p_feed_O": 94854.07029729921, - "injector_pressure.delta_p_feed_F": 314471.5765382244, - "diagnostics.Pc": 2082692.755019595, - "diagnostics.mdot_O": 1.481931953271966, - "diagnostics.mdot_F": 0.8214389566624514, - "diagnostics.mdot_total": 2.3033709099344173, - "diagnostics.MR": 1.8040682649056863, - "diagnostics.cstar_ideal": 1835.0419461618608, - "diagnostics.Tc_ideal": 3008.3019007527582, - "diagnostics.cstar_actual": 1600.7803022144597, - "diagnostics.eta_cstar": 0.8723398969503785, - "diagnostics.Tc": 2981.316950412318, - "diagnostics.gamma": 1.1707059398987925, - "diagnostics.R": 464.08911327718874, - "diagnostics.TMR": 0.43027091789803024, - "diagnostics.We_F": 449.7210459983448, - "diagnostics.D32_F": 7.233005908636332e-05, - "diagnostics.turbulence_intensity_F": 0.03442226633781292, - "diagnostics.turbulence_length_F": 0.00014000000000000001, - "diagnostics.Oh_F": 0.0011275845138450941, - "diagnostics.D_pitch_F": 0.03819718634205488, - "diagnostics.element_gap_F": 0.004, - "diagnostics.Cd_F": 0.46426856249999987, - "diagnostics.u_F": 30.936126455532023, - "diagnostics.P_injector_F": 3020887.010603859, - "diagnostics.delta_p_injector_F": 938194.2555842642, - "diagnostics.delta_p_feed_O": 94854.07029729921, - "diagnostics.delta_p_feed_F": 314471.5765382244, - "diagnostics.feed_orifice_coupling_iterations": 22.0, - "diagnostics.mdot_from_bernoulli_F": 0.8214389164655095, - "diagnostics.A_jet_F": 3.141592653589793e-06, - "diagnostics.momentum_ratio_n_elements_O": 20.0, - "diagnostics.momentum_ratio_n_elements_F": 20.0, - "diagnostics.d_jet_F": 0.002, - "diagnostics.v_F_bulk": 30.936126455532026, - "diagnostics.rho_F_momentum": 422.6, - "diagnostics.momentum_ratio_R": 1.0984120475439563, - "diagnostics.A_geom_F": 6.283185307179586e-05, - "diagnostics.A_eff_F": 2.9170854104853863e-05, - "_ok": 1 - }, - { - "P_tank_O": 3502536.704929344, - "P_tank_F": 3584697.487464803, - "Pc": 2181790.4470740934, - "mdot_O": 1.5376330711761417, - "mdot_F": 0.8693048985216494, - "mdot_total": 2.406937969697791, - "MR": 1.7688075539331014, - "F": 6420.213133819532, - "Isp": 271.99684851669065, - "v_exit": 2742.9306206710326, - "P_exit": 71783.20658133044, - "P_throat": 1243900.6902897183, - "T_exit": 1555.5739854682877, - "Tc": 2984.8227096621476, - "Cf_actual": 1.6621338984311391, - "Cf_ideal": 1.466761273567351, - "eps": 4.608954911214158, - "A_throat": 0.0017703959321061754, - "A_exit": 0.008159675026074325, - "cstar_actual": 1604.7912249232002, - "cstar_ideal": 1835.3035967711232, - "eta_cstar": 0.874400958918477, - "gamma": 1.1712373512724348, - "gamma_exit": 1.2397937997126776, - "R": 463.9832270183142, - "R_exit": 499.9821416274274, - "Cd_F": 0.46426856249999987, - "A_geom_F": 6.283185307179586e-05, - "A_eff_F": 2.9170854104853863e-05, - "stability.stability_score": 0.8051209128945793, - "stability.is_stable": 1, - "stability.chugging.frequency": 107.42200365971638, - "stability.chugging.frequency_residence": 231.9727016843638, - "stability.chugging.frequency_helmholtz": 1253.7818270036737, - "stability.chugging.period": 0.0013461173843878878, - "stability.chugging.stability_index": 0.9, - "stability.chugging.stability_margin": 1.2999999832648186, - "stability.chugging.tau_residence": 0.0006860934150279943, - "stability.chugging.Lstar": 1.1010366919145165, - "stability.chugging.chug_gain_margin": 2.539492701985363, - "stability.acoustic.sound_speed": 1273.5994639493174, - "stability.acoustic.modes.L1": 1768.888144374052, - "stability.acoustic.modes.L2": 5306.664433122156, - "stability.acoustic.modes.L3": 8844.44072187026, - "stability.acoustic.modes.L4": 12382.217010618362, - "stability.acoustic.modes.L5": 15919.99329936647, - "stability.acoustic.modes.T1": 6356.592033802544, - "stability.acoustic.modes.T2": 10544.62771338005, - "stability.acoustic.modes.T3": 13228.808297853306, - "stability.acoustic.modes.T4": 14504.421559266835, - "stability.acoustic.modes.T5": 18406.55939815566, - "stability.acoustic.stability_margin": 1.2123044108025607, - "stability.acoustic.alpha_max": -532.523698406307, - "stability.feed_system.pogo_frequency": 286.7696673382022, - "stability.feed_system.surge_frequency": 573.5393346764045, - "stability.feed_system.water_hammer_pressure": 22457222.07457013, - "stability.feed_system.water_hammer_margin": 0.0971531759284104, - "stability.feed_system.stability_margin": 1.2999999832648186, - "stability.feed_system.sound_speed": 1147.078669352809, - "stability.Lstar": 1.1010366919145165, - "stability_results.stability_score": 0.8051209128945793, - "stability_results.is_stable": 1, - "stability_results.chugging.frequency": 107.42200365971638, - "stability_results.chugging.frequency_residence": 231.9727016843638, - "stability_results.chugging.frequency_helmholtz": 1253.7818270036737, - "stability_results.chugging.period": 0.0013461173843878878, - "stability_results.chugging.stability_index": 0.9, - "stability_results.chugging.stability_margin": 1.2999999832648186, - "stability_results.chugging.tau_residence": 0.0006860934150279943, - "stability_results.chugging.Lstar": 1.1010366919145165, - "stability_results.chugging.chug_gain_margin": 2.539492701985363, - "stability_results.acoustic.sound_speed": 1273.5994639493174, - "stability_results.acoustic.modes.L1": 1768.888144374052, - "stability_results.acoustic.modes.L2": 5306.664433122156, - "stability_results.acoustic.modes.L3": 8844.44072187026, - "stability_results.acoustic.modes.L4": 12382.217010618362, - "stability_results.acoustic.modes.L5": 15919.99329936647, - "stability_results.acoustic.modes.T1": 6356.592033802544, - "stability_results.acoustic.modes.T2": 10544.62771338005, - "stability_results.acoustic.modes.T3": 13228.808297853306, - "stability_results.acoustic.modes.T4": 14504.421559266835, - "stability_results.acoustic.modes.T5": 18406.55939815566, - "stability_results.acoustic.stability_margin": 1.2123044108025607, - "stability_results.acoustic.alpha_max": -532.523698406307, - "stability_results.feed_system.pogo_frequency": 286.7696673382022, - "stability_results.feed_system.surge_frequency": 573.5393346764045, - "stability_results.feed_system.water_hammer_pressure": 22457222.07457013, - "stability_results.feed_system.water_hammer_margin": 0.0971531759284104, - "stability_results.feed_system.stability_margin": 1.2999999832648186, - "stability_results.feed_system.sound_speed": 1147.078669352809, - "stability_results.Lstar": 1.1010366919145165, - "pressure_profile.P_throat": 2181790.4470740934, - "chamber_intrinsics.A_throat": 0.0017703959321061754, - "injector_pressure.P_injector_F": 3232509.0766252372, - "injector_pressure.delta_p_injector_F": 1050718.6295511439, - "injector_pressure.delta_p_feed_O": 102118.6040296604, - "injector_pressure.delta_p_feed_F": 352188.4108395654, - "diagnostics.Pc": 2181790.4470740934, - "diagnostics.mdot_O": 1.5376330711761417, - "diagnostics.mdot_F": 0.8693048985216494, - "diagnostics.mdot_total": 2.406937969697791, - "diagnostics.MR": 1.7688075539331014, - "diagnostics.cstar_ideal": 1835.3035967711232, - "diagnostics.Tc_ideal": 3010.811797548269, - "diagnostics.cstar_actual": 1604.7912249232002, - "diagnostics.eta_cstar": 0.874400958918477, - "diagnostics.Tc": 2984.8227096621476, - "diagnostics.gamma": 1.1712373512724348, - "diagnostics.R": 463.9832270183142, - "diagnostics.TMR": 0.4188832927856789, - "diagnostics.We_F": 520.3517052335316, - "diagnostics.D32_F": 6.887865132066223e-05, - "diagnostics.turbulence_intensity_F": 0.03417943321034006, - "diagnostics.turbulence_length_F": 0.00014000000000000001, - "diagnostics.Oh_F": 0.0011275845138450941, - "diagnostics.D_pitch_F": 0.03819718634205488, - "diagnostics.element_gap_F": 0.004, - "diagnostics.Cd_F": 0.46426856249999987, - "diagnostics.u_F": 32.738800675276615, - "diagnostics.P_injector_F": 3232509.0766252372, - "diagnostics.delta_p_injector_F": 1050718.6295511439, - "diagnostics.delta_p_feed_O": 102118.6040296604, - "diagnostics.delta_p_feed_F": 352188.4108395654, - "diagnostics.feed_orifice_coupling_iterations": 22.0, - "diagnostics.mdot_from_bernoulli_F": 0.8693048559823975, - "diagnostics.A_jet_F": 3.141592653589793e-06, - "diagnostics.momentum_ratio_n_elements_O": 20.0, - "diagnostics.momentum_ratio_n_elements_F": 20.0, - "diagnostics.d_jet_F": 0.002, - "diagnostics.v_F_bulk": 32.738800675276615, - "diagnostics.rho_F_momentum": 422.6, - "diagnostics.momentum_ratio_R": 1.0769434642920486, - "diagnostics.A_geom_F": 6.283185307179586e-05, - "diagnostics.A_eff_F": 2.9170854104853863e-05, - "_ok": 1 - }, - { - "P_tank_O": 3695589.909138048, - "P_tank_F": 3752613.4868412507, - "Pc": 2265748.2279162444, - "mdot_O": 1.599878467121894, - "mdot_F": 0.8949391076675977, - "mdot_total": 2.494817574789492, - "MR": 1.787695334145715, - "F": 6685.65333885473, - "Isp": 273.2652348160339, - "v_exit": 2743.8494567307134, - "P_exit": 74491.67436663192, - "P_throat": 1291584.6277954793, - "T_exit": 1555.148533037541, - "Tc": 2987.5726189946, - "Cf_actual": 1.6667167577806563, - "Cf_ideal": 1.4667473194700336, - "eps": 4.608954911214158, - "A_throat": 0.0017703959321061754, - "A_exit": 0.008159675026074325, - "cstar_actual": 1607.8415858595713, - "cstar_ideal": 1835.5050454445266, - "eta_cstar": 0.8759668571056315, - "gamma": 1.17164333258417, - "gamma_exit": 1.2401468157049838, - "R": 463.89930252693125, - "R_exit": 500.09186810870267, - "Cd_F": 0.46426856249999987, - "A_geom_F": 6.283185307179586e-05, - "A_eff_F": 2.9170854104853863e-05, - "stability.stability_score": 0.7842875202766231, - "stability.is_stable": 1, - "stability.chugging.frequency": 112.89018901506468, - "stability.chugging.frequency_residence": 232.41363160504952, - "stability.chugging.frequency_helmholtz": 1254.4631569529695, - "stability.chugging.period": 0.001345101366428358, - "stability.chugging.stability_index": 0.9, - "stability.chugging.stability_margin": 1.299999996962974, - "stability.chugging.tau_residence": 0.0006847917740141602, - "stability.chugging.Lstar": 1.1010366919145165, - "stability.chugging.chug_gain_margin": 2.7101561461252026, - "stability.acoustic.sound_speed": 1274.291563196177, - "stability.acoustic.modes.L1": 1769.8493933280238, - "stability.acoustic.modes.L2": 5309.548179984072, - "stability.acoustic.modes.L3": 8849.24696664012, - "stability.acoustic.modes.L4": 12388.945753296168, - "stability.acoustic.modes.L5": 15928.644539952214, - "stability.acoustic.modes.T1": 6360.046332177912, - "stability.acoustic.modes.T2": 10550.35787353277, - "stability.acoustic.modes.T3": 13235.99709505286, - "stability.acoustic.modes.T4": 14512.303550050796, - "stability.acoustic.modes.T5": 18416.561888151406, - "stability.acoustic.stability_margin": 1.2029293841244804, - "stability.acoustic.alpha_max": -472.50552923605846, - "stability.feed_system.pogo_frequency": 286.7696673382022, - "stability.feed_system.surge_frequency": 573.5393346764045, - "stability.feed_system.water_hammer_pressure": 23366319.768992167, - "stability.feed_system.water_hammer_margin": 0.09696641363793039, - "stability.feed_system.stability_margin": 1.299999996962974, - "stability.feed_system.sound_speed": 1147.078669352809, - "stability.Lstar": 1.1010366919145165, - "stability_results.stability_score": 0.7842875202766231, - "stability_results.is_stable": 1, - "stability_results.chugging.frequency": 112.89018901506468, - "stability_results.chugging.frequency_residence": 232.41363160504952, - "stability_results.chugging.frequency_helmholtz": 1254.4631569529695, - "stability_results.chugging.period": 0.001345101366428358, - "stability_results.chugging.stability_index": 0.9, - "stability_results.chugging.stability_margin": 1.299999996962974, - "stability_results.chugging.tau_residence": 0.0006847917740141602, - "stability_results.chugging.Lstar": 1.1010366919145165, - "stability_results.chugging.chug_gain_margin": 2.7101561461252026, - "stability_results.acoustic.sound_speed": 1274.291563196177, - "stability_results.acoustic.modes.L1": 1769.8493933280238, - "stability_results.acoustic.modes.L2": 5309.548179984072, - "stability_results.acoustic.modes.L3": 8849.24696664012, - "stability_results.acoustic.modes.L4": 12388.945753296168, - "stability_results.acoustic.modes.L5": 15928.644539952214, - "stability_results.acoustic.modes.T1": 6360.046332177912, - "stability_results.acoustic.modes.T2": 10550.35787353277, - "stability_results.acoustic.modes.T3": 13235.99709505286, - "stability_results.acoustic.modes.T4": 14512.303550050796, - "stability_results.acoustic.modes.T5": 18416.561888151406, - "stability_results.acoustic.stability_margin": 1.2029293841244804, - "stability_results.acoustic.alpha_max": -472.50552923605846, - "stability_results.feed_system.pogo_frequency": 286.7696673382022, - "stability_results.feed_system.surge_frequency": 573.5393346764045, - "stability_results.feed_system.water_hammer_pressure": 23366319.768992167, - "stability_results.feed_system.water_hammer_margin": 0.09696641363793039, - "stability_results.feed_system.stability_margin": 1.299999996962974, - "stability_results.feed_system.sound_speed": 1147.078669352809, - "stability_results.Lstar": 1.1010366919145165, - "pressure_profile.P_throat": 2265748.2279162444, - "chamber_intrinsics.A_throat": 0.0017703959321061754, - "injector_pressure.P_injector_F": 3379348.047580558, - "injector_pressure.delta_p_injector_F": 1113599.8196643135, - "injector_pressure.delta_p_feed_O": 110553.7385408938, - "injector_pressure.delta_p_feed_F": 373265.4392606929, - "diagnostics.Pc": 2265748.2279162444, - "diagnostics.mdot_O": 1.599878467121894, - "diagnostics.mdot_F": 0.8949391076675977, - "diagnostics.mdot_total": 2.494817574789492, - "diagnostics.MR": 1.787695334145715, - "diagnostics.cstar_ideal": 1835.5050454445266, - "diagnostics.Tc_ideal": 3012.770117548031, - "diagnostics.cstar_actual": 1607.8415858595713, - "diagnostics.eta_cstar": 0.8759668571056315, - "diagnostics.Tc": 2987.5726189946, - "diagnostics.gamma": 1.17164333258417, - "diagnostics.R": 463.89930252693125, - "diagnostics.TMR": 0.42497790032341687, - "diagnostics.We_F": 576.9893246594024, - "diagnostics.D32_F": 6.657449993850682e-05, - "diagnostics.turbulence_intensity_F": 0.03405549417633275, - "diagnostics.turbulence_length_F": 0.00014000000000000001, - "diagnostics.Oh_F": 0.0011275845138450941, - "diagnostics.D_pitch_F": 0.03819718634205488, - "diagnostics.element_gap_F": 0.004, - "diagnostics.Cd_F": 0.46426856249999987, - "diagnostics.u_F": 33.70420793931569, - "diagnostics.P_injector_F": 3379348.047580558, - "diagnostics.delta_p_injector_F": 1113599.8196643135, - "diagnostics.delta_p_feed_O": 110553.7385408938, - "diagnostics.delta_p_feed_F": 373265.4392606929, - "diagnostics.feed_orifice_coupling_iterations": 22.0, - "diagnostics.mdot_from_bernoulli_F": 0.8949390638739414, - "diagnostics.A_jet_F": 3.141592653589793e-06, - "diagnostics.momentum_ratio_n_elements_O": 20.0, - "diagnostics.momentum_ratio_n_elements_F": 20.0, - "diagnostics.d_jet_F": 0.002, - "diagnostics.v_F_bulk": 33.704207939315694, - "diagnostics.rho_F_momentum": 422.6, - "diagnostics.momentum_ratio_R": 1.0884433425064584, - "diagnostics.A_geom_F": 6.283185307179586e-05, - "diagnostics.A_eff_F": 2.9170854104853863e-05, - "_ok": 1 - }, - { - "P_tank_O": 3888643.113346752, - "P_tank_F": 3831804.2214190774, - "Pc": 2334071.557606035, - "mdot_O": 1.6682009926415116, - "mdot_F": 0.8982036802822216, - "mdot_total": 2.566404672923733, - "MR": 1.8572635909455988, - "F": 6901.916689992718, - "Isp": 274.23564271298704, - "v_exit": 2744.5665427529566, - "P_exit": 76697.49725020674, - "P_throat": 1330388.443511762, - "T_exit": 1554.8822118638645, - "Tc": 2989.698718749256, - "Cf_actual": 1.6702640872112686, - "Cf_ideal": 1.4667367918762118, - "eps": 4.608954911214158, - "A_throat": 0.0017703959321061754, - "A_exit": 0.008159675026074325, - "cstar_actual": 1610.1244026036286, - "cstar_ideal": 1835.6596707048807, - "eta_cstar": 0.8771366655265417, - "gamma": 1.1719533496926375, - "gamma_exit": 1.2404045766785279, - "R": 463.83366878860795, - "R_exit": 500.17454630646114, - "Cd_F": 0.46426856249999987, - "A_geom_F": 6.283185307179586e-05, - "A_eff_F": 2.9170854104853863e-05, - "stability.stability_score": 0.7709424077270127, - "stability.is_stable": 1, - "stability.chugging.frequency": 116.56904094067563, - "stability.chugging.frequency_residence": 232.74361295050124, - "stability.chugging.frequency_helmholtz": 1254.986670397318, - "stability.chugging.period": 0.001344329696307201, - "stability.chugging.stability_index": 0.9, - "stability.chugging.stability_margin": 1.2999999991092948, - "stability.chugging.tau_residence": 0.0006838208837367479, - "stability.chugging.Lstar": 1.1010366919145165, - "stability.chugging.chug_gain_margin": 2.832818202149786, - "stability.acoustic.sound_speed": 1274.823351444924, - "stability.acoustic.modes.L1": 1770.5879881179499, - "stability.acoustic.modes.L2": 5311.76396435385, - "stability.acoustic.modes.L3": 8852.93994058975, - "stability.acoustic.modes.L4": 12394.115916825649, - "stability.acoustic.modes.L5": 15935.291893061549, - "stability.acoustic.modes.T1": 6362.700511173222, - "stability.acoustic.modes.T2": 10554.760756278965, - "stability.acoustic.modes.T3": 13241.52075064228, - "stability.acoustic.modes.T4": 14518.35983474502, - "stability.acoustic.modes.T5": 18424.247500673137, - "stability.acoustic.stability_margin": 1.1969240834771557, - "stability.acoustic.alpha_max": -436.4857472637127, - "stability.feed_system.pogo_frequency": 286.7696673382022, - "stability.feed_system.surge_frequency": 573.5393346764045, - "stability.feed_system.water_hammer_pressure": 24364174.300773226, - "stability.feed_system.water_hammer_margin": 0.09579932932641844, - "stability.feed_system.stability_margin": 1.2999999991092948, - "stability.feed_system.sound_speed": 1147.078669352809, - "stability.Lstar": 1.1010366919145165, - "stability_results.stability_score": 0.7709424077270127, - "stability_results.is_stable": 1, - "stability_results.chugging.frequency": 116.56904094067563, - "stability_results.chugging.frequency_residence": 232.74361295050124, - "stability_results.chugging.frequency_helmholtz": 1254.986670397318, - "stability_results.chugging.period": 0.001344329696307201, - "stability_results.chugging.stability_index": 0.9, - "stability_results.chugging.stability_margin": 1.2999999991092948, - "stability_results.chugging.tau_residence": 0.0006838208837367479, - "stability_results.chugging.Lstar": 1.1010366919145165, - "stability_results.chugging.chug_gain_margin": 2.832818202149786, - "stability_results.acoustic.sound_speed": 1274.823351444924, - "stability_results.acoustic.modes.L1": 1770.5879881179499, - "stability_results.acoustic.modes.L2": 5311.76396435385, - "stability_results.acoustic.modes.L3": 8852.93994058975, - "stability_results.acoustic.modes.L4": 12394.115916825649, - "stability_results.acoustic.modes.L5": 15935.291893061549, - "stability_results.acoustic.modes.T1": 6362.700511173222, - "stability_results.acoustic.modes.T2": 10554.760756278965, - "stability_results.acoustic.modes.T3": 13241.52075064228, - "stability_results.acoustic.modes.T4": 14518.35983474502, - "stability_results.acoustic.modes.T5": 18424.247500673137, - "stability_results.acoustic.stability_margin": 1.1969240834771557, - "stability_results.acoustic.alpha_max": -436.4857472637127, - "stability_results.feed_system.pogo_frequency": 286.7696673382022, - "stability_results.feed_system.surge_frequency": 573.5393346764045, - "stability_results.feed_system.water_hammer_pressure": 24364174.300773226, - "stability_results.feed_system.water_hammer_margin": 0.09579932932641844, - "stability_results.feed_system.stability_margin": 1.2999999991092948, - "stability_results.feed_system.sound_speed": 1147.078669352809, - "stability_results.Lstar": 1.1010366919145165, - "pressure_profile.P_throat": 2334071.557606035, - "chamber_intrinsics.A_throat": 0.0017703959321061754, - "injector_pressure.P_injector_F": 3455810.6084823855, - "injector_pressure.delta_p_injector_F": 1121739.0508763506, - "injector_pressure.delta_p_feed_O": 120197.71109876427, - "injector_pressure.delta_p_feed_F": 375993.6129366919, - "diagnostics.Pc": 2334071.557606035, - "diagnostics.mdot_O": 1.6682009926415116, - "diagnostics.mdot_F": 0.8982036802822216, - "diagnostics.mdot_total": 2.566404672923733, - "diagnostics.MR": 1.8572635909455988, - "diagnostics.cstar_ideal": 1835.6596707048807, - "diagnostics.Tc_ideal": 3014.286383042172, - "diagnostics.cstar_actual": 1610.1244026036286, - "diagnostics.eta_cstar": 0.8771366655265417, - "diagnostics.Tc": 2989.698718749256, - "diagnostics.gamma": 1.1719533496926375, - "diagnostics.R": 463.83366878860795, - "diagnostics.TMR": 0.44752924876302264, - "diagnostics.We_F": 615.3381181296968, - "diagnostics.D32_F": 6.522903630703802e-05, - "diagnostics.turbulence_intensity_F": 0.03403999743784308, - "diagnostics.turbulence_length_F": 0.00014000000000000001, - "diagnostics.Oh_F": 0.0011275845138450941, - "diagnostics.D_pitch_F": 0.03819718634205488, - "diagnostics.element_gap_F": 0.004, - "diagnostics.Cd_F": 0.46426856249999987, - "diagnostics.u_F": 33.82715466640983, - "diagnostics.P_injector_F": 3455810.6084823855, - "diagnostics.delta_p_injector_F": 1121739.0508763506, - "diagnostics.delta_p_feed_O": 120197.71109876427, - "diagnostics.delta_p_feed_F": 375993.6129366919, - "diagnostics.feed_orifice_coupling_iterations": 22.0, - "diagnostics.mdot_from_bernoulli_F": 0.8982036363288141, - "diagnostics.A_jet_F": 3.141592653589793e-06, - "diagnostics.momentum_ratio_n_elements_O": 20.0, - "diagnostics.momentum_ratio_n_elements_F": 20.0, - "diagnostics.d_jet_F": 0.002, - "diagnostics.v_F_bulk": 33.82715466640983, - "diagnostics.rho_F_momentum": 422.6, - "diagnostics.momentum_ratio_R": 1.1308001717252347, - "diagnostics.A_geom_F": 6.283185307179586e-05, - "diagnostics.A_eff_F": 2.9170854104853863e-05, - "_ok": 1 - }, - { - "P_tank_O": 4081696.317555456, - "P_tank_F": 4040362.1686195806, - "Pc": 2421341.498508913, - "mdot_O": 1.7240246468008442, - "mdot_F": 0.9338645211505808, - "mdot_total": 2.657889167951425, - "MR": 1.8461185833216316, - "F": 7178.350373117722, - "Isp": 275.4020124754155, - "v_exit": 2745.459020531676, - "P_exit": 79513.33143444009, - "P_throat": 1379940.6823654764, - "T_exit": 1554.58489038831, - "Tc": 2992.366697087295, - "Cf_actual": 1.674550325392375, - "Cf_ideal": 1.4667233448955859, - "eps": 4.608954911214158, - "A_throat": 0.0017703959321061754, - "A_exit": 0.008159675026074325, - "cstar_actual": 1612.8336692861646, - "cstar_ideal": 1835.8571747989354, - "eta_cstar": 0.8785180521806134, - "gamma": 1.1723493370494844, - "gamma_exit": 1.2407231909581842, - "R": 463.7498342844831, - "R_exit": 500.26867564056056, - "Cd_F": 0.46426856249999987, - "A_geom_F": 6.283185307179586e-05, - "A_eff_F": 2.9170854104853863e-05, - "stability.stability_score": 0.7462510115566912, - "stability.is_stable": 1, - "stability.chugging.frequency": 122.78904913293654, - "stability.chugging.frequency_residence": 233.135237669138, - "stability.chugging.frequency_helmholtz": 1255.6451231350366, - "stability.chugging.period": 0.0013433815038503645, - "stability.chugging.stability_index": 0.9, - "stability.chugging.stability_margin": 1.2999999998849558, - "stability.chugging.tau_residence": 0.0006826721892542286, - "stability.chugging.Lstar": 1.1010366919145165, - "stability.chugging.chug_gain_margin": 3.0374878922434685, - "stability.acoustic.sound_speed": 1275.4922118764061, - "stability.acoustic.modes.L1": 1771.5169609394532, - "stability.acoustic.modes.L2": 5314.550882818359, - "stability.acoustic.modes.L3": 8857.584804697266, - "stability.acoustic.modes.L4": 12400.618726576171, - "stability.acoustic.modes.L5": 15943.652648455078, - "stability.acoustic.modes.T1": 6366.038823578994, - "stability.acoustic.modes.T2": 10560.298513196922, - "stability.acoustic.modes.T3": 13248.46816753162, - "stability.acoustic.modes.T4": 14525.9771696585, - "stability.acoustic.modes.T5": 18433.914134186765, - "stability.acoustic.stability_margin": 1.185812955200511, - "stability.acoustic.alpha_max": -373.9929181390443, - "stability.feed_system.pogo_frequency": 286.7696673382022, - "stability.feed_system.surge_frequency": 573.5393346764045, - "stability.feed_system.water_hammer_pressure": 25179482.076061394, - "stability.feed_system.water_hammer_margin": 0.09616327655964488, - "stability.feed_system.stability_margin": 1.2999999998849558, - "stability.feed_system.sound_speed": 1147.078669352809, - "stability.Lstar": 1.1010366919145165, - "stability_results.stability_score": 0.7462510115566912, - "stability_results.is_stable": 1, - "stability_results.chugging.frequency": 122.78904913293654, - "stability_results.chugging.frequency_residence": 233.135237669138, - "stability_results.chugging.frequency_helmholtz": 1255.6451231350366, - "stability_results.chugging.period": 0.0013433815038503645, - "stability_results.chugging.stability_index": 0.9, - "stability_results.chugging.stability_margin": 1.2999999998849558, - "stability_results.chugging.tau_residence": 0.0006826721892542286, - "stability_results.chugging.Lstar": 1.1010366919145165, - "stability_results.chugging.chug_gain_margin": 3.0374878922434685, - "stability_results.acoustic.sound_speed": 1275.4922118764061, - "stability_results.acoustic.modes.L1": 1771.5169609394532, - "stability_results.acoustic.modes.L2": 5314.550882818359, - "stability_results.acoustic.modes.L3": 8857.584804697266, - "stability_results.acoustic.modes.L4": 12400.618726576171, - "stability_results.acoustic.modes.L5": 15943.652648455078, - "stability_results.acoustic.modes.T1": 6366.038823578994, - "stability_results.acoustic.modes.T2": 10560.298513196922, - "stability_results.acoustic.modes.T3": 13248.46816753162, - "stability_results.acoustic.modes.T4": 14525.9771696585, - "stability_results.acoustic.modes.T5": 18433.914134186765, - "stability_results.acoustic.stability_margin": 1.185812955200511, - "stability_results.acoustic.alpha_max": -373.9929181390443, - "stability_results.feed_system.pogo_frequency": 286.7696673382022, - "stability_results.feed_system.surge_frequency": 573.5393346764045, - "stability_results.feed_system.water_hammer_pressure": 25179482.076061394, - "stability_results.feed_system.water_hammer_margin": 0.09616327655964488, - "stability_results.feed_system.stability_margin": 1.2999999998849558, - "stability_results.feed_system.sound_speed": 1147.078669352809, - "stability_results.Lstar": 1.1010366919145165, - "pressure_profile.P_throat": 2421341.498508913, - "chamber_intrinsics.A_throat": 0.0017703959321061754, - "injector_pressure.P_injector_F": 3633920.18742895, - "injector_pressure.delta_p_injector_F": 1212578.688920037, - "injector_pressure.delta_p_feed_O": 128376.75314733684, - "injector_pressure.delta_p_feed_F": 406441.98119063076, - "diagnostics.Pc": 2421341.498508913, - "diagnostics.mdot_O": 1.7240246468008442, - "diagnostics.mdot_F": 0.9338645211505808, - "diagnostics.mdot_total": 2.657889167951425, - "diagnostics.MR": 1.8461185833216316, - "diagnostics.cstar_ideal": 1835.8571747989354, - "diagnostics.Tc_ideal": 3016.2231212056595, - "diagnostics.cstar_actual": 1612.8336692861646, - "diagnostics.eta_cstar": 0.8785180521806134, - "diagnostics.Tc": 2992.366697087295, - "diagnostics.gamma": 1.1723493370494844, - "diagnostics.R": 463.7498342844831, - "diagnostics.TMR": 0.44390581933622264, - "diagnostics.We_F": 687.0243219380731, - "diagnostics.D32_F": 6.287627970397679e-05, - "diagnostics.turbulence_intensity_F": 0.033874733559464645, - "diagnostics.turbulence_length_F": 0.00014000000000000001, - "diagnostics.Oh_F": 0.0011275845138450941, - "diagnostics.D_pitch_F": 0.03819718634205488, - "diagnostics.element_gap_F": 0.004, - "diagnostics.Cd_F": 0.46426856249999987, - "diagnostics.u_F": 35.17017385690033, - "diagnostics.P_injector_F": 3633920.18742895, - "diagnostics.delta_p_injector_F": 1212578.688920037, - "diagnostics.delta_p_feed_O": 128376.75314733684, - "diagnostics.delta_p_feed_F": 406441.98119063076, - "diagnostics.feed_orifice_coupling_iterations": 22.0, - "diagnostics.mdot_from_bernoulli_F": 0.9338644754521176, - "diagnostics.A_jet_F": 3.141592653589793e-06, - "diagnostics.momentum_ratio_n_elements_O": 20.0, - "diagnostics.momentum_ratio_n_elements_F": 20.0, - "diagnostics.d_jet_F": 0.002, - "diagnostics.v_F_bulk": 35.170173856900334, - "diagnostics.rho_F_momentum": 422.6, - "diagnostics.momentum_ratio_R": 1.1240145024230948, - "diagnostics.A_geom_F": 6.283185307179586e-05, - "diagnostics.A_eff_F": 2.9170854104853863e-05, - "_ok": 1 - }, - { - "P_tank_O": 4274749.52176416, - "P_tank_F": 4352016.332025177, - "Pc": 2522357.7607356887, - "mdot_O": 1.7711633545873195, - "mdot_F": 0.9927564257679795, - "mdot_total": 2.763919780355299, - "MR": 1.7840865177147331, - "F": 7498.740412752381, - "Isp": 276.6573483841649, - "v_exit": 2746.426389881708, - "P_exit": 82774.93278393493, - "P_throat": 1437295.0805557815, - "T_exit": 1554.315344657121, - "Tc": 2995.230814279817, - "Cf_actual": 1.6792340486055986, - "Cf_ideal": 1.4667095229668228, - "eps": 4.608954911214158, - "A_throat": 0.0017703959321061754, - "A_exit": 0.008159675026074325, - "cstar_actual": 1615.6662543762125, - "cstar_ideal": 1836.0687366601164, - "eta_cstar": 0.8799595691145937, - "gamma": 1.1727797257630348, - "gamma_exit": 1.2410515421841632, - "R": 463.660087560952, - "R_exit": 500.37111868934915, - "Cd_F": 0.46426856249999987, - "A_geom_F": 6.283185307179586e-05, - "A_eff_F": 2.9170854104853863e-05, - "stability.stability_score": 0.7156153439961277, - "stability.is_stable": 1, - "stability.chugging.frequency": 130.913669631794, - "stability.chugging.frequency_residence": 233.54468807357966, - "stability.chugging.frequency_helmholtz": 1256.3548822395512, - "stability.chugging.period": 0.001342372358413166, - "stability.chugging.stability_index": 0.9, - "stability.chugging.stability_margin": 1.299999999992743, - "stability.chugging.tau_residence": 0.0006814753287891207, - "stability.chugging.Lstar": 1.1010366919145165, - "stability.chugging.chug_gain_margin": 3.3138207039007845, - "stability.acoustic.sound_speed": 1276.2131896379065, - "stability.acoustic.modes.L1": 1772.5183189415368, - "stability.acoustic.modes.L2": 5317.554956824611, - "stability.acoustic.modes.L3": 8862.591594707685, - "stability.acoustic.modes.L4": 12407.628232590758, - "stability.acoustic.modes.L5": 15952.664870473833, - "stability.acoustic.modes.T1": 6369.637255915869, - "stability.acoustic.modes.T2": 10566.267769858723, - "stability.acoustic.modes.T3": 13255.956924290615, - "stability.acoustic.modes.T4": 14534.188044178838, - "stability.acoustic.modes.T5": 18444.333987812217, - "stability.acoustic.stability_margin": 1.1720269047982574, - "stability.acoustic.alpha_max": -302.60796878140656, - "stability.feed_system.pogo_frequency": 286.7696673382022, - "stability.feed_system.surge_frequency": 573.5393346764045, - "stability.feed_system.water_hammer_pressure": 25867945.695186995, - "stability.feed_system.water_hammer_margin": 0.09750900943034685, - "stability.feed_system.stability_margin": 1.299999999992743, - "stability.feed_system.sound_speed": 1147.078669352809, - "stability.Lstar": 1.1010366919145165, - "stability_results.stability_score": 0.7156153439961277, - "stability_results.is_stable": 1, - "stability_results.chugging.frequency": 130.913669631794, - "stability_results.chugging.frequency_residence": 233.54468807357966, - "stability_results.chugging.frequency_helmholtz": 1256.3548822395512, - "stability_results.chugging.period": 0.001342372358413166, - "stability_results.chugging.stability_index": 0.9, - "stability_results.chugging.stability_margin": 1.299999999992743, - "stability_results.chugging.tau_residence": 0.0006814753287891207, - "stability_results.chugging.Lstar": 1.1010366919145165, - "stability_results.chugging.chug_gain_margin": 3.3138207039007845, - "stability_results.acoustic.sound_speed": 1276.2131896379065, - "stability_results.acoustic.modes.L1": 1772.5183189415368, - "stability_results.acoustic.modes.L2": 5317.554956824611, - "stability_results.acoustic.modes.L3": 8862.591594707685, - "stability_results.acoustic.modes.L4": 12407.628232590758, - "stability_results.acoustic.modes.L5": 15952.664870473833, - "stability_results.acoustic.modes.T1": 6369.637255915869, - "stability_results.acoustic.modes.T2": 10566.267769858723, - "stability_results.acoustic.modes.T3": 13255.956924290615, - "stability_results.acoustic.modes.T4": 14534.188044178838, - "stability_results.acoustic.modes.T5": 18444.333987812217, - "stability_results.acoustic.stability_margin": 1.1720269047982574, - "stability_results.acoustic.alpha_max": -302.60796878140656, - "stability_results.feed_system.pogo_frequency": 286.7696673382022, - "stability_results.feed_system.surge_frequency": 573.5393346764045, - "stability_results.feed_system.water_hammer_pressure": 25867945.695186995, - "stability_results.feed_system.water_hammer_margin": 0.09750900943034685, - "stability_results.feed_system.stability_margin": 1.299999999992743, - "stability_results.feed_system.sound_speed": 1147.078669352809, - "stability_results.Lstar": 1.1010366919145165, - "pressure_profile.P_throat": 2522357.7607356887, - "chamber_intrinsics.A_throat": 0.0017703959321061754, - "injector_pressure.P_injector_F": 3892695.417631609, - "injector_pressure.delta_p_injector_F": 1370337.6568959202, - "injector_pressure.delta_p_feed_O": 135492.94520804033, - "injector_pressure.delta_p_feed_F": 459320.91439356783, - "diagnostics.Pc": 2522357.7607356887, - "diagnostics.mdot_O": 1.7711633545873195, - "diagnostics.mdot_F": 0.9927564257679795, - "diagnostics.mdot_total": 2.763919780355299, - "diagnostics.MR": 1.7840865177147331, - "diagnostics.cstar_ideal": 1836.0687366601164, - "diagnostics.Tc_ideal": 3018.291494632045, - "diagnostics.cstar_actual": 1615.6662543762125, - "diagnostics.eta_cstar": 0.8799595691145937, - "diagnostics.Tc": 2995.230814279817, - "diagnostics.gamma": 1.1727797257630348, - "diagnostics.R": 463.660087560952, - "diagnostics.TMR": 0.4238124756284007, - "diagnostics.We_F": 789.3040385475352, - "diagnostics.D32_F": 5.9993415480447315e-05, - "diagnostics.turbulence_intensity_F": 0.033616773975395954, - "diagnostics.turbulence_length_F": 0.00014000000000000001, - "diagnostics.Oh_F": 0.0011275845138450941, - "diagnostics.D_pitch_F": 0.03819718634205488, - "diagnostics.element_gap_F": 0.004, - "diagnostics.Cd_F": 0.46426856249999987, - "diagnostics.u_F": 37.38809570449981, - "diagnostics.P_injector_F": 3892695.417631609, - "diagnostics.delta_p_injector_F": 1370337.6568959202, - "diagnostics.delta_p_feed_O": 135492.94520804033, - "diagnostics.delta_p_feed_F": 459320.91439356783, - "diagnostics.feed_orifice_coupling_iterations": 22.0, - "diagnostics.mdot_from_bernoulli_F": 0.9927563771876531, - "diagnostics.A_jet_F": 3.141592653589793e-06, - "diagnostics.momentum_ratio_n_elements_O": 20.0, - "diagnostics.momentum_ratio_n_elements_F": 20.0, - "diagnostics.d_jet_F": 0.002, - "diagnostics.v_F_bulk": 37.38809570449982, - "diagnostics.rho_F_momentum": 422.6, - "diagnostics.momentum_ratio_R": 1.0862461044516263, - "diagnostics.A_geom_F": 6.283185307179586e-05, - "diagnostics.A_eff_F": 2.9170854104853863e-05, - "_ok": 1 - } -] \ No newline at end of file diff --git a/EngineDesign/engine/native/tests/golden/injector_impinging.json b/EngineDesign/engine/native/tests/golden/injector_impinging.json deleted file mode 100644 index 81a92acc1..000000000 --- a/EngineDesign/engine/native/tests/golden/injector_impinging.json +++ /dev/null @@ -1,587 +0,0 @@ -{ - "state": { - "injector.type": 1, - "imp_O.n_elements": 20, - "imp_O.d_jet": 0.002, - "imp_O.impingement_angle": 50.0, - "imp_O.spacing": 0.006, - "imp_F.n_elements": 20, - "imp_F.d_jet": 0.002, - "imp_F.impingement_angle": 60.0, - "imp_F.spacing": 0.006, - "discharge_O.Cd_inf": 0.6, - "discharge_O.a_Re": 0.18, - "discharge_O.Cd_min": 0.35, - "discharge_O.d_ref_m": 0.002, - "discharge_O.d_min_m": 0.0004, - "discharge_O.cd_small_hole_exponent": 0.2, - "discharge_O.cd_large_hole_log_gain": 0.015, - "discharge_O.cd_inf_max": 0.62, - "discharge_O.cd_inf_min_geom": 0.48, - "discharge_O.P_ref": 5000000.0, - "discharge_O.a_P": 0.0, - "discharge_O.T_ref": 90.0, - "discharge_O.a_T": 0.0, - "discharge_O.use_geometry_cd": 1, - "discharge_O.use_pressure_correction": 0, - "discharge_O.use_temperature_correction": 0, - "discharge_F.Cd_inf": 0.6, - "discharge_F.a_Re": 0.18, - "discharge_F.Cd_min": 0.35, - "discharge_F.d_ref_m": 0.002, - "discharge_F.d_min_m": 0.0004, - "discharge_F.cd_small_hole_exponent": 0.2, - "discharge_F.cd_large_hole_log_gain": 0.015, - "discharge_F.cd_inf_max": 0.62, - "discharge_F.cd_inf_min_geom": 0.48, - "discharge_F.P_ref": 5000000.0, - "discharge_F.a_P": 0.0, - "discharge_F.T_ref": 300.0, - "discharge_F.a_T": 0.0, - "discharge_F.use_geometry_cd": 1, - "discharge_F.use_pressure_correction": 0, - "discharge_F.use_temperature_correction": 0, - "feed_O.d_inlet": 0.013470353244117012, - "feed_O.A_hydraulic": 0.00014251150082346222, - "feed_O.K0": 2.0, - "feed_O.K1": 0.0, - "feed_O.phi_type": 0, - "feed_F.d_inlet": 0.009525, - "feed_F.A_hydraulic": 7.13e-05, - "feed_F.K0": 2.0, - "feed_F.K1": 0.0, - "feed_F.phi_type": 0, - "fluid_O.density": 1140.0, - "fluid_O.viscosity": 0.00018, - "fluid_O.surface_tension": 0.013, - "fluid_O.temperature": 90.0, - "fluid_F.density": 422.6, - "fluid_F.viscosity": 0.00012, - "fluid_F.surface_tension": 0.0134, - "fluid_F.temperature": 112.0, - "spray.smd_model": 1, - "spray.smd_C": 0.5, - "spray.smd_m": 0.6, - "spray.smd_p": 0.0, - "spray.smd_C_ingebo": 3.9, - "spray.smd_we_corr_max": 0.0, - "spray.chamber_gas_R": 360.0, - "spray.chamber_gas_T": 3500.0, - "spray.spray_angle_model": 1, - "spray.spray_angle_k": 0.5, - "spray.spray_angle_n": 0.5, - "spray.we_min": 15.0, - "spray.evap_K": 300000.0, - "spray.evap_x_star_limit": 0.05, - "spray.evap_use_constraint": 1, - "solver.closure_max_iterations": 6, - "solver.closure_Cd_reduction_factor": 0.95, - "cooling.regen_enabled": 0 - }, - "samples": [ - { - "P_tank_O": 3390241.678048665, - "P_tank_F": 3346690.6137495036, - "Pc": 1400000.0, - "mdot_O": 1.8875389624125136, - "mdot_F": 1.0240145959293858, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1222822619298312, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.769296221726347e-05, - "D32_F": 6.731846809559588e-05, - "We_O": 491.77282026360143, - "We_F": 477.0930345840909, - "J": 1.259517475442338, - "TMR": 0.4429814711756817, - "theta": 0.8657176648100757, - "x_star": 0.07292054367304399, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3509906.9768442707, - "P_tank_F": 3523502.6719813026, - "Pc": 1452173.9130434783, - "mdot_O": 1.919276451144707, - "mdot_F": 1.0562876377719645, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1062866161104454, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.606932319615457e-05, - "D32_F": 6.542394080074711e-05, - "We_O": 537.1820002761184, - "We_F": 521.1467166857865, - "J": 1.2238700769850999, - "TMR": 0.4344584065164749, - "theta": 0.8613554389786245, - "x_star": 0.07067861273790907, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3433379.0443754373, - "P_tank_F": 3422313.2799789323, - "Pc": 1504347.8260869565, - "mdot_O": 1.858286384077198, - "mdot_F": 1.0164313924412833, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.113132583881046, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.710851830992405e-05, - "D32_F": 6.663651544456488e-05, - "We_O": 517.5628785905654, - "We_F": 502.11324042368284, - "J": 1.2390641492976944, - "TMR": 0.43810342318889994, - "theta": 0.8632310513108755, - "x_star": 0.07071233718585686, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3507442.054092668, - "P_tank_F": 3451137.631695374, - "Pc": 1556521.7391304348, - "mdot_O": 1.8687998142787574, - "mdot_F": 1.0102253622454875, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.126307135355799, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.670337404143888e-05, - "D32_F": 6.616377682162117e-05, - "We_O": 533.5118512637029, - "We_F": 517.5861243603086, - "J": 1.2685677631533863, - "TMR": 0.44512960098936644, - "theta": 0.8668042855213483, - "x_star": 0.06958221059372502, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3563661.8728366997, - "P_tank_F": 3499734.5690398575, - "Pc": 1608695.652173913, - "mdot_O": 1.8707366109138646, - "mdot_F": 1.0092712747268675, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1285402511951443, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.624738126694037e-05, - "D32_F": 6.563170611728984e-05, - "We_O": 551.1475582256325, - "We_F": 534.6953923084493, - "J": 1.2736030985676001, - "TMR": 0.4463220497403038, - "theta": 0.867405286907593, - "x_star": 0.06845222882706332, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3537299.7617252213, - "P_tank_F": 3540069.829634444, - "Pc": 1660869.5652173914, - "mdot_O": 1.8327753001898925, - "mdot_F": 1.006107095778736, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1091169236003429, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.6195538575058696e-05, - "D32_F": 6.557121397985577e-05, - "We_O": 558.4140713193582, - "We_F": 541.7449945635564, - "J": 1.2301403502166885, - "TMR": 0.43596484902449145, - "theta": 0.8621324441905227, - "x_star": 0.06768618978485998, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3563716.850954555, - "P_tank_F": 3578079.7729575876, - "Pc": 1713043.4782608696, - "mdot_O": 1.8201530382579196, - "mdot_F": 1.0023082924623563, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1056531363310573, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.596805003671538e-05, - "D32_F": 6.530577120621368e-05, - "We_O": 570.3372506437677, - "We_F": 553.3122580872372, - "J": 1.2224688578787035, - "TMR": 0.4341213323961193, - "theta": 0.8611812239369533, - "x_star": 0.06681100528724823, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3627063.6305045723, - "P_tank_F": 3702564.078094531, - "Pc": 1765217.391304348, - "mdot_O": 1.8256390753820888, - "mdot_F": 1.0215540542000834, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.0880926875343047, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.499660868386419e-05, - "D32_F": 6.417225437495125e-05, - "We_O": 603.6053095241485, - "We_F": 585.5872405831292, - "J": 1.1839456966656263, - "TMR": 0.4247918814551939, - "theta": 0.8563068468882433, - "x_star": 0.06537854109450732, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3585493.3860678254, - "P_tank_F": 3610074.112613266, - "Pc": 1817391.3043478262, - "mdot_O": 1.7790849444960977, - "mdot_F": 0.9826738662420913, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.10229947056975, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.601662724935741e-05, - "D32_F": 6.536245305116905e-05, - "We_O": 580.3460193891401, - "We_F": 563.02225761633, - "J": 1.2150641228183514, - "TMR": 0.43233744911374716, - "theta": 0.8602570533388342, - "x_star": 0.0655449404876609, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3663292.275487114, - "P_tank_F": 3628992.9124278687, - "Pc": 1869565.2173913044, - "mdot_O": 1.7919306440790566, - "mdot_F": 0.9735166675631693, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1207019450697508, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.576451010470263e-05, - "D32_F": 6.506827262938934e-05, - "We_O": 592.937032070677, - "We_F": 575.2374191730448, - "J": 1.2559728496831233, - "TMR": 0.4421384193529402, - "theta": 0.8652898152784142, - "x_star": 0.06473449117474593, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3588476.9882924063, - "P_tank_F": 3666822.4366717115, - "Pc": 1921739.1304347827, - "mdot_O": 1.7273353729163496, - "mdot_F": 0.9695400674046599, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.0847339324645375, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.60385903686218e-05, - "D32_F": 6.53880805018434e-05, - "We_O": 590.6293400939269, - "We_F": 572.9986135239589, - "J": 1.1766477042399803, - "TMR": 0.42301067280459137, - "theta": 0.8553645105378133, - "x_star": 0.06435327815791418, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3650403.6371190525, - "P_tank_F": 3619623.1242605303, - "Pc": 1973913.0434782607, - "mdot_O": 1.7323816595186128, - "mdot_F": 0.9415303939582049, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1202670275820006, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.641421519741831e-05, - "D32_F": 6.582637465560908e-05, - "We_O": 585.4043545703001, - "We_F": 567.9295977174554, - "J": 1.2549982130874113, - "TMR": 0.44190644207935165, - "theta": 0.8651719479568649, - "x_star": 0.06406591689431397, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3753202.229008072, - "P_tank_F": 3767294.496230163, - "Pc": 2026086.9565217393, - "mdot_O": 1.758343332743321, - "mdot_F": 0.9684628123295462, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1054345919115696, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.5071832246955875e-05, - "D32_F": 6.426002825303633e-05, - "We_O": 629.6850323222882, - "We_F": 610.8884641932647, - "J": 1.2219856369946986, - "TMR": 0.4340050534096816, - "theta": 0.8611210954365156, - "x_star": 0.0624996628651932, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3716214.959843853, - "P_tank_F": 3761435.2475718046, - "Pc": 2078260.8695652173, - "mdot_O": 1.7123552876461534, - "mdot_F": 0.9521869724329209, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.0949239729654914, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.55642726594231e-05, - "D32_F": 6.483462752687527e-05, - "We_O": 620.1466596919951, - "We_F": 601.6348191041743, - "J": 1.1988585065745363, - "TMR": 0.42841784454069193, - "theta": 0.8582134505322785, - "x_star": 0.06234109411212537, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3676392.2559055085, - "P_tank_F": 3710639.6343703764, - "Pc": 2130434.782608696, - "mdot_O": 1.6635727133218658, - "mdot_F": 0.9226019643979693, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.0978417281612154, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.6503764931461526e-05, - "D32_F": 6.593086488600246e-05, - "We_O": 597.9512746939414, - "We_F": 580.1019829120327, - "J": 1.2052564600920037, - "TMR": 0.4299678545552162, - "theta": 0.8590237402940177, - "x_star": 0.06252295955709629, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3744798.140694227, - "P_tank_F": 3677094.830993113, - "Pc": 2182608.695652174, - "mdot_O": 1.672283352172907, - "mdot_F": 0.897229666083152, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1347980015622128, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.683914392086586e-05, - "D32_F": 6.632219857611748e-05, - "We_O": 593.3550329467744, - "We_F": 575.6429424110497, - "J": 1.2877665043495918, - "TMR": 0.4496658715023141, - "theta": 0.8690823175335002, - "x_star": 0.06226592002460493, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3805255.529764707, - "P_tank_F": 3876651.655185899, - "Pc": 2234782.6086956523, - "mdot_O": 1.6767111137096142, - "mdot_F": 0.9404310012983705, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.0855344897122432, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.5200982732907285e-05, - "D32_F": 6.441072623306566e-05, - "We_O": 646.5513374388546, - "We_F": 627.2512975153066, - "J": 1.1783851283548208, - "TMR": 0.4234351278896645, - "theta": 0.8555894113061705, - "x_star": 0.060584769937647436, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3763730.4769038376, - "P_tank_F": 3785257.04539359, - "Pc": 2286956.5217391304, - "mdot_O": 1.625923203621943, - "mdot_F": 0.8983739394595348, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1019330386027102, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.657405969146432e-05, - "D32_F": 6.601288763846081e-05, - "We_O": 610.2237827889211, - "We_F": 592.008147481789, - "J": 1.2142564215642027, - "TMR": 0.4321425977700203, - "theta": 0.8601558845190724, - "x_star": 0.0611133763412677, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3797259.107372712, - "P_tank_F": 3837263.5945666344, - "Pc": 2339130.434782609, - "mdot_O": 1.6156264023484634, - "mdot_F": 0.8983237627639282, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.0950157718287068, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.635265562062358e-05, - "D32_F": 6.575454446615152e-05, - "We_O": 621.2921407062784, - "We_F": 602.7461066553446, - "J": 1.1990595405536186, - "TMR": 0.4284665993766657, - "theta": 0.858238980806205, - "x_star": 0.0604972261770361, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3876710.929987308, - "P_tank_F": 3830165.5969174705, - "Pc": 2391304.347826087, - "mdot_O": 1.6306685256940066, - "mdot_F": 0.8803738574317141, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1277448997905888, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.644834721880076e-05, - "D32_F": 6.586620127057506e-05, - "We_O": 623.0521731347704, - "We_F": 604.4536008023892, - "J": 1.271808559003685, - "TMR": 0.4458972952941533, - "theta": 0.8671913868994734, - "x_star": 0.06012197244988311, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3912549.3239450376, - "P_tank_F": 3938636.693481894, - "Pc": 2443478.2608695654, - "mdot_O": 1.621677230344718, - "mdot_F": 0.8974314535802353, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1002096574337115, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.571253828015708e-05, - "D32_F": 6.500762981477075e-05, - "We_O": 649.8967779666133, - "We_F": 630.4968741467144, - "J": 1.210461290310405, - "TMR": 0.4312263469209795, - "theta": 0.8596795669753754, - "x_star": 0.05917112433365397, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3913250.651297368, - "P_tank_F": 3966214.9220058764, - "Pc": 2495652.1739130435, - "mdot_O": 1.5930141585241453, - "mdot_F": 0.8900193723526046, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.089764099729729, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.5906189393288456e-05, - "D32_F": 6.523358972010405e-05, - "We_O": 648.4617842765467, - "We_F": 629.1047160891871, - "J": 1.1875857930597469, - "TMR": 0.42567865033247954, - "theta": 0.8567745702226054, - "x_star": 0.058891948898599826, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3899178.1633896716, - "P_tank_F": 3941987.236151598, - "Pc": 2547826.0869565215, - "mdot_O": 1.5553469195369343, - "mdot_F": 0.8665909865579097, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.0927616027940015, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.6700497702103254e-05, - "D32_F": 6.616042059322942e-05, - "We_O": 628.8412407191261, - "We_F": 610.0698603991522, - "J": 1.1941279205409154, - "TMR": 0.42726962162562276, - "theta": 0.8576113779315364, - "x_star": 0.05903986264342937, - "constraints_satisfied": 0 - }, - { - "P_tank_O": 3982202.2373767043, - "P_tank_F": 3916396.4545284947, - "Pc": 2600000.0, - "mdot_O": 1.5730003257226925, - "mdot_F": 0.8420754993277428, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "momentum_ratio_R": 1.1373394455862083, - "A_geom_O": 6.283185307179586e-05, - "A_geom_F": 6.283185307179586e-05, - "D32_O": 5.70224198729211e-05, - "D32_F": 6.653605232633157e-05, - "We_O": 623.6182435019309, - "We_F": 605.0027735466493, - "J": 1.2935410144863442, - "TMR": 0.4510248433511423, - "theta": 0.8697604246007422, - "x_star": 0.058864030768124055, - "constraints_satisfied": 0 - } - ] -} \ No newline at end of file diff --git a/EngineDesign/engine/native/tests/golden/nozzle_golden.json b/EngineDesign/engine/native/tests/golden/nozzle_golden.json deleted file mode 100644 index b1fee6d6e..000000000 --- a/EngineDesign/engine/native/tests/golden/nozzle_golden.json +++ /dev/null @@ -1,140 +0,0 @@ -[ - { - "case": "pintle", - "Pc": 2458941.676024208, - "mdot_total": 3.0708206425034454, - "A_throat": 0.0017651044259737317, - "A_exit": 0.00801184666481737, - "eps": 4.539021344528996, - "Pa": 101325.0, - "nozzle_efficiency": 0.95, - "Cf_ideal": 1.4624071176110276, - "gamma": 1.1887950926243585, - "R": 416.4622811812359, - "Tc": 2781.1781966145113, - "F": 7403.6735619317205, - "Cf_actual": 1.7058020427743608, - "P_exit": 91770.33807578635, - "T_exit": 1649.8246621654137, - "v_exit": 2435.9039224461303, - "M_exit": 2.6952553727806596, - "P_throat": 1393389.180877033, - "T_throat": 2541.2869445717615, - "Isp": 245.85108821409625 - }, - { - "case": "pintle", - "Pc": 2041708.3410043393, - "mdot_total": 3.3251146070307906, - "A_throat": 0.0017651044259737317, - "A_exit": 0.00801184666481737, - "eps": 4.539021344528996, - "Pa": 101325.0, - "nozzle_efficiency": 0.95, - "Cf_ideal": 1.462362324332623, - "gamma": 1.2016013640674488, - "R": 428.46701086881404, - "Tc": 2635.2261124084503, - "F": 7750.152307706822, - "Cf_actual": 2.15053309552547, - "P_exit": 74220.23258276173, - "T_exit": 1511.1491935123922, - "v_exit": 2396.101334760849, - "M_exit": 2.7165181310349404, - "P_throat": 1151855.6840716612, - "T_throat": 2393.9175869149003, - "Isp": 237.67469381375736 - }, - { - "case": "pintle", - "Pc": 2650093.003673395, - "mdot_total": 3.0430123100964286, - "A_throat": 0.0017651044259737317, - "A_exit": 0.00801184666481737, - "eps": 4.539021344528996, - "Pa": 101325.0, - "nozzle_efficiency": 0.95, - "Cf_ideal": 1.4625191280664733, - "gamma": 1.1782672063676896, - "R": 407.6149737616211, - "Tc": 2885.8807684696653, - "F": 7491.261910095075, - "Cf_actual": 1.6014871624092177, - "P_exit": 101072.87592696537, - "T_exit": 1760.5394476845229, - "v_exit": 2462.4553323845366, - "M_exit": 2.6779256870633206, - "P_throat": 1507200.752624603, - "T_throat": 2649.703176941214, - "Isp": 251.0328729214337 - }, - { - "case": "impinging", - "Pc": 2345929.077092061, - "mdot_total": 2.5788318570937774, - "A_throat": 0.0017703959321061754, - "A_exit": 0.008159675026074325, - "eps": 4.608954911214158, - "Pa": 101325.0, - "nozzle_efficiency": 0.95, - "Cf_ideal": 1.4667349648114385, - "gamma": 1.1720071531873053, - "R": 463.8222780479248, - "Tc": 3014.5495310433657, - "F": 6850.996554905593, - "Cf_actual": 1.6495613330011334, - "P_exit": 88767.10646357901, - "T_exit": 1864.3024918199906, - "v_exit": 2696.3622564408124, - "M_exit": 2.678422692269409, - "P_throat": 1337121.9954032952, - "T_throat": 2775.8191556778934, - "Isp": 270.900649954206 - }, - { - "case": "impinging", - "Pc": 2236455.36511577, - "mdot_total": 2.4641353021498373, - "A_throat": 0.0017703959321061754, - "A_exit": 0.008159675026074325, - "eps": 4.608954911214158, - "Pa": 101325.0, - "nozzle_efficiency": 0.95, - "Cf_ideal": 1.4667518330580396, - "gamma": 1.171510416219213, - "R": 463.9274422575403, - "Tc": 3012.1200356903323, - "F": 6507.7152136926325, - "Cf_actual": 1.643606693911911, - "P_exit": 84712.22388410405, - "T_exit": 1865.2901467132433, - "v_exit": 2695.9842920489573, - "M_exit": 2.6776021674687946, - "P_throat": 1274945.3586571736, - "T_throat": 2774.216520623183, - "Isp": 269.30431567844084 - }, - { - "case": "impinging", - "Pc": 2430048.336890125, - "mdot_total": 2.6670192317291157, - "A_throat": 0.0017703959321061754, - "A_exit": 0.008159675026074325, - "eps": 4.608954911214158, - "Pa": 101325.0, - "nozzle_efficiency": 0.95, - "Cf_ideal": 1.4667220033032136, - "gamma": 1.172388844328639, - "R": 463.74147019633733, - "Tc": 3016.416347716434, - "F": 7114.931668961573, - "Cf_actual": 1.6538092554917896, - "P_exit": 91877.1222370609, - "T_exit": 1863.5434622038072, - "v_exit": 2696.652200940601, - "M_exit": 2.679053388005756, - "P_throat": 1384883.7118685863, - "T_throat": 2777.050117515804, - "Isp": 272.03445294867396 - } -] \ No newline at end of file diff --git a/EngineDesign/engine/native/tests/golden/nozzle_oracle.json b/EngineDesign/engine/native/tests/golden/nozzle_oracle.json deleted file mode 100644 index a085a4fc1..000000000 --- a/EngineDesign/engine/native/tests/golden/nozzle_oracle.json +++ /dev/null @@ -1,453 +0,0 @@ -{ - "_meta": { - "oracle": "python runner.evaluate (ED_USE_NATIVE=0)", - "rtol_target": 0.001, - "python_evaluate_ms": 2.7287043748947326 - }, - "cases": [ - { - "case": "pintle", - "config": "configs/canonical/pintle.yaml", - "P_O_Pa": 3610619.76028896, - "P_F_Pa": 3704289.4239973365, - "P_ambient_Pa": 101325.0, - "shifting": { - "result": { - "F": 7453.463918135184, - "Isp": 247.50445841644148, - "Pc": 2458941.676024208, - "MR": 1.1791598541604373, - "v_exit": 2486.0844394561555, - "P_exit": 78751.49040606567, - "P_throat": 1393389.180877033, - "T_exit": 1356.0163335190086, - "T_throat": 2541.2869445717615, - "Cf_actual": 1.7172737116171144, - "Cf_ideal": 1.4624071176110276, - "diagnostics": { - "cstar_actual": 1413.3970494702207, - "cstar_ideal": 1661.753338662022, - "eta_cstar": 0.8505456354961994, - "mdot_O": 1.6616442405792564, - "mdot_F": 1.409176401924189, - "MR": 1.1791598541604373, - "gamma": 1.1887950926243585, - "R": 416.4622811812359, - "Tc": 2763.808741142434, - "momentum_ratio_R": null, - "Cd_O": 0.39889941419593605, - "Cd_F": 0.6453449463069021, - "SMD": null - } - }, - "error": null - }, - "frozen": { - "result": { - "F": 7403.6735619317205, - "Isp": 245.85108821409625, - "Pc": 2458941.676024208, - "MR": 1.1791598541604373, - "v_exit": 2435.9039224461303, - "P_exit": 91770.33807578635, - "P_throat": 1393389.180877033, - "T_exit": 1649.8246621654137, - "T_throat": 2541.2869445717615, - "Cf_actual": 1.7058020427743608, - "Cf_ideal": 1.4624071176110276, - "diagnostics": { - "cstar_actual": 1413.3970494702207, - "cstar_ideal": 1661.753338662022, - "eta_cstar": 0.8505456354961994, - "mdot_O": 1.6616442405792564, - "mdot_F": 1.409176401924189, - "MR": 1.1791598541604373, - "gamma": 1.1887950926243585, - "R": 416.4622811812359, - "Tc": 2763.808741142434, - "momentum_ratio_R": null, - "Cd_O": 0.39889941419593605, - "Cd_F": 0.6453449463069021, - "SMD": null - } - }, - "error": null - }, - "shift_delta": { - "F_rel": 0.006725087996785274, - "Isp_rel": 0.006725087996785327, - "P_exit_rel": 0.14186335086800458 - } - }, - { - "case": "pintle", - "config": "configs/canonical/pintle.yaml", - "P_O_Pa": 3321770.179465843, - "P_F_Pa": 3593160.7412774167, - "P_ambient_Pa": 101325.0, - "shifting": { - "result": { - "F": 7824.400622098295, - "Isp": 239.9516742766616, - "Pc": 2041708.3410043393, - "MR": 1.113641445202598, - "v_exit": 2444.2572278724597, - "P_exit": 63501.66134678626, - "P_throat": 1151855.6840716612, - "T_exit": 1237.8415692955875, - "T_throat": 2393.9175869149003, - "Cf_actual": 2.1711357173896957, - "Cf_ideal": 1.462362324332623, - "diagnostics": { - "cstar_actual": 1083.8208167724904, - "cstar_ideal": 1634.3930010325303, - "eta_cstar": 0.6631335401508602, - "mdot_O": 1.751945886963386, - "mdot_F": 1.5731687200674047, - "MR": 1.113641445202598, - "gamma": 1.2016013640674488, - "R": 428.46701086881404, - "Tc": 2622.004662962844, - "momentum_ratio_R": null, - "Cd_O": 0.39889941419593605, - "Cd_F": 0.6453449463069021, - "SMD": null - } - }, - "error": null - }, - "frozen": { - "result": { - "F": 7750.152307706822, - "Isp": 237.67469381375736, - "Pc": 2041708.3410043393, - "MR": 1.113641445202598, - "v_exit": 2396.101334760849, - "P_exit": 74220.23258276173, - "P_throat": 1151855.6840716612, - "T_exit": 1511.1491935123922, - "T_throat": 2393.9175869149003, - "Cf_actual": 2.15053309552547, - "Cf_ideal": 1.462362324332623, - "diagnostics": { - "cstar_actual": 1083.8208167724904, - "cstar_ideal": 1634.3930010325303, - "eta_cstar": 0.6631335401508602, - "mdot_O": 1.751945886963386, - "mdot_F": 1.5731687200674047, - "MR": 1.113641445202598, - "gamma": 1.2016013640674488, - "R": 428.46701086881404, - "Tc": 2622.004662962844, - "momentum_ratio_R": null, - "Cd_O": 0.39889941419593605, - "Cd_F": 0.6453449463069021, - "SMD": null - } - }, - "error": null - }, - "shift_delta": { - "F_rel": 0.009580239386732978, - "Isp_rel": 0.009580239386732933, - "P_exit_rel": 0.14441575919373967 - } - }, - { - "case": "pintle", - "config": "configs/canonical/pintle.yaml", - "P_O_Pa": 3827256.945906298, - "P_F_Pa": 3815418.1067172564, - "P_ambient_Pa": 101325.0, - "shifting": { - "result": { - "F": 7532.074698007783, - "Isp": 252.40051318346403, - "Pc": 2650093.003673395, - "MR": 1.2324928026536757, - "v_exit": 2513.3873348820903, - "P_exit": 86822.23840957692, - "P_throat": 1507200.752624603, - "T_exit": 1448.4002168236975, - "T_throat": 2649.703176941214, - "Cf_actual": 1.6102121484915026, - "Cf_ideal": 1.4625191280664733, - "diagnostics": { - "cstar_actual": 1537.1909195790395, - "cstar_ideal": 1680.1107392629795, - "eta_cstar": 0.9149342859705574, - "mdot_O": 1.6799564890522036, - "mdot_F": 1.3630558210442247, - "MR": 1.2324928026536757, - "gamma": 1.1782672063676896, - "R": 407.6149737616211, - "Tc": 2865.922143300946, - "momentum_ratio_R": null, - "Cd_O": 0.39889941419593605, - "Cd_F": 0.6453449463069021, - "SMD": null - } - }, - "error": null - }, - "frozen": { - "result": { - "F": 7491.261910095075, - "Isp": 251.0328729214337, - "Pc": 2650093.003673395, - "MR": 1.2324928026536757, - "v_exit": 2462.4553323845366, - "P_exit": 101072.87592696537, - "P_throat": 1507200.752624603, - "T_exit": 1760.5394476845229, - "T_throat": 2649.703176941214, - "Cf_actual": 1.6014871624092177, - "Cf_ideal": 1.4625191280664733, - "diagnostics": { - "cstar_actual": 1537.1909195790395, - "cstar_ideal": 1680.1107392629795, - "eta_cstar": 0.9149342859705574, - "mdot_O": 1.6799564890522036, - "mdot_F": 1.3630558210442247, - "MR": 1.2324928026536757, - "gamma": 1.1782672063676896, - "R": 407.6149737616211, - "Tc": 2865.922143300946, - "momentum_ratio_R": null, - "Cd_O": 0.39889941419593605, - "Cd_F": 0.6453449463069021, - "SMD": null - } - }, - "error": null - }, - "shift_delta": { - "F_rel": 0.005448052464660099, - "Isp_rel": 0.00544805246466015, - "P_exit_rel": 0.14099368783852423 - } - }, - { - "case": "impinging", - "config": "configs/canonical/impinging.yaml", - "P_O_Pa": 3884970.6035156813, - "P_F_Pa": 3913766.004255717, - "P_ambient_Pa": 101325.0, - "shifting": { - "result": { - "F": 6880.26256545397, - "Isp": 272.05788032435487, - "Pc": 2345929.077092061, - "MR": 1.8061759772085038, - "v_exit": 2744.689313805446, - "P_exit": 77080.2017475932, - "P_throat": 1337121.9954032952, - "T_exit": 1554.8391282736718, - "T_throat": 2775.8191556778934, - "Cf_actual": 1.6566079106756233, - "Cf_ideal": 1.4667349648114385, - "diagnostics": { - "cstar_actual": 1610.505657307138, - "cstar_ideal": 1835.68650593153, - "eta_cstar": 0.8773315335179617, - "mdot_O": 1.6598474890289068, - "mdot_F": 0.9189843680648705, - "MR": 1.8061759772085038, - "gamma": 1.1720071531873053, - "R": 463.8222780479248, - "Tc": 2990.0642620661056, - "momentum_ratio_R": 1.099695334119751, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "SMD": null - } - }, - "error": null - }, - "frozen": { - "result": { - "F": 6850.996554905593, - "Isp": 270.900649954206, - "Pc": 2345929.077092061, - "MR": 1.8061759772085038, - "v_exit": 2696.3622564408124, - "P_exit": 88767.10646357901, - "P_throat": 1337121.9954032952, - "T_exit": 1864.3024918199906, - "T_throat": 2775.8191556778934, - "Cf_actual": 1.6495613330011334, - "Cf_ideal": 1.4667349648114385, - "diagnostics": { - "cstar_actual": 1610.505657307138, - "cstar_ideal": 1835.68650593153, - "eta_cstar": 0.8773315335179617, - "mdot_O": 1.6598474890289068, - "mdot_F": 0.9189843680648705, - "MR": 1.8061759772085038, - "gamma": 1.1720071531873053, - "R": 463.8222780479248, - "Tc": 2990.0642620661056, - "momentum_ratio_R": 1.099695334119751, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "SMD": null - } - }, - "error": null - }, - "shift_delta": { - "F_rel": 0.004271788828651623, - "Isp_rel": 0.004271788828651664, - "P_exit_rel": 0.1316580564759192 - } - }, - { - "case": "impinging", - "config": "configs/canonical/impinging.yaml", - "P_O_Pa": 3574172.955234427, - "P_F_Pa": 3796353.0241280454, - "P_ambient_Pa": 101325.0, - "shifting": { - "result": { - "F": 6533.775068029361, - "Isp": 270.3827327585347, - "Pc": 2236455.36511577, - "MR": 1.6881829236036445, - "v_exit": 2743.5367943393026, - "P_exit": 73545.60940848949, - "P_throat": 1274945.3586571736, - "T_exit": 1555.272794522553, - "T_throat": 2774.216520623183, - "Cf_actual": 1.650188443362178, - "Cf_ideal": 1.4667518330580396, - "diagnostics": { - "cstar_actual": 1606.8157772358325, - "cstar_ideal": 1835.4387515950084, - "eta_cstar": 0.8754396058378406, - "mdot_O": 1.547480679983523, - "mdot_F": 0.9166546221663145, - "MR": 1.6881829236036445, - "gamma": 1.171510416219213, - "R": 463.9274422575403, - "Tc": 2986.6502508076046, - "momentum_ratio_R": 1.0278549308893044, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "SMD": null - } - }, - "error": null - }, - "frozen": { - "result": { - "F": 6507.7152136926325, - "Isp": 269.30431567844084, - "Pc": 2236455.36511577, - "MR": 1.6881829236036445, - "v_exit": 2695.9842920489573, - "P_exit": 84712.22388410405, - "P_throat": 1274945.3586571736, - "T_exit": 1865.2901467132433, - "T_throat": 2774.216520623183, - "Cf_actual": 1.643606693911911, - "Cf_ideal": 1.4667518330580396, - "diagnostics": { - "cstar_actual": 1606.8157772358325, - "cstar_ideal": 1835.4387515950084, - "eta_cstar": 0.8754396058378406, - "mdot_O": 1.547480679983523, - "mdot_F": 0.9166546221663145, - "MR": 1.6881829236036445, - "gamma": 1.171510416219213, - "R": 463.9274422575403, - "Tc": 2986.6502508076046, - "momentum_ratio_R": 1.0278549308893044, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "SMD": null - } - }, - "error": null - }, - "shift_delta": { - "F_rel": 0.004004455247503324, - "Isp_rel": 0.004004455247503445, - "P_exit_rel": 0.13181821894903578 - } - }, - { - "case": "impinging", - "config": "configs/canonical/impinging.yaml", - "P_O_Pa": 4118068.839726622, - "P_F_Pa": 4031178.984383389, - "P_ambient_Pa": 101325.0, - "shifting": { - "result": { - "F": 7146.741142648381, - "Isp": 273.2506660025147, - "Pc": 2430048.336890125, - "MR": 1.8718058946532024, - "v_exit": 2745.5466864175223, - "P_exit": 79794.15825986993, - "P_throat": 1384883.7118685863, - "T_exit": 1554.5576132881815, - "T_throat": 2777.050117515804, - "Cf_actual": 1.6612031145537198, - "Cf_ideal": 1.4667220033032136, - "diagnostics": { - "cstar_actual": 1613.09211394976, - "cstar_ideal": 1835.8768795931492, - "eta_cstar": 0.8786493973970843, - "mdot_O": 1.7383285995750983, - "mdot_F": 0.9286906321540173, - "MR": 1.8718058946532024, - "gamma": 1.172388844328639, - "R": 463.74147019633733, - "Tc": 2992.630130246534, - "momentum_ratio_R": 1.139654294322591, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "SMD": null - } - }, - "error": null - }, - "frozen": { - "result": { - "F": 7114.931668961573, - "Isp": 272.03445294867396, - "Pc": 2430048.336890125, - "MR": 1.8718058946532024, - "v_exit": 2696.652200940601, - "P_exit": 91877.1222370609, - "P_throat": 1384883.7118685863, - "T_exit": 1863.5434622038072, - "T_throat": 2777.050117515804, - "Cf_actual": 1.6538092554917896, - "Cf_ideal": 1.4667220033032136, - "diagnostics": { - "cstar_actual": 1613.09211394976, - "cstar_ideal": 1835.8768795931492, - "eta_cstar": 0.8786493973970843, - "mdot_O": 1.7383285995750983, - "mdot_F": 0.9286906321540173, - "MR": 1.8718058946532024, - "gamma": 1.172388844328639, - "R": 463.74147019633733, - "Tc": 2992.630130246534, - "momentum_ratio_R": 1.139654294322591, - "Cd_O": 0.46426856249999987, - "Cd_F": 0.46426856249999987, - "SMD": null - } - }, - "error": null - }, - "shift_delta": { - "F_rel": 0.004470805225800645, - "Isp_rel": 0.004470805225800681, - "P_exit_rel": 0.13151221634929494 - } - } - ] -} \ No newline at end of file diff --git a/EngineDesign/engine/native/tests/golden/residual_samples.json b/EngineDesign/engine/native/tests/golden/residual_samples.json deleted file mode 100644 index 472d9001d..000000000 --- a/EngineDesign/engine/native/tests/golden/residual_samples.json +++ /dev/null @@ -1,697 +0,0 @@ -{ - "comb": { - "model": 2, - "C": 0.3, - "K": 0.15, - "tau_ref": 1e-05, - "tau_ref_P": 4000000.0, - "tau_ref_T": 3500.0, - "n_pressure": 0.8, - "has_tau_Tc_floor": 0, - "tau_Tc_floor": 0.0, - "Em_peak": 0.96, - "mixing_sigma": 1.5, - "R_opt": 0.0 - }, - "cooling": { - "regen_enabled": 0, - "film_enabled": 0, - "ablative_enabled": 1, - "graphite_enabled": 1, - "use_cooling_coupling": 1, - "hot_gas_viscosity": 4e-05, - "hot_gas_thermal_conductivity": 0.12, - "hot_gas_prandtl": 0.7, - "gas_turbulence_intensity": 0.1, - "recovery_factor": 0.94, - "radiation_emissivity_hot": 0.85, - "radiation_view_factor": 1.0, - "regen_chamber_inner_diameter": 0.08491, - "ablative_coverage_fraction": 0.9, - "ablative_surface_temperature_limit": 1200.0, - "ablative_material_density": 1600.0, - "ablative_heat_of_ablation": 2500000.0, - "ablative_specific_heat": 1500.0, - "ablative_pyrolysis_temperature": 950.0, - "ablative_use_physics_based_blowing": 1, - "ablative_blowing_efficiency": 0.75, - "ablative_blowing_coefficient": 0.5, - "ablative_blowing_min_reduction_factor": 0.1, - "ablative_turbulence_reference_intensity": 0.08, - "ablative_turbulence_sensitivity": 1.5, - "ablative_turbulence_exponent": 1.0, - "ablative_turbulence_max_multiplier": 3.0, - "ablative_surface_emissivity": 0.85, - "ablative_ambient_temperature": 300.0, - "ablative_radiative_sink_minimum_threshold": 400.0, - "ablative_radiative_sink_fallback_temperature": 600.0, - "cooling_efficiency_floor": 0.25 - }, - "geom": { - "A_throat": 0.0017703959321061754, - "A_exit": 0.008159675026074325, - "volume": 0.0019492708804651003, - "Lstar": 1.1010366919145165, - "length": 0.2202290337591734, - "length_cylindrical": 0.121, - "length_contraction": 0.0451, - "chamber_diameter": 0.11504160142419745, - "exit_diameter": 0.10192752776058954, - "expansion_ratio": 4.608954911214158, - "nozzle_efficiency": 0.95, - "Cf": 1.6603014402898282, - "design_pressure": 2413166.0 - }, - "samples": [ - { - "P_tank_O": 3447378.646584, - "P_tank_F": 3585273.7924473598, - "Pc": 1800000.0, - "mdot_O": 1.7172745467545045, - "mdot_F": 0.9806411071967972, - "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, - "momentum_ratio_R": 1.0662080550786204, - "R_opt": 1.0632572007031675, - "Tc": 3000.442000000001, - "gamma": 1.16912, - "R": 464.42770336561114, - "M": 17.902600000000003, - "cstar_ideal": 1834.2102, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 0.9999999999437091, - "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 - }, - { - "P_tank_O": 3447378.646584, - "P_tank_F": 3585273.7924473598, - "Pc": 2200000.0, - "mdot_O": 1.494315046188041, - "mdot_F": 0.8638244424811437, - "MR": 1.729882801065403, - "Lstar": 1.1010366919145165, - "Ac": 0.010394407017099627, - "At": 0.0017703959321061754, - "Dinj": 0.002, - "chamber_length": 0.2202290337591734, - "D32_O": 5.952893855958958e-05, - "D32_F": 6.946075911472761e-05, - "u_O": 20.862072468194796, - "u_F": 32.53240179471714, - "momentum_ratio_R": 1.053244018805829, - "R_opt": 1.0632572007031675, - "Tc": 3011.273, - "gamma": 1.171335, - "R": 463.963770042121, - "M": 17.920499999999997, - "cstar_ideal": 1835.3516760000002, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 0.9999999999999901, - "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 - }, - { - "P_tank_O": 3447378.646584, - "P_tank_F": 3585273.7924473598, - "Pc": 2600000.0, - "mdot_O": 1.2316342986443036, - "mdot_F": 0.7285110525802317, - "MR": 1.6906185490008916, - "Lstar": 1.1010366919145165, - "Ac": 0.010394407017099627, - "At": 0.0017703959321061754, - "Dinj": 0.002, - "chamber_length": 0.2202290337591734, - "D32_O": 6.525341780299035e-05, - "D32_F": 7.614031167192778e-05, - "u_O": 17.19479707989128, - "u_F": 27.43637839924836, - "momentum_ratio_R": 1.0293378682768095, - "R_opt": 1.0632572007031675, - "Tc": 3019.816, - "gamma": 1.1731, - "R": 463.5938519009786, - "M": 17.9348, - "cstar_ideal": 1836.224928, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 1.0, - "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 - }, - { - "P_tank_O": 3447378.646584, - "P_tank_F": 3861064.08417408, - "Pc": 1800000.0, - "mdot_O": 1.7172745467545045, - "mdot_F": 1.0536671181885018, - "MR": 1.6298074762994388, - "Lstar": 1.1010366919145165, - "Ac": 0.010394407017099627, - "At": 0.0017703959321061754, - "Dinj": 0.002, - "chamber_length": 0.2202290337591734, - "D32_O": 5.473110062610303e-05, - "D32_F": 6.386244889732283e-05, - "u_O": 23.974801119462594, - "u_F": 39.682046907972634, - "momentum_ratio_R": 0.9923128752769803, - "R_opt": 1.0632572007031675, - "Tc": 3000.442000000001, - "gamma": 1.16912, - "R": 464.42770336561114, - "M": 17.902600000000003, - "cstar_ideal": 1834.2102, - "fuel_latent_heat": 510000.0, - "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.9926152486828815, - "heat_removed": 195675.00744282446, - "eta_final": 0.9051062164731649, - "cstar_actual": 1660.1550543384872, - "mdot_demand": 1.9195271366149036 - }, - { - "P_tank_O": 3447378.646584, - "P_tank_F": 3861064.08417408, - "Pc": 2200000.0, - "mdot_O": 1.494315046188041, - "mdot_F": 0.9459123015667785, - "MR": 1.5797606646122542, - "Lstar": 1.1010366919145165, - "Ac": 0.010394407017099627, - "At": 0.0017703959321061754, - "Dinj": 0.002, - "chamber_length": 0.2202290337591734, - "D32_O": 5.685927991820251e-05, - "D32_F": 6.634569406745966e-05, - "u_O": 20.862072468194796, - "u_F": 35.623904052480924, - "momentum_ratio_R": 0.9618417329329064, - "R_opt": 1.0632572007031675, - "Tc": 3011.273, - "gamma": 1.171335, - "R": 463.963770042121, - "M": 17.920499999999997, - "cstar_ideal": 1835.3516760000002, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 0.999999999999997, - "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 - }, - { - "P_tank_O": 3447378.646584, - "P_tank_F": 3861064.08417408, - "Pc": 2600000.0, - "mdot_O": 1.2316342986443036, - "mdot_F": 0.824187944924614, - "MR": 1.4943609721893683, - "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, - "u_O": 17.19479707989128, - "u_F": 31.039655814364203, - "momentum_ratio_R": 0.9098458895168869, - "R_opt": 1.0632572007031675, - "Tc": 3019.816, - "gamma": 1.1731, - "R": 463.5938519009786, - "M": 17.9348, - "cstar_ideal": 1836.224928, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 1.0, - "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 - }, - { - "P_tank_O": 3723168.93831072, - "P_tank_F": 3585273.7924473598, - "Pc": 1800000.0, - "mdot_O": 1.85546059150968, - "mdot_F": 0.9806411071967972, - "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_F": 6.483705715971558e-05, - "u_O": 25.904010951840142, - "u_F": 36.93182196154271, - "momentum_ratio_R": 1.1520039310472432, - "R_opt": 1.0632572007031675, - "Tc": 3000.442000000001, - "gamma": 1.16912, - "R": 464.42770336561114, - "M": 17.902600000000003, - "cstar_ideal": 1834.2102, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 0.9999999999277608, - "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 - }, - { - "P_tank_O": 3723168.93831072, - "P_tank_F": 3585273.7924473598, - "Pc": 2200000.0, - "mdot_O": 1.6512660582045917, - "mdot_F": 0.8638244424811437, - "MR": 1.9115759834969441, - "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, - "u_F": 32.53240179471714, - "momentum_ratio_R": 1.1638684250002587, - "R_opt": 1.0632572007031675, - "Tc": 3011.273, - "gamma": 1.171335, - "R": 463.963770042121, - "M": 17.920499999999997, - "cstar_ideal": 1835.3516760000002, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 0.9999999999999846, - "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 - }, - { - "P_tank_O": 3723168.93831072, - "P_tank_F": 3585273.7924473598, - "Pc": 2600000.0, - "mdot_O": 1.4179651558876927, - "mdot_F": 0.7285110525802317, - "MR": 1.946387979791879, - "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, - "u_F": 27.43637839924836, - "momentum_ratio_R": 1.185063807056054, - "R_opt": 1.0632572007031675, - "Tc": 3019.816, - "gamma": 1.1731, - "R": 463.5938519009786, - "M": 17.9348, - "cstar_ideal": 1836.224928, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 1.0, - "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 - }, - { - "P_tank_O": 3723168.93831072, - "P_tank_F": 3861064.08417408, - "Pc": 1800000.0, - "mdot_O": 1.85546059150968, - "mdot_F": 1.0536671181885018, - "MR": 1.7609552006326696, - "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, - "u_F": 39.682046907972634, - "momentum_ratio_R": 1.0721625368545733, - "R_opt": 1.0632572007031675, - "Tc": 3000.442000000001, - "gamma": 1.16912, - "R": 464.42770336561114, - "M": 17.902600000000003, - "cstar_ideal": 1834.2102, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 0.9999999999627182, - "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 - }, - { - "P_tank_O": 3723168.93831072, - "P_tank_F": 3861064.08417408, - "Pc": 2200000.0, - "mdot_O": 1.6512660582045917, - "mdot_F": 0.9459123015667785, - "MR": 1.7456862073465882, - "Lstar": 1.1010366919145165, - "Ac": 0.010394407017099627, - "At": 0.0017703959321061754, - "Dinj": 0.002, - "chamber_length": 0.2202290337591734, - "D32_O": 5.5481086956525096e-05, - "D32_F": 6.473756310391392e-05, - "u_O": 23.05325925641493, - "u_F": 35.623904052480924, - "momentum_ratio_R": 1.0628659672593108, - "R_opt": 1.0632572007031675, - "Tc": 3011.273, - "gamma": 1.171335, - "R": 463.963770042121, - "M": 17.920499999999997, - "cstar_ideal": 1835.3516760000002, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 0.999999999999995, - "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 - }, - { - "P_tank_O": 3723168.93831072, - "P_tank_F": 3861064.08417408, - "Pc": 2600000.0, - "mdot_O": 1.4179651558876927, - "mdot_F": 0.824187944924614, - "MR": 1.7204390874916153, - "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, - "R_opt": 1.0632572007031675, - "Tc": 3019.816, - "gamma": 1.1731, - "R": 463.5938519009786, - "M": 17.9348, - "cstar_ideal": 1836.224928, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 1.0, - "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 - }, - { - "P_tank_O": 3998959.2300374396, - "P_tank_F": 3585273.7924473598, - "Pc": 1800000.0, - "mdot_O": 1.9840453987648603, - "mdot_F": 0.9806411071967972, - "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_F": 6.360157322783368e-05, - "u_O": 27.69917829229458, - "u_F": 36.93182196154271, - "momentum_ratio_R": 1.2318386654030913, - "R_opt": 1.0632572007031675, - "Tc": 3000.442000000001, - "gamma": 1.16912, - "R": 464.42770336561114, - "M": 17.902600000000003, - "cstar_ideal": 1834.2102, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 0.9999999999124622, - "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 - }, - { - "P_tank_O": 3998959.2300374396, - "P_tank_F": 3585273.7924473598, - "Pc": 2200000.0, - "mdot_O": 1.7945422069968409, - "mdot_F": 0.8638244424811437, - "MR": 2.0774385612919417, - "Lstar": 1.1010366919145165, - "Ac": 0.010394407017099627, - "At": 0.0017703959321061754, - "Dinj": 0.002, - "chamber_length": 0.2202290337591734, - "D32_O": 5.656429075782314e-05, - "D32_F": 6.600148885388852e-05, - "u_O": 25.05353182724443, - "u_F": 32.53240179471714, - "momentum_ratio_R": 1.2648543229458922, - "R_opt": 1.0632572007031675, - "Tc": 3011.273, - "gamma": 1.171335, - "R": 463.963770042121, - "M": 17.920499999999997, - "cstar_ideal": 1835.3516760000002, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 0.9999999999999786, - "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 - }, - { - "P_tank_O": 3998959.2300374396, - "P_tank_F": 3585273.7924473598, - "Pc": 2600000.0, - "mdot_O": 1.5825066574982143, - "mdot_F": 0.7285110525802317, - "MR": 2.172247973305705, - "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, - "u_F": 27.43637839924836, - "momentum_ratio_R": 1.322579300654494, - "R_opt": 1.0632572007031675, - "Tc": 3019.816, - "gamma": 1.1731, - "R": 463.5938519009786, - "M": 17.9348, - "cstar_ideal": 1836.224928, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 1.0, - "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 - }, - { - "P_tank_O": 3998959.2300374396, - "P_tank_F": 3861064.08417408, - "Pc": 1800000.0, - "mdot_O": 1.9840453987648603, - "mdot_F": 1.0536671181885018, - "MR": 1.882990713590735, - "Lstar": 1.1010366919145165, - "Ac": 0.010394407017099627, - "At": 0.0017703959321061754, - "Dinj": 0.002, - "chamber_length": 0.2202290337591734, - "D32_O": 5.2722789222136396e-05, - "D32_F": 6.151907039883598e-05, - "u_O": 27.69917829229458, - "u_F": 39.682046907972634, - "momentum_ratio_R": 1.1464642028551963, - "R_opt": 1.0632572007031675, - "Tc": 3000.442000000001, - "gamma": 1.16912, - "R": 464.42770336561114, - "M": 17.902600000000003, - "cstar_ideal": 1834.2102, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 0.9999999999536211, - "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 - }, - { - "P_tank_O": 3998959.2300374396, - "P_tank_F": 3861064.08417408, - "Pc": 2200000.0, - "mdot_O": 1.7945422069968409, - "mdot_F": 0.9459123015667785, - "MR": 1.897154951917233, - "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, - "u_O": 25.05353182724443, - "u_F": 35.623904052480924, - "momentum_ratio_R": 1.1550881392797538, - "R_opt": 1.0632572007031675, - "Tc": 3011.273, - "gamma": 1.171335, - "R": 463.963770042121, - "M": 17.920499999999997, - "cstar_ideal": 1835.3516760000002, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 0.9999999999999927, - "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 - }, - { - "P_tank_O": 3998959.2300374396, - "P_tank_F": 3861064.08417408, - "Pc": 2600000.0, - "mdot_O": 1.5825066574982143, - "mdot_F": 0.824187944924614, - "MR": 1.9200798400939503, - "Lstar": 1.1010366919145165, - "Ac": 0.010394407017099627, - "At": 0.0017703959321061754, - "Dinj": 0.002, - "chamber_length": 0.2202290337591734, - "D32_O": 5.7505850326938364e-05, - "D32_F": 6.710013841836902e-05, - "u_O": 22.09331201900648, - "u_F": 31.039655814364203, - "momentum_ratio_R": 1.169046022056003, - "R_opt": 1.0632572007031675, - "Tc": 3019.816, - "gamma": 1.1731, - "R": 463.5938519009786, - "M": 17.9348, - "cstar_ideal": 1836.224928, - "fuel_latent_heat": 510000.0, - "fuel_T_star_cap": 500.0, - "eta_Lstar": 1.0, - "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 - } - ] -} \ No newline at end of file diff --git a/EngineDesign/engine/native/tests/golden/state_impinging.json b/EngineDesign/engine/native/tests/golden/state_impinging.json deleted file mode 100644 index 29e586976..000000000 --- a/EngineDesign/engine/native/tests/golden/state_impinging.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "injector_type": 1, - "feed_O": { - "d_inlet": 0.013470353244117012, - "A_hydraulic": 0.00014251150082346222, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0 - }, - "feed_F": { - "d_inlet": 0.009525, - "A_hydraulic": 7.13e-05, - "K0": 2.0, - "K1": 0.0, - "phi_type": 0 - }, - "discharge_O": { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 90.0, - "a_T": 0.0 - }, - "discharge_F": { - "Cd_inf": 0.6, - "a_Re": 0.18, - "Cd_min": 0.35, - "use_geometry_cd": 1, - "d_ref_m": 0.002, - "d_min_m": 0.0004, - "cd_small_hole_exponent": 0.2, - "cd_large_hole_log_gain": 0.015, - "cd_inf_max": 0.62, - "cd_inf_min_geom": 0.48, - "use_pressure_correction": 0, - "P_ref": 5000000.0, - "a_P": 0.0, - "use_temperature_correction": 0, - "T_ref": 300.0, - "a_T": 0.0 - }, - "geom": { - "A_throat": 0.0017703959321061754, - "A_exit": 0.008159675026074325, - "volume": 0.0019492708804651003, - "Lstar": 1.1010366919145165, - "length": 0.2202290337591734, - "length_cylindrical": 0.121, - "length_contraction": 0.0451, - "chamber_diameter": 0.11504160142419745, - "exit_diameter": 0.10192752776058954, - "expansion_ratio": 4.608954911214158, - "nozzle_efficiency": 0.95, - "Cf": 1.6603014402898282, - "design_pressure": 2413166.0 - }, - "solver": { - "Pc_min_bound": 100000.0, - "Pc_max_bound": 8000000.0, - "tolerance": 1e-06, - "max_iterations": 100 - }, - "cooling": { - "regen_enabled": 0, - "film_enabled": 0, - "ablative_enabled": 1, - "graphite_enabled": 1 - }, - "imp_O": { - "n_elements": 20, - "d_jet": 0.002, - "impingement_angle": 50.0, - "spacing": 0.006 - }, - "imp_F": { - "n_elements": 20, - "d_jet": 0.002, - "impingement_angle": 60.0, - "spacing": 0.006 - } -} \ No newline at end of file diff --git a/EngineDesign/engine/native/tests/test_cea_interp.c b/EngineDesign/engine/native/tests/test_cea_interp.c deleted file mode 100644 index 864f7a7a2..000000000 --- a/EngineDesign/engine/native/tests/test_cea_interp.c +++ /dev/null @@ -1,80 +0,0 @@ -/* test_cea_interp.c - Parity of ed_cea_eval vs Python CEACache.eval(). - * - * Loads tests/golden/cea_tables.bin + cea_samples.json (produced by - * export_cea_tables.py) and asserts each of the 6 properties matches the Python - * reference. Same float64 tables + identical formula => agreement near machine - * precision; tolerance is rtol 1e-9 (far tighter than the 1e-4 spec target). - */ -#include "ed_cea.h" -#include "ed_test_util.h" - -#ifndef ED_GOLDEN_DIR -#define ED_GOLDEN_DIR "." -#endif - -static int g_fail = 0; - -static void check(const char *name, double got, double want, int idx) { - if (!edt_close(got, want, 1e-9, 1e-7)) { - fprintf(stderr, " [FAIL] sample %d %-12s got=%.12g want=%.12g\n", - idx, name, got, want); - g_fail++; - } -} - -int main(void) { - EdCeaTables t; - if (ed_cea_load(ED_GOLDEN_DIR "/cea_tables.bin", &t) != ED_OK) { - fprintf(stderr, "FAIL: cannot load cea_tables.bin from %s\n", ED_GOLDEN_DIR); - return 1; - } - printf("loaded CEA grid %zux%zux%zu Pc[%.0f..%.0f] MR[%.2f..%.2f] eps[%.2f..%.2f]\n", - t.n_pc, t.n_mr, t.n_eps, t.Pc_min, t.Pc_max, t.MR_min, t.MR_max, t.eps_min, t.eps_max); - - size_t len = 0; - char *js = edt_slurp(ED_GOLDEN_DIR "/cea_samples.json", &len); - if (!js) { - fprintf(stderr, "FAIL: cannot load cea_samples.json\n"); - ed_cea_free(&t); - return 1; - } - - int n = 0; - const char *p = js, *end; - while ((p = edt_next_object(p, &end)) != NULL) { - double MR, Pc, Pa, eps; - double cstar, Cf, Tc, gamma, R, M; - if (edt_find_double(p, end, "MR", &MR) && - edt_find_double(p, end, "Pc", &Pc) && - edt_find_double(p, end, "Pa", &Pa) && - edt_find_double(p, end, "eps", &eps) && - edt_find_double(p, end, "cstar_ideal", &cstar) && - edt_find_double(p, end, "Cf_ideal", &Cf) && - edt_find_double(p, end, "Tc", &Tc) && - edt_find_double(p, end, "gamma", &gamma) && - edt_find_double(p, end, "R", &R) && - edt_find_double(p, end, "M", &M)) { - EdCeaResult r; - if (ed_cea_eval(&t, MR, Pc, Pa, eps, &r) != ED_OK) { - fprintf(stderr, " [FAIL] sample %d eval returned error\n", n); - g_fail++; - } else { - check("cstar_ideal", r.cstar_ideal, cstar, n); - check("Cf_ideal", r.Cf_ideal, Cf, n); - check("Tc", r.Tc, Tc, n); - check("gamma", r.gamma, gamma, n); - check("R", r.R, R, n); - check("M", r.M, M, n); - } - n++; - } - p = end + 1; - } - - free(js); - ed_cea_free(&t); - - printf("checked %d samples, %d failures\n", n, g_fail); - if (n == 0) { fprintf(stderr, "FAIL: no samples parsed\n"); return 1; } - return g_fail ? 1 : 0; -} diff --git a/EngineDesign/engine/native/tests/test_chamber_golden.c b/EngineDesign/engine/native/tests/test_chamber_golden.c deleted file mode 100644 index f07ff395f..000000000 --- a/EngineDesign/engine/native/tests/test_chamber_golden.c +++ /dev/null @@ -1,50 +0,0 @@ -/* test_chamber_golden.c - Golden parity for ed_chamber_solve / ed_evaluate / - * ed_stability_analyze vs Python runner.evaluate(). - * - * Stage 3 implemented ed_chamber_solve (verified to ~5e-10 vs Python via the - * ctypes parity harness, since building a full EdEngineState in C is redundant). - * ed_evaluate / ed_stability_analyze remain incomplete (nozzle + stability are - * Stages 4-5). This test verifies the API contracts that still hold: - * - ed_chamber_solve rejects an invalid (zeroed) state gracefully (error, no crash) - * - ed_evaluate does NOT yet return ED_OK (nozzle pending) - * - ed_stability_analyze returns ED_ERR_NOT_IMPLEMENTED - * It then SKIPs (exit 77); end-to-end chamber parity lives in the ctypes harness. - */ -#include "ed_evaluate.h" -#include "ed_chamber.h" -#include "ed_stability.h" -#include -#include - -int main(void) { - EdEngineState st; memset(&st, 0, sizeof st); - EdCeaTables cea; memset(&cea, 0, sizeof cea); - EdWorkspace ws; ed_workspace_reset(&ws); - - /* Zeroed state -> degenerate bounds: chamber solve must fail cleanly, not crash. */ - EdChamberDiagnostics ch; - ed_status_t rc_ch = ed_chamber_solve(&st, &cea, 5e6, 5e6, 0.0, &ws, &ch); - if (rc_ch == ED_OK) { - fprintf(stderr, "FAIL: chamber solve unexpectedly succeeded on zeroed state\n"); - return 1; - } - - /* ed_evaluate is not complete until the nozzle port (Stage 4): must not be OK. */ - EdEvaluateResult ev; - ed_status_t rc = ed_evaluate(&st, &cea, 5.0e6, 5.0e6, 101325.0, 0.0, &ws, &ev); - if (rc == ED_OK) { - fprintf(stderr, "FAIL: ed_evaluate returned OK before nozzle port\n"); - return 1; - } - - EdStabilityResult stab; - if (ed_stability_analyze(&st, &ch, &ev, &stab) != ED_ERR_NOT_IMPLEMENTED) { - fprintf(stderr, "FAIL: ed_stability_analyze contract changed\n"); - return 1; - } - - printf("chamber solve: implemented (parity via ctypes harness). " - "evaluate/stability: pending Stages 4-5.\n"); - printf("SKIP: C-side evaluate/stability golden deferred.\n"); - return 77; /* CTest SKIP_RETURN_CODE */ -} diff --git a/EngineDesign/engine/native/tests/test_feed_discharge.c b/EngineDesign/engine/native/tests/test_feed_discharge.c deleted file mode 100644 index 74e80fe4b..000000000 --- a/EngineDesign/engine/native/tests/test_feed_discharge.c +++ /dev/null @@ -1,121 +0,0 @@ -/* test_feed_discharge.c - Parity of ed_feed_loss + ed_discharge vs Python. - * - * Reads tests/golden/component_samples.json (export_component_golden.py), which - * carries config fields + inputs + the live-Python expected output per sample. - */ -#include "ed_feed_loss.h" -#include "ed_discharge.h" -#include "ed_test_util.h" - -#ifndef ED_GOLDEN_DIR -#define ED_GOLDEN_DIR "." -#endif - -static int g_fail = 0, g_checked = 0; - -static double need(const char *o, const char *e, const char *k) { - double v = 0.0; - if (!edt_find_double(o, e, k, &v)) { - fprintf(stderr, " [FAIL] missing key %s\n", k); - g_fail++; - } - return v; -} - -static void fill_feed(const char *o, const char *e, EdFeed *f) { - f->d_inlet = need(o, e, "d_inlet"); - f->A_hydraulic = need(o, e, "A_hydraulic"); - f->K0 = need(o, e, "K0"); - f->K1 = need(o, e, "K1"); - f->phi_type = (ed_phi_type_t)(int)need(o, e, "phi_type"); -} - -static void fill_disc(const char *o, const char *e, EdDischarge *d) { - d->Cd_inf = need(o, e, "Cd_inf"); - d->a_Re = need(o, e, "a_Re"); - d->Cd_min = need(o, e, "Cd_min"); - d->use_geometry_cd = (uint8_t)(int)need(o, e, "use_geometry_cd"); - d->d_ref_m = need(o, e, "d_ref_m"); - d->d_min_m = need(o, e, "d_min_m"); - d->cd_small_hole_exponent = need(o, e, "cd_small_hole_exponent"); - d->cd_large_hole_log_gain = need(o, e, "cd_large_hole_log_gain"); - d->cd_inf_max = need(o, e, "cd_inf_max"); - d->cd_inf_min_geom = need(o, e, "cd_inf_min_geom"); - d->use_pressure_correction = (uint8_t)(int)need(o, e, "use_pressure_correction"); - d->P_ref = need(o, e, "P_ref"); - d->a_P = need(o, e, "a_P"); - d->use_temperature_correction = (uint8_t)(int)need(o, e, "use_temperature_correction"); - d->T_ref = need(o, e, "T_ref"); - d->a_T = need(o, e, "a_T"); -} - -static void cmp(const char *tag, double got, double want) { - g_checked++; - if (!edt_close(got, want, 1e-9, 1e-9)) { - fprintf(stderr, " [FAIL] %-12s got=%.12g want=%.12g\n", tag, got, want); - g_fail++; - } -} - -/* Returns region [*beg,*reg_end) for the array following "key" up to next_key. */ -static void section(const char *buf, const char *key, const char *next_key, - const char **beg, const char **reg_end) { - char pat[48]; - snprintf(pat, sizeof pat, "\"%s\"", key); - const char *s = strstr(buf, pat); - *beg = s ? s + strlen(pat) : NULL; - if (next_key) { - snprintf(pat, sizeof pat, "\"%s\"", next_key); - const char *n = strstr(buf, pat); - *reg_end = n ? n : buf + strlen(buf); - } else { - *reg_end = buf + strlen(buf); - } -} - -int main(void) { - char *js = edt_slurp(ED_GOLDEN_DIR "/component_samples.json", NULL); - if (!js) { fprintf(stderr, "FAIL: cannot load component_samples.json\n"); return 1; } - - const char *beg, *reg_end, *p, *e; - - /* feed loss */ - section(js, "feed", "cd_inf", &beg, ®_end); - p = beg; - while (p && (p = edt_next_object(p, &e)) != NULL && p < reg_end) { - EdFeed f; - fill_feed(p, e, &f); - double mdot = need(p, e, "mdot"), rho = need(p, e, "rho"), P = need(p, e, "P_tank"); - double want = need(p, e, "expected"); - cmp("delta_p_feed", ed_delta_p_feed(mdot, rho, &f, P), want); - p = e + 1; - } - - /* cd_inf_from_orifice_diameter */ - section(js, "cd_inf", "cd_from_re", &beg, ®_end); - p = beg; - while (p && (p = edt_next_object(p, &e)) != NULL && p < reg_end) { - EdDischarge d; - fill_disc(p, e, &d); - double dh = need(p, e, "d_hyd"), want = need(p, e, "expected"); - cmp("cd_inf", ed_cd_inf_from_orifice_diameter(dh, &d), want); - p = e + 1; - } - - /* cd_from_re */ - section(js, "cd_from_re", NULL, &beg, ®_end); - p = beg; - while (p && (p = edt_next_object(p, &e)) != NULL && p < reg_end) { - EdDischarge d; - fill_disc(p, e, &d); - double Re = need(p, e, "Re"), Pin = need(p, e, "P_inlet"), - Tin = need(p, e, "T_inlet"), dh = need(p, e, "d_hyd"); - double want = need(p, e, "expected"); - cmp("cd_from_re", ed_cd_from_re(Re, &d, Pin, Tin, dh), want); - p = e + 1; - } - - free(js); - printf("feed/discharge: checked %d, %d failures\n", g_checked, g_fail); - return g_fail ? 1 : 0; -} diff --git a/EngineDesign/engine/native/tests/test_injector_golden.c b/EngineDesign/engine/native/tests/test_injector_golden.c deleted file mode 100644 index b9290d06a..000000000 --- a/EngineDesign/engine/native/tests/test_injector_golden.c +++ /dev/null @@ -1,154 +0,0 @@ -/* test_injector_golden.c - Stage 2 parity: ed_injector_solve (impinging) vs - * Python ImpingingInjector.solve(), over tests/golden/injector_impinging.json. - * Asserts mdot, Cd, momentum ratio R, jet areas, and SMD. */ -#include "ed_injector.h" -#include "ed_test_util.h" - -#ifndef ED_GOLDEN_DIR -#define ED_GOLDEN_DIR "." -#endif - -static int g_fail = 0, g_checked = 0; -static const char *g_sbeg, *g_send; - -static double S(const char *key) { - double v = 0.0; - if (!edt_find_double(g_sbeg, g_send, key, &v)) { - fprintf(stderr, " [FAIL] state missing %s\n", key); - g_fail++; - } - return v; -} - -static void load_disc(const char *p, EdDischarge *d) { - char k[80]; -#define D(field) (snprintf(k, sizeof k, "%s." #field, p), S(k)) - d->Cd_inf = D(Cd_inf); d->a_Re = D(a_Re); d->Cd_min = D(Cd_min); - d->use_geometry_cd = (uint8_t)D(use_geometry_cd); - d->d_ref_m = D(d_ref_m); d->d_min_m = D(d_min_m); - d->cd_small_hole_exponent = D(cd_small_hole_exponent); - d->cd_large_hole_log_gain = D(cd_large_hole_log_gain); - d->cd_inf_max = D(cd_inf_max); d->cd_inf_min_geom = D(cd_inf_min_geom); - d->use_pressure_correction = (uint8_t)D(use_pressure_correction); - d->P_ref = D(P_ref); d->a_P = D(a_P); - d->use_temperature_correction = (uint8_t)D(use_temperature_correction); - d->T_ref = D(T_ref); d->a_T = D(a_T); -#undef D -} - -static void load_feed(const char *p, EdFeed *f) { - char k[64]; -#define F(field) (snprintf(k, sizeof k, "%s." #field, p), S(k)) - f->d_inlet = F(d_inlet); f->A_hydraulic = F(A_hydraulic); - f->K0 = F(K0); f->K1 = F(K1); f->phi_type = (ed_phi_type_t)(int)F(phi_type); -#undef F -} - -static void load_fluid(const char *p, EdFluid *fl) { - char k[64]; -#define G(field) (snprintf(k, sizeof k, "%s." #field, p), S(k)) - fl->density = G(density); fl->viscosity = G(viscosity); - fl->surface_tension = G(surface_tension); fl->temperature = G(temperature); -#undef G -} - -static void load_imp(const char *p, EdImpingingBranch *b) { - char k[64]; -#define I(field) (snprintf(k, sizeof k, "%s." #field, p), S(k)) - b->n_elements = (int)I(n_elements); b->d_jet = I(d_jet); - b->impingement_angle = I(impingement_angle); b->spacing = I(spacing); -#undef I -} - -static void build_state(EdEngineState *st) { - memset(st, 0, sizeof(*st)); - st->injector.type = (ed_injector_type_t)(int)S("injector.type"); - load_imp("imp_O", &st->injector.imp_O); - load_imp("imp_F", &st->injector.imp_F); - load_disc("discharge_O", &st->discharge_O); - load_disc("discharge_F", &st->discharge_F); - load_feed("feed_O", &st->feed_O); - load_feed("feed_F", &st->feed_F); - load_fluid("fluid_O", &st->fluid_O); - load_fluid("fluid_F", &st->fluid_F); - st->spray.smd_model = (ed_smd_model_t)(int)S("spray.smd_model"); - st->spray.smd_C = S("spray.smd_C"); - st->spray.smd_m = S("spray.smd_m"); - st->spray.smd_p = S("spray.smd_p"); - st->spray.smd_C_ingebo = S("spray.smd_C_ingebo"); - st->spray.smd_we_corr_max = S("spray.smd_we_corr_max"); - st->spray.chamber_gas_R = S("spray.chamber_gas_R"); - st->spray.chamber_gas_T = S("spray.chamber_gas_T"); - st->spray.spray_angle_model = (ed_spray_angle_model_t)(int)S("spray.spray_angle_model"); - st->spray.spray_angle_k = S("spray.spray_angle_k"); - st->spray.spray_angle_n = S("spray.spray_angle_n"); - st->spray.we_min = S("spray.we_min"); - st->spray.evap_K = S("spray.evap_K"); - st->spray.evap_x_star_limit = S("spray.evap_x_star_limit"); - st->spray.evap_use_constraint = (uint8_t)S("spray.evap_use_constraint"); - st->solver.closure_max_iterations = (int)S("solver.closure_max_iterations"); - st->solver.closure_Cd_reduction_factor = S("solver.closure_Cd_reduction_factor"); - st->cooling.regen_enabled = (uint8_t)S("cooling.regen_enabled"); -} - -static void cmp(const char *tag, int idx, double got, double want, double rtol, double atol) { - g_checked++; - if (!edt_close(got, want, rtol, atol)) { - fprintf(stderr, " [FAIL] s%-2d %-16s got=%.10g want=%.10g\n", idx, tag, got, want); - g_fail++; - } -} - -int main(void) { - char *js = edt_slurp(ED_GOLDEN_DIR "/injector_impinging.json", NULL); - if (!js) { fprintf(stderr, "FAIL: cannot load injector_impinging.json\n"); return 1; } - - /* state region */ - const char *sp = strstr(js, "\"state\""); - if (!sp) { fprintf(stderr, "FAIL: no state\n"); return 1; } - g_sbeg = strchr(sp, '{'); - g_send = strchr(g_sbeg, '}'); /* flat object: first '}' closes it */ - - EdEngineState st; - build_state(&st); - - /* samples region (after state object) */ - const char *p = strstr(g_send, "\"samples\""), *e; - int n = 0; - while (p && (p = edt_next_object(p, &e)) != NULL) { - double P_O, P_F, Pc; - if (!(edt_find_double(p, e, "P_tank_O", &P_O) && - edt_find_double(p, e, "P_tank_F", &P_F) && - edt_find_double(p, e, "Pc", &Pc))) { p = e + 1; continue; } - - EdInjectorResult r; - ed_status_t rc = ed_injector_solve(&st, P_O, P_F, Pc, &r); - if (rc != ED_OK) { - fprintf(stderr, " [FAIL] s%d solve rc=%d\n", n, rc); - g_fail++; p = e + 1; n++; continue; - } - double w; -#define CHK(key, field, rt, at) do { if (edt_find_double(p, e, key, &w)) cmp(key, n, r.field, w, rt, at); } while (0) - CHK("mdot_O", mdot_O, 1e-6, 1e-9); - CHK("mdot_F", mdot_F, 1e-6, 1e-9); - CHK("Cd_O", Cd_O, 1e-6, 1e-9); - CHK("Cd_F", Cd_F, 1e-6, 1e-9); - CHK("momentum_ratio_R", momentum_ratio_R, 1e-6, 1e-9); - CHK("A_geom_O", A_geom_O, 1e-9, 1e-15); - CHK("A_geom_F", A_geom_F, 1e-9, 1e-15); - CHK("D32_O", D32_O, 1e-6, 1e-12); - CHK("D32_F", D32_F, 1e-6, 1e-12); - CHK("We_O", We_O, 1e-6, 1e-9); - CHK("We_F", We_F, 1e-6, 1e-9); - CHK("J", J, 1e-6, 1e-9); - CHK("theta", theta, 1e-6, 1e-9); - CHK("x_star", x_star, 1e-6, 1e-12); -#undef CHK - p = e + 1; n++; - } - - free(js); - printf("injector golden: %d samples, %d checks, %d failures\n", n, g_checked, g_fail); - if (n == 0) { fprintf(stderr, "FAIL: no samples\n"); return 1; } - return g_fail ? 1 : 0; -} diff --git a/EngineDesign/engine/native/tests/test_nozzle_golden.c b/EngineDesign/engine/native/tests/test_nozzle_golden.c deleted file mode 100644 index 08f30aef6..000000000 --- a/EngineDesign/engine/native/tests/test_nozzle_golden.c +++ /dev/null @@ -1,87 +0,0 @@ -/* test_nozzle_golden.c - Pin ed_nozzle_solve arithmetic against a frozen snapshot. - * - * Loads tests/golden/nozzle_golden.json (captured 2026-06 by - * tools/export_nozzle_golden.py from the then-current Python frozen nozzle). - * The exit/throat-state fields are the live contract (ed_evaluate reports them); - * the F/Isp/Cf fields pin the RETIRED momentum-method arithmetic, which nothing - * consumes — delivered thrust is RPA (ed_evaluate.c) and is live-verified against - * Python by tests/test_native_ab_parity.py. This test therefore guards against - * accidental edits to the kernel, not production thrust parity. - */ -#include "ed_nozzle.h" -#include "ed_test_util.h" - -#ifndef ED_GOLDEN_DIR -#define ED_GOLDEN_DIR "." -#endif - -static int g_fail = 0; - -static void check(const char *name, double got, double want, int idx) { - if (!edt_close(got, want, 1e-7, 1e-6)) { - fprintf(stderr, " [FAIL] sample %d %-12s got=%.12g want=%.12g (rel=%.2e)\n", - idx, name, got, want, fabs(got - want) / (fabs(want) + 1e-30)); - g_fail++; - } -} - -int main(void) { - size_t len = 0; - char *js = edt_slurp(ED_GOLDEN_DIR "/nozzle_golden.json", &len); - if (!js) { - fprintf(stderr, "FAIL: cannot load nozzle_golden.json from %s\n", ED_GOLDEN_DIR); - return 1; - } - - int n = 0; - 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; - int ok = - edt_find_double(p, end, "Pc", &in.Pc) && - edt_find_double(p, end, "mdot_total", &in.mdot_total) && - edt_find_double(p, end, "A_throat", &in.A_throat) && - edt_find_double(p, end, "A_exit", &in.A_exit) && - edt_find_double(p, end, "eps", &in.eps) && - edt_find_double(p, end, "Pa", &in.Pa) && - edt_find_double(p, end, "nozzle_efficiency", &in.nozzle_efficiency) && - edt_find_double(p, end, "Cf_ideal", &in.Cf_ideal) && - 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); - if (ok) { - EdNozzleResult r; - if (ed_nozzle_solve(&in, &r) != ED_OK) { - fprintf(stderr, " [FAIL] sample %d ed_nozzle_solve returned error\n", n); - g_fail++; - } else { - check("M_exit", r.M_exit, M_exit, n); - check("P_exit", r.P_exit, P_exit, n); - check("T_exit", r.T_exit, T_exit, n); - 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++; - } - p = end + 1; - } - - free(js); - printf("checked %d nozzle samples, %d failures\n", n, g_fail); - if (n == 0) { fprintf(stderr, "FAIL: no samples parsed\n"); return 1; } - return g_fail ? 1 : 0; -} diff --git a/EngineDesign/engine/native/tests/test_residual_golden.c b/EngineDesign/engine/native/tests/test_residual_golden.c deleted file mode 100644 index aaddbbbb9..000000000 --- a/EngineDesign/engine/native/tests/test_residual_golden.c +++ /dev/null @@ -1,154 +0,0 @@ -/* 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 "ed_combustion.h" -#include "ed_cooling.h" -#include "ed_test_util.h" - -#ifndef ED_GOLDEN_DIR -#define ED_GOLDEN_DIR "." -#endif - -static int g_fail = 0, g_checked = 0; -static double g_worst = 0.0; - -static double need(const char *o, const char *e, const char *k) { - double v = 0.0; - if (!edt_find_double(o, e, k, &v)) { fprintf(stderr, " [FAIL] missing %s\n", k); g_fail++; } - return v; -} - -static void cmp(const char *tag, double got, double want) { - g_checked++; - double rel = fabs(want) > 1e-12 ? fabs(got - want) / fabs(want) : fabs(got - want); - if (rel > g_worst) g_worst = rel; - if (!edt_close(got, want, 1e-7, 1e-6)) { - fprintf(stderr, " [FAIL] %-14s got=%.10g want=%.10g rel=%.2e\n", tag, got, want, rel); - g_fail++; - } -} - -/* locate the flat object that follows "key" */ -static const char *obj_for(const char *buf, const char *key, const char **end) { - char pat[40]; snprintf(pat, sizeof pat, "\"%s\"", key); - const char *p = strstr(buf, pat); - if (!p) return NULL; - return edt_next_object(p, end); -} - -int main(void) { - char *js = edt_slurp(ED_GOLDEN_DIR "/residual_samples.json", NULL); - if (!js) { fprintf(stderr, "FAIL: cannot load residual_samples.json\n"); return 1; } - - const char *ce, *cl, *ge; - const char *co = obj_for(js, "comb", &ce); - const char *cc = obj_for(js, "cooling", &cl); - const char *go = obj_for(js, "geom", &ge); - if (!co || !cc || !go) { fprintf(stderr, "FAIL: missing comb/cooling/geom\n"); return 1; } - - EdEngineState st; memset(&st, 0, sizeof st); - EdCombustionEff *cb = &st.comb; - cb->model = (ed_eff_model_t)(int)need(co, ce, "model"); - cb->C = need(co, ce, "C"); cb->K = need(co, ce, "K"); - cb->tau_ref = need(co, ce, "tau_ref"); cb->tau_ref_P = need(co, ce, "tau_ref_P"); - cb->tau_ref_T = need(co, ce, "tau_ref_T"); cb->n_pressure = need(co, ce, "n_pressure"); - cb->has_tau_Tc_floor = (uint8_t)(int)need(co, ce, "has_tau_Tc_floor"); - cb->tau_Tc_floor = need(co, ce, "tau_Tc_floor"); - cb->Em_peak = need(co, ce, "Em_peak"); - cb->mixing_sigma = need(co, ce, "mixing_sigma"); - cb->R_opt = need(co, ce, "R_opt"); - - EdCooling *cg = &st.cooling; - cg->regen_enabled = (uint8_t)(int)need(cc, cl, "regen_enabled"); - cg->film_enabled = (uint8_t)(int)need(cc, cl, "film_enabled"); - cg->ablative_enabled = (uint8_t)(int)need(cc, cl, "ablative_enabled"); - cg->graphite_enabled = (uint8_t)(int)need(cc, cl, "graphite_enabled"); - 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_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"); - cg->radiation_emissivity_hot = need(cc, cl, "radiation_emissivity_hot"); - cg->radiation_view_factor = need(cc, cl, "radiation_view_factor"); - cg->regen_chamber_inner_diameter = need(cc, cl, "regen_chamber_inner_diameter"); - cg->ablative_coverage_fraction = need(cc, cl, "ablative_coverage_fraction"); - cg->ablative_surface_temperature_limit = need(cc, cl, "ablative_surface_temperature_limit"); - cg->ablative_material_density = need(cc, cl, "ablative_material_density"); - cg->ablative_heat_of_ablation = need(cc, cl, "ablative_heat_of_ablation"); - cg->ablative_specific_heat = need(cc, cl, "ablative_specific_heat"); - cg->ablative_pyrolysis_temperature = need(cc, cl, "ablative_pyrolysis_temperature"); - cg->ablative_use_physics_based_blowing = (uint8_t)(int)need(cc, cl, "ablative_use_physics_based_blowing"); - cg->ablative_blowing_efficiency = need(cc, cl, "ablative_blowing_efficiency"); - cg->ablative_blowing_coefficient = need(cc, cl, "ablative_blowing_coefficient"); - cg->ablative_blowing_min_reduction_factor = need(cc, cl, "ablative_blowing_min_reduction_factor"); - cg->ablative_turbulence_reference_intensity = need(cc, cl, "ablative_turbulence_reference_intensity"); - cg->ablative_turbulence_sensitivity = need(cc, cl, "ablative_turbulence_sensitivity"); - cg->ablative_turbulence_exponent = need(cc, cl, "ablative_turbulence_exponent"); - cg->ablative_turbulence_max_multiplier = need(cc, cl, "ablative_turbulence_max_multiplier"); - cg->ablative_surface_emissivity = need(cc, cl, "ablative_surface_emissivity"); - cg->ablative_ambient_temperature = need(cc, cl, "ablative_ambient_temperature"); - cg->ablative_radiative_sink_minimum_threshold = need(cc, cl, "ablative_radiative_sink_minimum_threshold"); - cg->ablative_radiative_sink_fallback_temperature = need(cc, cl, "ablative_radiative_sink_fallback_temperature"); - cg->cooling_efficiency_floor = need(cc, cl, "cooling_efficiency_floor"); - - EdGeometry *gm = &st.geom; - gm->A_throat = need(go, ge, "A_throat"); gm->A_exit = need(go, ge, "A_exit"); - gm->volume = need(go, ge, "volume"); gm->Lstar = need(go, ge, "Lstar"); - gm->length = need(go, ge, "length"); - gm->length_cylindrical = need(go, ge, "length_cylindrical"); - gm->length_contraction = need(go, ge, "length_contraction"); - gm->chamber_diameter = need(go, ge, "chamber_diameter"); - gm->exit_diameter = need(go, ge, "exit_diameter"); - gm->expansion_ratio = need(go, ge, "expansion_ratio"); - gm->nozzle_efficiency = need(go, ge, "nozzle_efficiency"); - - /* iterate samples */ - const char *beg = strstr(js, "\"samples\""); - const char *p = beg, *e; - int n = 0; - while (p && (p = edt_next_object(p, &e)) != NULL) { - if (strstr(p, "_error") && strstr(p, "_error") < e) { p = e + 1; continue; } - double Pc = need(p, e, "Pc"), mo = need(p, e, "mdot_O"), mf = need(p, e, "mdot_F"); - double MR = need(p, e, "MR"), Lstar = need(p, e, "Lstar"), Ac = need(p, e, "Ac"); - double At = need(p, e, "At"), Dinj = need(p, e, "Dinj"); - double D32_O = need(p, e, "D32_O"), D32_F = need(p, e, "D32_F"); - double u_O = need(p, e, "u_O"), u_F = need(p, e, "u_F"); - double mom_R = need(p, e, "momentum_ratio_R"), R_opt = need(p, e, "R_opt"); - double Tc = need(p, e, "Tc"), gamma = need(p, e, "gamma"), R = need(p, e, "R"), M = need(p, e, "M"); - double cstar_ideal = need(p, e, "cstar_ideal"); - double L_eff = need(p, e, "fuel_latent_heat"), Tcap = need(p, e, "fuel_T_star_cap"); - - EdEtaResult eta; - ed_status_t rc = ed_combustion_efficiency_advanced( - cb, Lstar, Pc, Tc, cstar_ideal, gamma, R, MR, Ac, At, Dinj, mo + mf, - u_F, u_O, D32_O, D32_F, mom_R, R_opt, L_eff, Tcap, &eta); - if (rc != ED_OK) { fprintf(stderr, " [FAIL] sample %d eta rc=%d\n", n, rc); g_fail++; p = e + 1; n++; continue; } - - cmp("eta_Lstar", eta.eta_Lstar, need(p, e, "eta_Lstar")); - cmp("eta_kinetics", eta.eta_kinetics, need(p, e, "eta_kinetics")); - cmp("eta_mixing", eta.eta_mixing, need(p, e, "eta_mixing")); - cmp("eta_total", eta.eta_total, need(p, e, "eta_total")); - - EdCoolingResult cool; - rc = ed_cooling_evaluate(&st, Pc, mo, mf, Tc, gamma, R, M, &cool); - if (rc != ED_OK) { fprintf(stderr, " [FAIL] sample %d cooling rc=%d\n", n, rc); g_fail++; p = e + 1; n++; continue; } - cmp("cooling_eff", cool.cooling_eff, need(p, e, "cooling_eff")); - cmp("heat_removed", cool.heat_removed, need(p, e, "heat_removed")); - - /* derived residual quantities */ - double eta_final = eta.eta_total * 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")); - cmp("cstar_actual", cstar_actual, need(p, e, "cstar_actual")); - cmp("mdot_demand", mdot_demand, need(p, e, "mdot_demand")); - n++; - p = e + 1; - } - - free(js); - printf("residual: %d samples, %d checks, %d failures, worst rel=%.2e\n", n, g_checked, g_fail, g_worst); - if (n == 0) { fprintf(stderr, "FAIL: no samples\n"); return 1; } - return g_fail ? 1 : 0; -} diff --git a/EngineDesign/engine/native/tests/test_root_find.c b/EngineDesign/engine/native/tests/test_root_find.c deleted file mode 100644 index bb76e899c..000000000 --- a/EngineDesign/engine/native/tests/test_root_find.c +++ /dev/null @@ -1,56 +0,0 @@ -/* test_root_find.c - Unit tests for ed_brentq against analytic roots. */ -#include "ed_root_find.h" -#include -#include - -static int g_fail = 0; - -static double f_cubic(double x, void *c) { (void)c; return x * x * x - 2.0 * x - 5.0; } /* root ~2.0945514815 */ -static double f_sin(double x, void *c) { (void)c; return sin(x); } /* root pi in [3,4] */ -static double f_lin(double x, void *c) { (void)c; return 3.0 * x - 6.0; } /* root 2 */ -static double f_exp(double x, void *c) { (void)c; return exp(x) - 5.0; } /* root ln5 */ - -static void expect_root(const char *name, ed_root_fn f, double a, double b, double want) { - ed_root_opts o = { .xtol = 1e-12, .rtol = 1e-14, .max_iter = 100 }; - ed_root_result r; - ed_status_t rc = ed_brentq(f, NULL, a, b, &o, &r); - if (rc != ED_OK || !r.converged) { - fprintf(stderr, " [FAIL] %-8s rc=%d converged=%d\n", name, rc, r.converged); - g_fail++; - return; - } - if (fabs(r.root - want) > 1e-9) { - fprintf(stderr, " [FAIL] %-8s root=%.12g want=%.12g (iters=%d)\n", - name, r.root, want, r.iterations); - g_fail++; - return; - } - printf(" [ok] %-8s root=%.12g iters=%d |f|=%.2e\n", name, r.root, r.iterations, fabs(r.f_root)); -} - -int main(void) { - expect_root("cubic", f_cubic, 1.0, 3.0, 2.0945514815423265); - expect_root("sin", f_sin, 3.0, 4.0, M_PI); - expect_root("linear",f_lin, 0.0, 10.0, 2.0); - expect_root("exp", f_exp, 0.0, 5.0, log(5.0)); - - /* No-bracket detection. */ - ed_root_result r; - if (ed_brentq(f_lin, NULL, 5.0, 10.0, NULL, &r) != ED_ERR_NO_BRACKET) { - fprintf(stderr, " [FAIL] expected ED_ERR_NO_BRACKET\n"); - g_fail++; - } else { - printf(" [ok] no-bracket detected\n"); - } - - /* Endpoint-root detection. */ - if (ed_brentq(f_lin, NULL, 2.0, 10.0, NULL, &r) != ED_OK || fabs(r.root - 2.0) > 1e-15) { - fprintf(stderr, " [FAIL] endpoint root not detected\n"); - g_fail++; - } else { - printf(" [ok] endpoint root detected\n"); - } - - printf("root_find: %s\n", g_fail ? "FAILURES" : "all passed"); - return g_fail ? 1 : 0; -} diff --git a/EngineDesign/engine/native/tools/bench_evaluate_paths.py b/EngineDesign/engine/native/tools/bench_evaluate_paths.py deleted file mode 100644 index 7a96e3060..000000000 --- a/EngineDesign/engine/native/tools/bench_evaluate_paths.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Decompose the Layer-1 evaluate cost to scope the ed_evaluate wiring (Phase 4). - -Times, on the impinging canonical engine, with the native kernel ENABLED (the real -production inner-loop setting): - 1. runner.evaluate() — current production per-candidate path - 2. native ed_evaluate() alone — chamber + frozen nozzle, one C call, no stability - 3. comprehensive_stability_analysis() alone — the Python stability the objective needs - -Phase-4 native path ≈ (2) + (3), since the objective scores stability per candidate. -This tells us whether bypassing runner.evaluate with ed_evaluate is worth the surgery. - -Run: .venv/bin/python -m engine.native.tools.bench_evaluate_paths -""" - -from __future__ import annotations - -import os - -os.environ["ED_USE_NATIVE"] = "1" - -import ctypes as C -import time -from pathlib import Path - -from engine.pipeline.io import load_config -from engine.core.runner import PintleEngineRunner -from engine.native.python import native_injector as ni - -REPO = Path(__file__).resolve().parents[3] -PA = 101325.0 -P_O = 563.4671262691785 * 6894.76 -P_F = 567.6435444099167 * 6894.76 - - -def _time(fn, n): - fn() # warm - t0 = time.perf_counter() - for _ in range(n): - fn() - return (time.perf_counter() - t0) / n * 1e6 # microseconds - - -def main() -> None: - config = load_config(REPO / "configs/canonical/impinging.yaml") - runner = PintleEngineRunner(config) - N = 400 - - # 1. production runner.evaluate (native chamber + python nozzle/shifting + stability) - t_runner = _time(lambda: runner.evaluate(P_O, P_F, P_ambient=PA, silent=True), N) - - # 2. native ed_evaluate alone - st = ni.build_state(config) - nat = ni._nat() - ni._ensure_cea(runner.cea_cache) - def _native(): - rc, res = nat.evaluate(C.byref(st), P_O, P_F, PA) - return res - t_native = _time(_native, N) - - # 3. comprehensive_stability_analysis alone (needs a diagnostics dict) - res = runner.evaluate(P_O, P_F, P_ambient=PA, silent=True) - diag = res["diagnostics"] - from engine.pipeline.stability.analysis import comprehensive_stability_analysis - def _stab(): - return comprehensive_stability_analysis( - config=config, Pc=res["Pc"], MR=res["MR"], mdot_total=diag["mdot_O"] + diag["mdot_F"], - cstar=res["cstar_actual"], gamma=res["gamma"], R=res["R"], Tc=res["Tc"], diagnostics=diag) - t_stab = _time(_stab, N) - - print(f"\n{'path':<46} {'us/call':>10}") - print(f"{'1. runner.evaluate (production today)':<46} {t_runner:>10.1f}") - print(f"{'2. native ed_evaluate (chamber+frozen nozzle)':<46} {t_native:>10.1f}") - print(f"{'3. comprehensive_stability_analysis':<46} {t_stab:>10.1f}") - print(f"{' => Phase-4 native path est (2 + 3)':<46} {t_native + t_stab:>10.1f}") - if t_runner > 0: - print(f"\nrunner.evaluate breakdown: stability is ~{100*t_stab/t_runner:.0f}% of it") - print(f"projected speedup of (2+3) vs (1): {t_runner/(t_native+t_stab):.2f}x") - print(f"projected speedup if stability stays as-is and only physics swaps: " - f"{t_runner/(t_native+t_stab):.2f}x (stability dominates → limited)") - - -if __name__ == "__main__": - main() diff --git a/EngineDesign/engine/native/tools/capture_nozzle_oracle.py b/EngineDesign/engine/native/tools/capture_nozzle_oracle.py deleted file mode 100644 index 8f83ad0c2..000000000 --- a/EngineDesign/engine/native/tools/capture_nozzle_oracle.py +++ /dev/null @@ -1,162 +0,0 @@ -"""HISTORICAL (Phase 0, 2026-06, pre-RPA) — parity oracle + baseline for the C port. - -Kept as a record of how the frozen-vs-shifting scope decision was made. Both the -shifting-equilibrium nozzle and the momentum-method thrust it captured were since -RETIRED (docs/thrust_efficiency_bug_analysis.md): the shifting toggle below is now -inert (config field is a deprecated no-op) and re-running this tool would capture -identical "shifting"/"frozen" rows on the current RPA nozzle. Live parity checking -is tests/test_native_ab_parity.py; do not use this tool for new validation. - -Captures the PURE-PYTHON ``runner.evaluate()`` results (the ground truth the C -``ed_nozzle``/``ed_evaluate`` port is checked against) for the canonical pintle and -impinging engines at several tank-pressure points, and: - - * dumps a golden JSON (engine/native/tests/golden/nozzle_oracle.json), - * measures the shifting-equilibrium vs frozen (chamber-gamma) delta on F/Isp so we - can decide whether the C nozzle must port shifting equilibrium or can stay frozen - within the rtol=1e-3 parity tolerance, - * reports baseline per-evaluate timing. - -Run: .venv/bin/python -m engine.native.tools.capture_nozzle_oracle -The oracle is the Python path, so we FORCE ED_USE_NATIVE=0 before importing engine code. -""" - -from __future__ import annotations - -import os - -# Ground truth = Python physics. Must be set before engine imports resolve native dispatch. -os.environ["ED_USE_NATIVE"] = "0" - -import copy -import json -import time -from pathlib import Path - -from engine.pipeline.io import load_config -from engine.core.runner import PintleEngineRunner - -PSI_TO_PA = 6894.76 -REPO = Path(__file__).resolve().parents[3] # .../EngineDesign -GOLDEN = REPO / "engine" / "native" / "tests" / "golden" / "nozzle_oracle.json" - -# Fields the Layer-1 objective + EdEvaluateResult care about (see ed_evaluate.h). -RESULT_KEYS = [ - "F", "Isp", "Pc", "MR", "v_exit", "P_exit", "P_throat", "T_exit", "T_throat", - "Cf_actual", "Cf_ideal", -] -DIAG_KEYS = [ - "cstar_actual", "cstar_ideal", "eta_cstar", "mdot_O", "mdot_F", "MR", - "gamma", "R", "Tc", "momentum_ratio_R", "Cd_O", "Cd_F", "SMD", -] - -CASES = [ - # (label, config_path, P_O_psi, P_F_psi) — nominals from each config's tank initial_pressure_psi - ("pintle", "configs/canonical/pintle.yaml", 523.6759162449396, 537.261547029532), - ("impinging", "configs/canonical/impinging.yaml", 563.4671262691785, 567.6435444099167), -] -# Perturbation multipliers applied to the nominal (P_O, P_F) to exercise the nozzle off-design. -PERTURB = [(1.00, 1.00), (0.92, 0.97), (1.06, 1.03)] - - -def _extract(result: dict) -> dict: - diag = result.get("diagnostics", {}) or {} - out = {k: _num(result.get(k)) for k in RESULT_KEYS} - out["diagnostics"] = {k: _num(diag.get(k)) for k in DIAG_KEYS} - return out - - -def _num(v): - try: - if v is None: - return None - return float(v) - except (TypeError, ValueError): - return None - - -def _set_shifting(config, enabled: bool) -> None: - eff = getattr(getattr(config, "combustion", None), "efficiency", None) - if eff is not None and hasattr(eff, "use_shifting_equilibrium"): - eff.use_shifting_equilibrium = enabled - - -def _rel(a, b) -> float: - if a is None or b is None: - return float("nan") - return abs(a - b) / max(abs(b), 1e-12) - - -def main() -> None: - golden = {"_meta": {"oracle": "python runner.evaluate (ED_USE_NATIVE=0)", - "rtol_target": 1e-3}, "cases": []} - print(f"{'case':>22} {'shift?':>6} {'F (N)':>12} {'Isp (s)':>9} {'Pc (bar)':>9}") - for label, rel_path, p_o_psi, p_f_psi in CASES: - config = load_config(REPO / rel_path) - runner = PintleEngineRunner(config) - p_amb = 101325.0 - for i, (mo, mf) in enumerate(PERTURB): - p_o = p_o_psi * mo * PSI_TO_PA - p_f = p_f_psi * mf * PSI_TO_PA - point = {"case": label, "config": rel_path, "P_O_Pa": p_o, "P_F_Pa": p_f, - "P_ambient_Pa": p_amb} - for shift in (True, False): - _set_shifting(runner.config, shift) - try: - res = runner.evaluate(p_o, p_f, P_ambient=p_amb, silent=True) - rec = _extract(res) - err = None - except Exception as e: # capture failures too — they're part of the contract - rec, err = None, f"{type(e).__name__}: {e}" - key = "shifting" if shift else "frozen" - point[key] = {"result": rec, "error": err} - if rec: - print(f"{label+'['+str(i)+']':>22} {key:>6} " - f"{rec['F']:>12.2f} {rec['Isp']:>9.2f} {rec['Pc']/1e5:>9.2f}") - else: - print(f"{label+'['+str(i)+']':>22} {key:>6} ERROR: {err}") - # shifting-vs-frozen delta (decides C nozzle scope) - s, f = point["shifting"].get("result"), point["frozen"].get("result") - if s and f: - point["shift_delta"] = { - "F_rel": _rel(s["F"], f["F"]), - "Isp_rel": _rel(s["Isp"], f["Isp"]), - "P_exit_rel": _rel(s["P_exit"], f["P_exit"]), - } - golden["cases"].append(point) - - # Baseline timing on the nominal pintle point (shifting on, the production default). - config = load_config(REPO / CASES[0][1]) - runner = PintleEngineRunner(config) - _set_shifting(runner.config, True) - p_o, p_f = CASES[0][2] * PSI_TO_PA, CASES[0][3] * PSI_TO_PA - runner.evaluate(p_o, p_f, P_ambient=101325.0, silent=True) # warm - N = 200 - t0 = time.perf_counter() - for _ in range(N): - runner.evaluate(p_o, p_f, P_ambient=101325.0, silent=True) - per_ms = (time.perf_counter() - t0) / N * 1e3 - golden["_meta"]["python_evaluate_ms"] = per_ms - - GOLDEN.parent.mkdir(parents=True, exist_ok=True) - GOLDEN.write_text(json.dumps(golden, indent=2)) - - # Summary: worst-case shifting delta across all points. - deltas = [c["shift_delta"] for c in golden["cases"] if "shift_delta" in c] - if deltas: - worst_F = max(d["F_rel"] for d in deltas) - worst_Isp = max(d["Isp_rel"] for d in deltas) - print("\n--- shifting-equilibrium impact (decides C nozzle scope) ---") - print(f"worst |dF|/F across points: {worst_F:.2e}") - print(f"worst |dIsp|/Isp: {worst_Isp:.2e}") - print(f"parity target rtol: {1e-3:.0e}") - verdict = ("FROZEN nozzle is WITHIN tolerance — C port can skip shifting equilibrium" - if max(worst_F, worst_Isp) < 1e-3 else - "shifting equilibrium EXCEEDS tolerance — C nozzle MUST port it") - print(f"verdict: {verdict}") - print(f"\nbaseline python evaluate: {per_ms:.3f} ms/call") - print(f"golden written: {GOLDEN.relative_to(REPO)}") - - -if __name__ == "__main__": - main() diff --git a/EngineDesign/engine/native/tools/check_fast_eval_parity.py b/EngineDesign/engine/native/tools/check_fast_eval_parity.py deleted file mode 100644 index 05eaf24f8..000000000 --- a/EngineDesign/engine/native/tools/check_fast_eval_parity.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Manual A/B check + timing — native_injector.evaluate() vs runner.evaluate(). - -Both paths now compute the same RPA delivered thrust (the old frozen-vs-shifting -distinction is gone; the shifting nozzle was retired), so this is a plain live -parity sweep plus a per-call timing comparison. The pytest version of this check -is tests/test_native_ab_parity.py (run in the CI parity job); this script remains -for interactive use because it prints per-field tables and the speedup number. - -Run: .venv/bin/python -m engine.native.tools.check_fast_eval_parity -""" - -from __future__ import annotations - -import os - -os.environ["ED_USE_NATIVE"] = "1" - -import time -from pathlib import Path - -from engine.pipeline.io import load_config -from engine.core.runner import PintleEngineRunner -from engine.native.python import native_injector as ni - -REPO = Path(__file__).resolve().parents[3] -PA = 101325.0 -POINTS = [(563.467, 567.644), (518.4, 550.6), (597.3, 584.7)] # psi (O, F) - -TOP = ["F", "Isp", "Pc", "MR", "Cf_actual", "P_exit", "T_exit", "v_exit"] -DIAG = ["mdot_O", "mdot_F", "D32_O", "D32_F", "delta_p_feed_O", "delta_p_feed_F", - "Cd_O", "Cd_F", "momentum_ratio_R", "impingement_angle_deg"] - - -def _stab_margins(s): - return { - "state": s.get("stability_state", "?"), - "score": float(s.get("stability_score", 0.0)), - "chug": float(s.get("chugging", {}).get("stability_margin", 0.0)), - "acoustic": float(s.get("acoustic", {}).get("stability_margin", 0.0)), - "feed": float(s.get("feed_system", {}).get("stability_margin", 0.0)), - } - - -def main() -> int: - config = load_config(REPO / "configs/canonical/impinging.yaml") - runner = PintleEngineRunner(config) - worst = 0.0 - fails = 0 - for po_psi, pf_psi in POINTS: - p_o, p_f = po_psi * 6894.76, pf_psi * 6894.76 - ref = runner.evaluate(p_o, p_f, P_ambient=PA, silent=True) - nat = ni.evaluate(config, runner.cea_cache, p_o, p_f, PA) - if nat is None: - print(f" [FAIL] native returned None at ({po_psi},{pf_psi})") - fails += 1 - continue - print(f"\npoint O={po_psi} F={pf_psi} psi") - for k in TOP: - fails, worst = _cmp(k, nat.get(k), ref.get(k), fails, worst) - rd, nd = ref.get("diagnostics", {}), nat.get("diagnostics", {}) - for k in DIAG: - fails, worst = _cmp("diag." + k, nd.get(k), rd.get(k), fails, worst) - rs, ns = _stab_margins(ref.get("stability_results", {})), _stab_margins(nat.get("stability_results", {})) - same_state = rs["state"] == ns["state"] - print(f" {'stab.state':>20}: native={ns['state']} ref={rs['state']} " - f"{'ok' if same_state else 'FAIL'}") - if not same_state: - fails += 1 - for k in ("score", "chug", "acoustic", "feed"): - fails, worst = _cmp("stab." + k, ns[k], rs[k], fails, worst) - - # timing: native fast path vs production runner.evaluate - p_o, p_f = POINTS[0][0] * 6894.76, POINTS[0][1] * 6894.76 - t_prod = _time(lambda: runner.evaluate(p_o, p_f, P_ambient=PA, silent=True)) - t_fast = _time(lambda: ni.evaluate(config, runner.cea_cache, p_o, p_f, PA)) - print(f"\n=== {fails} failures, worst rel = {worst:.2e} (live native-vs-Python parity) ===") - print(f"timing: production runner.evaluate = {t_prod*1e6:7.1f} us/call") - print(f" native_injector.evaluate = {t_fast*1e6:7.1f} us/call") - print(f" speedup = {t_prod/t_fast:.2f}x") - return 1 if fails else 0 - - -def _time(fn, n=400): - fn() - t0 = time.perf_counter() - for _ in range(n): - fn() - return (time.perf_counter() - t0) / n - - -def _cmp(name, got, want, fails, worst): - if want is None and got is None: - return fails, worst - if got is None or want is None: - print(f" {name:>20}: got={got} want={want} [MISSING]") - return fails + 1, worst - rel = abs(got - want) / max(abs(want), 1e-12) - worst = max(worst, rel) - flag = "FAIL" if rel > 1e-3 else "ok" - if flag == "FAIL": - fails += 1 - print(f" {name:>20}: native={got:13.6g} ref={want:13.6g} rel={rel:.2e} {flag}") - return fails, worst - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/EngineDesign/engine/native/tools/export_cea_tables.py b/EngineDesign/engine/native/tools/export_cea_tables.py deleted file mode 100644 index 3fe46151b..000000000 --- a/EngineDesign/engine/native/tools/export_cea_tables.py +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env python3 -"""Export a CEACache to the flat .bin consumed by ed_cea_load, plus a JSON of -reference eval() samples for the C parity test. - -NEW FILE — reads the existing engine package read-only; modifies nothing under -engine/. Run from the repository root (EngineDesign/): - - python engine/native/tools/export_cea_tables.py \ - --config configs/canonical/impinging.yaml \ - --out engine/native/tests/golden - -Outputs: - /cea_tables.bin little-endian: "EDCA", i32 version=2, i32 n_pc/n_mr/n_eps, - f64 Pc_grid, MR_grid, eps_grid, then cstar,Cf,Tc,gamma,R,M, - Cf_vac tables (C-order, n_pc*n_mr*n_eps each). - (ed_cea_load also still reads version=1 files, which - lack the Cf_vac table.) - /cea_samples.json list of {MR,Pc,Pa,eps, cstar_ideal,Cf_ideal,Tc,gamma,R,M} - from CEACache.eval(), incl. interior + clamp-edge points. -""" -import argparse -import json -import os -import struct -import sys - -import numpy as np - -# Repo root = three levels up from this file (engine/native/tools/). -ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) -if ROOT not in sys.path: - sys.path.insert(0, ROOT) - -from engine.pipeline.io import load_config # noqa: E402 -from engine.pipeline.cea_cache import CEACache # noqa: E402 - - -def _c64(a): - return np.ascontiguousarray(a, dtype=np.float64) - - -def write_bin(cache, path): - Pc = _c64(cache.Pc_grid) - MR = _c64(cache.MR_grid) - eps = _c64(cache.eps_grid) - n_pc, n_mr, n_eps = len(Pc), len(MR), len(eps) - # Format v2: append the Cf_vac table (RPA delivered-thrust basis). Old caches - # without the column get the same isentropic per-gridpoint fallback the - # runtime dump (native_injector._ensure_cea) uses. - cf_vac = getattr(cache, "Cf_vac_table", None) - if cf_vac is None: - from engine.pipeline.cea_cache import _isentropic_cf_vac - gamma_t = np.asarray(cache.gamma_table, dtype=np.float64) - cf_vac = np.empty_like(gamma_t) - for k in range(gamma_t.shape[2]): - for i in range(gamma_t.shape[0]): - for j in range(gamma_t.shape[1]): - cf_vac[i, j, k] = _isentropic_cf_vac(gamma_t[i, j, k], float(eps[k])) - tables = [cache.cstar_table, cache.Cf_table, cache.Tc_table, - cache.gamma_table, cache.R_table, cache.M_table, cf_vac] - for t in tables: - if t.shape != (n_pc, n_mr, n_eps): - raise ValueError(f"table shape {t.shape} != grid {(n_pc, n_mr, n_eps)}") - with open(path, "wb") as f: - f.write(b"EDCA") - f.write(struct.pack(" None: - eff = getattr(getattr(config, "combustion", None), "efficiency", None) - if eff is not None and hasattr(eff, "use_shifting_equilibrium"): - eff.use_shifting_equilibrium = enabled - - -def main() -> None: - samples = [] - for label, rel_path, p_o_psi, p_f_psi in CASES: - config = load_config(REPO / rel_path) - runner = PintleEngineRunner(config) - _set_shifting(runner.config, False) # FROZEN reference. - cg = runner.config.chamber_geometry - eps = float(cg.expansion_ratio) - for mo, mf in PERTURB: - p_o = p_o_psi * mo * PSI_TO_PA - p_f = p_f_psi * mf * PSI_TO_PA - res = runner.evaluate(p_o, p_f, P_ambient=PA, silent=True) - diag = res["diagnostics"] - Pc = float(res["Pc"]) - MR = float(res["MR"]) - mdot_total = float(diag["mdot_O"]) + float(diag["mdot_F"]) - # Exact thermo the nozzle read internally (nozzle.py:229). - cea = runner.cea_cache.eval(MR, Pc, PA, eps) - samples.append({ - "case": label, - # --- inputs --- - "Pc": Pc, - "mdot_total": mdot_total, - "A_throat": float(cg.A_throat), - "A_exit": float(cg.A_exit), - "eps": eps, - "Pa": PA, - "nozzle_efficiency": float(cg.nozzle_efficiency), - "Cf_ideal": float(cea["Cf_ideal"]), - "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"]), - "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}") - - OUT.parent.mkdir(parents=True, exist_ok=True) - OUT.write_text(json.dumps(samples, indent=1)) - print(f"\nwrote {len(samples)} samples -> {OUT.relative_to(REPO)}") - - -if __name__ == "__main__": - main() diff --git a/EngineDesign/engine/native/tools/export_residual_golden.py b/EngineDesign/engine/native/tools/export_residual_golden.py deleted file mode 100644 index 897fe4850..000000000 --- a/EngineDesign/engine/native/tools/export_residual_golden.py +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env python3 -"""Export residual-physics parity samples: for the canonical config, dump the -combustion-efficiency components and ablative cooling_eff that the chamber residual -computes, at a sweep of (P_tank_O, P_tank_F, Pc) points. Self-contained: each -sample carries the config-derived struct fields so the C test reconstructs an -identical EdCombustionEff / EdCooling / EdGeometry. Read-only on engine/. - - python engine/native/tools/export_residual_golden.py --out engine/native/tests/golden -""" -import argparse, json, os, sys -ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) -if ROOT not in sys.path: - sys.path.insert(0, ROOT) - -from engine.pipeline.io import load_config -from engine.pipeline.config_schemas import ensure_chamber_geometry -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_physics import calculate_combustion_efficiency_advanced - -EFF_MODEL = {"constant": 0, "linear": 1, "exponential": 2} - - -def comb_dict(eff): - return { - "model": EFF_MODEL[eff.model], "C": float(eff.C), "K": float(eff.K), - "tau_ref": float(eff.tau_ref), "tau_ref_P": float(eff.tau_ref_P), - "tau_ref_T": float(eff.tau_ref_T), "n_pressure": float(eff.n_pressure), - "has_tau_Tc_floor": int(getattr(eff, "tau_Tc_floor_K", None) is not None), - "tau_Tc_floor": float(getattr(eff, "tau_Tc_floor_K", None) or 0.0), - "Em_peak": float(getattr(eff, "Em_peak", 0.96)), - "mixing_sigma": float(getattr(eff, "mixing_sigma", 1.5)), - "R_opt": float(getattr(eff, "R_opt", None) or 0.0), - } - - -def cooling_dict(cfg): - rg, ab, eff = cfg.regen_cooling, cfg.ablative_cooling, cfg.combustion.efficiency - fc = cfg.film_cooling - return { - "regen_enabled": int(bool(rg and rg.enabled)), - "film_enabled": int(bool(fc and fc.enabled)), - "ablative_enabled": int(bool(ab and ab.enabled)), - "graphite_enabled": int(bool(getattr(cfg, "graphite_insert", None) and cfg.graphite_insert.enabled)), - "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_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, - "radiation_emissivity_hot": float(rg.radiation_emissivity_hot), - "radiation_view_factor": float(rg.radiation_view_factor), - "regen_chamber_inner_diameter": float(rg.chamber_inner_diameter), - "ablative_coverage_fraction": float(ab.coverage_fraction), - "ablative_surface_temperature_limit": float(ab.surface_temperature_limit), - "ablative_material_density": float(ab.material_density), - "ablative_heat_of_ablation": float(ab.heat_of_ablation), - "ablative_specific_heat": float(ab.specific_heat), - "ablative_pyrolysis_temperature": float(ab.pyrolysis_temperature), - "ablative_use_physics_based_blowing": int(bool(ab.use_physics_based_blowing)), - "ablative_blowing_efficiency": float(ab.blowing_efficiency), - "ablative_blowing_coefficient": float(ab.blowing_coefficient), - "ablative_blowing_min_reduction_factor": float(ab.blowing_min_reduction_factor), - "ablative_turbulence_reference_intensity": float(ab.turbulence_reference_intensity), - "ablative_turbulence_sensitivity": float(ab.turbulence_sensitivity), - "ablative_turbulence_exponent": float(ab.turbulence_exponent), - "ablative_turbulence_max_multiplier": float(ab.turbulence_max_multiplier), - "ablative_surface_emissivity": float(ab.surface_emissivity), - "ablative_ambient_temperature": float(ab.ambient_temperature), - "ablative_radiative_sink_minimum_threshold": float(ab.radiative_sink_minimum_threshold), - "ablative_radiative_sink_fallback_temperature": float(ab.radiative_sink_fallback_temperature), - "cooling_efficiency_floor": float(eff.cooling_efficiency_floor), - } - - -def geom_dict(cg): - return { - "A_throat": float(cg.A_throat), "A_exit": float(cg.A_exit), "volume": float(cg.volume), - "Lstar": float(cg.Lstar) if cg.Lstar else 0.0, "length": float(cg.length), - "length_cylindrical": float(cg.length_cylindrical or 0.0), - "length_contraction": float(cg.length_contraction or 0.0), - "chamber_diameter": float(cg.chamber_diameter or 0.0), - "exit_diameter": float(cg.exit_diameter or 0.0), - "expansion_ratio": float(cg.expansion_ratio), - "nozzle_efficiency": float(cg.nozzle_efficiency), "Cf": float(cg.Cf or 0.0), - "design_pressure": float(cg.design_pressure or 0.0), - } - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--config", default="configs/canonical/impinging.yaml") - ap.add_argument("--out", default="engine/native/tests/golden") - args = ap.parse_args() - - cfg = load_config(args.config) - cg = ensure_chamber_geometry(cfg) - cache = CEACache(cfg.combustion.cea) - solver = ChamberSolver(cfg, cache) - solver._debug = False - eff = cfg.combustion.efficiency - - comb = comb_dict(eff) - cooling = cooling_dict(cfg) - geom = geom_dict(cg) - - fuel = cfg.fluids["fuel"] - L_eff = float(getattr(fuel, "latent_heat", 300e3)) - T_star_cap = float(getattr(eff, "T_star_fuel_cap_K", 1000.0)) - Dinj = float(cfg.injector.geometry.oxidizer.d_jet) - Lstar = float(calculate_Lstar(cg.volume, cg.A_throat, Lstar_override=cg.Lstar)) - Ac = float(3.141592653589793 * (cg.chamber_diameter / 2.0) ** 2) - eps_default = float(cg.expansion_ratio) - - PSI = 6894.757293168 - samples = [] - for po_psi in (500.0, 540.0, 580.0): - for pf_psi in (520.0, 560.0): - P_O, P_F = po_psi * PSI, pf_psi * PSI - for Pc in (1.8e6, 2.2e6, 2.6e6): - try: - mo, mf, diag = flows(P_O, P_F, Pc, cfg) - MR = mo / mf - cea = cache.eval(MR, Pc, 101325.0, eps_default) - Tc, gamma, R, M = cea["Tc"], cea["gamma"], cea["R"], cea["M"] - cstar_ideal = cea["cstar_ideal"] - momentum_ratio_R = diag.get("momentum_ratio_R") - R_opt = solver._rupe_R_opt() - res = calculate_combustion_efficiency_advanced( - Lstar, Pc, Tc, cstar_ideal, gamma, R, MR, eff, - Ac, cg.A_throat, Dinj, mo + mf, - u_fuel=diag["u_F"], u_lox=diag["u_O"], - spray_diagnostics=diag, - momentum_ratio_R=momentum_ratio_R, R_opt=R_opt, - chamber_length=cg.length, fuel_props=solver._get_fuel_props(), - ) - _, cooling_eff, eff_Tc = solver._evaluate_cooling_models(Pc, mo, mf, cea, diag) - heat_removed = 0.0 - 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 - cstar_actual = eta_final * cstar_ideal - mdot_demand = Pc * cg.A_throat / cstar_actual - samples.append({ - "P_tank_O": P_O, "P_tank_F": P_F, "Pc": Pc, - "mdot_O": mo, "mdot_F": mf, "MR": MR, - "Lstar": Lstar, "Ac": Ac, "At": float(cg.A_throat), - "Dinj": Dinj, "chamber_length": float(cg.length), - "D32_O": float(diag.get("D32_O") or 0.0), "D32_F": float(diag.get("D32_F") or 0.0), - "u_O": float(diag["u_O"]), "u_F": float(diag["u_F"]), - "momentum_ratio_R": float(momentum_ratio_R), "R_opt": float(R_opt), - "Tc": Tc, "gamma": gamma, "R": R, "M": M, "cstar_ideal": cstar_ideal, - "fuel_latent_heat": L_eff, "fuel_T_star_cap": T_star_cap, - # expected - "eta_Lstar": res["eta_Lstar"], "eta_kinetics": res["eta_kinetics"], - "eta_mixing": res["eta_mixing"], - "eta_total": res["eta_total"], "cooling_eff": float(cooling_eff), - "heat_removed": heat_removed, "eta_final": eta_final, - "cstar_actual": cstar_actual, "mdot_demand": mdot_demand, - }) - except Exception as e: # noqa: BLE001 - samples.append({"P_tank_O": P_O, "P_tank_F": P_F, "Pc": Pc, - "_error": str(e)[:160]}) - - os.makedirs(args.out, exist_ok=True) - out = {"comb": comb, "cooling": cooling, "geom": geom, "samples": samples} - path = os.path.join(args.out, "residual_samples.json") - with open(path, "w") as f: - json.dump(out, f, indent=1) - ok = sum(1 for s in samples if "_error" not in s) - print(f"[ok] wrote {path} ({ok}/{len(samples)} samples)") - - -if __name__ == "__main__": - main() diff --git a/EngineDesign/engine/native/tools/state_from_yaml.c b/EngineDesign/engine/native/tools/state_from_yaml.c deleted file mode 100644 index c2eb8bc16..000000000 --- a/EngineDesign/engine/native/tools/state_from_yaml.c +++ /dev/null @@ -1,28 +0,0 @@ -/* state_from_yaml.c - Offline CLI: YAML config -> binary EdEngineState snapshot. - * - * Stage 1 status: the supported producer of the snapshot is the Python builder - * engine/native/python/ed_state_builder.py, which reuses the project's Pydantic - * loader (presets, defaults, derived geometry) instead of re-implementing that - * resolution in C. A standalone C/libyaml path is intentionally deferred to avoid - * a libyaml build dependency for tooling and to keep a single source of truth for - * config resolution. This file builds as a usage shim so the documented layout is - * complete; it is wired to the binary writer alongside the ed_evaluate port. - */ -#include -#include - -int main(int argc, char **argv) { - if (argc >= 2 && strcmp(argv[1], "--help") != 0) { - fprintf(stderr, - "state_from_yaml: standalone C YAML->snapshot is deferred (Stage 1).\n"); - } - printf( - "Produce an EdEngineState snapshot with the Python builder:\n" - " python engine/native/python/ed_state_builder.py \\\n" - " --config configs/canonical/impinging.yaml \\\n" - " --out engine/native/tests/golden/state_impinging.json\n" - "\n" - "The packed-binary writer (build_state_bin) and this C path are enabled\n" - "together with the ed_evaluate port; see engine/native/README.md.\n"); - return 0; -} diff --git a/EngineDesign/scripts/bench_layer1_native_vs_python.py b/EngineDesign/scripts/bench_layer1_native_vs_python.py deleted file mode 100644 index 9c525408c..000000000 --- a/EngineDesign/scripts/bench_layer1_native_vs_python.py +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark the Layer-1 optimizer inner loop: plain-Python vs full-native (C). - -This is the *heaviest optimizer path on the config with the most C ports completed* -(impinging + ablative + advanced combustion => _can_handle_chamber is True, so every -converging candidate runs the whole physics chain in C via ed_evaluate). - -Correct measurement is fiddly, so this script pins down the variables: - - * FORCE SERIAL (num_workers=1, in-process) -- the native fast-eval path lives in - the ProcessPool worker function `_eval_candidate`; with a real pool it runs in - child processes and is invisible/uncontrolled. Serial runs it in-process so the - per-candidate cost is measured cleanly and the native-vs-fallback split is counted. - * ONE CONDITION PER SUBPROCESS -- native availability is cached per-process and the - Python run pins ED_USE_NATIVE=0; running both in one process cross-poisons. The - driver re-execs this script once per mode. - * WARMUP -- a throwaway 1-iteration solve first (loads the CEA cache, builds/loads - the native lib, warms imports), then the counters reset and the timed run happens. - -Two conditions, identical budget: - * python : ED_USE_NATIVE=0 -> native disabled everywhere, incl. the chamber solve - inside runner.evaluate. True all-Python baseline. - * native : ED_USE_NATIVE=1, ED_LAYER1_NATIVE_EVAL=1 -> single C ed_evaluate per - candidate; Python fallback only on a non-converged native solve. - -The us/candidate NATIVE number is the bar a Numba kernel has to approach; Numba slots -in as a third mode once its kernel exists. - -Run: - .venv/bin/python -m scripts.bench_layer1_native_vs_python --max-iterations 12 -""" - -from __future__ import annotations - -import argparse -import copy -import json -import os -import subprocess -import sys -import time -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) - - -# --- per-candidate counters --------------------------------------------------- -class _Counters: - def reset(self): - self.native_calls = 0 # native_injector.evaluate invocations - self.native_none = 0 # ... that returned None (fell back to Python) - self.runner_calls = 0 # PintleEngineRunner.evaluate invocations - - -C = _Counters() -C.reset() - - -def _install_instrumentation(): - from engine.native.python import native_injector as ni - from engine.core.runner import PintleEngineRunner - if getattr(ni.evaluate, "_benched", False): - return - _orig_native, _orig_runner = ni.evaluate, PintleEngineRunner.evaluate - - def native_evaluate(*a, **k): - r = _orig_native(*a, **k) - C.native_calls += 1 - if r is None: - C.native_none += 1 - return r - - def runner_evaluate(self, *a, **k): - C.runner_calls += 1 - return _orig_runner(self, *a, **k) - - native_evaluate._benched = True - ni.evaluate = native_evaluate - PintleEngineRunner.evaluate = runner_evaluate - - -def _one_optimization(config_path: Path, max_iterations: int, cma_restarts: int, seed: int): - import numpy as np - import engine.optimizer.layers.layer1_static_optimization as L1 - from engine.core.runner import PintleEngineRunner - from engine.pipeline.io import load_config - - L1._get_num_workers = lambda cfg: 1 # force serial, in-process - - base_cfg = load_config(str(config_path)) - cfg = copy.deepcopy(base_cfg) - req = cfg.design_requirements.model_dump() - 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"]), - } - np.random.seed(seed) - t0 = time.perf_counter() - L1.run_layer1_optimization( - cfg, PintleEngineRunner(copy.deepcopy(base_cfg)), req, - target_burn_time=float(req.get("target_burn_time", 6.0)), - tolerances={"thrust": 0.10, "apogee": 0.15}, - pressure_config=pcfg, layer1_smoke=True, - layer1_max_iterations=int(max_iterations), layer1_cma_restarts=int(cma_restarts), - ) - return time.perf_counter() - t0 - - -def _run_condition(mode: str, config_path: Path, max_iterations: int, cma_restarts: int, seed: int): - """Runs one condition in THIS process. Emits a JSON result line for the driver.""" - if mode == "python": - os.environ["ED_USE_NATIVE"] = "0" - os.environ["ED_LAYER1_NATIVE_EVAL"] = "0" - else: - os.environ["ED_USE_NATIVE"] = "1" - os.environ["ED_LAYER1_NATIVE_EVAL"] = "1" - - _install_instrumentation() - # warmup (loads CEA cache, builds native lib, warms imports) — discarded - _one_optimization(config_path, max_iterations=1, cma_restarts=1, seed=seed) - C.reset() - wall = _one_optimization(config_path, max_iterations, cma_restarts, seed) - cands = C.native_calls if C.native_calls else C.runner_calls - out = { - "mode": mode, "wall_s": wall, "candidates": cands, - "native_calls": C.native_calls, "native_fallbacks": C.native_none, - "runner_calls": C.runner_calls, - "us_per_candidate": (wall / cands * 1e6) if cands else None, - } - print("BENCH_JSON " + json.dumps(out)) - return out - - -def _print_condition(o: dict): - print(f"\n[{o['mode'].upper()}]") - print(f" wall-clock : {o['wall_s']:8.3f} s") - print(f" candidate evals : {o['candidates']}") - if o["native_calls"]: - went = o["native_calls"] - o["native_fallbacks"] - print(f" went native (C) : {went}/{o['native_calls']} " - f"({100.0*went/o['native_calls']:.0f}%) | fallback: {o['native_fallbacks']}") - print(f" runner.evaluate : {o['runner_calls']} (fallback + finalization replay)") - print(f" us / candidate : {o['us_per_candidate']:8.1f}") - - -def main(): - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--config", type=str, default="configs/impinging_lox_ch4_8000N.yaml") - ap.add_argument("--max-iterations", type=int, default=12) - ap.add_argument("--cma-restarts", type=int, default=1) - ap.add_argument("--seed", type=int, default=0) - ap.add_argument("--mode", choices=["python", "native"], default=None, - help="internal: run a single condition in this process") - args = ap.parse_args() - - cfg_path = (ROOT / args.config) if not os.path.isabs(args.config) else Path(args.config) - - if args.mode: # child: run one condition - _run_condition(args.mode, cfg_path, args.max_iterations, args.cma_restarts, args.seed) - return - - # driver: spawn one subprocess per condition - print(f"config: {cfg_path}") - print(f"budget: max_iterations={args.max_iterations} cma_restarts={args.cma_restarts} " - f"seed={args.seed} (serial, in-process, warmup discarded)") - results = {} - for mode in ("python", "native"): - cmd = [sys.executable, str(Path(__file__).resolve()), - "--config", args.config, "--max-iterations", str(args.max_iterations), - "--cma-restarts", str(args.cma_restarts), "--seed", str(args.seed), - "--mode", mode] - p = subprocess.run(cmd, cwd=str(ROOT), capture_output=True, text=True) - line = next((l for l in p.stdout.splitlines() if l.startswith("BENCH_JSON ")), None) - if not line: - print(f"!! {mode} run produced no result. stderr tail:\n" + "\n".join(p.stderr.splitlines()[-15:])) - return - results[mode] = json.loads(line[len("BENCH_JSON "):]) - _print_condition(results[mode]) - - py, nat = results["python"], results["native"] - print("\n" + "=" * 60) - print(f" end-to-end optimizer wall speedup : {py['wall_s'] / nat['wall_s']:5.2f}x") - if py["us_per_candidate"] and nat["us_per_candidate"]: - print(f" per-candidate speedup (C vs Py) : {py['us_per_candidate'] / nat['us_per_candidate']:5.2f}x") - print("=" * 60) - print("\nNext: add a Numba kernel as a third mode and compare us/candidate.") - - -if __name__ == "__main__": - main() diff --git a/EngineDesign/scripts/bench_layer1_numba.py b/EngineDesign/scripts/bench_layer1_numba.py index 9f81f637f..4d64e78fc 100644 --- a/EngineDesign/scripts/bench_layer1_numba.py +++ b/EngineDesign/scripts/bench_layer1_numba.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 -"""Three-way Layer-1 optimizer benchmark: plain Python vs C vs Numba. +"""Two-way Layer-1 optimizer benchmark: plain Python vs the numba accelerator. -Same harness as bench_layer1_native_vs_python.py (serial in-process, subprocess per -condition, warmup discarded), with a third mode that patches native_injector.evaluate -to the Numba core (numba_eval.make_native_signature_evaluate) — Numba does the -chamber+nozzle+thrust physics, the C diagnostic injector solve + Python stability tail -is identical to the C mode, so the only difference is the chamber-solve core. +Serial in-process, one subprocess per condition, warmup discarded. "python" sets +ED_ACCEL=off so every candidate takes the authoritative Python path; "numba" runs +the accelerator. The C backend this once compared against was deleted after the +numba path overtook it. -Run: .venv/bin/python scripts/bench_layer1_numba.py --max-iterations 8 +Timing here is noisy enough that a single run is not a measurement: identical work +(seed pinned) has varied by 40% on a loaded box. Hence --reps and min/median. + +Run: .venv/bin/python scripts/bench_layer1_numba.py --max-iterations 20 --reps 5 """ from __future__ import annotations import argparse, copy, json, os, subprocess, sys, time @@ -25,11 +27,7 @@ def reset(self): def _install_instrumentation(): - """Count accelerated evaluates and Python fallbacks. - - Wraps engine.accel.evaluate, which is what Layer 1 now calls; the ED_ACCEL - dispatcher inside it routes to numba or C, so one wrapper counts both modes. - """ + """Count accelerated evaluates and Python fallbacks.""" from engine import accel as ni from engine.core.runner import PintleEngineRunner if getattr(ni.evaluate, "_benched", False): @@ -70,11 +68,7 @@ def _one(cfg_path, max_it, restarts, seed): def _run_condition(mode, cfg_path, max_it, restarts, seed): - # One knob now selects the backend: the accel dispatcher routes ED_ACCEL=c to - # native_injector and ED_ACCEL=numba to the kernels. "python" disables the - # accelerator entirely so every candidate takes the authoritative Python path. - os.environ["ED_ACCEL"] = {"python": "off", "native": "c", "numba": "numba"}[mode] - os.environ["ED_USE_NATIVE"] = "0" if mode == "python" else "1" + os.environ["ED_ACCEL"] = "off" if mode == "python" else "numba" os.environ["ED_LAYER1_NATIVE_EVAL"] = "0" if mode == "python" else "1" _install_instrumentation() _one(cfg_path, 1, 1, seed) # warmup (JIT compile, CEA load) — discarded @@ -125,7 +119,7 @@ def main(): help="timed repetitions per accelerated mode (noise control)") ap.add_argument("--python-reps", type=int, default=1, help="reps for the Python baseline (~10x slower, 1 is usually enough)") - ap.add_argument("--mode", choices=["python", "native", "numba"], default=None) + ap.add_argument("--mode", choices=["python", "numba"], default=None) a = ap.parse_args() cfg_path = (ROOT / a.config) if not os.path.isabs(a.config) else Path(a.config) if a.mode: @@ -133,7 +127,7 @@ def main(): print(f"config: {cfg_path}\nbudget: max_iterations={a.max_iterations} restarts={a.cma_restarts} " f"seed={a.seed} reps={a.reps} (serial, warmup discarded)") agg = {} - for mode in ("python", "native", "numba"): + for mode in ("python", "numba"): runs = [] for _ in range(a.python_reps if mode == "python" else a.reps): cmd = [sys.executable, str(Path(__file__).resolve()), "--config", a.config, @@ -146,13 +140,11 @@ def main(): + "\n".join(p.stderr.splitlines()[-20:])); return runs.append(json.loads(line[len("BENCH_JSON "):])) agg[mode] = _pc(mode, runs) - py, nat, nb = agg["python"], agg["native"], agg["numba"] + py, nb = agg["python"], agg["numba"] print("\n" + "=" * 68) for stat in ("min", "median"): - print(f" [{stat:6s}] per-candidate us: python={py[stat]:.0f} C={nat[stat]:.0f} numba={nb[stat]:.0f}") - print(f" speedup vs python: C={py[stat]/nat[stat]:.1f}x numba={py[stat]/nb[stat]:.1f}x") - print(f" numba vs C: {nb[stat]/nat[stat]:.2f}x the C time" - f" ({nat[stat]/nb[stat]:.2f}x speed of C)") + print(f" [{stat:6s}] per-candidate us: python={py[stat]:.0f} numba={nb[stat]:.0f}" + f" speedup={py[stat]/nb[stat]:.1f}x") print("=" * 68) diff --git a/EngineDesign/scripts/thrust_model_comparison.py b/EngineDesign/scripts/thrust_model_comparison.py index ee23b68a5..cc2407f19 100644 --- a/EngineDesign/scripts/thrust_model_comparison.py +++ b/EngineDesign/scripts/thrust_model_comparison.py @@ -12,13 +12,13 @@ The claim under test: delivered Isp must be <= eta_c* * Isp_ideal. The MODEL instead sits at the ideal ceiling; the CF_VAC method should land at eta_c**ideal. -Run: ED_USE_NATIVE=0 python scripts/thrust_model_comparison.py +Run: ED_ACCEL=off python scripts/thrust_model_comparison.py """ import os import sys from pathlib import Path -os.environ["ED_USE_NATIVE"] = "0" # pure-Python authoritative path is the subject under test +os.environ["ED_ACCEL"] = "off" # pure-Python authoritative path is the subject under test sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import numpy as np # noqa: E402 diff --git a/EngineDesign/tests/test_numba_ab_parity.py b/EngineDesign/tests/test_numba_ab_parity.py index b95e74e13..6bc009aeb 100644 --- a/EngineDesign/tests/test_numba_ab_parity.py +++ b/EngineDesign/tests/test_numba_ab_parity.py @@ -12,7 +12,7 @@ divergence, not rounding: fix it rather than widening the bound. THE REFERENCE MUST BE FORCED TO PYTHON. With the accelerator enabled, -runner.evaluate reaches chamber_solver._native_chamber_pc -> accel.chamber_solve +runner.evaluate reaches chamber_solver._accel_chamber_pc -> accel.chamber_solve and closure.flows -> accel.solve, so an unguarded "Python" reference is largely the same numba kernels and the comparison is self-referential (it reads as ~1e-15 agreement, which is the tell). _python_only() below disables the accelerator for From 1a1395700e71106ac16178c7765c9f8e75fd7d56 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 03:00:21 -0700 Subject: [PATCH 12/20] Port the pintle injector to numba, extending accelerated coverage beyond impinging --- EngineDesign/engine/accel/__init__.py | 19 ++- EngineDesign/engine/accel/diagnostics.py | 30 ++-- EngineDesign/engine/accel/kernels.py | 183 +++++++++++++++++++-- EngineDesign/engine/accel/params.py | 59 ++++++- EngineDesign/tests/test_numba_ab_parity.py | 17 +- 5 files changed, 266 insertions(+), 42 deletions(-) diff --git a/EngineDesign/engine/accel/__init__.py b/EngineDesign/engine/accel/__init__.py index e8a81dfe3..6b8f4fd62 100644 --- a/EngineDesign/engine/accel/__init__.py +++ b/EngineDesign/engine/accel/__init__.py @@ -45,13 +45,20 @@ def require() -> bool: def can_handle(config) -> bool: - """Impinging only; regen-coupled feed loss is not ported.""" + """Impinging and pintle; regen-coupled feed loss is not ported for either.""" inj = getattr(config, "injector", None) - if inj is None or inj.type != "impinging": - return False + if inj is None or inj.type not in ("impinging", "pintle"): + return False # coaxial has no port regen = getattr(config, "regen_cooling", None) if regen is not None and getattr(regen, "enabled", False): - return False # regen-coupled feed loss not ported + return False # regen-coupled feed loss not ported + if inj.type == "pintle": + # PintleInjector.solve calls cd_from_re WITHOUT an orifice diameter, so a + # config with geometry-based Cd enabled resolves Cd_inf differently there + # than the kernel would. Untested corner: hand it to Python. + for side in ("oxidizer", "fuel"): + if getattr(config.discharge[side], "use_geometry_cd", False): + return False return True @@ -100,7 +107,7 @@ def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): if F != F: return None - sol = _k.injector_solve(P, float(P_tank_O), float(P_tank_F), float(Pc)) + sol = _k._solve_injector(P, float(P_tank_O), float(P_tank_F), float(Pc)) if not sol[0]: return None diag = _diag.build_diag(P, sol) @@ -152,7 +159,7 @@ def solve(config, P_tank_O, P_tank_F, Pc): P = _p.extract_params(config) except AssertionError: return None - sol = _k.injector_solve(P, float(P_tank_O), float(P_tank_F), float(Pc)) + sol = _k._solve_injector(P, float(P_tank_O), float(P_tank_F), float(Pc)) if not sol[0]: return None return float(sol[1]), float(sol[2]), _diag.build_diag(P, sol) diff --git a/EngineDesign/engine/accel/diagnostics.py b/EngineDesign/engine/accel/diagnostics.py index 8c7093395..db9b69fb5 100644 --- a/EngineDesign/engine/accel/diagnostics.py +++ b/EngineDesign/engine/accel/diagnostics.py @@ -30,6 +30,8 @@ _RHO_O, _RHO_F = _IDX["RHO_O"], _IDX["RHO_F"] _ANG_O, _ANG_F = _IDX["ANG_O"], _IDX["ANG_F"] _MU_O, _MU_F = _IDX["MU_O"], _IDX["MU_F"] +_INJ_TYPE = _IDX["INJ_TYPE"] +_PIN_DHO, _PIN_DHF = _IDX["PIN_DHO"], _IDX["PIN_DHF"] def build_diag(P, sol): @@ -39,7 +41,8 @@ def build_diag(P, sol): """ (_ok, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, Pi_O, Pi_F, dpi_O, dpi_F, A_geom_O, A_geom_F, - dpf_O, dpf_F, We_O, We_F, u_rel, x_star, constraints_ok, n_iter) = sol + dpf_O, dpf_F, We_O, We_F, u_rel, x_star, constraints_ok, n_iter, + ti_O, ti_F) = sol djo, djf = float(P[_DJO]), float(P[_DJF]) rho_O, rho_F = float(P[_RHO_O]), float(P[_RHO_F]) @@ -54,16 +57,19 @@ def build_diag(P, sol): A_eff_O = Cd_O * A_geom_O A_eff_F = Cd_F * A_geom_F - # Shear-layer turbulence, mirroring impinging.py:155-172 (d_hyd == d_jet for - # impinging, impinging.py:117-118). Consumed via closure diagnostics at - # chamber_solver.py:180 -- that is the accel.solve path, not the evaluate path. - def _ti(rho, u, d, mu): - Re = (rho * u * d / mu) if mu > 0 else 0.0 - t = 0.16 * (Re ** -0.125) if Re > 0 else 0.1 - return min(max(t, 0.02), 0.3) - - ti_O = _ti(rho_O, u_O, djo, float(P[_MU_O])) - ti_F = _ti(rho_F, u_F, djf, float(P[_MU_F])) + # Shear-layer turbulence, mirroring impinging.py:155-172 / pintle.py:218-228. + # The characteristic length is the HYDRAULIC diameter, which for impinging + # happens to equal d_jet (impinging.py:117-118) but for pintle is a separate + # geometry field -- using d_jet there gives Re=0 and a flat 0.1. + # Consumed via closure diagnostics at chamber_solver.py:180, i.e. the + # accel.solve path rather than accel.evaluate. + # ti_O/ti_F come from the solve rather than being recomputed here: pintle + # derives them from PRE-update Reynolds numbers while weighting with + # POST-update velocities, which cannot be reconstructed from the outputs. + if float(P[_INJ_TYPE]) == 0.0: # pintle + dh_O, dh_F = float(P[_PIN_DHO]), float(P[_PIN_DHF]) + else: + dh_O, dh_F = djo, djf v_tot = max(u_O + u_F, 1e-6) ti_mix = min(max((ti_O * u_O + ti_F * u_F) / v_tot, 0.02), 0.35) @@ -71,7 +77,7 @@ def _ti(rho, u, d, mu): "injector_type": "impinging", "A_eff_O": A_eff_O, "A_eff_F": A_eff_F, "turbulence_intensity_O": ti_O, "turbulence_intensity_F": ti_F, - "turbulence_length_O": 0.07 * djo, "turbulence_length_F": 0.07 * djf, + "turbulence_length_O": 0.07 * dh_O, "turbulence_length_F": 0.07 * dh_F, "turbulence_intensity_mix": ti_mix, "iterations": int(n_iter), "constraints_satisfied": bool(constraints_ok), diff --git a/EngineDesign/engine/accel/kernels.py b/EngineDesign/engine/accel/kernels.py index 87360f9b4..8b4e6678f 100644 --- a/EngineDesign/engine/accel/kernels.py +++ b/EngineDesign/engine/accel/kernels.py @@ -197,6 +197,7 @@ def injector_solve(P, P_tank_O, P_tank_F, Pc): dpi_O = 0.0; dpi_F = 0.0 We_O = 0.0; We_F = 0.0; D32_O = 0.0; D32_F = 0.0; u_rel = 0.0 dpf_O = 0.0; dpf_F = 0.0; x_star = 0.0; n_iter = 0 + ti_O = 0.1; ti_F = 0.1 u_O = 0.0; u_F = 0.0 constraints_ok = 0 @@ -282,6 +283,13 @@ def injector_solve(P, P_tank_O, P_tank_F, Pc): Cd_O_eff *= Cd_red; Cd_F_eff *= Cd_red Cd_O_eff = max(Cd_O_eff, P[DO_CDMIN]); Cd_F_eff = max(Cd_F_eff, P[DF_CDMIN]) + # Shear-layer turbulence. impinging.py calls _injector_turbulence_fields with + # the FINAL velocities (:612), so Re and the ti_mix weighting are consistent + # here -- unlike pintle, which weights pre-update Re with post-update u. + _reO = _reynolds(rho_O, u_O, djo, mu_O); _reF = _reynolds(rho_F, u_F, djf, mu_F) + ti_O = _clip(0.16*(_reO**(-0.125)) if _reO > 0 else 0.1, 0.02, 0.3) + ti_F = _clip(0.16*(_reF**(-0.125)) if _reF > 0 else 0.1, 0.02, 0.3) + # momentum ratio (bulk jet velocities) A_jet_O = PI*(djo*0.5)**2; A_jet_F = PI*(djf*0.5)**2 n_O = nO if nO >= 1 else 1; n_F = nF if nF >= 1 else 1 @@ -294,8 +302,8 @@ def injector_solve(P, P_tank_O, P_tank_F, Pc): if den > 0 and num >= 0: mom_R = np.sqrt(num/den) if not (np.isfinite(mdot_O) and np.isfinite(mdot_F)) or mdot_F <= 0.0: - return (0, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, Pi_O, Pi_F, dpi_O, dpi_F, A_O, A_F, dpf_O, dpf_F, We_O, We_F, u_rel, x_star, float(constraints_ok), float(n_iter)) - return (1, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, Pi_O, Pi_F, dpi_O, dpi_F, A_O, A_F, dpf_O, dpf_F, We_O, We_F, u_rel, x_star, float(constraints_ok), float(n_iter)) + return (0.0, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, Pi_O, Pi_F, dpi_O, dpi_F, A_O, A_F, dpf_O, dpf_F, We_O, We_F, u_rel, x_star, float(constraints_ok), float(n_iter), ti_O, ti_F) + return (1.0, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F, Pi_O, Pi_F, dpi_O, dpi_F, A_O, A_F, dpf_O, dpf_F, We_O, We_F, u_rel, x_star, float(constraints_ok), float(n_iter), ti_O, ti_F) @njit(cache=True) @@ -332,7 +340,7 @@ def _gasification(Tc, Pc, tau_res, SMD, L_eff, cp_g, rho_g, U_slip, T_star_cap): @njit(cache=True) def _eta_advanced(P, Lstar, Pc, Tc, gamma, R, MR, Ac, At, Dinj, mdot_total, - u_F, u_O, D32_O, D32_F, mom_R, R_opt): + u_F, u_O, D32_O, D32_F, mom_R, R_opt, use_mom_penalty): if R <= 0 or Tc <= 0 or Ac <= 0 or At <= 0 or Dinj <= 0 or Lstar <= 0 or mdot_total <= 0: return -1.0 rho_ch = Pc/(R*Tc) @@ -380,13 +388,24 @@ def _eta_advanced(P, Lstar, Pc, Tc, gamma, R, MR, Ac, At, Dinj, mdot_total, tau_chem = P[C_TAUREF]*pf*np.exp(exp_arg) Da = np.inf if tau_chem <= 0 else tau_res/tau_chem eta_k = 1.0 - np.exp(-np.sqrt(Da)) - # eta_mixing (Rupe) - if not (mom_R > 0.0 and np.isfinite(mom_R)): - return -1.0 - Ro = R_opt if (R_opt > 0 and np.isfinite(R_opt)) else 1.0 - sig = P[C_SIGMA] if (P[C_SIGMA] > 0 and np.isfinite(P[C_SIGMA])) else 1.5 - z = np.log(mom_R/Ro) - eta_m = P[C_EMPEAK]*np.exp(-(z*z)/(2.0*sig*sig)) + # eta_mixing (Rupe) -- IMPINGING ONLY. + # + # combustion_physics.py:1183-1200 deliberately applies NO momentum-mixing + # penalty for pintle: momentum_ratio_R is None there, and TMR is explicitly + # NOT substituted for it (different quantity, different scale -- doing so + # crushes eta_mix). So pintle gets eta_m = Em_peak flat. This is an explicit + # flag rather than a branch on mom_R validity, because the impinging path + # RELIES on invalid mom_R -> -1.0 -> NaN -> bail; a lenient branch here would + # silently change impinging results. + if use_mom_penalty == 0.0: + eta_m = P[C_EMPEAK] + else: + if not (mom_R > 0.0 and np.isfinite(mom_R)): + return -1.0 + Ro = R_opt if (R_opt > 0 and np.isfinite(R_opt)) else 1.0 + sig = P[C_SIGMA] if (P[C_SIGMA] > 0 and np.isfinite(P[C_SIGMA])) else 1.5 + z = np.log(mom_R/Ro) + eta_m = P[C_EMPEAK]*np.exp(-(z*z)/(2.0*sig*sig)) eta_total = eta_L*eta_k*eta_m if not np.isfinite(eta_total): return -1.0 @@ -550,12 +569,142 @@ def _cooling_evaluate(P, Pc, mdot_total, Tc, gamma, R, M): return 1.0, cooling_eff, effective_Tc +@njit(cache=True) +def _smd_pintle(L_open, V_rel, rho_f, mu_f, sigma_f, C, B, n, p): + """spray.smd_pintle: SMD = C * L_open * We_rel^(-n) * (1 + B*Oh_f)^p.""" + We_rel = (rho_f*V_rel*V_rel*L_open)/sigma_f + denom = np.sqrt(rho_f*sigma_f*L_open) + Oh_f = mu_f/denom if denom > 0 else 0.0 + factor_we = We_rel**(-n) if We_rel > 0 else 1.0 + factor_oh = (1.0 + B*Oh_f)**p + return C*L_open*factor_we*factor_oh + + +@njit(cache=True) +def injector_solve_pintle(P, P_tank_O, P_tank_F, Pc): + """Pintle branch flows. Same 24-tuple shape as injector_solve. + + Ports what PintleInjector.solve actually EXECUTES. Two things in that + function do not run and are deliberately not reproduced: + * the `if feed_iter < 2:` quick-update block is dead -- `feed_iter` is 2 + once the `for feed_iter in range(3)` loop exits, so the condition is + never true (confirmed by counting cd_from_re calls: 2/iteration, not 4); + * that 3-iteration feed loop recomputes delta_p_feed from an unchanged + mdot, so its 6 calls all return the same two values. + Mass flow therefore converges through the outer Cd-relaxation loop alone. + + mom_R is left NaN: pintle has no momentum_ratio_R (see _eta_advanced). + """ + rho_O = P[RHO_O]; mu_O = P[MU_O]; sig_O = P[SIG_O]; tO = P[T_O] + rho_F = P[RHO_F]; mu_F = P[MU_F]; sig_F = P[SIG_F]; tF = P[T_F] + A_O = P[PIN_AO]; A_F = P[PIN_AF] + dh_O = P[PIN_DHO]; dh_F = P[PIN_DHF] + d_orif = P[PIN_DORIF]; h_gap = P[PIN_HGAP] + max_iter = int(P[SV_CLMAX]); Cd_red = P[SV_CLCDRED] + Cd_O_eff = P[DO_CDINF]; Cd_F_eff = P[DF_CDINF] + + mdot_O = 0.1; mdot_F = 0.1 + Cd_O = 0.0; Cd_F = 0.0; Pi_O = P_tank_O; Pi_F = P_tank_F + dpi_O = 0.0; dpi_F = 0.0; dpf_O = 0.0; dpf_F = 0.0 + We_O = 0.0; We_F = 0.0; D32 = 0.0; u_O = 0.0; u_F = 0.0 + V_rel = 0.0; x_star = 0.0; constraints_ok = 0; n_iter = 0 + ti_O = 0.1; ti_F = 0.1 + + for iteration in range(max_iter): + n_iter = iteration + 1 + dpf_bal_O = _dpf(mdot_O, rho_O, P[FO_DIN], P[FO_AH], P[FO_K0], P[FO_K1], P[FO_PHI], P_tank_O) + dpf_bal_F = _dpf(mdot_F, rho_F, P[FF_DIN], P[FF_AH], P[FF_K0], P[FF_K1], P[FF_PHI], P_tank_F) + Pi_O = P_tank_O - dpf_bal_O + Pi_F = P_tank_F - dpf_bal_F + dpi_O = Pi_O - Pc if Pi_O - Pc > 0.0 else 0.0 + dpi_F = Pi_F - Pc if Pi_F - Pc > 0.0 else 0.0 + if Pi_F < Pc: + mdot_F = 0.0 + if Pi_O < Pc: + mdot_O = 0.0 + + u_O = mdot_O/(rho_O*A_O) if A_O > 0 else 0.0 + u_F = mdot_F/(rho_F*A_F) if A_F > 0 else 0.0 + Re_O = _reynolds(rho_O, u_O, dh_O, mu_O) + Re_F = _reynolds(rho_F, u_F, dh_F, mu_F) + cO = _cd_from_re(Re_O, Pi_O, tO, dh_O, P[DO_CDINF], P[DO_ARE], P[DO_CDMIN], P[DO_GEOM], + P[DO_DREF], P[DO_DMIN], P[DO_EXPS], P[DO_LOGG], P[DO_CDMAX], P[DO_CDFLOOR], + P[DO_UPC], P[DO_PREF], P[DO_AP], P[DO_UTC], P[DO_TREF], P[DO_AT]) + cF = _cd_from_re(Re_F, Pi_F, tF, dh_F, P[DF_CDINF], P[DF_ARE], P[DF_CDMIN], P[DF_GEOM], + P[DF_DREF], P[DF_DMIN], P[DF_EXPS], P[DF_LOGG], P[DF_CDMAX], P[DF_CDFLOOR], + P[DF_UPC], P[DF_PREF], P[DF_AP], P[DF_UTC], P[DF_TREF], P[DF_AT]) + Cd_O = cO if cO < Cd_O_eff else Cd_O_eff + Cd_F = cF if cF < Cd_F_eff else Cd_F_eff + + mdot_O = Cd_O*A_O*np.sqrt(2.0*rho_O*dpi_O) if dpi_O > 0 else 0.0 + mdot_F = Cd_F*A_F*np.sqrt(2.0*rho_F*dpi_F) if dpi_F > 0 else 0.0 + u_O = mdot_O/(rho_O*A_O) if A_O > 0 else 0.0 + u_F = mdot_F/(rho_F*A_F) if A_F > 0 else 0.0 + + We_O = rho_O*u_O*u_O*d_orif/sig_O if sig_O > 0 else np.inf + We_F = rho_F*u_F*u_F*dh_F/sig_F if sig_F > 0 else np.inf + + V_rel = np.sqrt(u_O*u_O + u_F*u_F) + D32 = _smd_pintle(h_gap, V_rel, rho_F, mu_F, sig_F, + P[PIN_SMDC], P[PIN_SMDB], P[PIN_SMDN], P[PIN_SMDP]) + + # ti uses the PRE-UPDATE Reynolds numbers, while the ti_mix weighting + # below uses the POST-update velocities. That asymmetry is pintle.py's + # (Re_O/Re_F are computed before mdot is refreshed, ti at :218 after); + # impinging is self-consistent instead, recomputing Re from the same u. + ti_O = 0.16*(Re_O**(-0.125)) if Re_O > 0 else 0.1 + ti_F = 0.16*(Re_F**(-0.125)) if Re_F > 0 else 0.1 + ti_O = _clip(ti_O, 0.02, 0.3); ti_F = _clip(ti_F, 0.02, 0.3) + + te = P[SP_EVAPK]*D32*D32 + x_star = V_rel*te # both streams share D32 + if P[SP_USETURB] != 0.0: + v_tot = u_O + u_F if u_O + u_F > 1e-6 else 1e-6 + ti_mix = _clip((ti_O*u_O + ti_F*u_F)/v_tot, 0.02, 0.35) + x_star *= _clip(1.0/(1.0 + P[SP_PENGAIN]*ti_mix), 0.3, 1.0) + + # Reported feed loss is recomputed from the CONVERGED mdot, matching + # pintle.py's "recalculate one final time ... so diagnostics have the + # correct final values". This deliberately makes the reported + # delta_p_feed inconsistent with the P_inj used in the balance above, + # which came from the previous iteration's mdot. Faithful, not tidy. + dpf_O = _dpf(mdot_O, rho_O, P[FO_DIN], P[FO_AH], P[FO_K0], P[FO_K1], P[FO_PHI], P_tank_O) + dpf_F = _dpf(mdot_F, rho_F, P[FF_DIN], P[FF_AH], P[FF_K0], P[FF_K1], P[FF_PHI], P_tank_F) + + constraints_ok = 1 + if We_O < P[SP_WEMIN] or We_F < P[SP_WEMIN]: + constraints_ok = 0 + if P[SP_EVAPUSE] != 0 and x_star >= P[SP_EVAPXLIM]: + constraints_ok = 0 + if constraints_ok: + break + Cd_O_eff *= Cd_red; Cd_F_eff *= Cd_red + if Cd_O_eff < P[DO_CDMIN]: Cd_O_eff = P[DO_CDMIN] + if Cd_F_eff < P[DF_CDMIN]: Cd_F_eff = P[DF_CDMIN] + + if not (np.isfinite(mdot_O) and np.isfinite(mdot_F) and mdot_F > 0.0): + return (0.0, mdot_O, mdot_F, u_O, u_F, D32, D32, np.nan, Cd_O, Cd_F, Pi_O, Pi_F, + dpi_O, dpi_F, A_O, A_F, dpf_O, dpf_F, We_O, We_F, V_rel, x_star, + float(constraints_ok), float(n_iter), ti_O, ti_F) + return (1.0, mdot_O, mdot_F, u_O, u_F, D32, D32, np.nan, Cd_O, Cd_F, Pi_O, Pi_F, + dpi_O, dpi_F, A_O, A_F, dpf_O, dpf_F, We_O, We_F, V_rel, x_star, + float(constraints_ok), float(n_iter), ti_O, ti_F) + + +@njit(cache=True) +def _solve_injector(P, P_O, P_F, Pc): + """Dispatch on injector type. Both branches return the same tuple type.""" + if P[INJ_TYPE] == 0.0: + return injector_solve_pintle(P, P_O, P_F, Pc) + return injector_solve(P, P_O, P_F, Pc) + + @njit(cache=True) def _residual(Pc, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F): if not (np.isfinite(Pc) and Pc > 0): return np.nan (ok, mO, mF, uO, uF, D32O, D32F, momR, CdO, CdF, PiO, PiF, dpiO, dpiF, AgO, AgF, - dpfO, dpfF, WeO, WeF, urel, xstar, constr, nit) = injector_solve(P, P_O, P_F, Pc) + dpfO, dpfF, WeO, WeF, urel, xstar, constr, nit, tiO, tiF) = _solve_injector(P, P_O, P_F, Pc) if ok == 0: return np.nan mdot_supply = mO + mF @@ -564,14 +713,17 @@ def _residual(Pc, P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F if not (cs_id > 0 and np.isfinite(cs_id)): return np.nan Lstar = P[G_LSTAR] if P[G_LSTAR] > 0 else (P[G_VOL]/P[G_AT] if P[G_AT] > 0 else 0.0) - Dinj = P[DJO] + # Characteristic injector diameter for the mixing models, matching + # chamber_solver._infer_injector_diameter: d_jet for impinging, + # d_pintle_tip for pintle (P[DJO] is zero on a pintle config). + Dinj = P[DJO] if P[INJ_TYPE] != 0.0 else P[PIN_DTIP] Ac = PI*(P[G_DCHAM]*0.5)**2 if P[C_ROPT] > 0.0: R_opt = P[C_ROPT] else: sO = np.sin(P[ANG_O]*PI/180.0); sF = np.sin(P[ANG_F]*PI/180.0) R_opt = np.sqrt(sF/sO) if (sO > 0 and sF > 0) else 1.0 - eta_total = _eta_advanced(P, Lstar, Pc, tc, gm, Rg, MR, Ac, P[G_AT], Dinj, mdot_supply, uF, uO, D32O, D32F, momR, R_opt) + eta_total = _eta_advanced(P, Lstar, Pc, tc, gm, Rg, MR, Ac, P[G_AT], Dinj, mdot_supply, uF, uO, D32O, D32F, momR, R_opt, 1.0 if P[INJ_TYPE] != 0.0 else 0.0) if eta_total < 0: return np.nan cok, cooling_eff, _tc_eff = _cooling_evaluate(P, Pc, mdot_supply, tc, gm, Rg, Mg) @@ -688,19 +840,20 @@ def evaluate_core(P, Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, P_O, P_F return (0.0,)*22 # recompute converged state (ok, mO, mF, uO, uF, D32O, D32F, momR, CdO, CdF, PiO, PiF, dpiO, dpiF, AgO, AgF, - dpfO, dpfF, WeO, WeF, urel, xstar, constr, nit) = injector_solve(P, P_O, P_F, Pc) + dpfO, dpfF, WeO, WeF, urel, xstar, constr, nit, tiO, tiF) = _solve_injector(P, P_O, P_F, Pc) if ok == 0: return (0.0,)*22 mdot_total = mO + mF; MR = mO/mF cs_id, cf_id, tc, gm, Rg, Mg, cfv = cea_eval(Pcg, MRg, epsg, cstar, Cf, Tc_t, gam, Rt, Mt, Cfv, MR, Pc, P[G_EPS]) Lstar = P[G_LSTAR] if P[G_LSTAR] > 0 else (P[G_VOL]/P[G_AT] if P[G_AT] > 0 else 0.0) + Dinj = P[DJO] if P[INJ_TYPE] != 0.0 else P[PIN_DTIP] Ac = PI*(P[G_DCHAM]*0.5)**2 if P[C_ROPT] > 0.0: R_opt = P[C_ROPT] else: sO = np.sin(P[ANG_O]*PI/180.0); sF = np.sin(P[ANG_F]*PI/180.0) R_opt = np.sqrt(sF/sO) if (sO > 0 and sF > 0) else 1.0 - eta_total = _eta_advanced(P, Lstar, Pc, tc, gm, Rg, MR, Ac, P[G_AT], P[DJO], mdot_total, uF, uO, D32O, D32F, momR, R_opt) + eta_total = _eta_advanced(P, Lstar, Pc, tc, gm, Rg, MR, Ac, P[G_AT], Dinj, mdot_total, uF, uO, D32O, D32F, momR, R_opt, 1.0 if P[INJ_TYPE] != 0.0 else 0.0) # Cooling at the converged point, matching the residual. C reports # eta_cstar = eta_total*cooling_eff and derives cstar_actual from THAT # (ed_chamber.c:91-92), so report eta_final here, not eta_total. diff --git a/EngineDesign/engine/accel/params.py b/EngineDesign/engine/accel/params.py index 9df439376..50278e213 100644 --- a/EngineDesign/engine/accel/params.py +++ b/EngineDesign/engine/accel/params.py @@ -179,6 +179,27 @@ def _geom(cg): ) +def _pintle(geom): + """Pintle geometry, flattened. + + The area/hydraulic-diameter maths lives in engine.core.geometry and is called + HERE, at the boundary, so the kernel sees scalars and the two implementations + cannot drift on geometry. + """ + from engine.core.geometry import get_effective_areas, get_hydraulic_diameters + A_O, A_F = get_effective_areas(geom) + dh_O, dh_F = get_hydraulic_diameters(geom) + return _ns(A_O=float(A_O), A_F=float(A_F), + d_hyd_O=float(dh_O), d_hyd_F=float(dh_F), + d_orifice=float(geom.lox.d_orifice), + h_gap=float(geom.fuel.h_gap), + d_pintle_tip=float(geom.fuel.d_pintle_tip)) + + +_PIN_ZERO = _ns(A_O=0.0, A_F=0.0, d_hyd_O=0.0, d_hyd_F=0.0, d_orifice=0.0, h_gap=0.0, + d_pintle_tip=0.0) + + def _imp(b): """Mirrors native_injector._fill_imp.""" return _ns( @@ -199,6 +220,17 @@ def build_state(config): g = config.injector.geometry sp = config.spray + inj_type = _INJ[config.injector.type] + + # Only one geometry block is populated; the other stays zero. The kernels + # branch on injector.type, so the unused half is never read. + if inj_type == _INJ["impinging"]: + imp_O, imp_F, pin = _imp(g.oxidizer), _imp(g.fuel), _PIN_ZERO + elif inj_type == _INJ["pintle"]: + _z = _ns(n_elements=0, d_jet=0.0, impingement_angle=0.0, spacing=0.0) + imp_O, imp_F, pin = _z, _z, _pintle(g) + else: + raise NotImplementedError(f"injector type {config.injector.type!r} not ported") spray = _ns( # Impinging atomization is ALWAYS Ingebo (see impinging.py); a stale @@ -210,6 +242,10 @@ def build_state(config): # Falsy check, not `is None`: we_corr_max of 0.0 and null both -> 0.0. smd_we_corr_max=float(getattr(sp.smd, "we_corr_max", None)) if getattr(sp.smd, "we_corr_max", None) else 0.0, + use_turbulence_corrections=int(bool(getattr(sp, "use_turbulence_corrections", False))), + turbulence_penetration_gain=float(getattr(sp, "turbulence_penetration_gain", 0.0) or 0.0), + pintle_C=float(sp.pintle.C), pintle_B=float(sp.pintle.B), + pintle_n=float(sp.pintle.n), pintle_p=float(sp.pintle.p), chamber_gas_R=float(sp.smd.chamber_gas_R), chamber_gas_T=float(sp.smd.chamber_gas_T), spray_angle_model=0 if sp.spray_angle.model == "J" else 1, @@ -230,8 +266,8 @@ def build_state(config): ) return _ns( - injector=_ns(type=_INJ[config.injector.type], - imp_O=_imp(g.oxidizer), imp_F=_imp(g.fuel)), + injector=_ns(type=inj_type, imp_O=imp_O, imp_F=imp_F), + pin=pin, discharge_O=_discharge(config.discharge["oxidizer"]), discharge_F=_discharge(config.discharge["fuel"]), feed_O=_feed(config.feed_system["oxidizer"]), @@ -294,6 +330,13 @@ def build_state(config): "AB_BLOWEFF", "AB_BLOWC", "AB_BLOWMIN", "AB_TIREF", "AB_TISENS", "AB_TIEXP", "AB_TIMAX", "AB_EMIS", "AB_TAMB", "AB_SINKMIN", "AB_SINKFB", + # injector-type discriminator (0=pintle, 1=impinging) -- kernels branch on it + "INJ_TYPE", + # pintle injector (zero for impinging configs) + "PIN_AO", "PIN_AF", "PIN_DHO", "PIN_DHF", "PIN_DORIF", "PIN_HGAP", + "PIN_SMDC", "PIN_SMDB", "PIN_SMDN", "PIN_SMDP", "PIN_DTIP", + # spray turbulence corrections (used by the pintle path) + "SP_USETURB", "SP_PENGAIN", ] _IDX = {n: i for i, n in enumerate(_NAMES)} globals().update(_IDX) # module-level int constants for njit @@ -302,7 +345,7 @@ def build_state(config): def _assert_supported(st): """Guard the assumptions that let this port skip cooling / use the impinging path.""" - assert int(st.injector.type) == 1, "not impinging" + assert int(st.injector.type) in (0, 1), "injector type not ported (coaxial)" # Ablative IS ported (see _cooling_evaluate). Film/regen are not -- C refuses # them too (ed_cooling.c:147), so they stay a Python fallback. assert int(getattr(st.cooling, "film_enabled")) == 0 and int(getattr(st.cooling, "regen_enabled")) == 0 @@ -400,6 +443,16 @@ def _build_path_table(): ("AB_SINKMIN","ablative_radiative_sink_minimum_threshold"), ("AB_SINKFB","ablative_radiative_sink_fallback_temperature")): paths[name] = f"cooling.{fld}" + paths["INJ_TYPE"] = "injector.type" + for suf, fld in (("AO","A_O"),("AF","A_F"),("DHO","d_hyd_O"),("DHF","d_hyd_F"), + ("DORIF","d_orifice"),("HGAP","h_gap"), + ("DTIP","d_pintle_tip")): + paths[f"PIN_{suf}"] = f"pin.{fld}" + for name, fld in (("PIN_SMDC","pintle_C"),("PIN_SMDB","pintle_B"), + ("PIN_SMDN","pintle_n"),("PIN_SMDP","pintle_p"), + ("SP_USETURB","use_turbulence_corrections"), + ("SP_PENGAIN","turbulence_penetration_gain")): + paths[name] = f"spray.{fld}" missing = set(_NAMES) - set(paths) assert not missing, f"param(s) with no source path: {sorted(missing)}" return paths diff --git a/EngineDesign/tests/test_numba_ab_parity.py b/EngineDesign/tests/test_numba_ab_parity.py index 6bc009aeb..a5954916b 100644 --- a/EngineDesign/tests/test_numba_ab_parity.py +++ b/EngineDesign/tests/test_numba_ab_parity.py @@ -18,10 +18,13 @@ agreement, which is the tell). _python_only() below disables the accelerator for the reference computation; without it this suite proves nothing. -BOTH CONFIGS ARE EXERCISED ON PURPOSE: configs/canonical/impinging.yaml has -ablative cooling ON (the path the project's default configs take) and -impinging_lox_ch4_8000N.yaml has it off. Different routes through -kernels._cooling_evaluate. +THE CONFIG LIST IS DELIBERATE. canonical/impinging.yaml has ablative cooling ON +(the path the project's default configs take), impinging_lox_ch4_8000N.yaml has +it off, and canonical/pintle.yaml exercises the pintle injector -- a different +solve (kernels.injector_solve_pintle) and, critically, a different mixing term: +pintle gets eta_mixing = Em_peak flat, with NO momentum-mixing penalty. Reusing +the impinging mom_R/R_opt logic there would silently diverge from the +authoritative path, so that divergence is pinned here. """ from __future__ import annotations @@ -46,14 +49,16 @@ RTOL = 1e-6 CONFIGS = [ - ("configs/canonical/impinging.yaml", True), # ablative ON - ("configs/impinging_lox_ch4_8000N.yaml", False), # ablative off + ("configs/canonical/impinging.yaml", True), # impinging, ablative ON + ("configs/impinging_lox_ch4_8000N.yaml", False), # impinging, ablative off + ("configs/canonical/pintle.yaml", True), # pintle, ablative ON ] CORE_FIELDS = ["Pc", "F", "Isp", "MR", "cstar_actual", "eta_cstar", "mdot_total", "mdot_O", "mdot_F", "Cf_actual", "P_exit", "T_exit", "v_exit"] +# momentum_ratio_R is impinging-only (absent for pintle); the .get() guards skip it. DIAG_FIELDS = ["D32_O", "D32_F", "Cd_O", "Cd_F", "momentum_ratio_R", "delta_p_feed_O", "delta_p_feed_F", "delta_p_injector_O", "delta_p_injector_F", "A_geom_O", "A_geom_F", "A_eff_O", "A_eff_F", From fb84c7dd36714d321dcb43b24e422b3bd82928fe Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 03:11:18 -0700 Subject: [PATCH 13/20] Discriminate unsupported-config from non-convergence in the accelerator API --- EngineDesign/engine/accel/__init__.py | 90 ++++++++++++++++------ EngineDesign/tests/test_accel_outcomes.py | 94 +++++++++++++++++++++++ 2 files changed, 160 insertions(+), 24 deletions(-) create mode 100644 EngineDesign/tests/test_accel_outcomes.py diff --git a/EngineDesign/engine/accel/__init__.py b/EngineDesign/engine/accel/__init__.py index 6b8f4fd62..12daf0f8f 100644 --- a/EngineDesign/engine/accel/__init__.py +++ b/EngineDesign/engine/accel/__init__.py @@ -11,11 +11,40 @@ """ from __future__ import annotations +import enum import os __all__ = ["available", "enabled", "can_handle", "can_handle_chamber", "evaluate", "solve", "chamber_solve", "warmup", "require", - "chug_margin_fast"] + "chug_margin_fast", "Outcome", + "evaluate_ex", "solve_ex", "chamber_solve_ex"] + + +class Outcome(enum.Enum): + """Why an accelerated call did not return a result. + + These two failures look identical to callers today -- both surface as a bare + None -- but they mean opposite things: + + NOT_HANDLED the accelerator has no implementation for this config (wrong + injector type, film/regen cooling, a non-3D CEA cache). Python + is the ONLY implementation, so falling back is mandatory and + the fallback does real work. + + NO_SOLUTION the physics ran and did not converge. Measured over 102 such + candidates, the Python path then failed on every one -- it + re-derives "infeasible" at full cost and also gives up. So this + fallback is (almost always) wasted work. + + Conflating them is what made that impossible to see or measure. The public + evaluate/solve/chamber_solve keep returning None exactly as before -- every + caller keys on `is None` and none of them change -- while the *_ex variants + expose the reason for instrumentation and for any future short-circuit. + """ + + OK = "ok" + NOT_HANDLED = "not_handled" + NO_SOLUTION = "no_solution" def available() -> bool: @@ -81,35 +110,38 @@ def can_handle_chamber(config) -> bool: def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): - """Single-call chamber + nozzle + thrust + stability. None => caller falls back. + """Single-call chamber + nozzle + thrust + stability. None => caller falls back.""" + return evaluate_ex(config, cache, P_tank_O, P_tank_F, P_ambient)[0] - """ + +def evaluate_ex(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): + """As evaluate(), but returns (result_or_None, Outcome).""" from engine.accel import diagnostics as _diag from engine.accel import kernels as _k from engine.accel import params as _p from engine.pipeline.stability.analysis import comprehensive_stability_analysis if not can_handle_chamber(config): - return None + return None, Outcome.NOT_HANDLED if not getattr(cache, "use_3d", False): - return None + return None, Outcome.NOT_HANDLED try: P = _p.extract_params(config) except AssertionError: - return None # config outside the ported subset + return None, Outcome.NOT_HANDLED # config outside the ported subset arr = _k._cea_arrays_cached(cache) r = _k.evaluate_core(P, *arr, float(P_tank_O), float(P_tank_F), float(P_ambient)) if not r[0]: - return None + return None, Outcome.NO_SOLUTION (_, Pc, F, Isp, MR, csa, gm, tc, mdt, vex, cfa, mO, mF, cs_id, eta, Rg, Pex, Pth, Tex, Tth, cf_id, tc_eff) = r if F != F: - return None + return None, Outcome.NO_SOLUTION sol = _k._solve_injector(P, float(P_tank_O), float(P_tank_F), float(Pc)) if not sol[0]: - return None + return None, Outcome.NO_SOLUTION diag = _diag.build_diag(P, sol) diag.update({ "mdot_O": mO, "mdot_F": mF, "mdot_total": mdt, "Pc": Pc, "MR": MR, @@ -121,7 +153,7 @@ def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): config=config, Pc=Pc, MR=MR, mdot_total=mdt, cstar=csa, gamma=gm, R=Rg, Tc=tc_eff, diagnostics=diag) except Exception: - return None + return None, Outcome.NO_SOLUTION return { "Pc": Pc, "mdot_O": mO, "mdot_F": mF, "mdot_total": mdt, "MR": MR, "F": F, "Isp": Isp, "v_exit": vex, "P_exit": Pex, "P_throat": Pth, @@ -133,11 +165,16 @@ def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0): "stability": stab, "stability_results": stab, "diagnostics": diag, "P_ambient": float(P_ambient), "native_fast_eval": True, "numba_fast_eval": True, - } + }, Outcome.OK def solve(config, P_tank_O, P_tank_F, Pc): - """Injector mass flows at a given Pc -> (mdot_O, mdot_F, diagnostics), or None. + """Injector mass flows at a given Pc -> (mdot_O, mdot_F, diagnostics), or None.""" + return solve_ex(config, P_tank_O, P_tank_F, Pc)[0] + + +def solve_ex(config, P_tank_O, P_tank_F, Pc): + """As solve(), but returns (result_or_None, Outcome). Sits on the FALLBACK path: closure.flows calls it on every residual iteration of the Python chamber solve, so it runs @@ -154,19 +191,24 @@ def solve(config, P_tank_O, P_tank_F, Pc): from engine.accel import params as _p if not can_handle(config): - return None + return None, Outcome.NOT_HANDLED try: P = _p.extract_params(config) except AssertionError: - return None + return None, Outcome.NOT_HANDLED sol = _k._solve_injector(P, float(P_tank_O), float(P_tank_F), float(Pc)) if not sol[0]: - return None - return float(sol[1]), float(sol[2]), _diag.build_diag(P, sol) + return None, Outcome.NO_SOLUTION + return (float(sol[1]), float(sol[2]), _diag.build_diag(P, sol)), Outcome.OK def chamber_solve(config, cache, P_tank_O, P_tank_F): - """Whole chamber residual loop -> (Pc, diagnostics), or None. + """Whole chamber residual loop -> (Pc, diagnostics), or None.""" + return chamber_solve_ex(config, cache, P_tank_O, P_tank_F)[0] + + +def chamber_solve_ex(config, cache, P_tank_O, P_tank_F): + """As chamber_solve(), but returns (result_or_None, Outcome). The only consumer (chamber_solver._native_chamber_pc) reads element 0. @@ -178,24 +220,24 @@ def chamber_solve(config, cache, P_tank_O, P_tank_F): from engine.accel import params as _p if not can_handle_chamber(config): - return None + return None, Outcome.NOT_HANDLED if not getattr(cache, "use_3d", False): - return None + return None, Outcome.NOT_HANDLED try: P = _p.extract_params(config) except AssertionError: - return None + return None, Outcome.NOT_HANDLED arr = _k._cea_arrays_cached(cache) r = _k.evaluate_core(P, *arr, float(P_tank_O), float(P_tank_F), 101325.0) if not r[0]: - return None + return None, Outcome.NO_SOLUTION Pc = float(r[1]) if not (Pc > 0.0) or Pc != Pc: - return None - return Pc, {"Pc": Pc, "mdot_O": r[11], "mdot_F": r[12], "mdot_total": r[8], + return None, Outcome.NO_SOLUTION + return (Pc, {"Pc": Pc, "mdot_O": r[11], "mdot_F": r[12], "mdot_total": r[8], "MR": r[4], "cstar_ideal": r[13], "cstar_actual": r[5], "eta_cstar": r[14], "gamma": r[6], "R": r[15], - "Tc": r[21], "Tc_ideal": r[7], "converged": True} + "Tc": r[21], "Tc_ideal": r[7], "converged": True}), Outcome.OK def warmup(): diff --git a/EngineDesign/tests/test_accel_outcomes.py b/EngineDesign/tests/test_accel_outcomes.py new file mode 100644 index 000000000..a7f7a47af --- /dev/null +++ b/EngineDesign/tests/test_accel_outcomes.py @@ -0,0 +1,94 @@ +"""The accelerator must distinguish "can't handle this" from "didn't converge". + +Both used to surface as a bare None, which made them impossible to tell apart at +the call site or in instrumentation. They mean opposite things: + + NOT_HANDLED no implementation exists for this config -- Python is the only + one, so the fallback is mandatory and does real work. + NO_SOLUTION the physics ran and did not converge. Measured over 102 such + candidates, the Python fallback then failed on every one, so that + fallback is re-deriving "infeasible" at full cost. + +Only the second is a candidate for ever being short-circuited. Conflating them +would make that change unsafe, because skipping the fallback on NOT_HANDLED would +silently drop every config the accelerator doesn't cover. + +The public evaluate/solve/chamber_solve keep the plain-None contract; these tests +also pin that, since every caller keys on `is None`. +""" +from __future__ import annotations + +import copy +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PA = 101325.0 + + +@pytest.fixture(scope="module") +def rig(): + from engine import accel + from engine.core.runner import PintleEngineRunner + from engine.pipeline.io import load_config + if not accel.available(): + pytest.skip("numba unavailable") + cfg = load_config(str(ROOT / "configs/canonical/impinging.yaml")) + return cfg, PintleEngineRunner(cfg) + + +def test_converged_is_ok(rig): + from engine import accel + cfg, r = rig + res, oc = accel.evaluate_ex(cfg, r.cea_cache, 4.0e6, 4.0e6, PA) + assert oc is accel.Outcome.OK + assert res is not None and res["Pc"] > 0 + + +def test_non_convergence_is_no_solution(rig): + """Tank pressures below the chamber: the physics runs and gives up.""" + from engine import accel + cfg, r = rig + res, oc = accel.evaluate_ex(cfg, r.cea_cache, 1.2e5, 1.2e5, PA) + assert res is None + assert oc is accel.Outcome.NO_SOLUTION, ( + "a non-converged solve must NOT report NOT_HANDLED -- that would mark a " + "skippable fallback as mandatory" + ) + + +def test_unsupported_config_is_not_handled(rig): + """Film cooling has no port; the accelerator must say so, not 'no solution'.""" + from engine import accel + cfg, r = rig + bad = copy.deepcopy(cfg) + bad.film_cooling.enabled = True + res, oc = accel.evaluate_ex(bad, r.cea_cache, 4.0e6, 4.0e6, PA) + assert res is None + assert oc is accel.Outcome.NOT_HANDLED, ( + "an unsupported config must NOT report NO_SOLUTION -- short-circuiting " + "that would silently drop the only implementation that can run it" + ) + + +def test_solve_and_chamber_solve_discriminate(rig): + from engine import accel + cfg, r = rig + assert accel.solve_ex(cfg, 4.0e6, 4.0e6, 2.4e6)[1] is accel.Outcome.OK + assert accel.chamber_solve_ex(cfg, r.cea_cache, 4.0e6, 4.0e6)[1] is accel.Outcome.OK + bad = copy.deepcopy(cfg) + bad.film_cooling.enabled = True + assert accel.solve_ex(bad, 4.0e6, 4.0e6, 2.4e6)[1] is accel.Outcome.NOT_HANDLED + assert accel.chamber_solve_ex(bad, r.cea_cache, 4.0e6, 4.0e6)[1] is accel.Outcome.NOT_HANDLED + + +def test_plain_api_still_returns_bare_none_or_result(rig): + """Callers key on `is None`; the *_ex split must not have changed that.""" + from engine import accel + cfg, r = rig + ok = accel.evaluate(cfg, r.cea_cache, 4.0e6, 4.0e6, PA) + assert isinstance(ok, dict), "evaluate() must still return the plain result dict" + assert accel.evaluate(cfg, r.cea_cache, 1.2e5, 1.2e5, PA) is None + assert isinstance(accel.solve(cfg, 4.0e6, 4.0e6, 2.4e6), tuple) + assert isinstance(accel.chamber_solve(cfg, r.cea_cache, 4.0e6, 4.0e6), tuple) From 9f8588cb6c782ecd3d9db24ad9139c47492d26ca Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 03:56:10 -0700 Subject: [PATCH 14/20] Fix parity suite under strict mode: forcing the Python reference is not a failure --- EngineDesign/tests/test_numba_ab_parity.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/EngineDesign/tests/test_numba_ab_parity.py b/EngineDesign/tests/test_numba_ab_parity.py index a5954916b..83c86debb 100644 --- a/EngineDesign/tests/test_numba_ab_parity.py +++ b/EngineDesign/tests/test_numba_ab_parity.py @@ -67,14 +67,22 @@ @contextmanager def _python_only(): - """Force the authoritative Python path for the reference computation.""" + """Force the authoritative Python path for the reference computation. + + Disables strict mode as well as the accelerator. Under ED_REQUIRE_ACCEL=1 (the + CI parity job) closure._try_native_flows treats "disabled + strict" as a + genuine accelerator failure and raises -- which is exactly right in + production, and exactly wrong here, where the accelerator is off ON PURPOSE. + Without this the whole suite fails under CI's env while passing locally. + """ from engine import accel - real = accel.enabled + real_enabled, real_require = accel.enabled, accel.require accel.enabled = lambda: False + accel.require = lambda: False try: yield finally: - accel.enabled = real + accel.enabled, accel.require = real_enabled, real_require def _rel(got, want): From 761fee355013c6fa3590f88b5630ba3fc3223776 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 04:08:54 -0700 Subject: [PATCH 15/20] Add a guard that production actually calls the accelerator, not just that it works --- .github/workflows/engine-design-ci.yml | 9 ++ .../tests/test_accel_is_actually_used.py | 102 ++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 EngineDesign/tests/test_accel_is_actually_used.py diff --git a/.github/workflows/engine-design-ci.yml b/.github/workflows/engine-design-ci.yml index 4a02f4f9d..8f1e30c8d 100644 --- a/.github/workflows/engine-design-ci.yml +++ b/.github/workflows/engine-design-ci.yml @@ -271,6 +271,13 @@ jobs: # ED_REQUIRE_ACCEL=1 makes a genuine accelerator failure RAISE instead of # falling back, so this step cannot pass on the Python path and report a false # green. It proves the backend RAN; the numeric comparison is the next step. + # + # test_accel_is_actually_used closes a different false-green: every other check + # proves the accelerator WORKS, none proves production still CALLS it. With the + # accel.evaluate call removed from Layer-1's _eval_candidate, all of them stay + # green while the optimizer silently runs ~120x slower on Python. That test + # asserts the real call sites are reached, and is mutation-verified against + # both the Layer-1 and chamber_solver/closure seams. - name: Run impinging tests through the accelerator (strict) env: ED_ACCEL: 'numba' @@ -279,6 +286,8 @@ jobs: python -m pytest \ tests/test_layer1_impinging_vector.py \ tests/test_flow_capacity_effective_area.py \ + tests/test_accel_is_actually_used.py \ + tests/test_accel_outcomes.py \ --timeout=180 --timeout-method=thread -q # Live A/B numeric parity: run the accelerator and the authoritative Python diff --git a/EngineDesign/tests/test_accel_is_actually_used.py b/EngineDesign/tests/test_accel_is_actually_used.py new file mode 100644 index 000000000..bfb20a525 --- /dev/null +++ b/EngineDesign/tests/test_accel_is_actually_used.py @@ -0,0 +1,102 @@ +"""Guard against a SILENT BYPASS: production quietly stopping using the accelerator. + +Everything else in CI proves the accelerator *works* -- that numba imports, that +the kernels compile, that their numbers match Python. None of it proves the +production call sites still *call* them. Delete the `accel.evaluate(...)` line +from Layer-1's `_eval_candidate` and every other check stays green while the +optimizer silently runs ~120x slower on the Python path. That was verified by +doing exactly that: pre-flight, the strict impinging tests, the A/B parity suite +and the outcome tests all passed with the call disabled. + +These tests close that hole by asserting the accelerator is actually reached +through the real code paths, not called directly by a test. +""" +from __future__ import annotations + +import copy +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PA = 101325.0 + + +@pytest.fixture(scope="module") +def cfg(): + from engine import accel + from engine.pipeline.io import load_config + if not accel.available(): + pytest.skip("numba unavailable") + return load_config(str(ROOT / "configs/canonical/impinging.yaml")) + + +def test_runner_evaluate_routes_through_the_accelerator(cfg, monkeypatch): + """chamber_solver / closure must reach the accelerator, not just be able to. + + runner.evaluate -> chamber_solver._accel_chamber_pc -> accel.chamber_solve, + and the Brent residual -> closure.flows -> accel.solve. + """ + from engine import accel + from engine.core.runner import PintleEngineRunner + + calls = {"chamber_solve": 0, "solve": 0} + for name in calls: + real = getattr(accel, name) + def counting(*a, _r=real, _n=name, **k): + calls[_n] += 1 + return _r(*a, **k) + monkeypatch.setattr(accel, name, counting) + + r = PintleEngineRunner(cfg) + res = r.evaluate(4.0e6, 4.0e6, P_ambient=PA, silent=True) + assert res is not None and res["Pc"] > 0 + + assert sum(calls.values()) > 0, ( + "runner.evaluate completed without reaching the accelerator at all -- the " + f"call sites have been bypassed (counts={calls}). Everything still " + "'passes' because Python silently produces the same numbers, just ~120x " + "slower." + ) + + +def test_layer1_inner_loop_routes_through_the_accelerator(cfg): + """The Layer-1 seam: _eval_candidate must call accel.evaluate per candidate. + + Runs a genuine one-iteration smoke optimization rather than calling the + accelerator directly, because the thing under test is the WIRING. + """ + from engine import accel + import engine.optimizer.layers.layer1_static_optimization as L1 + from engine.core.runner import PintleEngineRunner + + n = {"calls": 0} + real = accel.evaluate + + def counting(*a, **k): + n["calls"] += 1 + return real(*a, **k) + + base = copy.deepcopy(cfg) + req = base.design_requirements.model_dump() + req["layer1_random_seed"] = 0 # pin the CMA trajectory + 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"])} + + L1._get_num_workers = lambda c: 1 # serial, so the patch is visible in-process + accel.evaluate = counting + try: + L1.run_layer1_optimization( + copy.deepcopy(base), PintleEngineRunner(copy.deepcopy(base)), req, + target_burn_time=float(req.get("target_burn_time", 6.0)), + tolerances={"thrust": 0.10, "apogee": 0.15}, pressure_config=pcfg, + layer1_smoke=True, layer1_max_iterations=1, layer1_cma_restarts=1) + finally: + accel.evaluate = real + + assert n["calls"] > 0, ( + "Layer-1 ran a full iteration without ever calling accel.evaluate -- the " + "inner-loop fast path is bypassed. The optimizer still produces correct " + "results on the Python path, which is why no other test catches this." + ) From 238811802ef7d51a3e7deabb1b2d2d5930712056 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 04:13:35 -0700 Subject: [PATCH 16/20] Strengthen the bypass guard: per-candidate ratio, chug seam, call-site notes --- .../layers/layer1_static_optimization.py | 4 ++ .../engine/pipeline/stability/analysis.py | 2 + .../tests/test_accel_is_actually_used.py | 67 +++++++++++++++---- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py index fa7895ac1..d1f9c6963 100644 --- a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py +++ b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py @@ -1947,6 +1947,10 @@ def _eval_candidate(x_raw): # itself a shifting-equilibrium coefficient baked into the cache. result = None if _native_fast_eval_enabled(): + # NOTE: `from engine import accel` + attribute access is load-bearing. + # tests/test_accel_is_actually_used.py patches accel.evaluate to prove + # this seam is still reached; a `from engine.accel import evaluate` + # import would bind at import time and silently defeat that guard. from engine import accel as _accel result = _accel.evaluate( _worker_runner.config, _worker_runner.cea_cache, diff --git a/EngineDesign/engine/pipeline/stability/analysis.py b/EngineDesign/engine/pipeline/stability/analysis.py index 9ea5f1a54..4fde0bc26 100644 --- a/EngineDesign/engine/pipeline/stability/analysis.py +++ b/EngineDesign/engine/pipeline/stability/analysis.py @@ -487,6 +487,8 @@ def compute_physical_stability(config, Pc: float, MR: float, mdot_total: float, chug_fast = None if accel.enabled(): try: + # attribute access, not a direct import -- see the note in + # tests/test_accel_is_actually_used.py chug_fast = accel.chug_margin_fast(inp["streams"], inp["chamber"]) except Exception: chug_fast = None diff --git a/EngineDesign/tests/test_accel_is_actually_used.py b/EngineDesign/tests/test_accel_is_actually_used.py index bfb20a525..3ca57b2bb 100644 --- a/EngineDesign/tests/test_accel_is_actually_used.py +++ b/EngineDesign/tests/test_accel_is_actually_used.py @@ -61,7 +61,12 @@ def counting(*a, _r=real, _n=name, **k): def test_layer1_inner_loop_routes_through_the_accelerator(cfg): - """The Layer-1 seam: _eval_candidate must call accel.evaluate per candidate. + """The Layer-1 seam: EVERY candidate must go through accel.evaluate. + + Asserts a RATIO, not merely a non-zero count. A count-only check would pass + if one candidate went through the accelerator and the next thousand did not. + Measured ratio is exactly 1.000 -- every candidate -- so the 0.9 floor has + real margin while still failing hard (ratio 0.0) on a bypass. Runs a genuine one-iteration smoke optimization rather than calling the accelerator directly, because the thing under test is the WIRING. @@ -70,12 +75,16 @@ def test_layer1_inner_loop_routes_through_the_accelerator(cfg): import engine.optimizer.layers.layer1_static_optimization as L1 from engine.core.runner import PintleEngineRunner - n = {"calls": 0} - real = accel.evaluate + n = {"cand": 0, "accel": 0} + real_ec, real_ev = L1._eval_candidate, accel.evaluate - def counting(*a, **k): - n["calls"] += 1 - return real(*a, **k) + def counting_candidate(x): + n["cand"] += 1 + return real_ec(x) + + def counting_evaluate(*a, **k): + n["accel"] += 1 + return real_ev(*a, **k) base = copy.deepcopy(cfg) req = base.design_requirements.model_dump() @@ -84,8 +93,8 @@ def counting(*a, **k): "max_lox_pressure_psi": float(req["max_lox_tank_pressure_psi"]), "max_fuel_pressure_psi": float(req["max_fuel_tank_pressure_psi"])} - L1._get_num_workers = lambda c: 1 # serial, so the patch is visible in-process - accel.evaluate = counting + L1._get_num_workers = lambda c: 1 # serial, so the patches are in-process + L1._eval_candidate, accel.evaluate = counting_candidate, counting_evaluate try: L1.run_layer1_optimization( copy.deepcopy(base), PintleEngineRunner(copy.deepcopy(base)), req, @@ -93,10 +102,44 @@ def counting(*a, **k): tolerances={"thrust": 0.10, "apogee": 0.15}, pressure_config=pcfg, layer1_smoke=True, layer1_max_iterations=1, layer1_cma_restarts=1) finally: - accel.evaluate = real + L1._eval_candidate, accel.evaluate = real_ec, real_ev + + assert n["cand"] > 0, "no candidates were evaluated -- the smoke run did nothing" + ratio = n["accel"] / n["cand"] + assert ratio >= 0.9, ( + f"only {n['accel']}/{n['cand']} candidates ({ratio:.1%}) went through the " + "accelerator -- the Layer-1 inner-loop fast path is bypassed. The optimizer " + "still produces correct results on the Python path, which is why no other " + "test catches this." + ) + + +def test_stability_tail_routes_through_the_chug_kernel(cfg, monkeypatch): + """The chug seam: stability analysis must reach accel.chug_margin_fast. + + Separate from the others because it is reached through + comprehensive_stability_analysis rather than the chamber solve, so a bypass + there is invisible to every check above. Chug is ~53x accelerated and is the + dominant per-evaluation stability cost, so silently dropping to Python here + is a large regression with no wrong answer to give it away. + """ + from engine import accel + from engine.core.runner import PintleEngineRunner + + n = {"calls": 0} + real = accel.chug_margin_fast + + def counting(*a, **k): + n["calls"] += 1 + return real(*a, **k) + + monkeypatch.setattr(accel, "chug_margin_fast", counting) + r = PintleEngineRunner(cfg) + res = r.evaluate(4.0e6, 4.0e6, P_ambient=PA, silent=True) + assert res is not None assert n["calls"] > 0, ( - "Layer-1 ran a full iteration without ever calling accel.evaluate -- the " - "inner-loop fast path is bypassed. The optimizer still produces correct " - "results on the Python path, which is why no other test catches this." + "stability analysis ran without reaching accel.chug_margin_fast -- the " + "compiled chug sweep is bypassed and the ~53x slower Python sweep is " + "running instead, with identical numbers." ) From 44185361361d6f4a067c321594aac70e2d8fbd53 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 15:05:58 -0700 Subject: [PATCH 17/20] Fix Layer 1 convergence plot x-axis, evaluation counter, and chart legend --- .../layers/layer1_static_optimization.py | 46 ++++++++++++++++--- .../src/components/Layer1Optimization.tsx | 46 +++++++++++++------ 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py index d1f9c6963..a5c5de08f 100644 --- a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py +++ b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py @@ -2153,11 +2153,24 @@ def _layer1_emit_objective_plot_point( current_obj: float, best_obj: float, ) -> None: - """SSE / UI objective curve (parallel CMA does not call ``objective()``).""" + """SSE / UI objective curve (parallel CMA does not call ``objective()``). + + ``iteration`` arrives from two different counters -- the parent candidate index in + ``_eval_candidate`` and ``function_evaluations`` in the parallel CMA generation loop -- + and both are reset between phases. Plotted raw, the x-axis folded back on itself + (observed: 1, 17, 65, 113, 161, 209, then 2, 3, 4 ...), which made the curve unreadable. + Clamp to a non-decreasing sequence here, at the single point every emission passes + through, rather than trying to keep every call site's counter in sync. + """ if objective_callback is None: return + x = int(iteration) + x_max = int(opt_state.get("_plot_x_max", 0)) + if x <= x_max: + x = x_max + 1 + opt_state["_plot_x_max"] = x try: - objective_callback(int(iteration), float(current_obj), float(best_obj)) + objective_callback(x, float(current_obj), float(best_obj)) except Exception: pass @@ -3361,8 +3374,19 @@ def objective(x: np.ndarray) -> float: iteration = opt_state["iteration"] opt_state["function_evaluations"] += 1 - # Progress update - progress = 0.10 + 0.40 * min(iteration / max_iterations, 1.0) + # Progress update. ``iteration`` counts candidate evaluations, so it must be + # measured against the evaluation budget (max_iterations x popsize x restarts), + # not against ``max_iterations``, which caps CMA *generations*. + # Set once popsize/restarts are known; before that (x0 validation, warm start) + # there is no budget to measure against, so report the raw count rather than a + # fraction of the wrong denominator. + eval_budget = int(opt_state.get("eval_budget", 0)) + if eval_budget > 0: + progress = 0.10 + 0.40 * min(iteration / eval_budget, 1.0) + eval_str = f"{iteration}/{eval_budget}" + else: + progress = 0.10 + eval_str = f"{iteration}" if iteration <= 3 or iteration % 25 == 0: try: _bo = float(opt_state["best_objective"]) @@ -3375,8 +3399,8 @@ def objective(x: np.ndarray) -> float: _lv = float("inf") best_obj_str = f"{_bo:.3e}" if np.isfinite(_bo) else "inf" curr_obj_str = f"{_lv:.3e}" if np.isfinite(_lv) else "inf" - update_progress("Layer 1: Optimization", progress, f"Iter {iteration}/{max_iterations} | Curr: {curr_obj_str} | Best: {best_obj_str}") - layer1_logger.info(f"[{int(progress*100)}%] Iteration {iteration}/{max_iterations} - " + update_progress("Layer 1: Optimization", progress, f"Eval {eval_str} | Curr: {curr_obj_str} | Best: {best_obj_str}") + layer1_logger.info(f"[{int(progress*100)}%] Evaluation {eval_str} - " f"Objective: {curr_obj_str} (Best: {best_obj_str})") for handler in layer1_logger.handlers: handler.flush() @@ -4306,7 +4330,9 @@ def _finite_or_none(v: Any) -> Optional[float]: try: # Send all buffered entries (batch reporting) for buffered_entry in opt_state["objective_buffer"]: - objective_callback( + _layer1_emit_objective_plot_point( + objective_callback, + opt_state, buffered_entry["iteration"], buffered_entry["objective"], buffered_entry["best_objective"], @@ -4412,6 +4438,11 @@ def __init__(self, x, fun, success=True): # Legacy CMA uses this for ``maxiter`` per restart (hybrid path sets its own budget). total_eval_budget = max_iterations + + # Evaluation budget for progress reporting only -- never used to bound the search. + # CMA runs ``max_iterations`` generations of ``popsize`` per restart; the hybrid + # branch replaces this with its own cap once it computes one. + opt_state["eval_budget"] = max(1, int(max_iterations) * int(popsize) * int(num_restarts)) best_x_global = x0_refined best_f_global = float('inf') @@ -4624,6 +4655,7 @@ def __init__(self, x, fun, success=True): # not a fixed run length. (The default max_iterations is sized so this is fast under the # native kernel — see where max_iterations is set.) total_budget_evals = max(int(popsize), int(max_iterations) * int(popsize)) + opt_state["eval_budget"] = int(total_budget_evals) layer1_logger.info( "Hybrid evaluation budget: %s (max(%s, max_iterations=%s x popsize=%s))", total_budget_evals, diff --git a/EngineDesign/frontend/src/components/Layer1Optimization.tsx b/EngineDesign/frontend/src/components/Layer1Optimization.tsx index cdcf2bc9c..70363f409 100644 --- a/EngineDesign/frontend/src/components/Layer1Optimization.tsx +++ b/EngineDesign/frontend/src/components/Layer1Optimization.tsx @@ -1236,7 +1236,7 @@ export function Layer1Optimization({ Objective Convergence {objectiveHistory.length > 0 && ( - ({objectiveHistory.length} iterations) + ({objectiveHistory.length} points · lower is better) )} @@ -1246,16 +1246,19 @@ export function Layer1Optimization({ - - + ) : ( @@ -1294,6 +1305,13 @@ export function Layer1Optimization({

)} + {objectiveHistory.length > 0 && ( +

+ Dot size scales inversely with the objective, so the better a candidate is the + bigger its dot. The axis is logarithmic: near the end of a run the incumbent and + the surrounding candidates can differ by well under 1% and still look like a gap. +

+ )} {/* Button to show parameter plots */} {results?.iteration_history && results.iteration_history.length > 0 && ( From e347ccce91fe33ab04a971a32d50b2e692212b65 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 15:35:56 -0700 Subject: [PATCH 18/20] Memoize decompressed CEA tables so a Layer 1 run reads the .npz once, not 73 times --- EngineDesign/engine/pipeline/cea_cache.py | 36 +++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/EngineDesign/engine/pipeline/cea_cache.py b/EngineDesign/engine/pipeline/cea_cache.py index 90eda3e8b..aaff421c9 100644 --- a/EngineDesign/engine/pipeline/cea_cache.py +++ b/EngineDesign/engine/pipeline/cea_cache.py @@ -291,6 +291,38 @@ def _compute_cea_point_chunk( return results +# Decompressed CEA tables, keyed by (path, mtime, size). np.load returns a lazy +# NpzFile: every table access re-inflates that member from the zip, and a fresh +# CEACache is built for every PintleEngineRunner. Layer 1 builds a runner per parent +# objective() call, so one short run re-read this file 73 times -- 0.80 s of a 3.22 s +# parent, against 0.11 s for all the interpolation it actually fed. The tables are +# written only in _load_cache/_build_cache and never mutated afterwards (no caller +# assigns to a cache's attributes), so the arrays are safe to share by reference. +# Keying on mtime+size means a regenerated .npz is picked up rather than served stale. +_NPZ_TABLE_MEMO: Dict[Tuple[str, int, int], Dict[str, np.ndarray]] = {} + + +def _load_npz_tables(path: str) -> Dict[str, np.ndarray]: + """Return the fully-materialised contents of a cache .npz, memoised per file version.""" + try: + st = os.stat(path) + key = (os.path.abspath(path), st.st_mtime_ns, st.st_size) + except OSError: + key = None + if key is not None: + hit = _NPZ_TABLE_MEMO.get(key) + if hit is not None: + return hit + with np.load(path) as z: + tables = {name: z[name] for name in z.files} + if key is not None: + # Bounded: one entry per propellant cache file, and they are a few hundred KB each. + if len(_NPZ_TABLE_MEMO) > 8: + _NPZ_TABLE_MEMO.clear() + _NPZ_TABLE_MEMO[key] = tables + return tables + + class CEACache: """CEA cache with bilinear interpolation""" @@ -355,7 +387,7 @@ def __init__(self, config: CEAConfig): def _load_cache(self): """Load CEA data from cache file""" # print(f"[OK] Loading CEA cache from {self.cache_file}") - data = np.load(self.cache_file) + data = _load_npz_tables(self.cache_file) meta_expected = { "table_schema": CEA_TABLE_SCHEMA_VERSION, @@ -446,7 +478,7 @@ def _meta_matches(meta_loaded_dict: Optional[dict], meta_expected_dict: dict) -> self.Cf_table = data["Cf"] # Cf_vac added later; old caches lack it -> None triggers an isentropic # fallback in eval() until the cache is regenerated. - self.Cf_vac_table = data["Cf_vac"] if "Cf_vac" in data.files else None + self.Cf_vac_table = data["Cf_vac"] if "Cf_vac" in data else None self.Tc_table = data["Tc"] self.gamma_table = data["gamma"] self.R_table = data["R"] From b4c9f1b6417152d6ba7e19b01ab132bb388b52bf Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 15:35:56 -0700 Subject: [PATCH 19/20] Floor CMA iter_budget at 1 so max_iterations below the restart count cannot divide by zero --- .../engine/optimizer/layers/layer1_static_optimization.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py index a5c5de08f..ef30865f6 100644 --- a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py +++ b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py @@ -4796,7 +4796,10 @@ def __init__(self, x, fun, success=True): layer1_logger.info(f"") layer1_logger.info(f"Starting {restart_name} (sigma: {current_sigma_fraction*100:.0f}% of range)...") - iter_budget = total_eval_budget // num_restarts + # Floor at 1: layer1_max_iterations < num_restarts floors this to 0, + # which hands CMA maxiter=0 and then divides by it at the progress + # update below (ZeroDivisionError on any run with max_iterations < 4). + iter_budget = max(1, total_eval_budget // num_restarts) # Without an explicit ``seed``, cma uses non-deterministic defaults → different optima each run. _cma_restart_seed = layer1_seed_base + int(restart_idx) * 1_000_003 From ce92dd5eccb86f5b429a1a52b2521d3280875fc3 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Fri, 4 Sep 2026 15:35:56 -0700 Subject: [PATCH 20/20] Drop two redundant deepcopies of the worker evaluate() payload --- .../optimizer/layers/layer1_static_optimization.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py index ef30865f6..4e802a6a3 100644 --- a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py +++ b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py @@ -241,8 +241,12 @@ def _store_last_good_eval_bundle_from_worker_res( rf = float("nan") if not np.isfinite(rf): return + # ``res`` came off the pool by unpickling, so this process is already the only + # holder of ``fr``; the parent reads scalars off ``res`` afterwards and never mutates + # full_results. The one consumer of this bundle (see the validation replay) deep-copies + # on read, so a copy here was the second of three on the same payload. state["last_good_eval_bundle"] = { - "results": copy.deepcopy(fr), + "results": fr, "P_O_Pa": float(res.get("P_O_Pa", 0.0)), "P_F_Pa": float(res.get("P_F_Pa", 0.0)), "thrust_error": float(res.get("thrust_error", 1.0)), @@ -1992,7 +1996,13 @@ def _eval_candidate(x_raw): 'F': _f, 'MR': _mr, 'Pc': float(result.get('Pc', 0)), - 'full_results': copy.deepcopy(result), + # No copy: ``result`` is a fresh dict (runner.evaluate and accel.evaluate + # both build one per call and retain no reference to it), nothing here mutates + # it, and returning it hands it straight to the pool -- which pickles it, and + # pickling already yields an object the parent owns outright. The deepcopy was + # a third copy of data that crosses a process boundary anyway, and it cost + # 0.12 ms against a 1.17 ms evaluate(), i.e. ~10% of every worker candidate. + 'full_results': result, 'P_O_Pa': float(P_O_Pa), 'P_F_Pa': float(P_F_Pa), 'thrust_error': float(thr_e),