pandeydigant31 commited on
Commit
2db5fdd
Β·
verified Β·
1 Parent(s): b2941c6

Sweep/Sim param sync + CSV I/O + HW presets (Old/New ICV)

Browse files

Major fix for Jimmy's reported bug: Sweep tab now honors Simulation tab baseline params. Cache key expanded to avoid stale results. Added CSV save/load for run params, 1D sweep point labels, and a Hardware dropdown with Baseline / Old ICV / New ICV presets sourced from the ICV sim-driver xlsx.

murphy_unified/__init__.py CHANGED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Murphy Unified β€” Consolidated Cryogenic Pump Dashboard."""
2
+ __version__ = "0.1.0"
murphy_unified/__main__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from murphy_unified.app import main
2
+ main()
murphy_unified/agent/config.py CHANGED
@@ -6,15 +6,14 @@ from pathlib import Path
6
  # ── Stored IDs (set by scripts/setup_agent.py, loaded from env) ──
7
 
8
  AGENT_ID = os.environ.get("CSH2_AGENT_ID", "")
9
- _agent_version_raw = os.environ.get("CSH2_AGENT_VERSION", "").strip()
10
- AGENT_VERSION = int(_agent_version_raw) if _agent_version_raw else 0
11
  ENVIRONMENT_ID = os.environ.get("CSH2_ENVIRONMENT_ID", "")
12
 
13
  # ── Paths ──
14
 
15
  DATA_DIR = Path(__file__).parent.parent / "data"
16
  SENSOR_TAG_MAP_PATH = DATA_DIR / "sensor_tag_map.json"
17
- DESIGN_RECORDS_DIR = Path("/tmp/design_records")
18
 
19
  # ── System Prompt ──
20
 
 
6
  # ── Stored IDs (set by scripts/setup_agent.py, loaded from env) ──
7
 
8
  AGENT_ID = os.environ.get("CSH2_AGENT_ID", "")
9
+ AGENT_VERSION = os.environ.get("CSH2_AGENT_VERSION", "")
 
10
  ENVIRONMENT_ID = os.environ.get("CSH2_ENVIRONMENT_ID", "")
11
 
12
  # ── Paths ──
13
 
14
  DATA_DIR = Path(__file__).parent.parent / "data"
15
  SENSOR_TAG_MAP_PATH = DATA_DIR / "sensor_tag_map.json"
16
+ DESIGN_RECORDS_DIR = Path(__file__).parent.parent.parent / "design_records"
17
 
18
  # ── System Prompt ──
19
 
murphy_unified/agent/orchestrator.py CHANGED
@@ -92,7 +92,7 @@ class DesignSessionOrchestrator:
92
  resources.append({
93
  "type": "file",
94
  "file_id": uploaded.id,
95
- "mount_path": "/workspace/sensor_tags.json",
96
  })
97
  log.info("Uploaded sensor tag map: %s", uploaded.id)
98
  except Exception: # noqa: BLE001
@@ -146,7 +146,7 @@ class DesignSessionOrchestrator:
146
 
147
  self._emit(SessionEvent(type="status", content="Sending message…"))
148
 
149
- with self._client.beta.sessions.events.stream(session_id=self.session_id) as stream:
150
  # Send after opening the stream (stream-first pattern)
151
  self._client.beta.sessions.events.send(
152
  session_id=self.session_id,
 
92
  resources.append({
93
  "type": "file",
94
  "file_id": uploaded.id,
95
+ "path": "/workspace/sensor_tags.json",
96
  })
97
  log.info("Uploaded sensor tag map: %s", uploaded.id)
98
  except Exception: # noqa: BLE001
 
146
 
147
  self._emit(SessionEvent(type="status", content="Sending message…"))
148
 
149
+ with self._client.beta.sessions.stream(session_id=self.session_id) as stream:
150
  # Send after opening the stream (stream-first pattern)
151
  self._client.beta.sessions.events.send(
152
  session_id=self.session_id,
murphy_unified/app.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Murphy Unified β€” Consolidated Cryogenic Pump Dashboard.
2
+
3
+ Launch: python -m murphy_unified.app
4
+ or: murphy (via pip install entry point)
5
+ """
6
+
7
+ import gradio as gr
8
+
9
+ from murphy_unified.sim_defaults import SIM_DEFAULTS
10
+ from murphy_unified.theme import CSS, FONTS_HTML, FORCE_DARK
11
+ from murphy_unified.tabs.simulation import build_simulation_tab
12
+ from murphy_unified.tabs.sweep import build_sweep_tab
13
+ from murphy_unified.tabs.sweep_params import build_sweep_params_tab
14
+ from murphy_unified.tabs.comparison import build_comparison_tab
15
+ from murphy_unified.tabs.real_cycles import build_real_cycles_tab
16
+ from murphy_unified.tabs.design_agent import build_design_agent_tab
17
+ from murphy_unified.tabs.process_sim import build_process_sim_tab
18
+
19
+ # Feature flag: set True to show work-in-progress tabs (Optimizer, Sensitivity)
20
+ SHOW_WIP_TABS = False
21
+
22
+ if SHOW_WIP_TABS:
23
+ from murphy_unified.tabs.optimizer import build_optimizer_tab
24
+ from murphy_unified.tabs.sensitivity import build_sensitivity_tab
25
+
26
+
27
+ def build_app() -> gr.Blocks:
28
+ with gr.Blocks(title="Murphy Unified") as app:
29
+ app.load(js=FORCE_DARK)
30
+
31
+ # Shared state: Simulation tab writes; Sweep tab reads (baseline panel).
32
+ sim_params_state = gr.State(dict(SIM_DEFAULTS))
33
+
34
+ # Cross-tab bridge: 2D sweep payload flows from Sweep β†’ Process Sim.
35
+ sweep_to_process_state = gr.State(None)
36
+
37
+ gr.HTML(
38
+ '<div style="display:flex;align-items:center;gap:12px;padding:8px 0 4px;">'
39
+ '<span style="font-family:var(--body);font-size:18px;font-weight:700;'
40
+ 'letter-spacing:3px;color:var(--accent);text-transform:uppercase;">'
41
+ 'Murphy Unified</span>'
42
+ '<span style="font-family:var(--mono);font-size:11px;color:var(--text-dim);">'
43
+ 'v0.2.0</span>'
44
+ '<span style="display:inline-flex;align-items:center;gap:6px;'
45
+ 'font-family:var(--mono);font-size:10px;color:var(--accent2);'
46
+ 'background:rgba(79,201,138,0.07);border:1px solid rgba(79,201,138,0.2);'
47
+ 'border-radius:20px;padding:3px 10px;margin-left:auto;">'
48
+ '<span style="width:7px;height:7px;border-radius:50%;background:var(--accent2);'
49
+ 'display:inline-block;"></span>READY</span>'
50
+ '</div>'
51
+ )
52
+
53
+ with gr.Tabs():
54
+ with gr.TabItem("Simulation"):
55
+ build_simulation_tab(sim_params_state)
56
+ with gr.TabItem("Sweep"):
57
+ sweep_handles = build_sweep_tab(sim_params_state)
58
+ with gr.TabItem("Sweep Across Parameters"):
59
+ build_sweep_params_tab()
60
+ with gr.TabItem("Process Sim"):
61
+ process_handles = build_process_sim_tab()
62
+ with gr.TabItem("Compare Engines"):
63
+ build_comparison_tab()
64
+ with gr.TabItem("Real Cycles"):
65
+ build_real_cycles_tab()
66
+ with gr.TabItem("Design Agent [WIP]"):
67
+ build_design_agent_tab()
68
+ if SHOW_WIP_TABS:
69
+ with gr.TabItem("Optimizer [WIP]"):
70
+ build_optimizer_tab()
71
+ with gr.TabItem("Sensitivity [WIP]"):
72
+ build_sensitivity_tab()
73
+
74
+ # ── Wire Sweep tab's Run button to produce the bridge payload ────
75
+ sweep_handles["run_btn"].click(
76
+ fn=sweep_handles["run_sweep"],
77
+ inputs=sweep_handles["run_inputs"],
78
+ outputs=[
79
+ sweep_handles["plot_out"],
80
+ sweep_handles["cache_html"],
81
+ sweep_handles["results_df"],
82
+ sweep_to_process_state,
83
+ sweep_handles["send_to_process_btn"],
84
+ ],
85
+ )
86
+
87
+ # ── Wire Send-to-Process-Sim button ──────────────────────────────
88
+ sweep_handles["send_to_process_btn"].click(
89
+ fn=process_handles["ingest_fn"],
90
+ inputs=[sweep_to_process_state],
91
+ outputs=[
92
+ process_handles["pump_lut_state"],
93
+ process_handles["pump_lut_status"],
94
+ process_handles["lut_mode"],
95
+ process_handles["sweep_provenance"],
96
+ ],
97
+ )
98
+
99
+ return app
100
+
101
+
102
+ def main():
103
+ app = build_app()
104
+ app.launch(server_port=7860, css=CSS, head=FONTS_HTML)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
murphy_unified/data/.gitkeep ADDED
File without changes
murphy_unified/engine_bridge.py CHANGED
@@ -17,16 +17,20 @@ from typing import Any, Dict, Tuple
17
 
18
  import numpy as np
19
 
20
- # ── Ensure engines/ is on sys.path ──────────────────────────────────────────
21
- # HF Space layout: engines/ sits next to murphy_unified/
22
- _ENGINES_DIR = os.path.normpath(
23
- os.path.join(os.path.dirname(__file__), os.pardir, "engines")
 
 
24
  )
25
- if _ENGINES_DIR not in sys.path:
26
- sys.path.insert(0, _ENGINES_DIR)
27
 
28
- # _ACCEL_DIR used by Numba warmup to locate h2_ps_lut.npz
29
- _ACCEL_DIR = _ENGINES_DIR
 
 
30
 
31
  # ── Euler engine (always available via cryosim) ──────────────────────────────
32
  from cryosim.engine.fast import ICV_open # noqa: E402
@@ -84,8 +88,9 @@ def _numba_warmup():
84
  """Background warmup for Numba JIT functions.
85
 
86
  Warms up both break_coolprop.h2_props (used by Lightspeed and DP45) and
87
- the DP45 compiled solver itself, so the first user-facing H2/general_ode
88
- click doesn't pay the ~30–60s JIT compilation cost.
 
89
  """
90
  global _warmup_ok
91
  try:
@@ -102,6 +107,8 @@ def _numba_warmup():
102
  finally:
103
  _warmup_event.set()
104
 
 
 
105
  if _HAS_DP45:
106
  try:
107
  print("[engine_bridge] DP45 warmup starting...")
 
17
 
18
  import numpy as np
19
 
20
+ # ── Ensure ode_reformulation is on sys.path ──────────────────────────────────
21
+ # __file__ = .../CSH2/murphy-unified/murphy_unified/engine_bridge.py
22
+ # Two parents up reaches CSH2/
23
+ _ODE_DIR = os.path.normpath(
24
+ os.path.join(os.path.dirname(__file__), os.pardir, os.pardir,
25
+ "cycle2mdot_acceleration", "ode_reformulation")
26
  )
27
+ if _ODE_DIR not in sys.path:
28
+ sys.path.insert(0, _ODE_DIR)
29
 
30
+ # Also add the parent of ode_reformulation so break_coolprop is importable
31
+ _ACCEL_DIR = os.path.dirname(_ODE_DIR)
32
+ if _ACCEL_DIR not in sys.path:
33
+ sys.path.insert(0, _ACCEL_DIR)
34
 
35
  # ── Euler engine (always available via cryosim) ──────────────────────────────
36
  from cryosim.engine.fast import ICV_open # noqa: E402
 
88
  """Background warmup for Numba JIT functions.
89
 
90
  Warms up both break_coolprop.h2_props (used by Lightspeed and DP45) and
91
+ the DP45 compiled solver itself (solve_cycle_compiled). The DP45 warmup
92
+ runs a tiny cycle so the first user-facing H2/general_ode click doesn't
93
+ pay the ~30–60s JIT compilation cost.
94
  """
95
  global _warmup_ok
96
  try:
 
107
  finally:
108
  _warmup_event.set()
109
 
110
+ # Pre-compile DP45 kernel (does not gate _warmup_ok β€” Lightspeed must
111
+ # still be declared ready even if DP45 warmup fails)
112
  if _HAS_DP45:
113
  try:
114
  print("[engine_bridge] DP45 warmup starting...")
murphy_unified/engine_params.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pack a flat Simulation-parameter dict into cycle_sim keyword arguments.
2
+
3
+ Shared by `run_simulation` (Tab 1) and `_sim_point` (Tab 2 sweep) so both
4
+ tabs always hand identical kwargs to `cycle_sim`. If either tab wants a
5
+ swept/override value, it merges `{key: value}` into the input dict BEFORE
6
+ calling this helper.
7
+
8
+ The array ordering here MUST match what `murphy_unified/engine_bridge.py`
9
+ and the underlying engines expect. See `tabs/simulation.py` (pre-refactor)
10
+ lines ~237-258 for the original packing.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+
16
+ def build_engine_kwargs(params: dict) -> dict:
17
+ """Convert a flat params dict into kwargs for ``cycle_sim``.
18
+
19
+ Parameters
20
+ ----------
21
+ params : dict
22
+ Flat dict keyed by `run_simulation` parameter names (see
23
+ `murphy_unified/sim_defaults.py::SIM_DEFAULTS`).
24
+
25
+ Returns
26
+ -------
27
+ dict with keys:
28
+ ``Pexit_barg``, ``speed_f``, ``Ptank_barg``, ``Psat_barg``,
29
+ ``ICVparam``, ``DCVparam``, ``pump_geom``, ``proc_param``,
30
+ ``flash_eff``, and optionally ``exit_param`` and ``n_cycles``.
31
+ """
32
+ ICVparam = [
33
+ params["icv_port"], params["icv_mass"], params["icv_travel"],
34
+ params["icv_dp_area"], params["icv_Fs"], params["icv_SC"],
35
+ params["icv_leakKv"], params["icv_comp_eff"], int(params["icv_npts"]),
36
+ ]
37
+ DCVparam = [
38
+ params["dcv_port"], params["dcv_mass"], params["dcv_travel"],
39
+ params["dcv_dp_area"], params["dcv_Fs"], params["dcv_SC"],
40
+ params["dcv_leakKv"], params["dcv_comp_eff"], int(params["dcv_npts"]),
41
+ ]
42
+ pump_geom = [
43
+ params["bore"], params["stroke"], params["hOD"], params["cLen"],
44
+ params["emH"], params["emS"], params["kvoid"], params["kH"],
45
+ params["Vfv"], params["vac"], params["cpm"], params["dvf"],
46
+ ]
47
+ proc_param = [
48
+ params["Tamb"], params["htc"], params["drive"], params["Fmult"],
49
+ params["KvBB"], params["Pbb"], params["fric"], params["Exp"],
50
+ ]
51
+
52
+ kw: dict = {
53
+ "Pexit_barg": params["Pexit"],
54
+ "speed_f": params["speed"],
55
+ "Ptank_barg": params["Ptank"],
56
+ "Psat_barg": params["Psat"],
57
+ "ICVparam": ICVparam,
58
+ "DCVparam": DCVparam,
59
+ "pump_geom": pump_geom,
60
+ "proc_param": proc_param,
61
+ "flash_eff": params["flash_eff"],
62
+ }
63
+
64
+ fill = params.get("fill_type", "Fixed Pexit")
65
+ if fill == "CHSS Fill":
66
+ kw["exit_param"] = [1, params["vsnubber"], params["vchss"],
67
+ params["aov140f"], params["rodia"]]
68
+ elif fill == "RO Vent":
69
+ kw["exit_param"] = [0, params["vsnubber"], 0,
70
+ params["aov140f"], params["rodia"]]
71
+
72
+ n_cyc = int(params.get("num_cycles", 1) or 1)
73
+ if n_cyc > 1:
74
+ kw["n_cycles"] = n_cyc
75
+
76
+ return kw
murphy_unified/params_io.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CSV round-trip for Simulation-tab run parameters.
2
+
3
+ Format
4
+ ------
5
+ Two-column CSV (``key,value``) written with ``csv.writer`` (QUOTE_MINIMAL).
6
+ One row per entry in :data:`murphy_unified.sim_defaults.SIM_DEFAULTS`.
7
+
8
+ Encoding rules (documented in
9
+ ``docs/superpowers/specs/2026-04-14-sweep-sim-sync-and-export.md``):
10
+ * floats/ints β†’ ``repr()`` then ``float()`` on read (bit-exact)
11
+ * strings β†’ as-is; csv.writer quotes automatically when needed
12
+ * lists[str] β†’ ``|``-joined (e.g. ``Energy Balance|Chamber Density``)
13
+ * empty numeric β†’ fall back to ``SIM_DEFAULTS[key]`` on read
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import csv
19
+ import io
20
+
21
+ from murphy_unified.sim_defaults import SIM_DEFAULTS
22
+
23
+
24
+ _LIST_KEYS = {"extra_plots"}
25
+ _STRING_KEYS = {"engine_name", "fluid", "fill_type"}
26
+
27
+
28
+ def params_to_csv(params: dict) -> str:
29
+ """Serialize a params dict to CSV text."""
30
+ buf = io.StringIO()
31
+ writer = csv.writer(buf)
32
+ writer.writerow(["key", "value"])
33
+ for key in SIM_DEFAULTS:
34
+ if key not in params:
35
+ continue
36
+ val = params[key]
37
+ if key in _LIST_KEYS:
38
+ encoded = "|".join(val) if val else ""
39
+ elif key in _STRING_KEYS:
40
+ encoded = str(val)
41
+ else:
42
+ encoded = repr(val)
43
+ writer.writerow([key, encoded])
44
+ return buf.getvalue()
45
+
46
+
47
+ def csv_to_params(csv_text: str) -> tuple[dict, list[str]]:
48
+ """Parse CSV text into a params dict.
49
+
50
+ Returns
51
+ -------
52
+ (params, skipped_keys)
53
+ ``params`` contains successfully-parsed entries; ``skipped_keys``
54
+ lists unknown keys so the UI can surface them.
55
+ """
56
+ reader = csv.reader(io.StringIO(csv_text))
57
+ rows = list(reader)
58
+ if not rows:
59
+ return {}, []
60
+ if rows[0] == ["key", "value"]:
61
+ rows = rows[1:]
62
+
63
+ parsed: dict = {}
64
+ skipped: list[str] = []
65
+ for row in rows:
66
+ if len(row) != 2:
67
+ continue
68
+ key, value = row
69
+ if key not in SIM_DEFAULTS:
70
+ skipped.append(key)
71
+ continue
72
+ if key in _LIST_KEYS:
73
+ parsed[key] = value.split("|") if value else []
74
+ elif key in _STRING_KEYS:
75
+ parsed[key] = value
76
+ else:
77
+ if value == "":
78
+ parsed[key] = SIM_DEFAULTS[key]
79
+ else:
80
+ parsed[key] = float(value)
81
+ return parsed, skipped
murphy_unified/process_sim/pump_lut.py CHANGED
@@ -138,6 +138,85 @@ class PumpLUT:
138
 
139
  return cls(speeds, pressures, mdot_grid, temp_grid)
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  def lookup(self, speed_pct: float, P_back_bar: float) -> tuple[float, float]:
142
  """Return (mdot_kg_s, T_discharge_K) at given speed and backpressure."""
143
  s = float(np.clip(speed_pct, self.speeds[0], self.speeds[-1]))
 
138
 
139
  return cls(speeds, pressures, mdot_grid, temp_grid)
140
 
141
+ @classmethod
142
+ def from_sweep_grid(
143
+ cls,
144
+ x_vals: "np.ndarray",
145
+ y_vals: "np.ndarray",
146
+ z_mdot_kgpm: "np.ndarray",
147
+ z_temp_K: "np.ndarray",
148
+ axis_x: str,
149
+ axis_y: str,
150
+ ) -> "PumpLUT":
151
+ """Build a PumpLUT from a 2D sweep payload on (Pexit_barg, speed_f).
152
+
153
+ Handles unit conversions and axis normalization.
154
+
155
+ Parameters
156
+ ----------
157
+ x_vals, y_vals : np.ndarray
158
+ 1D arrays of the sweep's X and Y axis values (in sweep-native units).
159
+ z_mdot_kgpm, z_temp_K : np.ndarray
160
+ 2D grids of shape ``(len(y_vals), len(x_vals))`` β€” mass flow in
161
+ kg/min and peak chamber temperature in Kelvin.
162
+ axis_x, axis_y : str
163
+ Axis names; must be exactly the set {"Pexit_barg", "speed_f"}.
164
+
165
+ Returns
166
+ -------
167
+ PumpLUT
168
+ LUT with ``speeds`` as axis 0 (motor_pct ∈ [0, 100]) and
169
+ ``pressures`` as axis 1 (absolute bar).
170
+
171
+ Notes
172
+ -----
173
+ Conversions:
174
+ * speed_f [0, 1] β†’ motor_pct [0, 100] (Γ— 100)
175
+ * Pexit_barg β†’ back_pressure_bar (+ 1.01325)
176
+ * mdot_kgpm β†’ mdot_kgs (Γ· 60)
177
+ * Tc_peak_K β†’ T_discharge_K (identity)
178
+ """
179
+ valid_pair = {"Pexit_barg", "speed_f"}
180
+ if {axis_x, axis_y} != valid_pair:
181
+ raise ValueError(
182
+ f"from_sweep_grid requires axes {valid_pair}; got "
183
+ f"{{'{axis_x}', '{axis_y}'}}"
184
+ )
185
+
186
+ import numpy as _np
187
+
188
+ # Normalize so `speed_vals` is along axis 0 and `pexit_vals` is axis 1.
189
+ # Convention: z arrays have shape (len(y_vals), len(x_vals)).
190
+ if axis_x == "speed_f":
191
+ # x=speed_f, y=Pexit_barg β†’ z shape is (Npexit, Nspeed)
192
+ # Transpose to (Nspeed, Npexit) for PumpLUT orientation.
193
+ speed_vals = _np.asarray(x_vals, dtype=float)
194
+ pexit_vals = _np.asarray(y_vals, dtype=float)
195
+ mdot_sp = _np.asarray(z_mdot_kgpm, dtype=float).T # (speeds, pressures)
196
+ temp_sp = _np.asarray(z_temp_K, dtype=float).T
197
+ else:
198
+ # axis_x == "Pexit_barg", axis_y == "speed_f"
199
+ # x=Pexit_barg, y=speed_f β†’ z shape is (Nspeed, Npexit) β€” correct.
200
+ speed_vals = _np.asarray(y_vals, dtype=float)
201
+ pexit_vals = _np.asarray(x_vals, dtype=float)
202
+ mdot_sp = _np.asarray(z_mdot_kgpm, dtype=float)
203
+ temp_sp = _np.asarray(z_temp_K, dtype=float)
204
+
205
+ speeds = speed_vals * 100.0
206
+ pressures = pexit_vals + 1.01325
207
+ mdot_grid = mdot_sp / 60.0 # kg/min β†’ kg/s
208
+ temp_grid = temp_sp # identity
209
+
210
+ # Sort both axes ascending (PumpLUT/RegularGridInterpolator requires this).
211
+ s_order = _np.argsort(speeds)
212
+ p_order = _np.argsort(pressures)
213
+ speeds = speeds[s_order]
214
+ pressures = pressures[p_order]
215
+ mdot_grid = mdot_grid[s_order][:, p_order]
216
+ temp_grid = temp_grid[s_order][:, p_order]
217
+
218
+ return cls(speeds, pressures, mdot_grid, temp_grid)
219
+
220
  def lookup(self, speed_pct: float, P_back_bar: float) -> tuple[float, float]:
221
  """Return (mdot_kg_s, T_discharge_K) at given speed and backpressure."""
222
  s = float(np.clip(speed_pct, self.speeds[0], self.speeds[-1]))
murphy_unified/sim_defaults.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single canonical source of Simulation-tab parameter defaults.
2
+
3
+ All Simulation-tab widgets, the initial `sim_params_state`, and the CSV I/O
4
+ key set derive from this dict. Keeping one source prevents drift between
5
+ Simulation widget defaults, Sweep-tab defaults, and engine defaults.
6
+
7
+ Keys match the `run_simulation` signature (murphy_unified/tabs/simulation.py).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ SIM_DEFAULTS: dict = {
13
+ # ── Operating ─────────────────────────────────────────────────────
14
+ "engine_name": "Lightspeed",
15
+ "fluid": "H2",
16
+ "Pexit": 350.0,
17
+ "speed": 0.65,
18
+ "Ptank": 7.0,
19
+ "Psat": 2.0,
20
+
21
+ # ── ICV (9) ───────────────────────────────────────────────────────
22
+ "icv_port": 20.5,
23
+ "icv_mass": 48.0,
24
+ "icv_travel": 5.0,
25
+ "icv_dp_area": 779.3,
26
+ "icv_Fs": 33.4,
27
+ "icv_SC": 3.75,
28
+ "icv_leakKv": 7.36e-5,
29
+ "icv_comp_eff": 1.0,
30
+ "icv_npts": 100,
31
+
32
+ # ── DCV (9) ───────────────────────────────────────────────────────
33
+ "dcv_port": 8.3,
34
+ "dcv_mass": 25.0,
35
+ "dcv_travel": 3.79,
36
+ "dcv_dp_area": 78.54,
37
+ "dcv_Fs": 7.8,
38
+ "dcv_SC": 1.053,
39
+ "dcv_leakKv": 2.6e-6,
40
+ "dcv_comp_eff": 0.8,
41
+ "dcv_npts": 200,
42
+
43
+ # ── Pump geometry (12) ────────────────────────────────────────────
44
+ "bore": 40.3,
45
+ "stroke": 60.0,
46
+ "hOD": 120.0,
47
+ "cLen": 300.0,
48
+ "emH": 0.8,
49
+ "emS": 0.8,
50
+ "kvoid": 0.026,
51
+ "kH": 15.0,
52
+ "Vfv": 37.0,
53
+ "vac": 760000.0,
54
+ "cpm": 500.0,
55
+ "dvf": 0.01,
56
+
57
+ # ── Process / losses (9) ──────────────────────────────────────────
58
+ "Tamb": 300.0,
59
+ "htc": 10.0,
60
+ "drive": 4.0,
61
+ "Fmult": 30.0,
62
+ "KvBB": 0.006,
63
+ "Pbb": 0.0,
64
+ "fric": 0.5,
65
+ "Exp": 0.2,
66
+ "flash_eff": 0.02,
67
+
68
+ # ── Downstream / fill (6) ─────────────────────────────────────────
69
+ "fill_type": "Fixed Pexit",
70
+ "num_cycles": 1,
71
+ "vsnubber": 5.0,
72
+ "vchss": 50.0,
73
+ "aov140f": 1.0,
74
+ "rodia": 0.94,
75
+
76
+ # ── Output selector ───────────────────────────────────────────────
77
+ "extra_plots": ["Energy Balance"],
78
+ }
murphy_unified/sweep_cache.py CHANGED
@@ -8,15 +8,26 @@ import sqlite3
8
  from typing import Any, Dict, Optional, Tuple
9
 
10
 
 
 
 
11
  class SweepCache:
12
  """Stores simulation scalar results in a local SQLite database.
13
 
14
  Keys are derived from a SHA256 hash of the canonicalised parameter dict,
15
  so identical runs are served from cache instead of re-simulated.
 
 
 
 
16
  """
17
 
18
  def __init__(self, db_path: str = "sweep_cache.db") -> None:
19
- self._conn = sqlite3.connect(db_path)
 
 
 
 
20
  self._conn.execute("PRAGMA journal_mode=WAL")
21
  self._conn.execute(
22
  "CREATE TABLE IF NOT EXISTS cache ("
@@ -26,10 +37,19 @@ class SweepCache:
26
  " ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
27
  ")"
28
  )
 
29
  self._conn.commit()
30
  self._session_hits = 0
31
  self._session_misses = 0
32
 
 
 
 
 
 
 
 
 
33
  # ------------------------------------------------------------------
34
  # Internal helpers
35
  # ------------------------------------------------------------------
 
8
  from typing import Any, Dict, Optional, Tuple
9
 
10
 
11
+ SCHEMA_VERSION = 3
12
+
13
+
14
  class SweepCache:
15
  """Stores simulation scalar results in a local SQLite database.
16
 
17
  Keys are derived from a SHA256 hash of the canonicalised parameter dict,
18
  so identical runs are served from cache instead of re-simulated.
19
+
20
+ Schema is versioned via ``PRAGMA user_version``. On open, if the stored
21
+ version differs from ``SCHEMA_VERSION``, all rows are dropped and the
22
+ version is written.
23
  """
24
 
25
  def __init__(self, db_path: str = "sweep_cache.db") -> None:
