Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |