cryo-pump-simulator / murphy_unified /engine_bridge.py
pandeydigant31
fix(engine): LUT warmup falls back to sys.path search for HF container
60a51fd
Raw
History Blame Contribute Delete
12.3 kB
"""
Unified 3-engine bridge for Murphy Unified dashboard.
Dispatches cycle simulations to one of three engines:
- euler : cryosim.engine.fast.ICV_open (H2+N2, ~3-5s)
- lightspeed : cycle2mdot_10var_njit.ICV_open_10var (H2 only, ~1s, Numba)
- general_ode : cycle2mdot_ode_v2.ODE_driver_v2 (H2+N2, ~25s)
Includes background Numba warmup thread and automatic N2/readiness fallbacks.
"""
import os
import sys
import time
import threading
from typing import Any, Dict, Tuple
import numpy as np
# ── Ensure ode_reformulation is on sys.path ──────────────────────────────────
# __file__ = .../CSH2/murphy-unified/murphy_unified/engine_bridge.py
# Two parents up reaches CSH2/
_ODE_DIR = os.path.normpath(
os.path.join(os.path.dirname(__file__), os.pardir, os.pardir,
"cycle2mdot_acceleration", "ode_reformulation")
)
if _ODE_DIR not in sys.path:
sys.path.insert(0, _ODE_DIR)
# Also add the parent of ode_reformulation so break_coolprop is importable
_ACCEL_DIR = os.path.dirname(_ODE_DIR)
if _ACCEL_DIR not in sys.path:
sys.path.insert(0, _ACCEL_DIR)
# ── Euler engine (always available via cryosim) ──────────────────────────────
from cryosim.engine.fast import ICV_open # noqa: E402
# ── Lightspeed engine (needs Numba) ──────────────────────────────────────────
_HAS_LIGHTSPEED = False
try:
from cycle2mdot_10var_njit import ICV_open_10var # noqa: E402
_HAS_LIGHTSPEED = True
except ImportError:
ICV_open_10var = None
# ── General-ODE engine ───────────────────────────────────────────────────────
# Dual backend under one "general_ode" label:
# - H2 β†’ DP45 compiled (cycle2mdot_ode_compiled.solve_cycle_compiled), ~20s/cycle
# - N2 β†’ scipy RK45 (cycle2mdot_ode_v2.ODE_driver_v2), ~30s/cycle
# Both have formal O(h^5) error bounds. DP45 validated to <0.07% mdot error
# vs ode_v2 across 50–1150 barg H2 sweep (2026-04-14).
_HAS_GENERAL_ODE = False
try:
from cycle2mdot_ode_v2 import ODE_driver_v2 # noqa: E402
_HAS_GENERAL_ODE = True
except ImportError:
ODE_driver_v2 = None
_HAS_DP45 = False
try:
from cycle2mdot_ode_compiled import solve_cycle_compiled # noqa: E402
_HAS_DP45 = True
except ImportError:
solve_cycle_compiled = None
# ── Engine metadata ──────────────────────────────────────────────────────────
ENGINES: Dict[str, Dict[str, Any]] = {
"euler": {
"label": "Euler (cryosim)",
"fluids": ["h2", "n2"],
},
"lightspeed": {
"label": "Lightspeed (Numba 10-var)",
"fluids": ["h2"],
},
"general_ode": {
"label": "General ODE v2",
"fluids": ["h2", "n2"],
},
}
# ── Numba warmup ─────────────────────────────────────────────────────────────
_warmup_event = threading.Event()
_warmup_ok = False
def _resolve_h2_ps_lut() -> str | None:
"""Locate h2_ps_lut.npz across local checkout and HF container layouts.
Local: cycle2mdot_acceleration/h2_ps_lut.npz (next to break_coolprop/).
HF: /app/engines/h2_ps_lut.npz (flat).
Falls back to scanning sys.path entries.
"""
candidates = [os.path.join(_ACCEL_DIR, "h2_ps_lut.npz")]
for entry in sys.path:
if entry:
candidates.append(os.path.join(entry, "h2_ps_lut.npz"))
for p in candidates:
if os.path.isfile(p):
return p
return None
def _numba_warmup():
"""Background warmup for Numba JIT functions.
Warms up both break_coolprop.h2_props (used by Lightspeed and DP45) and
the DP45 compiled solver itself (solve_cycle_compiled). The DP45 warmup
runs a tiny cycle so the first user-facing H2/general_ode click doesn't
pay the ~30–60s JIT compilation cost.
"""
global _warmup_ok
try:
print("[engine_bridge] Numba warmup starting...")
from break_coolprop import h2_props # noqa: E402
lut_path = _resolve_h2_ps_lut()
if lut_path is None:
print("[engine_bridge] h2_ps_lut.npz not found on disk or sys.path; "
"skipping LUT warmup.")
_warmup_ok = False
else:
h2_props.init(lut_path)
_warmup_ok = True
print(f"[engine_bridge] Numba warmup complete (LUT: {lut_path}).")
except Exception as exc:
print(f"[engine_bridge] Numba warmup failed: {exc}")
_warmup_ok = False
finally:
_warmup_event.set()
# Pre-compile Lightspeed _ICV_loop_10var kernel so first user click
# doesn't pay JIT cost (~3-5s on cpu-basic).
if _HAS_LIGHTSPEED:
try:
print("[engine_bridge] Lightspeed warmup starting...")
_lightspeed_warmup_call()
print("[engine_bridge] Lightspeed warmup complete.")
except Exception as exc:
print(f"[engine_bridge] Lightspeed warmup failed (non-fatal): {exc}")
# Pre-compile DP45 kernel (does not gate _warmup_ok β€” Lightspeed must
# still be declared ready even if DP45 warmup fails)
if _HAS_DP45:
try:
print("[engine_bridge] DP45 warmup starting...")
_dp45_warmup_call()
print("[engine_bridge] DP45 warmup complete.")
except Exception as exc:
print(f"[engine_bridge] DP45 warmup failed (non-fatal): {exc}")
def _lightspeed_warmup_call():
"""Run one dummy Lightspeed cycle to JIT-compile _ICV_loop_10var."""
ICVparam = [20.5, 48.0, 5.0, 779.3, 33.4, 3.75, 7.36e-5, 1.0, 100]
DCVparam = [8.3, 25.0, 3.79, 78.54, 7.8, 1.053, 2.6e-6, 0.8, 200]
pump_geom = [40.3, 60.0, 120.0, 300.0, 0.8, 0.8, 0.026, 15.0,
37.0, 760000.0, 500.0, 0.01]
proc_param = [300.0, 10.0, 4.0, 30.0, 0.006, 0.0, 0.5, 0.2]
ICV_open_10var(
Pexit_barg=350.0, speed_f=0.65,
Ptank_barg=7.0, Psat_barg=2.0,
ICVparam=ICVparam, DCVparam=DCVparam,
pump_geom=pump_geom, proc_param=proc_param,
fluid="h2", prtMode=0, flash_eff=0.0,
)
def _dp45_warmup_call():
"""Run a single tiny cycle on DP45 to trigger Numba compilation."""
ICVparam = [20.5, 48.0, 5.0, 779.3, 33.4, 3.75, 7.36e-5, 1.0, 100]
DCVparam = [8.3, 25.0, 3.79, 78.54, 7.8, 1.053, 2.6e-6, 0.8, 200]
pump_geom = [40.3, 60.0, 120.0, 300.0, 0.8, 0.8, 0.026, 15.0,
37.0, 760000.0, 500.0, 0.01]
proc_param = [300.0, 10.0, 4.0, 30.0, 0.006, 0.0, 0.5, 0.2]
solve_cycle_compiled(
Pexit_barg=350.0, speed_f=0.65,
Ptank_barg=7.0, Psat_barg=2.0,
ICVparam=ICVparam, DCVparam=DCVparam,
pump_geom=pump_geom, proc_param=proc_param,
fluid="h2", prtMode=0, flash_eff=0.0,
)
# Launch warmup daemon
_warmup_thread = threading.Thread(target=_numba_warmup, daemon=True)
_warmup_thread.start()
def is_lightspeed_ready() -> bool:
"""Return True if the Numba warmup completed successfully."""
return _warmup_event.is_set() and _warmup_ok and _HAS_LIGHTSPEED
# ── Main dispatch ────────────────────────────────────────────────────────────
def cycle_sim(
engine: str = "euler",
Pexit_barg: float = 350.0,
speed_f: float = 0.65,
Ptank_barg: float = 7.0,
Psat_barg: float = 2.0,
ICVparam: list | None = None,
DCVparam: list | None = None,
pump_geom: list | None = None,
proc_param: list | None = None,
fluid: str = "h2",
flash_eff: float = 0.0,
**kwargs,
) -> Tuple[np.ndarray, Dict[str, Any]]:
"""Run a single pump-cycle simulation on the selected engine.
Parameters
----------
engine : str
One of "euler", "lightspeed", "general_ode".
Pexit_barg, speed_f, Ptank_barg, Psat_barg : float
Operating conditions.
ICVparam, DCVparam, pump_geom, proc_param : list or None
Valve / geometry / process arrays. None uses MVP 1.0 defaults.
fluid : str
"h2" or "n2".
flash_eff : float
Thermal mass film-boiling efficiency (0 = disabled).
**kwargs
Forwarded to the underlying engine.
Returns
-------
out : np.ndarray
Simulation output array (rows x 2).
hist : dict
History dict with at least "engine", "cpu_s", and optionally "fallback".
"""
# ── MVP 1.0 Simplex defaults ─────────────────────────────────────────
if ICVparam is None:
ICVparam = [20.5, 48, 5, 779.3, 33.4, 3.75, 0.0000736, 1.0, 100]
if DCVparam is None:
DCVparam = [8.3, 25, 3.79, 78.54, 7.8, 1.053, 0.0000026, 0.8, 200]
if pump_geom is None:
pump_geom = [40.3, 60, 120, 300, 0.8, 0.8, 0.026, 15, 37, 760000, 500, 0.01]
if proc_param is None:
proc_param = [300, 10, 4, 30, 0.006, 0, 0.5, 0.2]
fallback_msg = None
# ── Fallback logic ───────────────────────────────────────────────────
if engine == "lightspeed":
if fluid.lower() not in ("h2", "hydrogen"):
fallback_msg = (
f"Lightspeed does not support {fluid}; falling back to general_ode."
)
engine = "general_ode"
elif not is_lightspeed_ready():
fallback_msg = (
"Lightspeed Numba warmup not ready; falling back to euler."
)
engine = "euler"
# ── Common kwargs for all engines ────────────────────────────────────
common = dict(
Pexit_barg=Pexit_barg,
speed_f=speed_f,
Ptank_barg=Ptank_barg,
Psat_barg=Psat_barg,
ICVparam=ICVparam,
DCVparam=DCVparam,
pump_geom=pump_geom,
proc_param=proc_param,
fluid=fluid,
prtMode=0,
flash_eff=flash_eff,
)
merged = {**common, **kwargs}
t0 = time.perf_counter()
# ── Dispatch ─────────────────────────────────────────────────────────
if engine == "lightspeed":
out, hist = ICV_open_10var(**merged)
elif engine == "general_ode":
# Fluid-based sub-dispatch: DP45 compiled for H2 (faster), ode_v2 for N2
is_h2 = fluid.lower() in ("h2", "hydrogen")
if is_h2 and _HAS_DP45:
out, hist = solve_cycle_compiled(**merged)
hist.setdefault("backend", "dp45_compiled")
elif _HAS_GENERAL_ODE:
if is_h2 and not _HAS_DP45:
fallback_msg = (fallback_msg or "") + (
" | DP45 compiled backend unavailable; using scipy ode_v2"
)
out, hist = ODE_driver_v2(**merged)
hist.setdefault("backend", "ode_v2_scipy")
else:
raise ImportError(
"Neither DP45 compiled (cycle2mdot_ode_compiled) nor "
"scipy ode_v2 (cycle2mdot_ode_v2) is available."
)
else: # euler (default)
# Euler (cryosim) does not accept exit_param / n_cycles β€” strip them
euler_kw = {k: v for k, v in merged.items()
if k not in ("exit_param", "n_cycles")}
out, hist = ICV_open(**euler_kw)
cpu_s = time.perf_counter() - t0
# ── Normalise output ─────────────────────────────────────────────────
hist["engine"] = engine
hist["cpu_s"] = cpu_s
if fallback_msg is not None:
hist["fallback"] = fallback_msg
return out, hist