26
+ # check_same_thread=False: Gradio serves requests from a worker pool,
27
+ # so the same SweepCache instance is used across threads. Access is
28
+ # serialized at the Python level by SQLite's own locking, so cross-
29
+ # thread use is safe in practice.
30
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
31
  self._conn.execute("PRAGMA journal_mode=WAL")
32
  self._conn.execute(
33
  "CREATE TABLE IF NOT EXISTS cache ("
 
37
  " ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
38
  ")"
39
  )
40
+ self._migrate_if_needed()
41
  self._conn.commit()
42
  self._session_hits = 0
43
  self._session_misses = 0
44
 
45
+ def _migrate_if_needed(self) -> None:
46
+ """If stored schema version != SCHEMA_VERSION, drop all rows."""
47
+ cur = self._conn.execute("PRAGMA user_version")
48
+ current = cur.fetchone()[0]
49
+ if current != SCHEMA_VERSION:
50
+ self._conn.execute("DELETE FROM cache")
51
+ self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
52
+
53
  # ------------------------------------------------------------------
54
  # Internal helpers
55
  # ------------------------------------------------------------------
murphy_unified/tabs/design_agent.py CHANGED
@@ -103,7 +103,7 @@ def _end_session():
103
  def _load_session_history():
104
  records = list_design_records(DESIGN_RECORDS_DIR)
105
  choices = [r["slug"] for r in records]
106
- return gr.Dropdown(choices=choices, value=choices[0] if choices else None)
107
 
108
 
109
  def _load_record(slug):
 
103
  def _load_session_history():
104
  records = list_design_records(DESIGN_RECORDS_DIR)
105
  choices = [r["slug"] for r in records]
106
+ return gr.update(choices=choices, value=choices[0] if choices else None)
107
 
108
 
109
  def _load_record(slug):
murphy_unified/tabs/optimizer.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tab 3 -- Constraint-aware hardware optimizer via cryosim.
2
+
3
+ Builds all Gradio components (left control panel + right results panel)
4
+ and wires up the click handler. Must be called inside an active
5
+ ``gr.TabItem`` context.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import traceback
11
+
12
+ import gradio as gr
13
+ import pandas as pd
14
+
15
+ import cryosim
16
+ from cryosim.calibration.params import PARAM_NAMES, get_nominal_values
17
+ from murphy_unified.theme import metric_html, TRACE_COLORS, ACCENT, TEXT_DIM, engine_banner
18
+
19
+
20
+ # -- Click handler -----------------------------------------------------------
21
+
22
+ def run_optimize(
23
+ hardware: str,
24
+ target: str,
25
+ Pexit: float,
26
+ speed: float,
27
+ maxiter: float,
28
+ c_metric: str,
29
+ c_op: str,
30
+ c_val: float,
31
+ ):
32
+ """Run constrained optimisation and return (metrics_html, table_df)."""
33
+
34
+ try:
35
+ # Build constraints dict
36
+ constraints: dict[str, str] = {}
37
+ if c_metric:
38
+ constraints[c_metric] = f"{c_op}{c_val}"
39
+
40
+ # Baseline prediction
41
+ baseline = cryosim.predict(hardware=hardware, Pexit=Pexit, speed=speed)
42
+
43
+ # Optimise
44
+ opt = cryosim.optimize(
45
+ hardware=hardware,
46
+ target=target,
47
+ speed=speed,
48
+ Pexit=Pexit,
49
+ constraints=constraints,
50
+ maxiter=int(maxiter),
51
+ )
52
+
53
+ # Parameter comparison table
54
+ nominal = get_nominal_values(hardware)
55
+ rows = []
56
+ for name, nom, opt_val in zip(PARAM_NAMES, nominal, opt.optimal_values):
57
+ pct = (opt_val / nom - 1) * 100 if abs(nom) > 1e-12 else 0
58
+ rows.append({
59
+ "Parameter": name,
60
+ "Nominal": f"{nom:.6f}",
61
+ "Optimized": f"{opt_val:.6f}",
62
+ "Change %": f"{pct:+.1f}%",
63
+ })
64
+ table_df = pd.DataFrame(rows)
65
+
66
+ # Metric cards
67
+ status_accent = "green" if opt.constraints_satisfied else "red"
68
+ baseline_val = getattr(baseline, "mdot_kgpm", 0) if "mdot" in target else 0
69
+ opt_val_scalar = opt.target_value
70
+ improvement = (
71
+ ((opt_val_scalar / baseline_val - 1) * 100)
72
+ if baseline_val != 0
73
+ else 0
74
+ )
75
+
76
+ metrics = (
77
+ '<div class="metric-row">'
78
+ + metric_html("BASELINE", f"{baseline_val:.4f}")
79
+ + metric_html("OPTIMIZED", f"{opt_val_scalar:.4f}", "", "green")
80
+ + metric_html(
81
+ "IMPROVEMENT",
82
+ f"{improvement:+.1f}",
83
+ "%",
84
+ "green" if improvement > 0 else "amber",
85
+ )
86
+ + metric_html(
87
+ "CONSTRAINTS",
88
+ "OK" if opt.constraints_satisfied else "VIOLATED",
89
+ "",
90
+ status_accent,
91
+ )
92
+ + "</div>"
93
+ + '<div class="metric-row">'
94
+ + metric_html("EVALUATIONS", f"{opt.n_evals}")
95
+ + metric_html("CONVERGED", "YES" if opt.converged else "NO")
96
+ + "</div>"
97
+ )
98
+
99
+ return metrics, table_df
100
+
101
+ except Exception:
102
+ err = traceback.format_exc()
103
+ err_html = (
104
+ f'<div style="color:#c94a4a;font-family:JetBrains Mono,monospace;'
105
+ f'font-size:12px;white-space:pre-wrap;">'
106
+ f"Optimizer failed:\n{err}</div>"
107
+ )
108
+ return err_html, pd.DataFrame()
109
+
110
+
111
+ # -- Builder -----------------------------------------------------------------
112
+
113
+ def build_optimizer_tab():
114
+ """Create all Gradio components for the Optimizer tab and wire events.
115
+
116
+ Must be called inside an active ``gr.TabItem(...)`` context manager.
117
+ """
118
+
119
+ with gr.Row():
120
+ # -- Left panel -- controls -------------------------------------------
121
+ with gr.Column(scale=1, min_width=280):
122
+ gr.HTML(engine_banner("ENGINE: EULER (optimizer default)"))
123
+
124
+ hw_dd = gr.Dropdown(
125
+ choices=["old_icv", "new_icv"],
126
+ value="old_icv",
127
+ label="Hardware Config",
128
+ )
129
+ tgt_dd = gr.Dropdown(
130
+ choices=["max_mdot", "max_mass_eff", "min_kWh", "min_Tc_peak"],
131
+ value="max_mdot",
132
+ label="Objective",
133
+ )
134
+ pexit_sl = gr.Slider(
135
+ minimum=50, maximum=900, value=500, step=10,
136
+ label="Exit Pressure [barg]",
137
+ )
138
+ speed_sl = gr.Slider(
139
+ minimum=0.1, maximum=1.0, value=0.65, step=0.05,
140
+ label="Speed Fraction",
141
+ )
142
+ maxiter_sl = gr.Slider(
143
+ minimum=3, maximum=50, value=10, step=1,
144
+ label="Max Iterations",
145
+ )
146
+
147
+ gr.Markdown("**Constraint (optional):**")
148
+
149
+ c_metric_dd = gr.Dropdown(
150
+ choices=["", "Tc_peak_K", "mass_eff", "kWh_extend", "pc_peak_barg"],
151
+ value="",
152
+ label="Metric",
153
+ )
154
+ c_op_dd = gr.Dropdown(
155
+ choices=["<", ">", "<=", ">="],
156
+ value="<",
157
+ label="Operator",
158
+ )
159
+ c_val_num = gr.Number(value=200, label="Threshold")
160
+
161
+ run_btn = gr.Button("OPTIMIZE", variant="primary")
162
+
163
+ # -- Right panel -- results -------------------------------------------
164
+ with gr.Column(scale=3):
165
+ metrics_out = gr.HTML(label="Metrics")
166
+ table_out = gr.Dataframe(label="Parameter Changes")
167
+
168
+ # -- Wire click event -----------------------------------------------------
169
+ run_btn.click(
170
+ fn=run_optimize,
171
+ inputs=[
172
+ hw_dd, tgt_dd, pexit_sl, speed_sl, maxiter_sl,
173
+ c_metric_dd, c_op_dd, c_val_num,
174
+ ],
175
+ outputs=[metrics_out, table_out],
176
+ )
murphy_unified/tabs/process_sim.py CHANGED
@@ -28,6 +28,7 @@ from murphy_unified.process_sim.pump_lut import (
28
  _load_csv,
29
  _find_col,
30
  DATA_DIR,
 
31
  )
