File size: 12,316 Bytes
8a169a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2db5fdd
 
 
 
 
 
8a169a0
2db5fdd
 
8a169a0
2db5fdd
 
 
 
8a169a0
 
 
 
 
 
 
 
 
 
 
 
 
2a3d72d
 
 
 
 
8a169a0
 
 
 
 
 
 
2a3d72d
 
 
 
 
 
 
8a169a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60a51fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a169a0
2a3d72d
 
 
2db5fdd
 
 
2a3d72d
8a169a0
 
 
 
 
60a51fd
 
 
 
 
 
 
 
 
8a169a0
 
 
 
 
 
374c67c
 
 
 
 
 
 
 
 
 
2db5fdd
 
2a3d72d
 
 
 
 
 
 
 
 
374c67c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a3d72d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a169a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cfac7e4
 
8a169a0
 
 
 
cfac7e4
8a169a0
 
2a3d72d
 
 
cfac7e4
2a3d72d
 
 
 
 
 
cfac7e4
2a3d72d
 
8a169a0
2a3d72d
 
8a169a0
 
 
 
cfac7e4
8a169a0
cfac7e4
8a169a0
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
"""
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