32
  from murphy_unified.theme import (
33
  ACCENT,
@@ -485,10 +486,75 @@ def _run_generate_lut(engine, spd_min, spd_max, spd_step, prs_min, prs_max,
485
  return lut, lut.summary()
486
 
487
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  # ── Tab builder ──────────────────────────────────────────────────────────────
489
 
490
  def build_process_sim_tab():
491
- """Build the Process Sim tab. Must be called inside a gr.TabItem context."""
 
 
 
 
 
 
 
 
 
 
 
492
 
493
  pump_lut_state = gr.State(None)
494
  results_state = gr.State(None)
@@ -500,7 +566,7 @@ def build_process_sim_tab():
500
  with gr.Column(scale=1):
501
  with gr.Accordion("LUT Source", open=True):
502
  lut_mode = gr.Radio(
503
- choices=["Upload CSV", "Auto-Generate from Engine"],
504
  value="Upload CSV",
505
  label="Pump LUT Source",
506
  )
@@ -536,6 +602,15 @@ def build_process_sim_tab():
536
  prs_max = gr.Number(label="Pressure max (bar)", value=900)
537
  prs_step = gr.Number(label="Pressure step (bar)", value=50)
538
 
 
 
 
 
 
 
 
 
 
539
  sim_mode_radio = gr.Radio(
540
  choices=SIM_MODES,
541
  value=SIM_MODE_VALVE,
@@ -613,12 +688,13 @@ def build_process_sim_tab():
613
  return (
614
  gr.update(visible=(mode == "Upload CSV")),
615
  gr.update(visible=(mode == "Auto-Generate from Engine")),
 
616
  )
617
 
618
  lut_mode.change(
619
  fn=_toggle_lut_mode,
620
  inputs=[lut_mode],
621
- outputs=[csv_group, gen_group],
622
  api_name=False,
623
  )
624
 
@@ -686,3 +762,11 @@ def build_process_sim_tab():
686
  auto_btn.click(fn=_auto_analyze, inputs=auto_inputs, outputs=[chatbot, chat_input], api_name=False)
687
 
688
  clear_chat_btn.click(fn=lambda: ([], ""), inputs=[], outputs=[chatbot, chat_input], api_name=False)
 
 
 
 
 
 
 
 
 
28
  _load_csv,
29
  _find_col,
30
  DATA_DIR,
31
+ GENERATED_DIR,
32
  )
33
  from murphy_unified.theme import (
34
  ACCENT,
 
486
  return lut, lut.summary()
487
 
488
 
489
+ # ── Sweep payload ingestion ─────────────────────────────────────────────────
490
+
491
+ def _ingest_sweep_payload(payload):
492
+ """Build a PumpLUT from a sweep bridge payload; save CSV; return UI updates.
493
+
494
+ Returns
495
+ -------
496
+ (PumpLUT, status_str, lut_mode_update, provenance_str)
497
+ """
498
+ if payload is None:
499
+ raise gr.Error(
500
+ "No compatible sweep data β€” run a 2D sweep on "
501
+ "Pexit_barg vs speed_f first."
502
+ )
503
+
504
+ lut = PumpLUT.from_sweep_grid(
505
+ x_vals=payload["x_vals"],
506
+ y_vals=payload["y_vals"],
507
+ z_mdot_kgpm=payload["z_mdot_kgpm"],
508
+ z_temp_K=payload["z_temp_K"],
509
+ axis_x=payload["axis_x"],
510
+ axis_y=payload["axis_y"],
511
+ )
512
+
513
+ # Save CSV to data/generated/ with a unique timestamp.
514
+ import datetime as _dt
515
+ now = _dt.datetime.now()
516
+ ts = now.strftime("%Y-%m-%d_%H%M%S")
517
+ hw = payload.get("hw", "unknown")
518
+ GENERATED_DIR.mkdir(parents=True, exist_ok=True)
519
+ csv_path = GENERATED_DIR / f"lut_{ts}_sweep_{hw}.csv"
520
+ lut.save(str(csv_path))
521
+
522
+ # Status + provenance strings.
523
+ n_speeds = len(lut.speeds)
524
+ n_press = len(lut.pressures)
525
+ status = (
526
+ f"Loaded from sweep ({n_speeds}x{n_press}, hw={hw}, "
527
+ f"{payload.get('timestamp', '')})\n"
528
+ f"{lut.summary()}\n"
529
+ f"Saved: {csv_path.name}"
530
+ )
531
+ provenance = (
532
+ f"Source: 2D sweep (axes: Pexit_barg x speed_f)\n"
533
+ f"Grid: {n_speeds} speeds x {n_press} pressures\n"
534
+ f"Hardware: {hw}\n"
535
+ f"Timestamp: {payload.get('timestamp', '')}\n"
536
+ f"Saved to: {csv_path.name}"
537
+ )
538
+ mode_update = gr.update(value="From Sweep")
539
+
540
+ return lut, status, mode_update, provenance
541
+
542
+
543
  # ── Tab builder ──────────────────────────────────────────────────────────────
544
 
545
  def build_process_sim_tab():
546
+ """Build the Process Sim tab. Must be called inside a gr.TabItem context.
547
+
548
+ Returns
549
+ -------
550
+ dict
551
+ Handles used by app.py to wire cross-tab events:
552
+ * ``pump_lut_state``
553
+ * ``pump_lut_status``
554
+ * ``lut_mode``
555
+ * ``sweep_provenance``
556
+ * ``ingest_fn`` (callable: bridge payload β†’ (lut, status, mode_update, provenance))
557
+ """
558
 
559
  pump_lut_state = gr.State(None)
560
  results_state = gr.State(None)
 
566
  with gr.Column(scale=1):
567
  with gr.Accordion("LUT Source", open=True):
568
  lut_mode = gr.Radio(
569
+ choices=["Upload CSV", "Auto-Generate from Engine", "From Sweep"],
570
  value="Upload CSV",
571
  label="Pump LUT Source",
572
  )
 
602
  prs_max = gr.Number(label="Pressure max (bar)", value=900)
603
  prs_step = gr.Number(label="Pressure step (bar)", value=50)
604
 
605
+ # From-sweep mode (read-only provenance panel)
606
+ with gr.Group(visible=False) as sweep_group:
607
+ sweep_provenance = gr.Textbox(
608
+ label="Sweep provenance",
609
+ value="No sweep data ingested yet. Run a 2D sweep on "
610
+ "(Pexit_barg, speed_f) and click 'Send to Process Sim'.",
611
+ interactive=False, lines=5,
612
+ )
613
+
614
  sim_mode_radio = gr.Radio(
615
  choices=SIM_MODES,
616
  value=SIM_MODE_VALVE,
 
688
  return (
689
  gr.update(visible=(mode == "Upload CSV")),
690
  gr.update(visible=(mode == "Auto-Generate from Engine")),
691
+ gr.update(visible=(mode == "From Sweep")),
692
  )
693
 
694
  lut_mode.change(
695
  fn=_toggle_lut_mode,
696
  inputs=[lut_mode],
697
+ outputs=[csv_group, gen_group, sweep_group],
698
  api_name=False,
699
  )
700
 
 
762
  auto_btn.click(fn=_auto_analyze, inputs=auto_inputs, outputs=[chatbot, chat_input], api_name=False)
763
 
764
  clear_chat_btn.click(fn=lambda: ([], ""), inputs=[], outputs=[chatbot, chat_input], api_name=False)
765
+
766
+ return {
767
+ "pump_lut_state": pump_lut_state,
768
+ "pump_lut_status": pump_lut_status,
769
+ "lut_mode": lut_mode,
770
+ "sweep_provenance": sweep_provenance,
771
+ "ingest_fn": _ingest_sweep_payload,
772
+ }
murphy_unified/tabs/sensitivity.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tab 4 -- Parameter sensitivity tornado chart via cryosim.
2
+
3
+ Builds all Gradio components (left control panel + right results panel)
4
+ and wires up the click handler. Must be called inside an active
5
+ ``gr.TabItem`` context.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import traceback
11
+
12
+ import gradio as gr
13
+ import plotly.graph_objects as go
14
+
15
+ import cryosim
16
+ from murphy_unified.theme import metric_html, TRACE_COLORS, ACCENT, TEXT_DIM, FONT_MONO, engine_banner
17
+
18
+
19
+ # -- Click handler -----------------------------------------------------------
20
+
21
+ def run_sensitivity(hardware: str, Pexit: float, speed: float):
22
+ """Run sensitivity analysis and return (metrics_html, tornado_fig)."""
23
+
24
+ try:
25
+ s = cryosim.sensitivity(hardware=hardware, speed=speed, Pexit=Pexit)
26
+
27
+ ranked = s.rankings["mdot_kgpm"]
28
+ params = [p for p, _ in ranked]
29
+ vals = [v for _, v in ranked]
30
+
31
+ colors = [TRACE_COLORS[1] if v >= 0 else TRACE_COLORS[3] for v in vals]
32
+
33
+ fig = go.Figure(go.Bar(
34
+ y=params,
35
+ x=vals,
36
+ orientation="h",
37
+ marker_color=colors,
38
+ text=[f"{v:+.3f}" for v in vals],
39
+ textposition="outside",
40
+ textfont=dict(family=FONT_MONO, size=11),
41
+ ))
42
+ fig.update_layout(
43
+ title=f"Sensitivity: mdot @ {Pexit:.0f} barg (% metric / % param)",
44
+ xaxis_title="Normalized Sensitivity",
45
+ height=350,
46
+ yaxis=dict(tickfont=dict(family=FONT_MONO, size=11)),
47
+ margin=dict(l=120),
48
+ )
49
+
50
+ top = ranked[0]
51
+ metrics = (
52
+ '<div class="metric-row">'
53
+ + metric_html("TOP PARAMETER", top[0], "", "amber")
54
+ + metric_html("SENSITIVITY", f"{top[1]:+.3f}", "norm")
55
+ + metric_html("METRIC", "mdot_kgpm")
56
+ + "</div>"
57
+ )
58
+
59
+ return metrics, fig
60
+
61
+ except Exception:
62
+ err = traceback.format_exc()
63
+ err_html = (
64
+ f'<div style="color:#c94a4a;font-family:JetBrains Mono,monospace;'
65
+ f'font-size:12px;white-space:pre-wrap;">'
66
+ f"Sensitivity analysis failed:\n{err}</div>"
67
+ )
68
+ empty_fig = go.Figure()
69
+ empty_fig.update_layout(height=350)
70
+ return err_html, empty_fig
71
+
72
+
73
+ # -- Builder -----------------------------------------------------------------
74
+
75
+ def build_sensitivity_tab():
76
+ """Create all Gradio components for the Sensitivity tab and wire events.
77
+
78
+ Must be called inside an active ``gr.TabItem(...)`` context manager.
79
+ """
80
+
81
+ with gr.Row():
82
+ # -- Left panel -- controls -------------------------------------------
83
+ with gr.Column(scale=1, min_width=280):
84
+ gr.HTML(engine_banner("ENGINE: EULER"))
85
+
86
+ hw_dd = gr.Dropdown(
87
+ choices=["old_icv", "new_icv"],
88
+ value="old_icv",
89
+ label="Hardware Config",
90
+ )
91
+ pexit_sl = gr.Slider(
92
+ minimum=50, maximum=900, value=500, step=10,
93
+ label="Exit Pressure [barg]",
94
+ )
95
+ speed_sl = gr.Slider(
96
+ minimum=0.1, maximum=1.0, value=0.65, step=0.05,
97
+ label="Speed Fraction",
98
+ )
99
+
100
+ run_btn = gr.Button("ANALYZE", variant="primary")
101
+
102
+ # -- Right panel -- results -------------------------------------------
103
+ with gr.Column(scale=3):
104
+ metrics_out = gr.HTML(label="Metrics")
105
+ plot_out = gr.Plot(label="Tornado Chart")
106
+
107
+ # -- Wire click event -----------------------------------------------------
108
+ run_btn.click(
109
+ fn=run_sensitivity,
110
+ inputs=[hw_dd, pexit_sl, speed_sl],
111
+ outputs=[metrics_out, plot_out],
112
+ )
murphy_unified/tabs/simulation.py CHANGED
@@ -15,7 +15,10 @@ Parameter surface mirrors the original DR MURPHY dashboard:
15
 
16
  from __future__ import annotations
17
 
 
 
18
  import traceback
 
19
 
20
  import gradio as gr
21
  import numpy as np
@@ -23,6 +26,9 @@ import plotly.graph_objects as go
23
 
24
  from murphy_unified.engine_bridge import cycle_sim, ENGINES
25
  from murphy_unified.diagnostics import evaluate
 
 
 
26
  from murphy_unified.theme import (
27
  metric_html, TRACE_COLORS, TEXT_DIM, TEXT_BRIGHT, ACCENT, ACCENT2, WARN, DANGER,
28
  FONT_MONO, VALVE_ICV, VALVE_DCV, ENERGY_POS, ENERGY_NEG, TEXT,
@@ -38,25 +44,6 @@ _ENGINE_MAP = {
38
  _FLUID_MAP = {"H2": "h2", "N2": "n2"}
39
 
40
 
41
- # ── MVP 1.0 Simplex defaults (ported from scenario_gui.py) ──────────────────
42
- # ICV: [port, mass, travel, dp_area, Fs, SC, leakKv, comp_eff, npts]
43
- D_ICV = dict(port=20.5, mass=48.0, travel=5.0, dp_area=779.3,
44
- Fs=33.4, SC=3.75, leakKv=0.0000736, comp_eff=1.0, npts=100)
45
-
46
- # DCV: same order
47
- D_DCV = dict(port=8.3, mass=25.0, travel=3.79, dp_area=78.54,
48
- Fs=7.8, SC=1.053, leakKv=0.0000026, comp_eff=0.8, npts=200)
49
-
50
- # Pump: [bore, stroke, hOD, cLen, emH, emS, kvoid, kH, Vfv, vac, cpm, dvf]
51
- D_PUMP = dict(bore=40.3, stroke=60.0, hOD=120.0, cLen=300.0,
52
- emH=0.8, emS=0.8, kvoid=0.026, kH=15.0,
53
- Vfv=37.0, vac=760000.0, cpm=500.0, dvf=0.01)
54
-
55
- # Proc: [Tamb, htc_amb, net_drive, F_mult, Kv_BB, Pbb_exit, fric2chamber, Exp_eff]
56
- D_PROC = dict(Tamb=300.0, htc=10.0, drive=4.0, Fmult=30.0,
57
- KvBB=0.006, Pbb=0.0, fric=0.5, Exp=0.2)
58
-
59
-
60
  # ── Extra plot definitions ──────────────────────────────────────────────────
61
  # Each entry: (hist_key, y-axis label, trace color index)
62
  # "Energy Balance" is a special case (bar chart, not time-series).
@@ -230,32 +217,33 @@ def run_simulation(
230
  ):
231
  """Run one pump cycle with the full parameter set and return plots + diagnostics."""
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  engine_id = _ENGINE_MAP.get(engine_name, "euler")
234
  fluid_id = _FLUID_MAP.get(fluid, "h2")
235
 
236
- # ── Build parameter arrays ───────────────────────────────────────────
237
- ICVparam = [icv_port, icv_mass, icv_travel, icv_dp_area,
238
- icv_Fs, icv_SC, icv_leakKv, icv_comp_eff, int(icv_npts)]
239
- DCVparam = [dcv_port, dcv_mass, dcv_travel, dcv_dp_area,
240
- dcv_Fs, dcv_SC, dcv_leakKv, dcv_comp_eff, int(dcv_npts)]
241
- pump_geom = [bore, stroke, hOD, cLen, emH, emS, kvoid, kH,
242
- Vfv, vac, cpm, dvf]
243
- proc_param = [Tamb, htc, drive, Fmult, KvBB, Pbb, fric, Exp]
244
-
245
- # ── Build exit_param if downstream mode is selected ──────────────────
246
- # [mode, snubber_L, chss_L, aov140_frac, ro_dia_mm] β€” mode: 1=CHSS, 0=RO
247
- exit_param = None
248
- if fill_type == "CHSS Fill":
249
- exit_param = [1, vsnubber, vchss, aov140f, rodia]
250
- elif fill_type == "RO Vent":
251
- exit_param = [0, vsnubber, 0, aov140f, rodia]
252
-
253
- extra_kwargs: dict = {}
254
- if exit_param is not None:
255
- extra_kwargs["exit_param"] = exit_param
256
  n_cyc = int(num_cycles) if num_cycles else 1
257
- if n_cyc > 1:
258
- extra_kwargs["n_cycles"] = n_cyc
259
 
260
  # ── Parameter summary (shown above plots on every run) ──────────────
261
  params_html = _build_params_summary_html(
@@ -267,22 +255,10 @@ def run_simulation(
267
  fill_type, vsnubber, vchss, aov140f, rodia, n_cyc,
268
  )
269
 
270
- # ── Call engine bridge ───────────────────────────────────────────────
 
271
  try:
272
- out, hist = cycle_sim(
273
- engine=engine_id,
274
- fluid=fluid_id,
275
- Pexit_barg=Pexit,
276
- speed_f=speed,
277
- Ptank_barg=Ptank,
278
- Psat_barg=Psat,
279
- ICVparam=ICVparam,
280
- DCVparam=DCVparam,
281
- pump_geom=pump_geom,
282
- proc_param=proc_param,
283
- flash_eff=flash_eff,
284
- **extra_kwargs,
285
- )
286
  except Exception:
287
  err = traceback.format_exc()
288
  err_html = (
@@ -291,7 +267,8 @@ def run_simulation(
291
  f'Simulation failed:\n{err}</div>'
292
  )
293
  ef = _empty_fig()
294
- return err_html, params_html, ef, ef, ef, ef, err_html
 
295
 
296
  # ── Extract scalars (prefer hist dict) ───────────────────────────────
297
  mdot = hist.get("mdot_kgpm", 0.0)
@@ -455,12 +432,62 @@ def run_simulation(
455
  status_html = f'<div class="status-bar">{" | ".join(parts)}</div>'
456
 
457
  return (metrics_html, params_html, fig_pt, fig_valves,
458
- extra_figs[0], extra_figs[1], status_html)
459
 
460
 
461
  # ── Builder ──────────────────────────────────────────────────────────────────
462
 
463
- def build_simulation_tab():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  """Create all Gradio components for the Simulation tab and wire events.
465
 
466
  Must be called inside an active ``gr.TabItem(...)`` context manager.
@@ -473,119 +500,129 @@ def build_simulation_tab():
473
  # ── Operating conditions (always visible) ───────────────────
474
  engine_dd = gr.Dropdown(
475
  choices=["Euler", "Lightspeed", "General-ODE"],
476
- value="Lightspeed",
477
  label="Engine",
478
  )
479
  fluid_dd = gr.Dropdown(
480
  choices=["H2", "N2"],
481
- value="H2",
482
  label="Fluid",
483
  )
484
  pexit_sl = gr.Slider(
485
- minimum=50, maximum=1100, value=350, step=10,
486
  label="Exit Pressure [barg]",
487
  )
488
  speed_sl = gr.Slider(
489
- minimum=0.1, maximum=1.0, value=0.65, step=0.05,
490
  label="Speed Fraction",
491
  )
492
  with gr.Row():
493
- ptank_num = gr.Number(value=7.0, label="Tank P [barg]", step=0.1)
494
- psat_num = gr.Number(value=2.0, label="Sat P [barg]", step=0.1)
495
 
496
  run_btn = gr.Button("RUN CYCLE", variant="primary")
497
 
 
 
 
 
 
 
 
 
 
 
498
  # ── ICV Parameters ──────────────────────────────────────────
499
  with gr.Accordion("ICV Parameters", open=False):
500
  with gr.Row():
501
- icv_port = gr.Number(value=D_ICV['port'], label="Port Dia (mm)", step=0.1)
502
- icv_mass = gr.Number(value=D_ICV['mass'], label="Mass (g)", step=0.1)
503
- icv_travel = gr.Number(value=D_ICV['travel'], label="Travel (mm)", step=0.1)
504
  with gr.Row():
505
- icv_dparea = gr.Number(value=D_ICV['dp_area'], label="dP Area (mmΒ²)", step=0.1)
506
- icv_Fs = gr.Number(value=D_ICV['Fs'], label="Spring F (N)", step=0.1)
507
- icv_SC = gr.Number(value=D_ICV['SC'], label="Spring K (N/mm)", step=0.01)
508
  with gr.Row():
509
- icv_leak = gr.Number(value=D_ICV['leakKv'], label="Leak Kv (mΒ³/hr)", step=1e-7)
510
- icv_ceff = gr.Number(value=D_ICV['comp_eff'], label="Comp Eff", step=0.01)
511
- icv_npts = gr.Number(value=D_ICV['npts'], label="Npts", step=1, precision=0)
512
 
513
  # ── DCV Parameters ──────────────────────────────────────────
514
  with gr.Accordion("DCV Parameters", open=False):
515
  with gr.Row():
516
- dcv_port = gr.Number(value=D_DCV['port'], label="Port Dia (mm)", step=0.1)
517
- dcv_mass = gr.Number(value=D_DCV['mass'], label="Mass (g)", step=0.1)
518
- dcv_travel = gr.Number(value=D_DCV['travel'], label="Travel (mm)", step=0.01)
519
  with gr.Row():
520
- dcv_dparea = gr.Number(value=D_DCV['dp_area'], label="dP Area (mmΒ²)", step=0.01)
521
- dcv_Fs = gr.Number(value=D_DCV['Fs'], label="Spring F (N)", step=0.1)
522
- dcv_SC = gr.Number(value=D_DCV['SC'], label="Spring K (N/mm)", step=0.001)
523
  with gr.Row():
524
- dcv_leak = gr.Number(value=D_DCV['leakKv'], label="Leak Kv (mΒ³/hr)", step=1e-7)
525
- dcv_ceff = gr.Number(value=D_DCV['comp_eff'], label="Comp Eff", step=0.01)
526
- dcv_npts = gr.Number(value=D_DCV['npts'], label="Npts", step=1, precision=0)
527
 
528
  # ── Pump Geometry ───────────────────────────────────────────
529
  with gr.Accordion("Pump Geometry", open=False):
530
  with gr.Row():
531
- bore = gr.Number(value=D_PUMP['bore'], label="Bore (mm)", step=0.1)
532
- stroke = gr.Number(value=D_PUMP['stroke'], label="Stroke (mm)", step=0.1)
533
  with gr.Row():
534
- cpm = gr.Number(value=D_PUMP['cpm'], label="Speed (cpm)", step=1)
535
- dvf = gr.Number(value=D_PUMP['dvf'], label="Dead Vol Frac", step=0.001)
536
  with gr.Row():
537
- hOD = gr.Number(value=D_PUMP['hOD'], label="Housing OD (mm)", step=1)
538
- cLen = gr.Number(value=D_PUMP['cLen'], label="Chamber Len (mm)", step=1)
539
  with gr.Row():
540
- emH = gr.Number(value=D_PUMP['emH'], label="Ξ΅ Housing", step=0.01)
541
- emS = gr.Number(value=D_PUMP['emS'], label="Ξ΅ Shield", step=0.01)
542
  with gr.Row():
543
- kvoid = gr.Number(value=D_PUMP['kvoid'], label="k void (W/m/K)", step=0.001)
544
- kH = gr.Number(value=D_PUMP['kH'], label="k housing (W/m/K)", step=0.1)
545
  with gr.Row():
546
- Vfv = gr.Number(value=D_PUMP['Vfv'], label="Void Vol Frac", step=0.1)
547
- vac = gr.Number(value=D_PUMP['vac'], label="Vacuum (Β΅Hg)", step=1000)
548
 
549
  # ── Process / Losses ────────────────────────────────────────
550
  with gr.Accordion("Process / Losses", open=False):
551
  with gr.Row():
552
- Tamb = gr.Number(value=D_PROC['Tamb'], label="T_amb (K)", step=1)
553
- htc = gr.Number(value=D_PROC['htc'], label="HTC (W/mΒ²/K)", step=0.1)
554
  with gr.Row():
555
- drive = gr.Number(value=D_PROC['drive'], label="Drive (kgf)", step=0.1)
556
- Fmult = gr.Number(value=D_PROC['Fmult'], label="F Multiplier", step=1)
557
  with gr.Row():
558
- fric = gr.Number(value=D_PROC['fric'], label="Friction (0-1)", step=0.01)
559
- KvBB = gr.Number(value=D_PROC['KvBB'], label="BB Kv (mΒ³/hr)", step=0.001)
560
  with gr.Row():
561
- Pbb = gr.Number(value=D_PROC['Pbb'], label="BB Exit P (barg)", step=0.1)
562
- Exp = gr.Number(value=D_PROC['Exp'], label="Exp Eff (0-1)", step=0.01)
563
- flash_eff = gr.Number(value=0.02, label="Thermal Mass Eff (0-1)", step=0.01)
564
 
565
  # ── Downstream / Fill mode ──────────────────────────────────
566
  with gr.Accordion("Downstream / Fill Mode", open=False):
567
  with gr.Row():
568
  fill_type = gr.Dropdown(
569
  choices=["Fixed Pexit", "CHSS Fill", "RO Vent"],
570
- value="Fixed Pexit", label="Fill Type",
571
  )
572
  num_cycles = gr.Number(
573
- value=1, label="No. cycles",
574
  step=1, minimum=1, maximum=50, precision=0,
575
  )
576
  with gr.Row():
577
- vsnubber = gr.Number(value=5.0, label="Snubber (L)", step=0.1)
578
- vchss = gr.Number(value=50.0, label="CHSS (L)", step=1)
579
  with gr.Row():
580
- aov140f = gr.Number(value=1.0, label="AOV140 (0-1)", step=0.01,
581
  minimum=0, maximum=1)
582
- rodia = gr.Number(value=0.94, label="RO Dia (mm)", step=0.01)
583
 
584
  # ── Extra plots selector ────────────────────────────────────
585
  gr.Markdown("**Additional plots** (select up to 2):")
586
  extras_cb = gr.CheckboxGroup(
587
  choices=EXTRA_PLOT_CHOICES,
588
- value=["Energy Balance"],
589
  label="Extra Graphs",
590
  )
591
 
@@ -623,5 +660,31 @@ def build_simulation_tab():
623
  extras_cb,
624
  ],
625
  outputs=[metrics_out, params_out, fig_pt_out, fig_valves_out,
626
- fig_extra1_out, fig_extra2_out, status_out],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
  )
 
15
 
16
  from __future__ import annotations
17
 
18
+ import os
19
+ import tempfile
20
  import traceback
21
+ from datetime import datetime, timezone
22
 
23
  import gradio as gr
24
  import numpy as np
 
26
 
27
  from murphy_unified.engine_bridge import cycle_sim, ENGINES
28
  from murphy_unified.diagnostics import evaluate
29
+ from murphy_unified.engine_params import build_engine_kwargs
30
+ from murphy_unified.params_io import params_to_csv, csv_to_params
31
+ from murphy_unified.sim_defaults import SIM_DEFAULTS
32
  from murphy_unified.theme import (
33
  metric_html, TRACE_COLORS, TEXT_DIM, TEXT_BRIGHT, ACCENT, ACCENT2, WARN, DANGER,
34
  FONT_MONO, VALVE_ICV, VALVE_DCV, ENERGY_POS, ENERGY_NEG, TEXT,
 
44
  _FLUID_MAP = {"H2": "h2", "N2": "n2"}
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  # ── Extra plot definitions ──────────────────────────────────────────────────
48
  # Each entry: (hist_key, y-axis label, trace color index)
49
  # "Energy Balance" is a special case (bar chart, not time-series).
 
217
  ):
218
  """Run one pump cycle with the full parameter set and return plots + diagnostics."""
219
 
220
+ # ── Snapshot submitted params BEFORE the engine call so sim_params_state
221
+ # reflects user inputs even if cycle_sim raises.
222
+ sim_state: dict = {
223
+ "engine_name": engine_name, "fluid": fluid,
224
+ "Pexit": Pexit, "speed": speed, "Ptank": Ptank, "Psat": Psat,
225
+ "icv_port": icv_port, "icv_mass": icv_mass, "icv_travel": icv_travel,
226
+ "icv_dp_area": icv_dp_area, "icv_Fs": icv_Fs, "icv_SC": icv_SC,
227
+ "icv_leakKv": icv_leakKv, "icv_comp_eff": icv_comp_eff, "icv_npts": icv_npts,
228
+ "dcv_port": dcv_port, "dcv_mass": dcv_mass, "dcv_travel": dcv_travel,
229
+ "dcv_dp_area": dcv_dp_area, "dcv_Fs": dcv_Fs, "dcv_SC": dcv_SC,
230
+ "dcv_leakKv": dcv_leakKv, "dcv_comp_eff": dcv_comp_eff, "dcv_npts": dcv_npts,
231
+ "bore": bore, "stroke": stroke, "hOD": hOD, "cLen": cLen,
232
+ "emH": emH, "emS": emS, "kvoid": kvoid, "kH": kH,
233
+ "Vfv": Vfv, "vac": vac, "cpm": cpm, "dvf": dvf,
234
+ "Tamb": Tamb, "htc": htc, "drive": drive, "Fmult": Fmult,
235
+ "KvBB": KvBB, "Pbb": Pbb, "fric": fric, "Exp": Exp, "flash_eff": flash_eff,
236
+ "fill_type": fill_type, "num_cycles": num_cycles,
237
+ "vsnubber": vsnubber, "vchss": vchss, "aov140f": aov140f, "rodia": rodia,
238
+ "extra_plots": list(extra_plots or []),
239
+ "_written_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
240
+ "_engine_ok": True,
241
+ }
242
+
243
  engine_id = _ENGINE_MAP.get(engine_name, "euler")
244
  fluid_id = _FLUID_MAP.get(fluid, "h2")
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  n_cyc = int(num_cycles) if num_cycles else 1
 
 
247
 
248
  # ── Parameter summary (shown above plots on every run) ──────────────
249
  params_html = _build_params_summary_html(
 
255
  fill_type, vsnubber, vchss, aov140f, rodia, n_cyc,
256
  )
257
 
258
+ # ── Call engine bridge via shared helper ─────────────────────────────
259
+ engine_kwargs = build_engine_kwargs(sim_state)
260
  try:
261
+ out, hist = cycle_sim(engine=engine_id, fluid=fluid_id, **engine_kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  except Exception:
263
  err = traceback.format_exc()
264
  err_html = (
 
267
  f'Simulation failed:\n{err}</div>'
268
  )
269
  ef = _empty_fig()
270
+ sim_state["_engine_ok"] = False
271
+ return err_html, params_html, ef, ef, ef, ef, err_html, sim_state
272
 
273
  # ── Extract scalars (prefer hist dict) ───────────────────────────────
274
  mdot = hist.get("mdot_kgpm", 0.0)
 
432
  status_html = f'<div class="status-bar">{" | ".join(parts)}</div>'
433
 
434
  return (metrics_html, params_html, fig_pt, fig_valves,
435
+ extra_figs[0], extra_figs[1], status_html, sim_state)
436
 
437
 
438
  # ── Builder ──────────────────────────────────────────────────────────────────
439
 
440
+ _WIDGET_KEYS_IN_ORDER = (
441
+ "engine_name", "fluid", "Pexit", "speed", "Ptank", "Psat",
442
+ "icv_port", "icv_mass", "icv_travel", "icv_dp_area",
443
+ "icv_Fs", "icv_SC", "icv_leakKv", "icv_comp_eff", "icv_npts",
444
+ "dcv_port", "dcv_mass", "dcv_travel", "dcv_dp_area",
445
+ "dcv_Fs", "dcv_SC", "dcv_leakKv", "dcv_comp_eff", "dcv_npts",
446
+ "bore", "stroke", "hOD", "cLen", "emH", "emS", "kvoid", "kH",
447
+ "Vfv", "vac", "cpm", "dvf",
448
+ "Tamb", "htc", "drive", "Fmult", "KvBB", "Pbb", "fric", "Exp", "flash_eff",
449
+ "fill_type", "num_cycles", "vsnubber", "vchss", "aov140f", "rodia",
450
+ "extra_plots",
451
+ )
452
+
453
+
454
+ def _export_params(*widget_values) -> str:
455
+ """Write current widget state to a tempfile CSV; return path for gr.File."""
456
+ state = dict(zip(_WIDGET_KEYS_IN_ORDER, widget_values))
457
+ state["extra_plots"] = list(state.get("extra_plots") or [])
458
+ csv_text = params_to_csv(state)
459
+ stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
460
+ path = os.path.join(tempfile.gettempdir(), f"murphy_run_{stamp}.csv")
461
+ with open(path, "w", encoding="utf-8", newline="") as f:
462
+ f.write(csv_text)
463
+ return path
464
+
465
+
466
+ def _import_params(file_obj):
467
+ """Load a CSV and return widget updates in _WIDGET_KEYS_IN_ORDER + info HTML."""
468
+ if file_obj is None:
469
+ return (*([gr.update()] * len(_WIDGET_KEYS_IN_ORDER)), "")
470
+ path = file_obj if isinstance(file_obj, str) else file_obj.name
471
+ with open(path, "r", encoding="utf-8") as f:
472
+ csv_text = f.read()
473
+ parsed, skipped = csv_to_params(csv_text)
474
+
475
+ updates = tuple(
476
+ gr.update(value=parsed[key]) if key in parsed else gr.update()
477
+ for key in _WIDGET_KEYS_IN_ORDER
478
+ )
479
+ loaded = len(parsed)
480
+ total = len(SIM_DEFAULTS)
481
+ skipped_msg = f" Skipped: {', '.join(skipped)}" if skipped else ""
482
+ info_html = (
483
+ f'<div style="font-family:JetBrains Mono,monospace;font-size:11px;'
484
+ f'color:{TEXT_DIM};padding:4px 0;">'
485
+ f'Loaded {loaded}/{total} params.{skipped_msg}</div>'
486
+ )
487
+ return (*updates, info_html)
488
+
489
+
490
+ def build_simulation_tab(sim_params_state: gr.State):
491
  """Create all Gradio components for the Simulation tab and wire events.
492
 
493
  Must be called inside an active ``gr.TabItem(...)`` context manager.
 
500
  # ── Operating conditions (always visible) ───────────────────
501
  engine_dd = gr.Dropdown(
502
  choices=["Euler", "Lightspeed", "General-ODE"],
503
+ value=SIM_DEFAULTS["engine_name"],
504
  label="Engine",
505
  )
506
  fluid_dd = gr.Dropdown(
507
  choices=["H2", "N2"],
508
+ value=SIM_DEFAULTS["fluid"],
509
  label="Fluid",
510
  )
511
  pexit_sl = gr.Slider(
512
+ minimum=50, maximum=1100, value=SIM_DEFAULTS["Pexit"], step=10,
513
  label="Exit Pressure [barg]",
514
  )
515
  speed_sl = gr.Slider(
516
+ minimum=0.1, maximum=1.0, value=SIM_DEFAULTS["speed"], step=0.05,
517
  label="Speed Fraction",
518
  )
519
  with gr.Row():
520
+ ptank_num = gr.Number(value=SIM_DEFAULTS["Ptank"], label="Tank P [barg]", step=0.1)
521
+ psat_num = gr.Number(value=SIM_DEFAULTS["Psat"], label="Sat P [barg]", step=0.1)
522
 
523
  run_btn = gr.Button("RUN CYCLE", variant="primary")
524
 
525
+ # ── Save / Load run params ──────────────────────────────────
526
+ with gr.Row():
527
+ save_btn = gr.DownloadButton("Save params to CSV")
528
+ load_file = gr.File(
529
+ label="Load params from CSV",
530
+ file_types=[".csv"],
531
+ type="filepath",
532
+ )
533
+ load_info_html = gr.HTML()
534
+
535
  # ── ICV Parameters ──────────────────────────────────────────
536
  with gr.Accordion("ICV Parameters", open=False):
537
  with gr.Row():
538
+ icv_port = gr.Number(value=SIM_DEFAULTS['icv_port'], label="Port Dia (mm)", step=0.1)
539
+ icv_mass = gr.Number(value=SIM_DEFAULTS['icv_mass'], label="Mass (g)", step=0.1)
540
+ icv_travel = gr.Number(value=SIM_DEFAULTS['icv_travel'], label="Travel (mm)", step=0.1)
541
  with gr.Row():
542
+ icv_dparea = gr.Number(value=SIM_DEFAULTS['icv_dp_area'], label="dP Area (mmΒ²)", step=0.1)
543
+ icv_Fs = gr.Number(value=SIM_DEFAULTS['icv_Fs'], label="Spring F (N)", step=0.1)
544
+ icv_SC = gr.Number(value=SIM_DEFAULTS['icv_SC'], label="Spring K (N/mm)", step=0.01)
545
  with gr.Row():
546
+ icv_leak = gr.Number(value=SIM_DEFAULTS['icv_leakKv'], label="Leak Kv (mΒ³/hr)", step=1e-7)
547
+ icv_ceff = gr.Number(value=SIM_DEFAULTS['icv_comp_eff'], label="Comp Eff", step=0.01)
548
+ icv_npts = gr.Number(value=SIM_DEFAULTS['icv_npts'], label="Npts", step=1, precision=0)
549
 
550
  # ── DCV Parameters ──────────────────────────────────────────
551
  with gr.Accordion("DCV Parameters", open=False):
552
  with gr.Row():
553
+ dcv_port = gr.Number(value=SIM_DEFAULTS['dcv_port'], label="Port Dia (mm)", step=0.1)
554
+ dcv_mass = gr.Number(value=SIM_DEFAULTS['dcv_mass'], label="Mass (g)", step=0.1)
555
+ dcv_travel = gr.Number(value=SIM_DEFAULTS['dcv_travel'], label="Travel (mm)", step=0.01)
556
  with gr.Row():
557
+ dcv_dparea = gr.Number(value=SIM_DEFAULTS['dcv_dp_area'], label="dP Area (mmΒ²)", step=0.01)
558
+ dcv_Fs = gr.Number(value=SIM_DEFAULTS['dcv_Fs'], label="Spring F (N)", step=0.1)
559
+ dcv_SC = gr.Number(value=SIM_DEFAULTS['dcv_SC'], label="Spring K (N/mm)", step=0.001)
560
  with gr.Row():
561
+ dcv_leak = gr.Number(value=SIM_DEFAULTS['dcv_leakKv'], label="Leak Kv (mΒ³/hr)", step=1e-7)
562
+ dcv_ceff = gr.Number(value=SIM_DEFAULTS['dcv_comp_eff'], label="Comp Eff", step=0.01)
563
+ dcv_npts = gr.Number(value=SIM_DEFAULTS['dcv_npts'], label="Npts", step=1, precision=0)
564
 
565
  # ── Pump Geometry ───────────────────────────────────────────
566
  with gr.Accordion("Pump Geometry", open=False):
567
  with gr.Row():
568
+ bore = gr.Number(value=SIM_DEFAULTS['bore'], label="Bore (mm)", step=0.1)
569
+ stroke = gr.Number(value=SIM_DEFAULTS['stroke'], label="Stroke (mm)", step=0.1)
570
  with gr.Row():
571
+ cpm = gr.Number(value=SIM_DEFAULTS['cpm'], label="Speed (cpm)", step=1)
572
+ dvf = gr.Number(value=SIM_DEFAULTS['dvf'], label="Dead Vol Frac", step=0.001)
573
  with gr.Row():
574
+ hOD = gr.Number(value=SIM_DEFAULTS['hOD'], label="Housing OD (mm)", step=1)
575
+ cLen = gr.Number(value=SIM_DEFAULTS['cLen'], label="Chamber Len (mm)", step=1)
576
  with gr.Row():
577
+ emH = gr.Number(value=SIM_DEFAULTS['emH'], label="Ξ΅ Housing", step=0.01)
578
+ emS = gr.Number(value=SIM_DEFAULTS['emS'], label="Ξ΅ Shield", step=0.01)
579
  with gr.Row():
580
+ kvoid = gr.Number(value=SIM_DEFAULTS['kvoid'], label="k void (W/m/K)", step=0.001)
581
+ kH = gr.Number(value=SIM_DEFAULTS['kH'], label="k housing (W/m/K)", step=0.1)
582
  with gr.Row():
583
+ Vfv = gr.Number(value=SIM_DEFAULTS['Vfv'], label="Void Vol Frac", step=0.1)
584
+ vac = gr.Number(value=SIM_DEFAULTS['vac'], label="Vacuum (Β΅Hg)", step=1000)
585
 
586
  # ── Process / Losses ────────────────────────────────────────
587
  with gr.Accordion("Process / Losses", open=False):
588
  with gr.Row():
589
+ Tamb = gr.Number(value=SIM_DEFAULTS['Tamb'], label="T_amb (K)", step=1)
590
+ htc = gr.Number(value=SIM_DEFAULTS['htc'], label="HTC (W/mΒ²/K)", step=0.1)
591
  with gr.Row():
592
+ drive = gr.Number(value=SIM_DEFAULTS['drive'], label="Drive (kgf)", step=0.1)
593
+ Fmult = gr.Number(value=SIM_DEFAULTS['Fmult'], label="F Multiplier", step=1)
594
  with gr.Row():
595
+ fric = gr.Number(value=SIM_DEFAULTS['fric'], label="Friction (0-1)", step=0.01)
596
+ KvBB = gr.Number(value=SIM_DEFAULTS['KvBB'], label="BB Kv (mΒ³/hr)", step=0.001)
597
  with gr.Row():
598
+ Pbb = gr.Number(value=SIM_DEFAULTS['Pbb'], label="BB Exit P (barg)", step=0.1)
599
+ Exp = gr.Number(value=SIM_DEFAULTS['Exp'], label="Exp Eff (0-1)", step=0.01)
600
+ flash_eff = gr.Number(value=SIM_DEFAULTS["flash_eff"], label="Thermal Mass Eff (0-1)", step=0.01)
601
 
602
  # ── Downstream / Fill mode ──────────────────────────────────
603
  with gr.Accordion("Downstream / Fill Mode", open=False):
604
  with gr.Row():
605
  fill_type = gr.Dropdown(
606
  choices=["Fixed Pexit", "CHSS Fill", "RO Vent"],
607
+ value=SIM_DEFAULTS["fill_type"], label="Fill Type",
608
  )
609
  num_cycles = gr.Number(
610
+ value=SIM_DEFAULTS["num_cycles"], label="No. cycles",
611
  step=1, minimum=1, maximum=50, precision=0,
612
  )
613
  with gr.Row():
614
+ vsnubber = gr.Number(value=SIM_DEFAULTS["vsnubber"], label="Snubber (L)", step=0.1)
615
+ vchss = gr.Number(value=SIM_DEFAULTS["vchss"], label="CHSS (L)", step=1)
616
  with gr.Row():
617
+ aov140f = gr.Number(value=SIM_DEFAULTS["aov140f"], label="AOV140 (0-1)", step=0.01,
618
  minimum=0, maximum=1)
619
+ rodia = gr.Number(value=SIM_DEFAULTS["rodia"], label="RO Dia (mm)", step=0.01)
620
 
621
  # ── Extra plots selector ────────────────────────────────────
622
  gr.Markdown("**Additional plots** (select up to 2):")
623
  extras_cb = gr.CheckboxGroup(
624
  choices=EXTRA_PLOT_CHOICES,
625
+ value=SIM_DEFAULTS["extra_plots"],
626
  label="Extra Graphs",
627
  )
628
 
 
660
  extras_cb,
661
  ],
662
  outputs=[metrics_out, params_out, fig_pt_out, fig_valves_out,
663
+ fig_extra1_out, fig_extra2_out, status_out, sim_params_state],
664
+ )
665
+
666
+ # ── Widget list (order must match _WIDGET_KEYS_IN_ORDER) ─────────────
667
+ _all_widgets = [
668
+ engine_dd, fluid_dd, pexit_sl, speed_sl, ptank_num, psat_num,
669
+ icv_port, icv_mass, icv_travel, icv_dparea,
670
+ icv_Fs, icv_SC, icv_leak, icv_ceff, icv_npts,
671
+ dcv_port, dcv_mass, dcv_travel, dcv_dparea,
672
+ dcv_Fs, dcv_SC, dcv_leak, dcv_ceff, dcv_npts,
673
+ bore, stroke, hOD, cLen, emH, emS, kvoid, kH,
674
+ Vfv, vac, cpm, dvf,
675
+ Tamb, htc, drive, Fmult, KvBB, Pbb, fric, Exp, flash_eff,
676
+ fill_type, num_cycles, vsnubber, vchss, aov140f, rodia,
677
+ extras_cb,
678
+ ]
679
+
680
+ save_btn.click(
681
+ fn=_export_params,
682
+ inputs=_all_widgets,
683
+ outputs=[save_btn],
684
+ )
685
+
686
+ load_file.change(
687
+ fn=_import_params,
688
+ inputs=[load_file],
689
+ outputs=[*_all_widgets, load_info_html],
690
  )
murphy_unified/tabs/sweep.py CHANGED
@@ -17,6 +17,8 @@ import pandas as pd
17
  import plotly.graph_objects as go
18
 
19
  from murphy_unified.engine_bridge import cycle_sim
 
 
20
  from murphy_unified.sweep_cache import SweepCache
21
  from murphy_unified.theme import (
22
  metric_html,
@@ -56,8 +58,10 @@ SWEEP_PARAMS = {
56
  "htc": {"label": "Heat Transfer Coeff", "min": 0.0, "max": 100.0, "default": 10.0},
57
  }
58
 
59
- # ── Parameter-to-array mapping ────────────────────────────────────────────
60
- # Maps sweep param name β†’ (array_name, index) or "direct" for top-level args.
 
 
61
  _PARAM_MAP = {
62
  "Pexit_barg": ("direct", "Pexit_barg"),
63
  "speed_f": ("direct", "speed_f"),
@@ -78,7 +82,6 @@ _PARAM_MAP = {
78
  "Exp_eff": ("proc_param", 7),
79
  }
80
 
81
- # MVP 1.0 Simplex defaults (must match engine_bridge.py)
82
  _DEFAULTS = {
83
  "ICVparam": [20.5, 48, 5, 779.3, 33.4, 3.75, 0.0000736, 1.0, 100],
84
  "DCVparam": [8.3, 25, 3.79, 78.54, 7.8, 1.053, 0.0000026, 0.8, 200],
@@ -86,32 +89,43 @@ _DEFAULTS = {
86
  "proc_param": [300, 10, 4, 30, 0.006, 0, 0.5, 0.2],
87
  }
88
 
89
- # HuggingFace Space caps
90
- _IS_HF = bool(os.environ.get("HF_SPACE"))
91
- _MAX_N_1D = 15 if _IS_HF else 20
92
- _MAX_GRID_2D = 10 if _IS_HF else 15
93
-
94
- # Module-level cache (created lazily so import doesn't touch disk)
95
- _cache: SweepCache | None = None
96
-
97
 
98
- def _get_cache() -> SweepCache:
99
- global _cache
100
- if _cache is None:
101
- _cache = SweepCache()
102
- return _cache
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
 
105
  def _build_overrides(overrides: dict[str, float]) -> dict:
106
- """Convert {param_name: value} into cycle_sim keyword overrides.
107
-
108
- Returns a dict with keys like Pexit_barg, speed_f, flash_eff,
109
- ICVparam, DCVparam, pump_geom, proc_param β€” only those that differ
110
- from defaults.
111
- """
112
  kw: dict = {}
113
  arrays: dict[str, list] = {}
114
-
115
  for name, val in overrides.items():
116
  mapping = _PARAM_MAP[name]
117
  if mapping[0] == "direct":
@@ -119,54 +133,114 @@ def _build_overrides(overrides: dict[str, float]) -> dict:
119
  else:
120
  arr_name, idx = mapping
121
  if arr_name not in arrays:
122
- arrays[arr_name] = list(_DEFAULTS[arr_name]) # copy
123
  arrays[arr_name][idx] = val
124
-
125
  kw.update(arrays)
126
  return kw
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  # ── Helper: run a single sim point with caching ─────────────────────────────
130
 
131
- def _sim_point(hw: str, Pexit: float, speed: float,
132
- flash_eff: float = 0.0,
133
- extra_overrides: dict[str, float] | None = None,
134
- ) -> tuple[float, float, bool]:
135
- """Run one cycle_sim call; return (mdot_kgpm, wall_s, was_cached)."""
 
 
 
 
 
 
 
 
 
136
  cache = _get_cache()
137
- params_key = {
138
- "engine": "lightspeed",
139
- "hw": hw,
140
- "Pexit_barg": Pexit,
141
- "speed_f": speed,
142
- "flash_eff": flash_eff,
143
- }
144
- if extra_overrides:
145
- params_key.update(extra_overrides)
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  hit = cache.get(params_key)
148
  if hit is not None:
149
  scalars, wall_s = hit
150
- return scalars["mdot_kgpm"], wall_s, True
151
-
152
- # Build engine kwargs from overrides
153
- all_overrides = {"Pexit_barg": Pexit, "speed_f": speed, "flash_eff": flash_eff}
154
- if extra_overrides:
155
- all_overrides.update(extra_overrides)
156
- kw = _build_overrides(all_overrides)
157
 
 
158
  t0 = time.perf_counter()
159
  _out, hist = cycle_sim(engine="lightspeed", **kw)
160
  wall_s = time.perf_counter() - t0
161
  mdot = hist.get("mdot_kgpm", 0.0)
 
162
 
163
- cache.put(params_key, {"mdot_kgpm": mdot}, wall_s)
164
- return mdot, wall_s, False
165
 
166
 
167
  # ── Main click handler ───────────────────────────────────────────────────────
168
 
169
  def run_sweep(
 
170
  mode: str,
171
  hw: str,
172
  param_1d: str,
@@ -178,14 +252,19 @@ def run_sweep(
178
  grid_2d: int,
179
  Pexit: float,
180
  speed: float,
 
181
  ):
182
  """Execute a 1D or 2D sweep and return (plot, cache_html, dataframe)."""
 
 
183
 
184
  try:
185
  if mode == "1D Sweep":
186
- return _run_1d(hw, param_1d, min_1d, max_1d, int(n_1d), Pexit, speed)
 
187
  else:
188
- return _run_2d(hw, param_x, param_y, int(grid_2d), Pexit, speed)
 
189
  except Exception:
190
  err = traceback.format_exc()
191
  err_html = (
@@ -195,10 +274,10 @@ def run_sweep(
195
  )
196
  empty_fig = go.Figure()
197
  empty_fig.update_layout(height=300)
198
- return empty_fig, err_html, pd.DataFrame()
199
 
200
 
201
- def _run_1d(hw, param, lo, hi, n, Pexit, speed):
202
  """1D parameter sweep β€” supports all 17 parameters."""
203
  n = min(n, _MAX_N_1D)
204
 
@@ -208,13 +287,11 @@ def _run_1d(hw, param, lo, hi, n, Pexit, speed):
208
  cache_hits = 0
209
 
210
  for v in values:
211
- # Direct args default to operating point; overridden if swept
212
  p = float(v) if param == "Pexit_barg" else Pexit
213
  s = float(v) if param == "speed_f" else speed
214
- fe = float(v) if param == "flash_eff" else 0.0
215
- # Extra overrides for array-based params
216
- extra = {param: float(v)} if param not in ("Pexit_barg", "speed_f", "flash_eff") else None
217
- mdot, wall, cached = _sim_point(hw, p, s, fe, extra)
218
  mdots.append(mdot)
219
  total_wall += wall
220
  if cached:
@@ -223,13 +300,16 @@ def _run_1d(hw, param, lo, hi, n, Pexit, speed):
223
  # ── Plotly line chart ────────────────────────────────────────────────
224
  label = SWEEP_PARAMS[param]["label"]
225
  fig = go.Figure()
 
226
  fig.add_trace(go.Scatter(
227
  x=values,
228
  y=mdots,
229
- mode="lines+markers",
230
  name="mdot",
231
  line=dict(color=TRACE_COLORS[0], width=2),
232
  marker=dict(size=6, color=TRACE_COLORS[0]),
 
 
233
  ))
234
  fig.update_layout(
235
  title=f"1D Sweep: {label}",
@@ -268,11 +348,20 @@ def _run_1d(hw, param, lo, hi, n, Pexit, speed):
268
  "mdot [kg/min]": np.round(mdots, 4),
269
  })
270
 
271
- return fig, cache_html, df
 
272
 
 
 
 
 
 
 
 
 
 
 
273
 
274
- def _run_2d(hw, param_x, param_y, grid_n, Pexit, speed):
275
- """2D parameter sweep β€” supports any two parameters from SWEEP_PARAMS."""
276
  grid_n = min(grid_n, _MAX_GRID_2D)
277
 
278
  if param_x == param_y:
@@ -283,7 +372,7 @@ def _run_2d(hw, param_x, param_y, grid_n, Pexit, speed):
283
  )
284
  empty_fig = go.Figure()
285
  empty_fig.update_layout(height=300)
286
- return empty_fig, info_html, pd.DataFrame()
287
 
288
  px_info = SWEEP_PARAMS[param_x]
289
  py_info = SWEEP_PARAMS[param_y]
@@ -291,21 +380,23 @@ def _run_2d(hw, param_x, param_y, grid_n, Pexit, speed):
291
  y_vals = np.linspace(py_info["min"], py_info["max"], grid_n)
292
 
293
  z = np.zeros((len(y_vals), len(x_vals)))
 
294
  total_wall = 0.0
295
  cache_hits = 0
296
  total_pts = len(x_vals) * len(y_vals)
297
 
298
- _direct_set = {"Pexit_barg", "speed_f", "flash_eff"}
299
-
300
  for j, yv in enumerate(y_vals):
301
  for i, xv in enumerate(x_vals):
302
- overrides = {param_x: float(xv), param_y: float(yv)}
303
- p = overrides.pop("Pexit_barg", Pexit)
304
- s = overrides.pop("speed_f", speed)
305
- fe = overrides.pop("flash_eff", 0.0)
306
- extra = overrides if overrides else None
307
- mdot, wall, cached = _sim_point(hw, p, s, fe, extra)
 
 
308
  z[j, i] = mdot
 
309
  total_wall += wall
310
  if cached:
311
  cache_hits += 1
@@ -374,11 +465,79 @@ def _run_2d(hw, param_x, param_y, grid_n, Pexit, speed):
374
  })
375
  df = pd.DataFrame(rows)
376
 
377
- return fig, cache_html, df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
 
379
 
380
  # ── Utility ──────────────────────────────────────────────────────────────────
381
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  def _hex_to_rgb(h: str) -> str:
383
  """Convert '#4a9eca' to '74,158,202'."""
384
  h = h.lstrip("#")
@@ -387,8 +546,15 @@ def _hex_to_rgb(h: str) -> str:
387
 
388
  # ── Builder ──────────────────────────────────────────────────────────────────
389
 
390
- def build_sweep_tab():
391
- """Create all Gradio components for the Parameter Sweep tab and wire events.
 
 
 
 
 
 
 
392
 
393
  Must be called inside an active ``gr.TabItem(...)`` context manager.
394
  """
@@ -401,9 +567,16 @@ def build_sweep_tab():
401
  with gr.Column(scale=1, min_width=280):
402
  gr.HTML(engine_banner("ENGINE: LIGHTSPEED"))
403
 
 
 
 
404
  hw_dd = gr.Dropdown(
405
- choices=["old_icv", "new_icv"],
406
- value="old_icv",
 
 
 
 
407
  label="Hardware",
408
  )
409
 
@@ -467,6 +640,11 @@ def build_sweep_tab():
467
  label="Operating Speed Fraction",
468
  )
469
 
 
 
 
 
 
470
  run_btn = gr.Button("RUN SWEEP", variant="primary")
471
 
472
  # ── Right panel β€” results ────────────────────────────────────────
@@ -474,6 +652,9 @@ def build_sweep_tab():
474
  plot_out = gr.Plot(label="Sweep Results")
475
  cache_html = gr.HTML(label="Cache Status")
476
  results_df = gr.Dataframe(label="Results Table")
 
 
 
477
 
478
  # ── Mode toggle: show/hide 1D vs 2D control groups ───────────────────
479
  def toggle_mode(mode):
@@ -488,6 +669,12 @@ def build_sweep_tab():
488
  outputs=[grp_1d, grp_2d],
489
  )
490
 
 
 
 
 
 
 
491
  # ── Auto-populate min/max when 1D parameter changes ──────────────────
492
  def update_minmax(param):
493
  info = SWEEP_PARAMS.get(param, SWEEP_PARAMS[first_key])
@@ -499,14 +686,27 @@ def build_sweep_tab():
499
  outputs=[min_1d, max_1d],
500
  )
501
 
502
- # ── Wire RUN SWEEP button ────────────────────────────────────────────
503
- run_btn.click(
504
- fn=run_sweep,
505
- inputs=[
 
 
 
 
 
 
 
 
506
  mode_radio, hw_dd,
507
  param_1d_dd, min_1d, max_1d, n_1d_sl,
508
  param_x_dd, param_y_dd, grid_2d_sl,
509
  pexit_sl, speed_sl,
 
510
  ],
511
- outputs=[plot_out, cache_html, results_df],
512
- )
 
 
 
 
 
17
  import plotly.graph_objects as go
18
 
19
  from murphy_unified.engine_bridge import cycle_sim
20
+ from murphy_unified.engine_params import build_engine_kwargs
21
+ from murphy_unified.sim_defaults import SIM_DEFAULTS
22
  from murphy_unified.sweep_cache import SweepCache
23
  from murphy_unified.theme import (
24
  metric_html,
 
58
  "htc": {"label": "Heat Transfer Coeff", "min": 0.0, "max": 100.0, "default": 10.0},
59
  }
60
 
61
+ # ── Legacy parameter-to-array mapping ─────────────────────────────────────
62
+ # Kept for compatibility with murphy_unified/tabs/comparison.py, which still
63
+ # uses the old override-dict shape. New sweep flow goes through
64
+ # build_engine_kwargs + _SWEEP_KEY_TO_SIM_KEY below.
65
  _PARAM_MAP = {
66
  "Pexit_barg": ("direct", "Pexit_barg"),
67
  "speed_f": ("direct", "speed_f"),
 
82
  "Exp_eff": ("proc_param", 7),
83
  }
84
 
 
85
  _DEFAULTS = {
86
  "ICVparam": [20.5, 48, 5, 779.3, 33.4, 3.75, 0.0000736, 1.0, 100],
87
  "DCVparam": [8.3, 25, 3.79, 78.54, 7.8, 1.053, 0.0000026, 0.8, 200],
 
89
  "proc_param": [300, 10, 4, 30, 0.006, 0, 0.5, 0.2],
90
  }
91
 
 
 
 
 
 
 
 
 
92
 
93
+ # ── Hardware presets ──────────────────────────────────────────────────────
94
+ # Sourced from "ICV open sim-driver for ode package (2).xlsx" (orig vs new ICV).
95
+ # Applied on top of sim_state in _sim_point when the user picks a preset;
96
+ # "baseline" leaves sim_state unchanged.
97
+ _HW_PRESETS: dict[str, dict] = {
98
+ "old_icv": {
99
+ "icv_port": 19.052559,
100
+ "icv_mass": 28.0,
101
+ "icv_travel": 8.0,
102
+ "icv_dp_area": 897.741517,
103
+ "icv_Fs": 34.2,
104
+ "icv_SC": 3.402,
105
+ "icv_leakKv": 0.00669,
106
+ "icv_comp_eff": 1.0,
107
+ "icv_npts": 200,
108
+ },
109
+ "new_icv": {
110
+ "icv_port": 20.5,
111
+ "icv_mass": 48.0,
112
+ "icv_travel": 5.0,
113
+ "icv_dp_area": 779.311328,
114
+ "icv_Fs": 33.4,
115
+ "icv_SC": 3.75,
116
+ "icv_leakKv": 0.000864,
117
+ "icv_comp_eff": 1.0,
118
+ "icv_npts": 200,
119
+ },
120
+ }
121
 
122
 
123
  def _build_overrides(overrides: dict[str, float]) -> dict:
124
+ """Legacy helper: convert {param_name: value} to cycle_sim kwargs using
125
+ engine defaults for unspecified parameters. Used only by comparison tab;
126
+ new sweep flow goes through build_engine_kwargs."""
 
 
 
127
  kw: dict = {}
128
  arrays: dict[str, list] = {}
 
129
  for name, val in overrides.items():
130
  mapping = _PARAM_MAP[name]
131
  if mapping[0] == "direct":
 
133
  else:
134
  arr_name, idx = mapping
135
  if arr_name not in arrays:
136
+ arrays[arr_name] = list(_DEFAULTS[arr_name])
137
  arrays[arr_name][idx] = val
 
138
  kw.update(arrays)
139
  return kw
140
 
141
 
142
+ # ── Sweep-key β†’ SIM_DEFAULTS-key aliases ──────────────────────────────────
143
+ # Sweep-tab parameter names differ in some places from SIM_DEFAULTS keys (which
144
+ # match run_simulation signature). This table translates before applying.
145
+ # Params not listed here (Pexit_barg, speed_f, flash_eff, dvf, Fmult, htc) keep
146
+ # the same name.
147
+ _SWEEP_KEY_TO_SIM_KEY = {
148
+ "Pexit_barg": "Pexit",
149
+ "speed_f": "speed",
150
+ "Kv_BB": "KvBB",
151
+ "bore_mm": "bore",
152
+ "stroke_mm": "stroke",
153
+ "ICV_spring_F": "icv_Fs",
154
+ "DCV_spring_F": "dcv_Fs",
155
+ "ICV_port_dia": "icv_port",
156
+ "DCV_port_dia": "dcv_port",
157
+ "ICV_leak_Kv": "icv_leakKv",
158
+ "DCV_leak_Kv": "dcv_leakKv",
159
+ "fric2chamber": "fric",
160
+ "Exp_eff": "Exp",
161
+ }
162
+
163
+
164
+ def _sweep_key_to_sim_key(sweep_key: str) -> str:
165
+ return _SWEEP_KEY_TO_SIM_KEY.get(sweep_key, sweep_key)
166
+
167
+ # HuggingFace Space caps
168
+ _IS_HF = bool(os.environ.get("HF_SPACE"))
169
+ _MAX_N_1D = 15 if _IS_HF else 20
170
+ _MAX_GRID_2D = 10 if _IS_HF else 15
171
+
172
+ # Module-level cache (created lazily so import doesn't touch disk)
173
+ _cache: SweepCache | None = None
174
+
175
+
176
+ def _get_cache() -> SweepCache:
177
+ global _cache
178
+ if _cache is None:
179
+ _cache = SweepCache()
180
+ return _cache
181
+
182
+
183
  # ── Helper: run a single sim point with caching ─────────────────────────────
184
 
185
+ def _sim_point(
186
+ sim_state: dict,
187
+ hw: str,
188
+ Pexit: float,
189
+ speed: float,
190
+ swept_param: str,
191
+ swept_value: float,
192
+ ) -> tuple[float, float, float, bool]:
193
+ """Run one cycle_sim call; return (mdot_kgpm, T_peak_K, wall_s, was_cached).
194
+
195
+ The full `sim_state` baseline is used β€” every parameter flows into
196
+ both the engine call AND the cache key, so stale cache rows from a
197
+ different baseline can never be returned.
198
+ """
199
  cache = _get_cache()
200
+
201
+ # Apply hw preset (if any) on top of baseline, then swept override.
202
+ sim_key = _sweep_key_to_sim_key(swept_param)
203
+ params = dict(sim_state)
204
+ params.pop("_written_at", None)
205
+ params.pop("_engine_ok", None)
206
+ if hw in _HW_PRESETS:
207
+ params.update(_HW_PRESETS[hw])
208
+ params["Pexit"] = Pexit
209
+ params["speed"] = speed
210
+ params[sim_key] = swept_value
211
+
212
+ # Cache key = full baseline + hw + engine, floats rounded for stability.
213
+ params_key = {"engine": "lightspeed", "hw": hw}
214
+ for k, v in params.items():
215
+ if isinstance(v, float):
216
+ params_key[k] = float(f"{v:.10g}")
217
+ elif isinstance(v, list):
218
+ params_key[k] = tuple(v)
219
+ else:
220
+ params_key[k] = v
221
 
222
  hit = cache.get(params_key)
223
  if hit is not None:
224
  scalars, wall_s = hit
225
+ return (scalars.get("mdot_kgpm", 0.0),
226
+ scalars.get("Tc_peak_K", 60.0),
227
+ wall_s, True)
 
 
 
 
228
 
229
+ kw = build_engine_kwargs(params)
230
  t0 = time.perf_counter()
231
  _out, hist = cycle_sim(engine="lightspeed", **kw)
232
  wall_s = time.perf_counter() - t0
233
  mdot = hist.get("mdot_kgpm", 0.0)
234
+ T_peak = hist.get("Tc_peak_K", 60.0)
235
 
236
+ cache.put(params_key, {"mdot_kgpm": mdot, "Tc_peak_K": T_peak}, wall_s)
237
+ return mdot, T_peak, wall_s, False
238
 
239
 
240
  # ── Main click handler ───────────────────────────────────────────────────────
241
 
242
  def run_sweep(
243
+ sim_state: dict | None,
244
  mode: str,
245
  hw: str,
246
  param_1d: str,
 
252
  grid_2d: int,
253
  Pexit: float,
254
  speed: float,
255
+ show_labels: bool = True,
256
  ):
257
  """Execute a 1D or 2D sweep and return (plot, cache_html, dataframe)."""
258
+ if sim_state is None:
259
+ sim_state = dict(SIM_DEFAULTS)
260
 
261
  try:
262
  if mode == "1D Sweep":
263
+ return _run_1d(sim_state, hw, param_1d, min_1d, max_1d,
264
+ int(n_1d), Pexit, speed, show_labels)
265
  else:
266
+ return _run_2d(sim_state, hw, param_x, param_y,
267
+ int(grid_2d), Pexit, speed)
268
  except Exception:
269
  err = traceback.format_exc()
270
  err_html = (
 
274
  )
275
  empty_fig = go.Figure()
276
  empty_fig.update_layout(height=300)
277
+ return empty_fig, err_html, pd.DataFrame(), None, gr.update(visible=False)
278
 
279
 
280
+ def _run_1d(sim_state, hw, param, lo, hi, n, Pexit, speed, show_labels=True):
281
  """1D parameter sweep β€” supports all 17 parameters."""
282
  n = min(n, _MAX_N_1D)
283
 
 
287
  cache_hits = 0
288
 
289
  for v in values:
 
290
  p = float(v) if param == "Pexit_barg" else Pexit
291
  s = float(v) if param == "speed_f" else speed
292
+ mdot, _T_peak, wall, cached = _sim_point(
293
+ sim_state, hw, p, s, param, float(v),
294
+ )
 
295
  mdots.append(mdot)
296
  total_wall += wall
297
  if cached:
 
300
  # ── Plotly line chart ────────────────────────────────────────────────
301
  label = SWEEP_PARAMS[param]["label"]
302
  fig = go.Figure()
303
+ trace_mode = "lines+markers+text" if show_labels else "lines+markers"
304
  fig.add_trace(go.Scatter(
305
  x=values,
306
  y=mdots,
307
+ mode=trace_mode,
308
  name="mdot",
309
  line=dict(color=TRACE_COLORS[0], width=2),
310
  marker=dict(size=6, color=TRACE_COLORS[0]),
311
+ text=[f"{val:.4g}" for val in values] if show_labels else None,
312
+ textposition="top center",
313
  ))
314
  fig.update_layout(
315
  title=f"1D Sweep: {label}",
 
348
  "mdot [kg/min]": np.round(mdots, 4),
349
  })
350
 
351
+ return fig, cache_html, df, None, gr.update(visible=False)
352
+
353
 
354
+ def _run_2d(sim_state, hw, param_x, param_y, grid_n, Pexit, speed):
355
+ """2D parameter sweep β€” supports any two parameters from SWEEP_PARAMS.
356
+
357
+ Returns a 5-tuple: ``(fig, cache_html, df, bridge_payload, send_btn_update)``.
358
+
359
+ ``bridge_payload`` is a dict (see design spec) iff the axes are exactly
360
+ ``{Pexit_barg, speed_f}``; otherwise ``None``. ``send_btn_update`` is a
361
+ ``gr.update(...)`` toggling the Send-to-Process-Sim button visibility.
362
+ """
363
+ import datetime as _dt
364
 
 
 
365
  grid_n = min(grid_n, _MAX_GRID_2D)
366
 
367
  if param_x == param_y:
 
372
  )
373
  empty_fig = go.Figure()
374
  empty_fig.update_layout(height=300)
375
+ return empty_fig, info_html, pd.DataFrame(), None, gr.update(visible=False)
376
 
377
  px_info = SWEEP_PARAMS[param_x]
378
  py_info = SWEEP_PARAMS[param_y]
 
380
  y_vals = np.linspace(py_info["min"], py_info["max"], grid_n)
381
 
382
  z = np.zeros((len(y_vals), len(x_vals)))
383
+ z_temp = np.zeros((len(y_vals), len(x_vals)))
384
  total_wall = 0.0
385
  cache_hits = 0
386
  total_pts = len(x_vals) * len(y_vals)
387
 
 
 
388
  for j, yv in enumerate(y_vals):
389
  for i, xv in enumerate(x_vals):
390
+ # Apply param_y on top of baseline; pass param_x as swept param.
391
+ local_state = dict(sim_state)
392
+ local_state[_sweep_key_to_sim_key(param_y)] = float(yv)
393
+ p = float(xv) if param_x == "Pexit_barg" else Pexit
394
+ s = float(xv) if param_x == "speed_f" else speed
395
+ mdot, T_peak, wall, cached = _sim_point(
396
+ local_state, hw, p, s, param_x, float(xv),
397
+ )
398
  z[j, i] = mdot
399
+ z_temp[j, i] = T_peak
400
  total_wall += wall
401
  if cached:
402
  cache_hits += 1
 
465
  })
466
  df = pd.DataFrame(rows)
467
 
468
+ # ── Bridge payload (only for compatible axes) ────────────────────────
469
+ if {param_x, param_y} == {"Pexit_barg", "speed_f"}:
470
+ payload = {
471
+ "axis_x": param_x,
472
+ "axis_y": param_y,
473
+ "x_vals": x_vals,
474
+ "y_vals": y_vals,
475
+ "z_mdot_kgpm": z,
476
+ "z_temp_K": z_temp,
477
+ "hw": hw,
478
+ "timestamp": _dt.datetime.now().isoformat(timespec="seconds"),
479
+ }
480
+ btn_update = gr.update(visible=True)
481
+ else:
482
+ payload = None
483
+ btn_update = gr.update(visible=False)
484
+
485
+ return fig, cache_html, df, payload, btn_update
486
 
487
 
488
  # ── Utility ──────────────────────────────────────────────────────────────────
489
 
490
+ _SECTIONS = [
491
+ ("Operating",
492
+ ["engine_name", "fluid", "Pexit", "speed", "Ptank", "Psat"]),
493
+ ("ICV",
494
+ ["icv_port", "icv_mass", "icv_travel", "icv_dp_area",
495
+ "icv_Fs", "icv_SC", "icv_leakKv", "icv_comp_eff", "icv_npts"]),
496
+ ("DCV",
497
+ ["dcv_port", "dcv_mass", "dcv_travel", "dcv_dp_area",
498
+ "dcv_Fs", "dcv_SC", "dcv_leakKv", "dcv_comp_eff", "dcv_npts"]),
499
+ ("Pump",
500
+ ["bore", "stroke", "hOD", "cLen", "emH", "emS",
501
+ "kvoid", "kH", "Vfv", "vac", "cpm", "dvf"]),
502
+ ("Process",
503
+ ["Tamb", "htc", "drive", "Fmult", "KvBB", "Pbb",
504
+ "fric", "Exp", "flash_eff"]),
505
+ ("Downstream",
506
+ ["fill_type", "num_cycles", "vsnubber", "vchss", "aov140f", "rodia"]),
507
+ ]
508
+
509
+
510
+ def _format_baseline_panel(sim_state: dict | None) -> str:
511
+ """Render sim_state as a grouped HTML table for the Sweep baseline panel."""
512
+ if sim_state is None:
513
+ sim_state = dict(SIM_DEFAULTS)
514
+ written_at = sim_state.get("_written_at", "defaults (no simulation run yet)")
515
+ engine_ok = sim_state.get("_engine_ok")
516
+ ok_str = ("ok" if engine_ok else "error") if engine_ok is not None else "β€”"
517
+ header = (
518
+ f'<div style="font-family:JetBrains Mono,monospace;font-size:11px;'
519
+ f'color:{TEXT_DIM};padding:4px 0;">'
520
+ f'Source: user inputs at {written_at} (engine: {ok_str})</div>'
521
+ )
522
+ rows = []
523
+ for section, keys in _SECTIONS:
524
+ rows.append(
525
+ f'<tr><td colspan="2" style="padding-top:8px;font-weight:600;'
526
+ f'color:{TEXT_BRIGHT};">{section}</td></tr>'
527
+ )
528
+ for key in keys:
529
+ val = sim_state.get(key, "")
530
+ rows.append(
531
+ f'<tr><td style="color:{TEXT_DIM};padding:2px 12px 2px 0;">{key}</td>'
532
+ f'<td style="font-family:JetBrains Mono,monospace;color:{TEXT_BRIGHT};">{val}</td></tr>'
533
+ )
534
+ table = (
535
+ '<table style="border-collapse:collapse;font-size:11px;">'
536
+ + "".join(rows) + "</table>"
537
+ )
538
+ return header + table
539
+
540
+
541
  def _hex_to_rgb(h: str) -> str:
542
  """Convert '#4a9eca' to '74,158,202'."""
543
  h = h.lstrip("#")
 
546
 
547
  # ── Builder ──────────────────────────────────────────────────────────────────
548
 
549
+ def build_sweep_tab(sim_params_state: gr.State):
550
+ """Create all Gradio components for the Parameter Sweep tab.
551
+
552
+ Returns a dict of handles for app.py to wire cross-tab events:
553
+ * ``run_btn``, ``run_sweep`` (callable)
554
+ * ``run_inputs`` (list of components for the click handler)
555
+ * ``plot_out``, ``cache_html``, ``results_df``
556
+ * ``send_to_process_btn``
557
+ * ``mode_radio``
558
 
559
  Must be called inside an active ``gr.TabItem(...)`` context manager.
560
  """
 
567
  with gr.Column(scale=1, min_width=280):
568
  gr.HTML(engine_banner("ENGINE: LIGHTSPEED"))
569
 
570
+ with gr.Accordion("Baseline parameters in use", open=False):
571
+ baseline_html = gr.HTML(_format_baseline_panel(None))
572
+
573
  hw_dd = gr.Dropdown(
574
+ choices=[
575
+ ("Baseline (Simulation tab)", "baseline"),
576
+ ("Old ICV", "old_icv"),
577
+ ("New ICV", "new_icv"),
578
+ ],
579
+ value="baseline",
580
  label="Hardware",
581
  )
582
 
 
640
  label="Operating Speed Fraction",
641
  )
642
 
643
+ show_labels_cb = gr.Checkbox(
644
+ label="Show point labels on 1D plot",
645
+ value=True,
646
+ )
647
+
648
  run_btn = gr.Button("RUN SWEEP", variant="primary")
649
 
650
  # ── Right panel β€” results ────────────────────────────────────────
 
652
  plot_out = gr.Plot(label="Sweep Results")
653
  cache_html = gr.HTML(label="Cache Status")
654
  results_df = gr.Dataframe(label="Results Table")
655
+ send_to_process_btn = gr.Button(
656
+ "Send to Process Sim", variant="secondary", visible=False,
657
+ )
658
 
659
  # ── Mode toggle: show/hide 1D vs 2D control groups ───────────────────
660
  def toggle_mode(mode):
 
669
  outputs=[grp_1d, grp_2d],
670
  )
671
 
672
+ mode_radio.change(
673
+ fn=lambda _m: gr.update(visible=False),
674
+ inputs=[mode_radio],
675
+ outputs=[send_to_process_btn],
676
+ )
677
+
678
  # ── Auto-populate min/max when 1D parameter changes ──────────────────
679
  def update_minmax(param):
680
  info = SWEEP_PARAMS.get(param, SWEEP_PARAMS[first_key])
 
686
  outputs=[min_1d, max_1d],
687
  )
688
 
689
+ # ── Reactive: baseline panel updates whenever sim_params_state changes ──
690
+ sim_params_state.change(
691
+ fn=_format_baseline_panel,
692
+ inputs=[sim_params_state],
693
+ outputs=[baseline_html],
694
+ )
695
+
696
+ return {
697
+ "run_btn": run_btn,
698
+ "run_sweep": run_sweep,
699
+ "run_inputs": [
700
+ sim_params_state,
701
  mode_radio, hw_dd,
702
  param_1d_dd, min_1d, max_1d, n_1d_sl,
703
  param_x_dd, param_y_dd, grid_2d_sl,
704
  pexit_sl, speed_sl,
705
+ show_labels_cb,
706
  ],
707
+ "plot_out": plot_out,
708
+ "cache_html": cache_html,
709
+ "results_df": results_df,
710
+ "send_to_process_btn": send_to_process_btn,
711
+ "mode_radio": mode_radio,
712
+ }