ode-pump-dashboard / scenario_gui.py
pandeydigant31's picture
Upload scenario_gui.py with huggingface_hub
37fbe36 verified
Raw
History Blame Contribute Delete
95.8 kB
"""
Scenario Analyzer GUI — Gradio dashboard for the CSH2 pump cycle simulator.
Provides interactive parameter sweeps, diagnostics, scenario comparisons,
and real pump cycle comparison from TimescaleDB.
Usage:
python scenario_gui.py
"""
import gradio as gr
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import pandas as pd
import time
import json as _json
import os as _os
import traceback
from scenario_analyzer import (
SimulationRunner, Diagnostician, SweepAnalyzer, IdealBaseline,
CycleBaseline, H2_SPEED_SCALE,
DEFAULT_OPERATING, DEFAULT_ICVparam, DEFAULT_DCVparam,
DEFAULT_pump_geom, DEFAULT_proc_param, SWEEP_PARAMS,
DIAGNOSTIC_CHECKS, _cache,
)
from sweep_analysis import sensitivity_ranking, sweep_insights, sweep_2d_insights
# ---------------------------------------------------------------------------
# Shared instances (created once)
# ---------------------------------------------------------------------------
_runner = SimulationRunner(engine='njit_10var')
_diag = Diagnostician()
_sweep = SweepAnalyzer(runner=_runner, diagnostician=_diag)
_baseline = IdealBaseline()
_cycles = CycleBaseline()
# Module-level cache for cycle query results
_cycle_cache = {'df': None}
# ---------------------------------------------------------------------------
# Pump Animation — load JS renderer + define helpers
# ---------------------------------------------------------------------------
_PUMP_ANIM_JS_PATH = _os.path.join(_os.path.dirname(__file__) or '.', 'pump_animation.js')
try:
with open(_PUMP_ANIM_JS_PATH) as _f:
_PUMP_ANIM_JS = _f.read()
except FileNotFoundError:
_PUMP_ANIM_JS = ""
def _downsample_for_animation(history, n_frames=360):
"""Downsample ~26k-step history arrays to n_frames uniform angle steps."""
if not history or 'angle_deg' not in history:
return None
angle = np.asarray(history['angle_deg'])
if len(angle) < 2:
return None
target = np.linspace(float(angle[0]), float(angle[-1]), n_frames)
keys = ['pc', 'Tc_K', 'yp', 'ICV_open_frac', 'DCV_open_frac',
'ICV_leak_kgpm', 'DCV_leak_kgpm', 'hc']
result = {'angle_deg': target.tolist()}
for k in keys:
if k in history and hasattr(history[k], '__len__') and len(history[k]) == len(angle):
result[k] = np.interp(target, angle, np.asarray(history[k])).tolist()
else:
result[k] = [0.0] * n_frames
return result
def _build_animation_html(anim_data, bore_mm, stroke_mm):
"""Build self-contained HTML blob for the pump cycle animation."""
if anim_data is None:
return ""
uid = str(int(time.time() * 1000))
data_json = _json.dumps({
'frames': anim_data,
'pumpGeom': {'bore_mm': float(bore_mm), 'stroke_mm': float(stroke_mm)},
}, separators=(',', ':')) # compact JSON
n_frames = len(anim_data['angle_deg'])
return f'''<div id="pump-anim-{uid}" style="margin:8px 0;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;
font-family:'JetBrains Mono',monospace; font-size:12px;">
<button id="pump-play-{uid}" onclick="globalThis.togglePumpAnim('{uid}')"
style="width:32px;height:28px;font-size:16px;cursor:pointer;
border:1px solid #a0a09a;background:#f0f0ec;border-radius:0;">&#9654;</button>
<select onchange="globalThis.setPumpAnimSpeed('{uid}',this.value)"
style="height:28px;font-family:inherit;font-size:11px;
border:1px solid #a0a09a;background:#f0f0ec;border-radius:0;padding:0 4px;">
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="5">5x</option>
<option value="10">10x</option>
</select>
<input id="pump-scrub-{uid}" type="range" min="0" max="{n_frames - 1}" value="0"
oninput="globalThis.scrubPumpAnim('{uid}',this.value)"
style="flex:1;height:6px;cursor:pointer;">
<span id="pump-angle-{uid}" style="min-width:52px;text-align:right;color:#505050;">0.0&deg;</span>
</div>
<canvas width="900" height="520"
style="width:100%;border:1px solid #a0a09a;background:#f0f0ec;display:block;"></canvas>
<div id="pump-data-{uid}" style="display:none">{data_json}</div>
<img src="x" onerror="if(globalThis.initPumpAnimation)globalThis.initPumpAnimation('{uid}')"
style="display:none">
</div>'''
# Styling — light-mode palette with enough contrast on warm-grey backgrounds
COLORS = {
'mdot': '#1a4fd6', # strong ink blue
'eff': '#1a7a4a', # dark green
'temp': '#b85c00', # burnt orange
'press': '#c01a1a', # deep red
'mawp': '#7a0000', # dark red
}
def _style_fig(fig, axes_flat):
"""Apply consistent light brutalist theme to all matplotlib figures."""
BG_BASE = '#e8e8e4' # warm light grey — matches app background
BG_PANEL = '#f0f0ec' # slightly lighter panel
TEXT_PRI = '#1a1a1a' # near-black for titles and axis labels
TEXT_SEC = '#505050' # mid grey for tick labels
BORDER = '#a0a09a' # medium grey borders
GRID = '#d0d0cc' # subtle grey gridlines
fig.patch.set_facecolor(BG_BASE)
fig.patch.set_alpha(1.0)
for ax in axes_flat:
ax.set_facecolor(BG_PANEL)
ax.tick_params(colors=TEXT_SEC, labelsize=9, length=3)
ax.xaxis.label.set_color(TEXT_SEC)
ax.yaxis.label.set_color(TEXT_SEC)
ax.title.set_color(TEXT_PRI)
ax.title.set_fontsize(11)
ax.title.set_fontweight('600')
for spine in ax.spines.values():
spine.set_edgecolor(BORDER)
spine.set_linewidth(1.0)
# Brutalist: keep bottom and left spines only
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['bottom'].set_linewidth(1.5)
ax.spines['left'].set_linewidth(1.5)
ax.spines['bottom'].set_edgecolor('#404040')
ax.spines['left'].set_edgecolor('#404040')
ax.grid(True, color=GRID, linewidth=0.8, linestyle='-', alpha=1.0)
# Style legends
if ax.get_legend():
ax.get_legend().get_frame().set_facecolor(BG_PANEL)
ax.get_legend().get_frame().set_edgecolor(BORDER)
ax.get_legend().get_frame().set_linewidth(1.0)
for text in ax.get_legend().get_texts():
text.set_color(TEXT_PRI)
text.set_fontsize(8)
# Style suptitle
if fig._suptitle:
fig._suptitle.set_color(TEXT_PRI)
fig._suptitle.set_fontsize(12)
fig._suptitle.set_fontweight('600')
fig.tight_layout(pad=1.5)
# ── Plotly theme (matches brutalist matplotlib style) ─────────────────────
_PLOTLY_BG = '#e8e8e4'
_PLOTLY_PANEL = '#f0f0ec'
_PLOTLY_GRID = '#d0d0cc'
_PLOTLY_TEXT_PRI = '#1a1a1a'
_PLOTLY_TEXT_SEC = '#505050'
def _plotly_axis_style():
"""Shared axis styling for Plotly subplots."""
return dict(
showgrid=True, gridcolor=_PLOTLY_GRID, gridwidth=1,
zeroline=False,
linecolor='#404040', linewidth=1.5,
tickfont=dict(size=10, color=_PLOTLY_TEXT_SEC),
title_font=dict(size=11, color=_PLOTLY_TEXT_SEC),
)
def _plotly_layout(fig, title=None, height=600):
"""Apply brutalist theme to a Plotly figure."""
fig.update_layout(
title=dict(text=title, font=dict(size=13, color=_PLOTLY_TEXT_PRI,
family='DM Sans, system-ui, sans-serif'),
x=0.5, xanchor='center') if title else None,
paper_bgcolor=_PLOTLY_BG,
plot_bgcolor=_PLOTLY_PANEL,
height=height,
margin=dict(l=50, r=10, t=50 if title else 30, b=40),
font=dict(family='DM Sans, system-ui, sans-serif', size=11,
color=_PLOTLY_TEXT_PRI),
legend=dict(bgcolor=_PLOTLY_PANEL, bordercolor='#a0a09a', borderwidth=1,
font=dict(size=9)),
hovermode='x unified',
)
# Apply axis style to all axes
axis_style = _plotly_axis_style()
fig.update_xaxes(**axis_style)
fig.update_yaxes(**axis_style)
return fig
def _build_diag_html(findings, interp_text):
"""Build styled HTML for diagnostics + physics interpretation."""
severity_class = {'CRITICAL': 'finding-crit', 'WARNING': 'finding-warn'}
findings_html = ""
if findings:
for f in findings:
cls = severity_class.get(f['severity'], 'finding-ok')
findings_html += f"""
<div class="finding {cls}">
<span class="finding-tag">{f['check']}</span>
<span class="finding-msg">{f['message']}</span>
<span class="finding-detail">{f['rationale']}</span>
</div>"""
else:
findings_html = '<div class="finding finding-ok"><span class="finding-msg">All checks passed — within normal operating bounds.</span></div>'
return f"""
<div style="margin-top: 12px;">
<div style="font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600;
color: #505050; text-transform: uppercase; letter-spacing: 0.1em;
margin-bottom: 8px; border-bottom: 1px solid #a0a09a; padding-bottom: 4px;">
Diagnostics
</div>
{findings_html}
<div style="margin-top: 12px; font-family: 'JetBrains Mono', monospace; font-size: 10px;
font-weight: 600; color: #505050; text-transform: uppercase;
letter-spacing: 0.1em; margin-bottom: 6px;
border-bottom: 1px solid #a0a09a; padding-bottom: 4px;">
Physics Interpretation
</div>
<div style="font-family: 'DM Sans', system-ui; font-size: 13px; color: #1a1a1a;
line-height: 1.7; background: #f0f0ec; border: 1px solid #a0a09a;
border-left: 4px solid #404040; border-radius: 0;
padding: 12px 16px;">{interp_text}</div>
</div>"""
CSS = """
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=DM+Sans:wght@300;400;500;600&display=swap');
:root {
--bg-base: #e8e8e4;
--bg-panel: #f0f0ec;
--bg-elevated: #d8d8d4;
--border: #a0a09a;
--border-heavy: #404040;
--text-primary: #1a1a1a;
--text-secondary:#505050;
--text-muted: #888880;
--accent-blue: #1a4fd6;
--accent-green: #1a7a4a;
--accent-amber: #b85c00;
--accent-red: #c01a1a;
--accent-dark-red: #7a0000;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
--font-ui: 'DM Sans', system-ui, sans-serif;
}
/* Force light grey background on the entire Gradio app — reduced side padding */
.gradio-container { background: var(--bg-base) !important; font-family: var(--font-ui) !important;
max-width: 100% !important; padding-left: 12px !important; padding-right: 12px !important; }
.contain { padding-left: 0 !important; padding-right: 0 !important; }
.tabs { background: var(--bg-base) !important; }
.tab-nav { border-bottom: 2px solid var(--border-heavy) !important; }
.tab-nav button, button[role="tab"] {
font-family: var(--font-mono) !important; font-size: 12px !important; font-weight: 600 !important;
color: var(--text-secondary) !important;
border-bottom: 3px solid transparent !important;
border-radius: 0 !important;
text-transform: uppercase !important; letter-spacing: 0.06em !important;
}
.tab-nav button.selected, button[role="tab"][aria-selected="true"] {
color: var(--text-primary) !important;
border-bottom: 3px solid var(--border-heavy) !important;
}
/* Input fields — flat, no rounding, forced dark text (exclude checkboxes & radios) */
input:not([type="checkbox"]):not([type="radio"]),
input[type="number"], input[type="text"], select, textarea,
.gr-input input:not([type="checkbox"]):not([type="radio"]),
.gr-text-input,
.block input:not([type="checkbox"]):not([type="radio"]),
.wrap input:not([type="checkbox"]):not([type="radio"]),
.gradio-container input:not([type="checkbox"]):not([type="radio"]),
.gradio-container select, .gradio-container textarea {
background: var(--bg-panel) !important;
border: 1px solid var(--border) !important;
border-radius: 0 !important;
color: #111111 !important;
font-family: var(--font-mono) !important;
font-size: 13px !important;
-webkit-text-fill-color: #111111 !important;
opacity: 1 !important;
}
/* Checkboxes & radio buttons — visible tick/dot with theme colors */
input[type="checkbox"], input[type="radio"] {
accent-color: var(--accent-blue) !important;
width: 16px !important; height: 16px !important;
cursor: pointer !important;
opacity: 1 !important;
-webkit-appearance: auto !important;
appearance: auto !important;
}
.block { border-radius: 0 !important; }
label { color: var(--text-secondary) !important; font-size: 11px !important;
font-family: var(--font-ui) !important; text-transform: uppercase;
letter-spacing: 0.05em; }
/* Buttons — flat, squared, slate blue theme (override Gradio 5.x orange) */
:root, body, body.dark, .dark {
--color-accent: #475569 !important;
--button-primary-background-fill: #475569 !important;
--button-primary-background-fill-hover: #334155 !important;
--button-primary-text-color: #ffffff !important;
--button-primary-border-color: #475569 !important;
}
.btn-primary, button.primary, .primary {
background: #475569 !important;
color: #ffffff !important;
border: 2px solid #475569 !important;
border-radius: 0 !important;
font-family: var(--font-mono) !important;
font-size: 12px !important;
font-weight: 600 !important;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.btn-primary:hover, button.primary:hover, .primary:hover {
background: #334155 !important;
color: #ffffff !important;
border-color: #334155 !important;
}
/* Accordion — flat, squared */
.accordion { background: var(--bg-elevated) !important;
border: 1px solid var(--border) !important;
border-radius: 0 !important; }
.accordion-header { font-family: var(--font-mono) !important; font-size: 11px !important;
color: var(--text-secondary) !important; text-transform: uppercase;
letter-spacing: 0.08em; font-weight: 600 !important; }
/* Force light mode — Gradio 6.x defaults to dark based on OS preference.
Override ALL Gradio dark-mode CSS variables at the body.dark scope. */
body.dark, .dark {
--body-background-fill: #e8e8e4 !important;
--background-fill-primary: #f0f0ec !important;
--background-fill-secondary: #e8e8e4 !important;
--block-background-fill: #f0f0ec !important;
--table-even-background-fill: #f0f0ec !important;
--table-odd-background-fill: #e8e8e4 !important;
--table-row-focus: #d8d8d4 !important;
--color-accent: #404040 !important;
--body-text-color: #1a1a1a !important;
--block-label-text-color: #505050 !important;
--block-title-text-color: #1a1a1a !important;
--input-background-fill: #f0f0ec !important;
--border-color-primary: #a0a09a !important;
--neutral-900: #1a1a1a !important;
--neutral-800: #333333 !important;
--neutral-700: #505050 !important;
--neutral-100: #f0f0ec !important;
--neutral-50: #e8e8e4 !important;
}
/* ── Input column styling ──
Gradio 6.x strips CSS rules targeting .column/.row (internal Svelte components).
Column width + gap is applied via inline JS in _FORCE_LIGHT_JS instead.
Rules below target .block/.form/label/input which Gradio does allow. */
/* Tables — minimal, ruled, override Gradio dark-mode DataFrame */
table { background: var(--bg-panel) !important; border-collapse: collapse;
border: 1px solid var(--border) !important; border-radius: 0 !important;
color: var(--text-primary) !important; }
thead, thead tr, thead th {
background: var(--bg-elevated) !important;
color: var(--text-secondary) !important;
-webkit-text-fill-color: var(--text-secondary) !important;
}
th { background: var(--bg-elevated) !important; color: var(--text-secondary) !important;
font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
letter-spacing: 0.08em; padding: 8px 12px;
border-bottom: 2px solid var(--border-heavy); font-weight: 600;
-webkit-text-fill-color: var(--text-secondary) !important; }
tr, tbody tr, .row-odd, tr.row-odd {
background: var(--bg-panel) !important;
color: var(--text-primary) !important;
}
tbody tr:nth-child(even) { background: var(--bg-base) !important; }
tbody tr:nth-child(odd) { background: var(--bg-panel) !important; }
td { color: var(--text-primary) !important; font-family: var(--font-mono);
font-size: 12px; padding: 6px 12px; border-bottom: 1px solid var(--border);
-webkit-text-fill-color: var(--text-primary) !important;
background: transparent !important; }
/* Gradio DataFrame virtual-table specific overrides */
svelte-virtual-table-viewport { background: var(--bg-panel) !important; color: var(--text-primary) !important; }
.table-wrap { background: var(--bg-panel) !important; border-radius: 0 !important; }
.table-container { background: var(--bg-panel) !important; }
/* App header */
.app-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 12px; margin-bottom: 0;
border-bottom: 3px solid var(--border-heavy);
background: var(--bg-base);
}
.app-title { font-family: var(--font-mono); font-size: 18px; font-weight: 600;
color: var(--text-primary); letter-spacing: 0.08em; }
.app-subtitle { font-family: var(--font-ui); font-size: 14px; color: var(--text-muted);
margin-top: 3px; letter-spacing: 0.02em; }
.app-status-indicator { display: flex; align-items: center; gap: 6px; }
.app-status-dot { width: 8px; height: 8px; border-radius: 0;
background: var(--accent-green); display: inline-block; }
.app-status-label { font-family: var(--font-mono); font-size: 10px; font-weight: 600;
color: var(--accent-green); letter-spacing: 0.1em; }
/* Metric cards */
.metric-card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 0;
padding: 14px 18px;
}
.metric-val {
font-family: var(--font-mono);
font-size: 26px;
font-weight: 600;
color: var(--text-primary);
line-height: 1;
}
.metric-label {
font-family: var(--font-ui);
font-size: 11px;
font-weight: 400;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 6px;
}
/* Status badges */
.badge { font-family: var(--font-mono); font-size: 11px; font-weight: 600;
padding: 2px 8px; border-radius: 0; letter-spacing: 0.1em;
border: 1px solid; }
.badge-ok { background: transparent; color: var(--accent-green); border-color: var(--accent-green); }
.badge-warn { background: transparent; color: var(--accent-amber); border-color: var(--accent-amber); }
.badge-crit { background: var(--accent-red); color: #ffffff; border-color: var(--accent-red); }
/* Diagnostic findings */
.finding { border-left: 4px solid; padding: 8px 12px; margin: 4px 0;
background: var(--bg-elevated); border-radius: 0; }
.finding-warn { border-color: var(--accent-amber); }
.finding-crit { border-color: var(--accent-red); }
.finding-ok { border-color: var(--accent-green); }
.finding-tag { font-family: var(--font-mono); font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.1em; display: block;
color: var(--text-secondary); margin-bottom: 3px; }
.finding-msg { font-family: var(--font-ui); font-size: 13px; color: var(--text-primary);
display: block; }
.finding-detail { font-family: var(--font-ui); font-size: 12px; color: var(--text-secondary);
display: block; margin-top: 2px; font-style: italic; }
/* Remove all border-radius on Gradio components */
.block, .form, .contain, .gr-box, .gr-panel, .gr-form, .gr-input, .gr-button,
.svelte-1gfkn6j, .wrap, .panel {
border-radius: 0 !important;
}
"""
# ===========================================================================
# Selectable history plot variables (Task 5)
# ===========================================================================
HISTORY_CHOICES = [
"Chamber Pressure (pc)",
"Chamber Temperature (Tc_K)",
"Chamber Density (den)",
"Chamber Mass (mc_g)",
"Valve Positions (ICV/DCV open frac)",
"Valve Flow Rates (ICV/DCV leak)",
"Piston Position (yp)",
"Specific Enthalpy (hc)",
"Total Mass Rate (dm_tot_kgps)",
"Snubber Pressure (psnub)",
"CHSS Pressure (pchss)",
]
_HIST_MAP = {
"Chamber Pressure (pc)": {'keys': ['pc'], 'ylabel': 'Pressure (barg)', 'colors': ['press']},
"Chamber Temperature (Tc_K)": {'keys': ['Tc_K'], 'ylabel': 'Temperature (K)', 'colors': ['temp']},
"Chamber Density (den)": {'keys': ['den'], 'ylabel': 'Density (kg/m³)', 'colors': ['mdot']},
"Chamber Mass (mc_g)": {'keys': ['mc_g'], 'ylabel': 'Mass (g)', 'colors': ['eff']},
"Valve Positions (ICV/DCV open frac)": {'keys': ['ICV_open_frac', 'DCV_open_frac'],
'ylabel': 'Open Fraction', 'colors': ['mdot', 'press']},
"Valve Flow Rates (ICV/DCV leak)": {'keys': ['ICV_leak_kgpm', 'DCV_leak_kgpm'],
'ylabel': 'Flow (kg/min)', 'colors': ['mdot', 'press']},
"Piston Position (yp)": {'keys': ['yp'], 'ylabel': 'Position (norm)', 'colors': ['eff']},
"Specific Enthalpy (hc)": {'keys': ['hc'], 'ylabel': 'Enthalpy (kJ/kg)','colors': ['temp']},
"Total Mass Rate (dm_tot_kgps)": {'keys': ['dm_tot_kgps'], 'ylabel': 'dm/dt (kg/s)', 'colors': ['mdot']},
"Snubber Pressure (psnub)": {'keys': ['psnub'], 'ylabel': 'Pressure (barg)', 'colors': ['press']},
"CHSS Pressure (pchss)": {'keys': ['pchss'], 'ylabel': 'Pressure (barg)', 'colors': ['eff']},
}
DEFAULT_HISTORY_SELECTION = [
"Chamber Pressure (pc)",
"Chamber Temperature (Tc_K)",
"Valve Positions (ICV/DCV open frac)",
"Valve Flow Rates (ICV/DCV leak)",
] # Max 4 — rendered in a fixed 2x2 grid
def _build_history_fig(hist, selected_vars, Pexit, speed_f):
"""Build fixed 2x2 subplot grid from selected history variables (max 4)."""
if hist is None:
return None
if not selected_vars:
selected_vars = []
# Stack plots vertically — one per selected variable (up to 6 with downstream)
selected_vars = selected_vars[:6]
nrows = len(selected_vars) if selected_vars else 1
titles = [s.split('(')[0].strip() for s in selected_vars] or ['']
vspacing = 0.06 if nrows <= 4 else 0.04
fig = make_subplots(rows=nrows, cols=1, subplot_titles=titles,
vertical_spacing=vspacing)
ang = hist['angle_deg']
for i, var_name in enumerate(selected_vars):
cfg = _HIST_MAP.get(var_name)
if cfg is None:
continue
row = i + 1
for j, key in enumerate(cfg['keys']):
if key in hist:
fig.add_trace(go.Scattergl(
x=ang, y=hist[key], mode='lines',
line=dict(color=COLORS[cfg['colors'][j]], width=2,
dash='dash' if j > 0 else 'solid'),
name=key,
), row=row, col=1)
fig.update_yaxes(title_text=cfg['ylabel'], row=row, col=1)
fig.update_xaxes(title_text='Angle (deg)', row=row, col=1,
tickvals=[0, 90, 180, 270, 360])
# MAWP line if pressure is plotted
if "Chamber Pressure (pc)" in selected_vars:
idx = selected_vars.index("Chamber Pressure (pc)")
fig.add_hline(y=960, line=dict(color=COLORS['mawp'], dash='dash', width=1.5),
row=idx + 1, col=1)
_plotly_layout(fig, f"Cycle at {Pexit:.0f} barg, {speed_f:.0%} speed",
height=max(400, 300 * nrows))
return fig
# ===========================================================================
# Defaults from validated MVP 1.0 Simplex preset
# Unpacked from the DEFAULT_* arrays for individual Gradio Number components
# ===========================================================================
# ICV: [port, mass, travel, dp_area, Fs, SC, leakKv, comp_eff, npts]
D_ICV = dict(port=20.5, mass=48.0, travel=5.0, dp_area=779.3,
Fs=33.4, SC=3.75, leakKv=0.0000736, comp_eff=1.0, npts=100)
# DCV: same order
D_DCV = dict(port=8.3, mass=25.0, travel=3.79, dp_area=78.54,
Fs=7.8, SC=1.053, leakKv=0.0000026, comp_eff=0.8, npts=200)
# Pump: [bore, stroke, hOD, cLen, emH, emS, kvoid, kH, Vfv, vac, cpm, dvf]
D_PUMP = dict(bore=40.3, stroke=60.0, hOD=120.0, cLen=300.0,
emH=0.8, emS=0.8, kvoid=0.026, kH=15.0,
Vfv=37.0, vac=760000.0, cpm=500.0, dvf=0.01)
# Proc: [Tamb, htc_amb, net_drive, F_mult, Kv_BB, Pbb_exit, fric2chamber, Exp_eff]
D_PROC = dict(Tamb=300.0, htc=10.0, drive=4.0, Fmult=30.0,
KvBB=0.006, Pbb=0.0, fric=0.5, Exp=0.2)
# ===========================================================================
# Shared: compact parameter summary HTML
# ===========================================================================
def _fmt_val(v):
"""Format a parameter value compactly."""
if isinstance(v, float) and v != 0 and (abs(v) < 0.001 or abs(v) >= 1e6):
return f'{v:.4g}'
if isinstance(v, float):
return f'{v:g}'
return str(v)
def _build_params_summary_html(Pexit, speed_f, Ptank, Psat, fluid,
icv_port, icv_mass, icv_Fs, icv_SC, icv_leakKv,
dcv_port, dcv_mass, dcv_Fs, dcv_SC, dcv_leakKv,
bore, stroke, dvf, cpm,
KvBB, fric, Exp, flash_eff,
fill_type, vsnubber, vchss, aov140f, rodia,
label="Base Parameters"):
"""Build a compact parameter summary box (reused by Single Run and Sweep tabs)."""
_f = _fmt_val
return (
'<div style="font-family: var(--font-mono); font-size: 10px; color: var(--text-secondary);'
' background: var(--bg-elevated); border: 1px solid var(--border); padding: 8px 12px;'
' line-height: 1.6; margin-bottom: 6px;">'
'<span style="font-weight:600; text-transform:uppercase; letter-spacing:0.08em;'
f' font-size:9px; color: var(--text-muted);">{label}</span><br>'
f'<b>Operating:</b> Pexit={_f(Pexit)} barg, speed={_f(speed_f)}, '
f'Ptank={_f(Ptank)}, Psat={_f(Psat)}, fluid={fluid}<br>'
f'<b>ICV:</b> port={_f(icv_port)}mm, mass={_f(icv_mass)}g, '
f'Fs={_f(icv_Fs)}N, SC={_f(icv_SC)}, leakKv={_f(icv_leakKv)}<br>'
f'<b>DCV:</b> port={_f(dcv_port)}mm, mass={_f(dcv_mass)}g, '
f'Fs={_f(dcv_Fs)}N, SC={_f(dcv_SC)}, leakKv={_f(dcv_leakKv)}<br>'
f'<b>Pump:</b> bore={_f(bore)}mm, stroke={_f(stroke)}mm, '
f'dvf={_f(dvf)}, cpm={_f(cpm)}<br>'
f'<b>Process:</b> Kv_BB={_f(KvBB)}, fric={_f(fric)}, '
f'Exp_eff={_f(Exp)}, flash_eff={_f(flash_eff)}<br>'
f'<b>Downstream:</b> mode={fill_type}, snubber={_f(vsnubber)}L, '
f'CHSS={_f(vchss)}L, AOV140={_f(aov140f)}, RO_dia={_f(rodia)}mm'
'</div>'
)
# ===========================================================================
# Tab 1: Single Run (full 42-parameter inputs)
# ===========================================================================
def run_single_sim(Pexit, speed_f, Ptank, Psat, fluid,
icv_port, icv_mass, icv_travel, icv_dp_area,
icv_Fs, icv_SC, icv_leakKv, icv_comp_eff, icv_npts,
dcv_port, dcv_mass, dcv_travel, dcv_dp_area,
dcv_Fs, dcv_SC, dcv_leakKv, dcv_comp_eff, dcv_npts,
bore, stroke, hOD, cLen, emH, emS, kvoid, kH,
Vfv, vac, cpm, dvf,
Tamb, htc, drive, Fmult, KvBB, Pbb, fric, Exp,
flash_eff,
fill_type, vsnubber, vchss, aov140f, rodia,
num_cycles=1, engine='njit_10var'):
"""Run a single simulation with full parameter control."""
# Switch engine on the runner
global _runner
if hasattr(_runner, '_engine') and _runner._engine != engine:
_runner = SimulationRunner(engine=engine)
_runner._engine = engine
# Build parameter arrays matching cycle_sim() signature order
ICVparam = [icv_port, icv_mass, icv_travel, icv_dp_area,
icv_Fs, icv_SC, icv_leakKv, icv_comp_eff, int(icv_npts)]
DCVparam = [dcv_port, dcv_mass, dcv_travel, dcv_dp_area,
dcv_Fs, dcv_SC, dcv_leakKv, dcv_comp_eff, int(dcv_npts)]
pump_geom = [bore, stroke, hOD, cLen, emH, emS, kvoid, kH,
Vfv, vac, cpm, dvf]
# proc_param order: [Tamb, htc_amb, net_drive_kgf, F_multiplier,
# Kv_BB, Pbb_exit_barg, fric2chamber, Exp_eff]
proc_param = [Tamb, htc, drive, Fmult, KvBB, Pbb, fric, Exp]
# Build exit_param if downstream mode is selected
exit_param = None
if fill_type == "CHSS Fill":
exit_param = [1, vsnubber, vchss, aov140f, rodia]
elif fill_type == "RO Vent":
exit_param = [0, vsnubber, 0, aov140f, rodia]
r = _runner.run_single(Pexit_barg=Pexit, speed_f=speed_f,
Ptank_barg=Ptank, Psat_barg=Psat,
ICVparam=ICVparam, DCVparam=DCVparam,
pump_geom=pump_geom, proc_param=proc_param,
fluid=fluid, keep_history=True,
flash_eff=flash_eff,
exit_param=exit_param,
num_cycles=int(num_cycles) if num_cycles else 1)
d = _diag.evaluate(r)
interp = _diag.physics_interpretation(d['findings'])
# Build parameter summary (shown above cycle plots)
params_html = _build_params_summary_html(
Pexit, speed_f, Ptank, Psat, fluid,
icv_port, icv_mass, icv_Fs, icv_SC, icv_leakKv,
dcv_port, dcv_mass, dcv_Fs, dcv_SC, dcv_leakKv,
bore, stroke, dvf, cpm,
KvBB, fric, Exp, flash_eff,
fill_type, vsnubber, vchss, aov140f, rodia)
if not r['success']:
return (f"**Simulation Failed**: {r['error']}", params_html, None, None, None, None, "")
s = r['scalars']
# Status badge
badge_cls = {'OK': 'badge-ok', 'WARNING': 'badge-warn', 'CRITICAL': 'badge-crit'}
badge_label = {'OK': 'OK', 'WARNING': 'WARNING', 'CRITICAL': ''}
status_badge = f'<span class="badge {badge_cls[d["overall_status"]]}">{badge_label[d["overall_status"]]}</span>'
metrics_html = f"""
<div style="display:grid; grid-template-columns: repeat(4, 1fr); gap:8px; margin:8px 0;">
<div class="metric-card" style="border-top: 3px solid var(--accent-blue);">
<div class="metric-val">{s['mdot_kgpm']:.4f}</div>
<div class="metric-label">Mass Flow (kg/min)</div>
</div>
<div class="metric-card" style="border-top: 3px solid var(--accent-green);">
<div class="metric-val">{s['mass_eff']:.4f}</div>
<div class="metric-label">Mass Efficiency</div>
</div>
<div class="metric-card" style="border-top: 3px solid var(--accent-amber);">
<div class="metric-val">{s['Tc_peak_K']:.1f}</div>
<div class="metric-label">Peak Temp (K)</div>
</div>
<div class="metric-card" style="border-top: 3px solid var(--accent-red);">
<div class="metric-val">{s['pc_peak_barg']:.1f}</div>
<div class="metric-label">Peak Pressure (barg)</div>
</div>
</div>
<div style="display:grid; grid-template-columns: repeat(4, 1fr); gap:8px; margin:4px 0 8px 0;">
<div class="metric-card">
<div class="metric-val">{s['DCV_ct_s']*1000:.2f}</div>
<div class="metric-label">DCV Close (ms)</div>
</div>
<div class="metric-card">
<div class="metric-val">{s.get('kWh_extend', 0):.4f}</div>
<div class="metric-label">kWh/kg (extend)</div>
</div>
<div class="metric-card">
<div class="metric-val">{s['ICVmax_open_frac']:.3f}</div>
<div class="metric-label">ICV Max Open</div>
</div>
<div class="metric-card">
<div class="metric-val">{r['wall_time_s']:.2f}</div>
<div class="metric-label">CPU Time (s)</div>
</div>
</div>
<div style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #888880; margin-top: 4px;">
{status_badge} &nbsp;
{r['wall_time_s']:.2f}s wall · {r['steps']} steps
</div>
"""
# Diagnostics HTML
diag_html = _build_diag_html(d['findings'], interp)
# Cycle plots — include downstream pressure channels if present
plot_selection = list(DEFAULT_HISTORY_SELECTION)
if r['history'] and 'psnub' in r['history']:
plot_selection.append("Snubber Pressure (psnub)")
if r['history'] and 'pchss' in r['history']:
plot_selection.append("CHSS Pressure (pchss)")
fig = _build_history_fig(r['history'], plot_selection, Pexit, speed_f)
# Summary table — all 18+ metrics (matches Tkinter GUI output)
summary_rows = [
{'Metric': 'Mass Flow Rate', 'Value': f"{s['mdot_kgpm']:.4f}", 'Unit': 'kg/min'},
{'Metric': 'Mass Discharged', 'Value': f"{s['m_discharged_kg']*1000:.4f}", 'Unit': 'g/cycle'},
{'Metric': 'Mass Inflow Eff', 'Value': f"{s['mass_eff']:.4f}", 'Unit': '-'},
{'Metric': 'Cycle Mass Eff', 'Value': f"{s['cycle_mass_eff']:.4f}", 'Unit': '-'},
{'Metric': 'Peak Chamber Temp', 'Value': f"{s['Tc_peak_K']:.1f}", 'Unit': 'K'},
{'Metric': 'Peak Chamber Press', 'Value': f"{s['pc_peak_barg']:.1f}", 'Unit': 'barg'},
{'Metric': 'ICV Opens At', 'Value': f"{s.get('ICV_opens_at_frac', 0)*100:.1f}", 'Unit': '% stroke'},
{'Metric': 'ICV Max Open Frac', 'Value': f"{s['ICVmax_open_frac']:.3f}", 'Unit': '-'},
{'Metric': 'ICV Closure', 'Value': f"{s.get('ICV_closure_s', 0)*1000:.2f}", 'Unit': 'ms after extend'},
{'Metric': 'Max ICV Open Force', 'Value': f"{s.get('max_ICV_open_force_N', 0):.1f}", 'Unit': 'N'},
{'Metric': 'Max ICV Close Force','Value': f"{s.get('max_ICV_close_force_N', 0):.1f}", 'Unit': 'N'},
{'Metric': 'Max ICV Velocity', 'Value': f"{s.get('max_ICV_velocity_mps', 0)*1000:.1f}", 'Unit': 'mm/s'},
{'Metric': 'DCV Closure Time', 'Value': f"{s['DCV_ct_s']*1000:.3f}", 'Unit': 'ms'},
{'Metric': 'DCV Opens At', 'Value': f"{s.get('DCV_opens_at_frac', 0)*100:.1f}", 'Unit': '% extend'},
{'Metric': 'Max DCV Force', 'Value': f"{s.get('max_DCV_force_N', 0):.1f}", 'Unit': 'N'},
{'Metric': 'kWh Retract', 'Value': f"{s.get('kWh_retract', 0):.6f}", 'Unit': 'kWh/kg'},
{'Metric': 'kWh Extend', 'Value': f"{s.get('kWh_extend', 0):.6f}", 'Unit': 'kWh/kg'},
{'Metric': 'CPU Time', 'Value': f"{s.get('cpu_time_s', 0):.2f}", 'Unit': 's'},
]
# Downstream volume metrics (only present when exit_param is set)
if 'psnub_peak' in s:
summary_rows.append({'Metric': 'Snubber Peak P', 'Value': f"{s['psnub_peak']:.1f}", 'Unit': 'barg'})
if 'pchss_final' in s:
summary_rows.append({'Metric': 'CHSS Final P', 'Value': f"{s['pchss_final']:.1f}", 'Unit': 'barg'})
summary_df = pd.DataFrame(summary_rows)
# Stash for Save Run + plot rebuild (gr.State)
# history is included for plot rebuild; save_current_run ignores it
stash = {
'Pexit_barg': Pexit, 'speed_f': speed_f,
'Ptank_barg': Ptank, 'Psat_barg': Psat,
'fluid': fluid, 'flash_eff': flash_eff,
'fill_type': fill_type,
'exit_param': exit_param,
'ICVparam': ICVparam, 'DCVparam': DCVparam,
'pump_geom': pump_geom, 'proc_param': proc_param,
'scalars': dict(s),
'history': r['history'],
}
# Pump cycle animation (downsample 26k -> 360 frames, ~36KB JSON)
anim_data = _downsample_for_animation(r['history'])
anim_html = _build_animation_html(anim_data, bore, stroke)
return metrics_html, params_html, fig, diag_html, summary_df, stash, anim_html
# ===========================================================================
# Tab 2: Parameter Sweep (unchanged logic)
# ===========================================================================
def _build_1d_sweep_fig(sr, param_name):
"""Build 3-panel Plotly figure for a 1D parameter sweep."""
ok = [r for r in sr['results'] if r['success']]
vals = [r['param_value'] for r in ok]
mdots = [r['mdot_kgpm'] for r in ok]
effs = [r['mass_eff'] for r in ok]
temps = [r['Tc_peak_K'] for r in ok]
presses = [r['pc_peak_barg'] for r in ok]
default_val = SWEEP_PARAMS[param_name]['default']
fig = make_subplots(rows=3, cols=1, shared_xaxes=True,
subplot_titles=['Mass Flow Rate', 'Efficiency & Temperature',
'Peak Pressure'],
specs=[[{}], [{'secondary_y': True}], [{}]],
vertical_spacing=0.08)
fig.add_trace(go.Scatter(x=vals, y=mdots, mode='lines+markers',
line=dict(color=COLORS['mdot'], width=2),
marker=dict(size=6), name='mdot (kg/min)'), row=1, col=1)
fig.add_vline(x=default_val, line=dict(color='gray', dash='dash', width=1),
annotation_text=f'Default ({default_val})', row=1, col=1)
fig.add_trace(go.Scatter(x=vals, y=effs, mode='lines+markers',
line=dict(color=COLORS['eff'], width=2),
marker=dict(size=5, symbol='square'), name='Mass Efficiency'),
row=2, col=1, secondary_y=False)
fig.add_trace(go.Scatter(x=vals, y=temps, mode='lines+markers',
line=dict(color=COLORS['temp'], width=2),
marker=dict(size=5, symbol='triangle-up'), name='Peak Temp (K)'),
row=2, col=1, secondary_y=True)
fig.add_hline(y=0.5, line=dict(color=COLORS['eff'], dash='dot', width=1),
row=2, col=1)
fig.add_trace(go.Scatter(x=vals, y=presses, mode='lines+markers',
line=dict(color=COLORS['press'], width=2),
marker=dict(size=5, symbol='diamond'), name='Peak Pressure'),
row=3, col=1)
fig.add_hline(y=960, line=dict(color=COLORS['mawp'], dash='dash', width=2),
annotation_text='MAWP (960 barg)', row=3, col=1)
fig.add_vline(x=default_val, line=dict(color='gray', dash='dash', width=1),
row=3, col=1)
fig.update_yaxes(title_text='mdot (kg/min)', row=1, col=1)
fig.update_yaxes(title_text='Mass Efficiency', row=2, col=1, secondary_y=False)
fig.update_yaxes(title_text='Peak Temp (K)', row=2, col=1, secondary_y=True)
fig.update_yaxes(title_text='Peak Pressure (barg)', row=3, col=1)
fig.update_xaxes(title_text=param_name, row=3, col=1)
pexit = sr['operating_conditions']['Pexit_barg']
speed = sr['operating_conditions']['speed_f']
_plotly_layout(fig, f"Sweep: {param_name} | Pexit={pexit:.0f} barg, speed={speed:.0%}",
height=750)
return fig
def _build_sweep_summary_html(sr, param_name):
"""Build HTML summary for a 1D sweep with sensitivity + optimals."""
parts = [f'<div style="font-family: \'JetBrains Mono\', monospace; font-size: 11px; '
f'color: #888880; margin-bottom: 8px;">'
f'{sr["n_runs"]} runs · {sr["n_failures"]} failures · '
f'{sr["total_time_s"]:.1f}s total</div>']
# Sensitivity from sweep_analysis
insights_text = sweep_insights(sr)
if insights_text:
parts.append(f'<pre style="font-family: var(--font-mono); font-size: 10px; '
f'color: var(--text-secondary); background: var(--bg-panel); '
f'border: 1px solid var(--border); padding: 8px; margin: 8px 0; '
f'white-space: pre-wrap; overflow-x: auto;">{insights_text}</pre>')
if sr['optimal']:
o = sr['optimal']
parts.append(
f'<div class="finding finding-ok">'
f'<span class="finding-tag">BEST BY FLOW</span>'
f'<span class="finding-msg">{param_name} = {o["by_mdot"]["value"]:.4g} '
f'→ mdot = {o["by_mdot"]["mdot_kgpm"]:.4f} kg/min</span></div>')
parts.append(
f'<div class="finding finding-ok">'
f'<span class="finding-tag">BEST BY EFFICIENCY</span>'
f'<span class="finding-msg">{param_name} = {o["by_efficiency"]["value"]:.4g} '
f'→ eff = {o["by_efficiency"]["mass_eff"]:.4f}</span></div>')
opt = _sweep.find_optimal(sr, objective='mdot_kgpm',
constraints={'pc_peak_barg': ('<', 960), 'mass_eff': ('>', 0.3)})
if opt['found']:
parts.append(
f'<div class="finding finding-ok">'
f'<span class="finding-tag">CONSTRAINED OPTIMAL</span>'
f'<span class="finding-msg">{param_name} = {opt["optimal_value"]:.4g} '
f'→ mdot = {opt["objective_value"]:.4f} kg/min</span>'
f'<span class="finding-detail">Constraints: pc &lt; 960 barg, eff &gt; 0.3</span></div>')
else:
pexit = sr['operating_conditions']['Pexit_barg']
parts.append(
f'<div class="finding finding-warn">'
f'<span class="finding-tag">NO FEASIBLE POINT</span>'
f'<span class="finding-msg">All points exceed MAWP at '
f'Pexit={pexit:.0f} barg</span></div>')
issues = [(r['param_value'], r.get('status', '')) for r in sr['results']
if r.get('status') in ('CRITICAL', 'WARNING')]
for val, st in issues:
cls = 'finding-crit' if st == 'CRITICAL' else 'finding-warn'
tag = '' if st == 'CRITICAL' else st
parts.append(
f'<div class="finding {cls}">'
f'<span class="finding-tag">{tag}</span>'
f'<span class="finding-msg">{param_name} = {val:.4g}</span></div>')
return "\n".join(parts)
def _build_2d_heatmap_fig(sr):
"""Build 2x2 Plotly heatmap figure for a 2D parameter sweep."""
grid = sr['grid']
vals_x = sr['vals_x']
vals_y = sr['vals_y']
param_x = sr['param_x']
param_y = sr['param_y']
metrics = [
('mdot_kgpm', 'Mass Flow (kg/min)', 'Blues'),
('mass_eff', 'Mass Efficiency', 'Greens'),
('Tc_peak_K', 'Peak Temp (K)', 'Oranges'),
('pc_peak_barg', 'Peak Pressure (barg)','Reds'),
]
fig = make_subplots(rows=2, cols=2,
subplot_titles=[m[1] for m in metrics],
horizontal_spacing=0.18, vertical_spacing=0.14)
# Position each colorbar next to its own subplot quadrant
# (x, y) anchors for top-left, top-right, bottom-left, bottom-right
_cbar_pos = [
dict(x=0.42, y=0.78, len=0.35), # top-left
dict(x=1.0, y=0.78, len=0.35), # top-right
dict(x=0.42, y=0.22, len=0.35), # bottom-left
dict(x=1.0, y=0.22, len=0.35), # bottom-right
]
for idx, (key, label, cscale) in enumerate(metrics):
row = idx // 2 + 1
col = idx % 2 + 1
z = grid.get(key)
if z is None:
continue
z_arr = np.asarray(z, dtype=np.float64)
cb = _cbar_pos[idx]
fig.add_trace(go.Heatmap(
x=vals_x, y=vals_y, z=z_arr,
colorscale=cscale,
colorbar=dict(title=dict(text=label, font=dict(size=10)),
len=cb['len'], x=cb['x'], y=cb['y'],
thickness=12, tickfont=dict(size=9)),
hovertemplate=(f'{param_x}: %{{x:.4g}}<br>{param_y}: %{{y:.4g}}<br>'
f'{label}: %{{z:.4f}}<extra></extra>'),
), row=row, col=col)
fig.update_xaxes(title_text=param_x, row=row, col=col)
fig.update_yaxes(title_text=param_y, row=row, col=col)
_plotly_layout(fig, f"2D Sweep: {param_x} × {param_y}", height=700)
return fig
def _build_2d_summary_html(sr):
"""Build HTML summary for a 2D sweep with insights from sweep_analysis."""
n_total = len(sr['vals_x']) * len(sr['vals_y'])
n_ok = sum(1 for r in sr['results'] if r.get('success', False))
elapsed = sr.get('total_time_s', 0)
parts = [f'<div style="font-family: var(--font-mono); font-size: 11px; '
f'color: #888880; margin-bottom: 8px;">'
f'{n_total} grid points · {n_ok} successful · {elapsed:.1f}s total</div>']
insights_text = sweep_2d_insights(sr)
if insights_text:
parts.append(f'<pre style="font-family: var(--font-mono); font-size: 10px; '
f'color: var(--text-secondary); background: var(--bg-panel); '
f'border: 1px solid var(--border); padding: 8px; margin: 8px 0; '
f'white-space: pre-wrap; overflow-x: auto;">{insights_text}</pre>')
return "\n".join(parts)
def _cache_stats_html():
"""Return a small HTML snippet with cache hit/miss stats."""
if _cache is None:
return '<span style="font-family: var(--font-mono); font-size: 10px; color: #888880;">Cache: disabled</span>'
s = _cache.stats
return (f'<span style="font-family: var(--font-mono); font-size: 10px; color: #888880;">'
f'Cache: {s["total_cached"]} entries · '
f'{s["session_hits"]} hits / {s["session_misses"]} misses '
f'({s["hit_rate"]:.0%} hit rate)</span>')
def run_sweep_unified(mode, param_x_label, param_y_label,
x_lo, x_hi, x_steps,
y_lo, y_hi, y_steps,
Pexit, speed_f, Ptank, Psat, fluid,
icv_port, icv_mass, icv_travel, icv_dp_area,
icv_Fs, icv_SC, icv_leakKv, icv_comp_eff, icv_npts,
dcv_port, dcv_mass, dcv_travel, dcv_dp_area,
dcv_Fs, dcv_SC, dcv_leakKv, dcv_comp_eff, dcv_npts,
bore, stroke, hOD, cLen, emH, emS, kvoid, kH,
Vfv, vac, cpm, dvf,
Tamb, htc, drive, Fmult, KvBB, Pbb, fric, Exp,
flash_eff,
fill_type, vsnubber, vchss, aov140f, rodia,
num_cycles=1, engine='njit_10var'):
"""Unified sweep callback — uses Simulation tab parameters as base values."""
# Resolve param names from tier-labelled dropdown values
param_x = _sweep_name_map.get(param_x_label, param_x_label)
param_y = _sweep_name_map.get(param_y_label, param_y_label)
# Build parameter arrays from Simulation tab values
ICVparam = [icv_port, icv_mass, icv_travel, icv_dp_area,
icv_Fs, icv_SC, icv_leakKv, icv_comp_eff, int(icv_npts)]
DCVparam = [dcv_port, dcv_mass, dcv_travel, dcv_dp_area,
dcv_Fs, dcv_SC, dcv_leakKv, dcv_comp_eff, int(dcv_npts)]
pump_geom = [bore, stroke, hOD, cLen, emH, emS, kvoid, kH,
Vfv, vac, cpm, dvf]
proc_param = [Tamb, htc, drive, Fmult, KvBB, Pbb, fric, Exp]
params_html = _build_params_summary_html(
Pexit, speed_f, Ptank, Psat, fluid,
icv_port, icv_mass, icv_Fs, icv_SC, icv_leakKv,
dcv_port, dcv_mass, dcv_Fs, dcv_SC, dcv_leakKv,
bore, stroke, dvf, cpm,
KvBB, fric, Exp, flash_eff,
fill_type, vsnubber, vchss, aov140f, rodia,
label="Base Parameters (from Simulation tab)")
# Build exit_param from fill mode settings (same logic as run_single_sim)
exit_param = None
if fill_type == "CHSS Fill":
exit_param = [1, vsnubber, vchss, aov140f, rodia]
elif fill_type == "RO Vent":
exit_param = [0, vsnubber, 0, aov140f, rodia]
try:
if mode == "2D Sweep":
if param_x == param_y:
return (params_html, None,
'<div class="finding finding-crit">'
'<span class="finding-tag">ERROR</span>'
'<span class="finding-msg">X and Y parameters must be different</span></div>',
None, _cache_stats_html())
sr = _sweep.sweep_two_params(
param_x, param_y,
n_x=int(x_steps), n_y=int(y_steps),
Pexit_barg=Pexit, speed_f=speed_f,
Ptank_barg=Ptank, Psat_barg=Psat,
flash_eff=flash_eff,
range_x=(x_lo, x_hi), range_y=(y_lo, y_hi),
ICVparam=ICVparam, DCVparam=DCVparam,
pump_geom=pump_geom, proc_param=proc_param,
exit_param=exit_param,
)
fig = _build_2d_heatmap_fig(sr)
summary = _build_2d_summary_html(sr)
# Build flat results table for 2D
df = pd.DataFrame(sr['results'])
return params_html, fig, summary, df, _cache_stats_html()
else:
sr = _sweep.sweep_single_param(
param_x, n_points=int(x_steps),
Pexit_barg=Pexit, speed_f=speed_f,
Ptank_barg=Ptank, Psat_barg=Psat,
flash_eff=flash_eff,
custom_range=(x_lo, x_hi),
ICVparam=ICVparam, DCVparam=DCVparam,
pump_geom=pump_geom, proc_param=proc_param,
exit_param=exit_param,
)
fig = _build_1d_sweep_fig(sr, param_x)
summary = _build_sweep_summary_html(sr, param_x)
df = _sweep.to_dataframe(sr)
return params_html, fig, summary, df, _cache_stats_html()
except Exception as e:
err_html = (f'<div class="finding finding-crit">'
f'<span class="finding-tag">ERROR</span>'
f'<span class="finding-msg">{e}</span></div>')
return params_html, None, err_html, None, _cache_stats_html()
# Tier-labelled name mapping (needed by run_sweep_unified, defined at module level)
_tier_labels = {1: "T1", 2: "T2", 3: "T3"}
_sweep_name_map = {f"[{_tier_labels[v['tier']]}] {k}": k
for k, v in SWEEP_PARAMS.items()}
_MAX_SAVED = 10
def save_current_run(name, last_run, saved_runs):
"""Save the last simulation result to session state (gr.State)."""
if saved_runs is None:
saved_runs = []
if last_run is None:
return ('<span style="color:var(--accent-red);">No simulation result to save. '
'Run a simulation first.</span>',
saved_runs, gr.CheckboxGroup(choices=[r['name'] for r in saved_runs]))
if len(saved_runs) >= _MAX_SAVED:
return (f'<span style="color:var(--accent-red);">Max {_MAX_SAVED} saved runs. '
f'Clear some first.</span>',
saved_runs, gr.CheckboxGroup(choices=[r['name'] for r in saved_runs]))
if not name:
name = f"Run #{len(saved_runs) + 1}"
saved_runs.append({'name': name, **last_run})
choices = [r['name'] for r in saved_runs]
return (f'<span style="color:var(--accent-green);">Saved "{name}" '
f'({len(saved_runs)} total)</span>',
saved_runs,
gr.CheckboxGroup(choices=choices))
def compare_saved_runs(selected_names, saved_runs):
"""Build parameter diff table + output comparison for selected saved runs."""
if saved_runs is None:
saved_runs = []
if len(selected_names) < 2:
return None, "Select at least 2 runs."
runs = [r for r in saved_runs if r['name'] in selected_names]
if len(runs) < 2:
return None, "Could not find selected runs."
# Build diff rows: params that differ, then all scalar outputs
param_keys = ['Pexit_barg', 'speed_f', 'Ptank_barg', 'Psat_barg',
'fluid', 'flash_eff']
# Also check array elements by name (using SWEEP_PARAMS for readable names)
array_map = {'ICVparam': 'ICV', 'DCVparam': 'DCV',
'pump_geom': 'Pump', 'proc_param': 'Proc'}
diff_rows = []
for key in param_keys:
vals = [r.get(key) for r in runs]
if len(set(str(v) for v in vals)) <= 1:
continue
row = {'Parameter': key}
for r, v in zip(runs, vals):
row[r['name']] = f"{v}"
diff_rows.append(row)
# Check array elements
for arr_key, prefix in array_map.items():
for r in runs:
arr = r.get(arr_key, [])
for i, val in enumerate(arr):
pname = f"{prefix}[{i}]"
vals = [run.get(arr_key, [None]*(i+1))[i] if i < len(run.get(arr_key, [])) else None
for run in runs]
if len(set(str(v) for v in vals)) <= 1:
continue
if any(pname == row['Parameter'] for row in diff_rows):
continue
row = {'Parameter': pname}
for run, v in zip(runs, vals):
row[run['name']] = f"{v:.6g}" if isinstance(v, float) else str(v)
diff_rows.append(row)
break # only need first run to enumerate indices
# Add scalar outputs
scalar_keys = ['mdot_kgpm', 'mass_eff', 'Tc_peak_K', 'pc_peak_barg',
'DCV_ct_s', 'ICVmax_open_frac', 'kWh_retract', 'kWh_extend']
for key in scalar_keys:
row = {'Parameter': f">> {key}"}
for r in runs:
v = r.get('scalars', {}).get(key, float('nan'))
row[r['name']] = f"{v:.4f}" if isinstance(v, (int, float)) else str(v)
diff_rows.append(row)
df = pd.DataFrame(diff_rows)
best = max(runs, key=lambda r: r.get('scalars', {}).get('mdot_kgpm', 0))
summary = (f'<div class="finding finding-ok">'
f'<span class="finding-tag">BEST FLOW</span>'
f'<span class="finding-msg">{best["name"]} — '
f'{best["scalars"]["mdot_kgpm"]:.4f} kg/min</span></div>')
return df, summary
def clear_saved_runs(saved_runs):
"""Clear all saved runs."""
return ([], '<span style="color:var(--accent-green);">Cleared all saved runs.</span>',
gr.CheckboxGroup(choices=[]))
def _rebuild_plot(selected_vars, last_run):
"""Rebuild the cycle plot from stashed history without re-running the simulation."""
if last_run is None or last_run.get('history') is None:
return None
return _build_history_fig(
last_run['history'], selected_vars,
last_run['Pexit_barg'], last_run['speed_f'])
# ===========================================================================
# Tab 3: Scenario Comparison (unchanged logic)
# ===========================================================================
def run_comparison(p1, s1, p2, s2, p3, s3, enable_3):
scenarios = [
{'Pexit_barg': p1, 'speed_f': s1},
{'Pexit_barg': p2, 'speed_f': s2},
]
names = [f"{p1:.0f} barg @ {s1:.0%}", f"{p2:.0f} barg @ {s2:.0%}"]
if enable_3:
scenarios.append({'Pexit_barg': p3, 'speed_f': s3})
names.append(f"{p3:.0f} barg @ {s3:.0%}")
results = []
for sc in scenarios:
r = _runner.run_single(**sc, keep_history=True)
d = _diag.evaluate(r)
results.append({'result': r, 'diag': d})
rows = []
for name, rd in zip(names, results):
s = rd['result']['scalars']
rows.append({
'Scenario': name,
'mdot (kg/min)': f"{s['mdot_kgpm']:.4f}",
'Efficiency': f"{s['mass_eff']:.4f}",
'Tc Peak (K)': f"{s['Tc_peak_K']:.1f}",
'pc Peak (barg)': f"{s['pc_peak_barg']:.1f}",
'DCV Close (ms)': f"{s['DCV_ct_s']*1000:.3f}",
'kWh Retract': f"{s.get('kWh_retract', 0):.6f}",
'kWh Extend': f"{s.get('kWh_extend', 0):.6f}",
'Status': rd['diag']['overall_status'],
})
comp_df = pd.DataFrame(rows)
plot_colors = [COLORS['mdot'], COLORS['eff'], COLORS['temp']]
fig = make_subplots(rows=2, cols=2,
subplot_titles=['Chamber Pressure (barg)', 'Chamber Temperature (K)',
'Valve Positions', 'Valve Flow Rates (kg/min)'],
horizontal_spacing=0.10, vertical_spacing=0.12)
for i, (name, rd) in enumerate(zip(names, results)):
h = rd['result'].get('history')
if h is None:
continue
c = plot_colors[i % len(plot_colors)]
ang = h['angle_deg']
show = (i == 0) # legend for first scenario only to avoid clutter
fig.add_trace(go.Scattergl(x=ang, y=h['pc'], mode='lines',
line=dict(color=c, width=2), name=name, legendgroup=name,
showlegend=show), row=1, col=1)
fig.add_trace(go.Scattergl(x=ang, y=h['Tc_K'], mode='lines',
line=dict(color=c, width=2), name=name, legendgroup=name,
showlegend=False), row=1, col=2)
fig.add_trace(go.Scattergl(x=ang, y=h['DCV_open_frac'], mode='lines',
line=dict(color=c, width=2), name=f'DCV {name}',
showlegend=show), row=2, col=1)
fig.add_trace(go.Scattergl(x=ang, y=h['ICV_open_frac'], mode='lines',
line=dict(color=c, width=2, dash='dash'), name=f'ICV {name}',
showlegend=False), row=2, col=1)
fig.add_trace(go.Scattergl(x=ang, y=h['DCV_leak_kgpm'], mode='lines',
line=dict(color=c, width=2), name=f'DCV {name}',
showlegend=False), row=2, col=2)
fig.add_trace(go.Scattergl(x=ang, y=h['ICV_leak_kgpm'], mode='lines',
line=dict(color=c, width=2, dash='dash'), name=f'ICV {name}',
showlegend=False), row=2, col=2)
fig.add_hline(y=960, line=dict(color=COLORS['mawp'], dash='dash', width=1.5),
annotation_text='MAWP', row=1, col=1)
fig.update_xaxes(title_text='Crank Angle (deg)', tickvals=[0, 90, 180, 270, 360], row=2, col=1)
fig.update_xaxes(title_text='Crank Angle (deg)', tickvals=[0, 90, 180, 270, 360], row=2, col=2)
_plotly_layout(fig, 'Scenario Comparison — Cycle Overlay', height=650)
winner_mdot = max(results, key=lambda x: x['result']['scalars']['mdot_kgpm'])
winner_name = names[results.index(winner_mdot)]
summary = (f'<div class="finding finding-ok">'
f'<span class="finding-tag">BEST FLOW RATE</span>'
f'<span class="finding-msg">{winner_name}{winner_mdot["result"]["scalars"]["mdot_kgpm"]:.4f} kg/min</span>'
f'</div>')
return comp_df, fig, summary
# ===========================================================================
# Tab 4: Real Pump Cycles (new — per-cycle DB comparison)
# ===========================================================================
def query_cycles(pressure_lo, pressure_hi, min_duration):
"""Query cycle_periods and return formatted table + dropdown choices."""
try:
df = _cycles.get_cycles(pressure_lo, pressure_hi, min_duration)
except Exception as e:
return f"DB Error: {e}", None, gr.Dropdown(choices=[], value=None)
if df.empty:
return "No cycles found in the specified pressure range.", None, gr.Dropdown(choices=[], value=None)
_cycle_cache['df'] = df
# Build display table
display_rows = []
for _, row in df.iterrows():
display_rows.append({
'ID': int(row['id']),
'Date': str(row['cycle_start'])[:16],
'Duration (s)': int(row.get('duration_sec') or 0),
'Speed (%)': f"{row.get('max_motor_speed') or 0:.0f}",
'Peak P (bar)': f"{row.get('max_discharge_pressure') or 0:.0f}",
'Tmin (K)': f"{row.get('min_fill_temperature') or 0:.0f}",
'Tmax (K)': f"{row.get('max_fill_temperature') or 0:.0f}",
'Ptank (bar)': f"{row.get('cryotank_start_pressure') or 0:.1f}",
'Strokes': f"{row.get('total_pump_strokes') or 0:.0f}",
})
display_df = pd.DataFrame(display_rows)
# Build dropdown choices
choices = []
for _, row in df.iterrows():
pmax = row.get('max_discharge_pressure') or 0
speed = row.get('max_motor_speed') or 0
cid = int(row['id'])
choices.append(f"Cycle {cid} - {pmax:.0f} bar, {speed:.0f}% speed")
status = (f'<div style="font-family: var(--font-mono); font-size: 11px; color: #1a7a4a;">'
f'Found {len(df)} cycles in {pressure_lo:.0f}&ndash;{pressure_hi:.0f} bar range</div>')
first_choice = choices[0] if choices else None
return status, display_df, gr.Dropdown(choices=choices, value=first_choice)
def get_cycle_conditions(cycle_selection):
"""Auto-fill sim conditions from selected cycle."""
if not cycle_selection or _cycle_cache.get('df') is None:
return 500, 0.2, 7.0, 2.0, ""
try:
cid = int(cycle_selection.split()[1])
df = _cycle_cache['df']
row = df[df['id'] == cid].iloc[0].to_dict()
cond = CycleBaseline.cycle_to_sim_conditions(row)
pmax = float(row.get('max_discharge_pressure') or 0)
speed_pct = float(row.get('max_motor_speed') or 0)
ptank = cond['Ptank_barg']
speed_f = cond['speed_f']
info = (f'<div style="font-family: var(--font-mono); font-size: 11px; color: #505050;">'
f'Cycle {cid}: Peak P = {pmax:.0f} bar, Motor = {speed_pct:.0f}%, Ptank = {ptank:.1f} bar<br>'
f'speed_f = {speed_pct:.0f}% &times; {H2_SPEED_SCALE:.4f} = '
f'<strong style="color:#1a1a1a;">{speed_f:.4f}</strong></div>')
return cond['Pexit_barg'], speed_f, ptank, cond['Psat_barg'], info
except Exception as e:
return 500, 0.2, 7.0, 2.0, f"Error: {e}"
def run_cycle_comparison(cycle_selection, sim_pexit, sim_speed, sim_ptank, sim_psat):
"""Run simulation at cycle conditions and overlay with real sensor data."""
if not cycle_selection or _cycle_cache.get('df') is None:
return "Select a cycle first.", None, None
try:
cid = int(cycle_selection.split()[1])
df = _cycle_cache['df']
row = df[df['id'] == cid].iloc[0]
cycle_start = row['cycle_start']
cycle_end = row['cycle_end']
except Exception as e:
return f"Error parsing cycle: {e}", None, None
# 1. Run simulation at the specified conditions
r = _runner.run_single(Pexit_barg=sim_pexit, speed_f=sim_speed,
Ptank_barg=sim_ptank, Psat_barg=sim_psat,
keep_history=True)
if not r['success']:
return f"**Simulation failed**: {r['error']}", None, None
s = r['scalars']
# 2. Query real sensor data for this cycle window
try:
real = _cycles.get_cycle_sensors(cycle_start, cycle_end)
except Exception as e:
real = pd.DataFrame()
# 3. Build comparison metrics
pmax_real = float(row.get('max_discharge_pressure') or 0)
speed_real = float(row.get('max_motor_speed') or 0)
tmin_real = float(row.get('min_fill_temperature') or 0)
tmax_real = float(row.get('max_fill_temperature') or 0)
dur_real = float(row.get('duration_sec') or 0)
strokes = float(row.get('total_kg_dispensed') or 0)
metrics_html = f"""
<div style="font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600;
color: #505050; text-transform: uppercase; letter-spacing: 0.1em;
margin-bottom: 8px; border-bottom: 1px solid #a0a09a; padding-bottom: 4px;">
Cycle {cid} vs Simulation
</div>
<table style="width:100%; border-collapse:collapse; background:#f0f0ec; border:1px solid #a0a09a;">
<tr><th style="text-align:left;">Metric</th><th>Real</th><th>Simulated</th></tr>
<tr><td>Peak Pressure (bar)</td><td>{pmax_real:.1f}</td><td>{s['pc_peak_barg']:.1f}</td></tr>
<tr><td>Peak Temperature (K)</td><td>{tmax_real:.1f}</td><td>{s['Tc_peak_K']:.1f}</td></tr>
<tr><td>Min Temperature (K)</td><td>{tmin_real:.1f}</td><td>—</td></tr>
<tr><td>Motor Speed (%)</td><td>{speed_real:.0f}</td><td>speed_f = {sim_speed:.4f}</td></tr>
<tr><td>Mass Flow (kg/min)</td><td>—</td><td>{s['mdot_kgpm']:.4f}</td></tr>
<tr><td>Duration (s)</td><td>{dur_real:.0f}</td><td>{r['wall_time_s']:.2f} (wall)</td></tr>
<tr><td>Mass Efficiency</td><td>—</td><td>{s['mass_eff']:.4f}</td></tr>
</table>
<div style="font-family: 'DM Sans', system-ui; font-size: 11px; color: #888880; margin-top: 6px; font-style: italic;">
FT140 has known calibration issues (~11.4x high for LH2).
Sim models a single pump stroke; real data is a multi-minute fill.
</div>
"""
# 4. Build overlay plot — interactive Plotly
has_real = not real.empty
# Panel 4 needs secondary y for flow rate
fig = make_subplots(rows=2, cols=2,
subplot_titles=['Discharge Pressure (PT130)', 'Discharge Temperature (TT130)',
'Motor Speed (VFD)', 'Inlet Pressure / Flow Rate'],
specs=[[{}, {}], [{}, {'secondary_y': True}]],
horizontal_spacing=0.10, vertical_spacing=0.12)
if has_real:
elapsed = (real.index - real.index[0]).total_seconds()
# Panel 1: Discharge Pressure
if 'PT130' in real.columns:
fig.add_trace(go.Scattergl(x=elapsed, y=real['PT130'], mode='lines',
line=dict(color=COLORS['press'], width=2), name='PT130 (real)',
opacity=0.8), row=1, col=1)
fig.add_hline(y=float(s['pc_peak_barg']),
line=dict(color=COLORS['mdot'], dash='dash', width=2),
annotation_text=f'Sim peak: {s["pc_peak_barg"]:.0f} bar',
row=1, col=1)
fig.add_hline(y=960, line=dict(color=COLORS['mawp'], dash='dot', width=1),
annotation_text='MAWP', row=1, col=1)
# Panel 2: Temperature
if 'TT130' in real.columns:
fig.add_trace(go.Scattergl(x=elapsed, y=real['TT130'], mode='lines',
line=dict(color=COLORS['temp'], width=2), name='TT130 (real)',
opacity=0.8), row=1, col=2)
fig.add_hline(y=float(s['Tc_peak_K']),
line=dict(color=COLORS['mdot'], dash='dash', width=2),
annotation_text=f'Sim Tc_peak: {s["Tc_peak_K"]:.1f} K',
row=1, col=2)
# Panel 3: Motor Speed
if 'VFD' in real.columns:
fig.add_trace(go.Scattergl(x=elapsed, y=real['VFD'], mode='lines',
line=dict(color=COLORS['eff'], width=2), name='Motor Speed (%)',
opacity=0.8), row=2, col=1)
fig.add_hline(y=speed_real,
line=dict(color=COLORS['mdot'], dash='dash', width=2),
annotation_text=f'Max: {speed_real:.0f}%',
row=2, col=1)
# Panel 4: Inlet Pressure + Flow (dual axis)
if 'PT110' in real.columns:
fig.add_trace(go.Scattergl(x=elapsed, y=real['PT110'], mode='lines',
line=dict(color=COLORS['mdot'], width=2), name='PT110 inlet (real)',
opacity=0.8), row=2, col=2, secondary_y=False)
if 'FT140' in real.columns:
ft_corrected = real['FT140'] / 11.4 * 60
fig.add_trace(go.Scattergl(x=elapsed, y=ft_corrected, mode='lines',
line=dict(color=COLORS['temp'], width=1.5), name='FT140/11.4 (kg/min)',
opacity=0.5), row=2, col=2, secondary_y=True)
fig.add_hline(y=float(s['mdot_kgpm']),
line=dict(color=COLORS['mdot'], dash='dash', width=2),
annotation_text=f'Sim: {s["mdot_kgpm"]:.3f} kg/min',
row=2, col=2)
fig.update_yaxes(title_text='Pressure (bar)', row=2, col=2, secondary_y=False)
fig.update_yaxes(title_text='Flow (kg/min)', row=2, col=2, secondary_y=True)
else:
# No real data — show sim-only plots
hist = r['history']
if hist is not None:
ang = hist['angle_deg']
fig.add_trace(go.Scattergl(x=ang, y=hist['pc'], mode='lines',
line=dict(color=COLORS['press'], width=2), name='Pressure (sim)'),
row=1, col=1)
fig.add_trace(go.Scattergl(x=ang, y=hist['Tc_K'], mode='lines',
line=dict(color=COLORS['temp'], width=2), name='Temperature (sim)'),
row=1, col=2)
fig.update_yaxes(title_text='Pressure (bar)', row=1, col=1)
fig.update_yaxes(title_text='Temperature (K)', row=1, col=2)
fig.update_yaxes(title_text='Speed (%)', row=2, col=1)
fig.update_xaxes(title_text='Elapsed Time (s)', row=2, col=1)
fig.update_xaxes(title_text='Elapsed Time (s)', row=2, col=2)
_plotly_layout(fig, f"Cycle {cid}: Real Sensors vs Simulation | "
f"Pexit={sim_pexit:.0f} barg, speed_f={sim_speed:.3f}",
height=650)
# 5. Real sensor summary table (first/last/mean for the cycle window)
sensor_df = None
if has_real:
summary_rows = []
for col in ['PT110', 'PT130', 'TT110', 'TT130', 'VFD', 'FT140', 'Power']:
if col in real.columns:
vals = real[col].dropna()
if len(vals) > 0:
label = col
if col == 'FT140':
label = 'FT140 (raw kg/s)'
summary_rows.append({
'Sensor': label,
'Mean': f"{vals.mean():.2f}",
'Min': f"{vals.min():.2f}",
'Max': f"{vals.max():.2f}",
'Std': f"{vals.std():.3f}",
'N points': len(vals),
})
sensor_df = pd.DataFrame(summary_rows) if summary_rows else None
return metrics_html, fig, sensor_df
# ===========================================================================
# Build Gradio App
# ===========================================================================
_FORCE_LIGHT_JS = """
function() {
document.body.classList.remove('dark');
document.body.classList.add('light');
// Observe and re-remove in case Gradio re-applies dark on hydration
new MutationObserver(function(muts) {
if (document.body.classList.contains('dark')) {
document.body.classList.remove('dark');
document.body.classList.add('light');
}
}).observe(document.body, {attributes: true, attributeFilter: ['class']});
// Style input columns via JS — Gradio 6.x strips .column CSS rules AND
// ignores elem_id/elem_classes on layout components, so we must apply inline.
function styleInputCols() {
// Find every .row that contains columns, style the first column if it has inputs
document.querySelectorAll('.row').forEach(function(row) {
var cols = row.querySelectorAll(':scope > .column');
if (cols.length < 2) return; // Only style in multi-column rows
var col = cols[0];
if (col.dataset.styled) return;
var hasInputs = col.querySelector('input[type="number"], select');
if (!hasInputs) return;
col.dataset.styled = '1';
col.style.maxWidth = '340px';
col.style.minWidth = '280px';
col.style.flex = '0 0 340px';
col.style.gap = '6px';
});
}
setTimeout(styleInputCols, 300);
setTimeout(styleInputCols, 1500);
setTimeout(styleInputCols, 4000);
new MutationObserver(function() { setTimeout(styleInputCols, 100); }).observe(
document.body, {childList: true, subtree: true}
);
}
"""
def build_app():
_gradio_major = int(gr.__version__.split('.')[0])
if _gradio_major >= 5:
ctx = gr.Blocks(title="Dr Murphy")
else:
ctx = gr.Blocks(theme=gr.themes.Base(), css=CSS, js=_FORCE_LIGHT_JS,
title="Dr Murphy")
with ctx as demo:
# Inject CSS and JS for Gradio 5.x (can't use constructor args)
if _gradio_major >= 5:
gr.HTML(f"<style>{CSS}</style>")
# Per-session state (gr.State — safe for multi-user HF Spaces)
last_run_state = gr.State(value=None)
saved_runs_state = gr.State(value=[])
# Flat ruled header — no gradient, no glow
gr.HTML(
'<div class="app-header">'
' <div class="app-header-inner">'
' <div class="app-title">DR MURPHY</div>'
' <div class="app-subtitle">Not everything has to go wrong</div>'
' </div>'
' <div class="app-status-indicator">'
' <span class="app-status-dot"></span>'
' <span class="app-status-label">RUNNING</span>'
' </div>'
'</div>'
)
with gr.Tabs():
# ====================== TAB 1: SIMULATION ======================
with gr.Tab("Simulation"):
with gr.Row(equal_height=False):
# LEFT COLUMN — inputs (fixed width, never stretches)
with gr.Column(scale=0, min_width=280, elem_classes=["sim-input-col"]):
# ── Section 1: Operating Conditions (always visible) ──
sr_pexit = gr.Number(value=900.0, label="Exit Pressure (barg)", step=1, interactive=True)
sr_speed = gr.Number(value=0.8, label="Speed Fraction (0-1)", step=0.01, interactive=True)
with gr.Row():
sr_ptank = gr.Number(value=7.0, label="Tank Pressure (barg)", step=0.1, interactive=True)
sr_psat = gr.Number(value=2.0, label="Sat Pressure (barg)", step=0.1, interactive=True)
with gr.Row():
sr_fluid = gr.Dropdown(choices=["h2", "he", "n2", "o2", "ar", "ch4"],
value="h2", label="Fluid", interactive=True)
sr_engine = gr.Dropdown(
choices=["njit_10var", "ode_v2", "fast"],
value="njit_10var",
label="Engine",
interactive=True,
info="njit: ~1s H2 | ode_v2: ~25s H2/N2 | fast: ~3s legacy"
)
sr_run_btn = gr.Button("Run Simulation", variant="primary")
# ── Section 2: Parameters (accordions, collapsed by default) ──
with gr.Accordion("ICV Parameters", open=False):
with gr.Row():
sr_icv_port = gr.Number(value=D_ICV['port'], label="Port Dia (mm)", step=0.1, interactive=True)
sr_icv_mass = gr.Number(value=D_ICV['mass'], label="Mass (g)", step=0.1, interactive=True)
sr_icv_travel = gr.Number(value=D_ICV['travel'], label="Travel (mm)", step=0.1, interactive=True)
with gr.Row():
sr_icv_dparea = gr.Number(value=D_ICV['dp_area'], label="dP Area (mm²)", step=0.1, interactive=True)
sr_icv_Fs = gr.Number(value=D_ICV['Fs'], label="Spring F (N)", step=0.1, interactive=True)
sr_icv_SC = gr.Number(value=D_ICV['SC'], label="Spring K (N/mm)", step=0.01, interactive=True)
with gr.Row():
sr_icv_leak = gr.Number(value=D_ICV['leakKv'], label="Leak Kv (m³/hr)", step=1e-7, interactive=True)
sr_icv_ceff = gr.Number(value=D_ICV['comp_eff'], label="Comp Eff", step=0.01, interactive=True)
sr_icv_npts = gr.Number(value=D_ICV['npts'], label="Npts", step=1, interactive=True)
with gr.Accordion("DCV Parameters", open=False):
with gr.Row():
sr_dcv_port = gr.Number(value=D_DCV['port'], label="Port Dia (mm)", step=0.1, interactive=True)
sr_dcv_mass = gr.Number(value=D_DCV['mass'], label="Mass (g)", step=0.1, interactive=True)
sr_dcv_travel = gr.Number(value=D_DCV['travel'], label="Travel (mm)", step=0.01, interactive=True)
with gr.Row():
sr_dcv_dparea = gr.Number(value=D_DCV['dp_area'], label="dP Area (mm²)", step=0.01, interactive=True)
sr_dcv_Fs = gr.Number(value=D_DCV['Fs'], label="Spring F (N)", step=0.1, interactive=True)
sr_dcv_SC = gr.Number(value=D_DCV['SC'], label="Spring K (N/mm)", step=0.001, interactive=True)
with gr.Row():
sr_dcv_leak = gr.Number(value=D_DCV['leakKv'], label="Leak Kv (m³/hr)", step=1e-7, interactive=True)
sr_dcv_ceff = gr.Number(value=D_DCV['comp_eff'], label="Comp Eff", step=0.01, interactive=True)
sr_dcv_npts = gr.Number(value=D_DCV['npts'], label="Npts", step=1, interactive=True)
with gr.Accordion("Pump Geometry", open=False):
with gr.Row():
sr_bore = gr.Number(value=D_PUMP['bore'], label="Bore (mm)", step=0.1, interactive=True)
sr_stroke = gr.Number(value=D_PUMP['stroke'], label="Stroke (mm)", step=0.1, interactive=True)
with gr.Row():
sr_cpm = gr.Number(value=D_PUMP['cpm'], label="Speed (cpm)", step=1, interactive=True)
sr_dvf = gr.Number(value=D_PUMP['dvf'], label="Dead Vol Frac", step=0.001, interactive=True)
with gr.Row():
sr_hOD = gr.Number(value=D_PUMP['hOD'], label="Housing OD (mm)", step=1, interactive=True)
sr_cLen = gr.Number(value=D_PUMP['cLen'], label="Chamber Len (mm)", step=1, interactive=True)
with gr.Row():
sr_emH = gr.Number(value=D_PUMP['emH'], label="ε Housing", step=0.01, interactive=True)
sr_emS = gr.Number(value=D_PUMP['emS'], label="ε Shield", step=0.01, interactive=True)
with gr.Row():
sr_kvoid = gr.Number(value=D_PUMP['kvoid'], label="k void (W/m/K)", step=0.001, interactive=True)
sr_kH = gr.Number(value=D_PUMP['kH'], label="k housing (W/m/K)", step=0.1, interactive=True)
with gr.Row():
sr_Vfv = gr.Number(value=D_PUMP['Vfv'], label="Void Vol Frac", step=0.1, interactive=True)
sr_vac = gr.Number(value=D_PUMP['vac'], label="Vacuum (µHg)", step=1000, interactive=True)
with gr.Accordion("Process / Losses", open=False):
with gr.Row():
sr_Tamb = gr.Number(value=D_PROC['Tamb'], label="T_amb (K)", step=1, interactive=True)
sr_htc = gr.Number(value=D_PROC['htc'], label="HTC (W/m²/K)", step=0.1, interactive=True)
with gr.Row():
sr_drive = gr.Number(value=D_PROC['drive'], label="Drive (kgf)", step=0.1, interactive=True)
sr_Fmult = gr.Number(value=D_PROC['Fmult'], label="F Multiplier", step=1, interactive=True)
with gr.Row():
sr_fric = gr.Number(value=D_PROC['fric'], label="Friction (0-1)", step=0.01, interactive=True)
sr_KvBB = gr.Number(value=D_PROC['KvBB'], label="BB Kv (m³/hr)", step=0.001, interactive=True)
with gr.Row():
sr_Pbb = gr.Number(value=D_PROC['Pbb'], label="BB Exit P (barg)", step=0.1, interactive=True)
sr_Exp = gr.Number(value=D_PROC['Exp'], label="Exp Eff (0-1)", step=0.01, interactive=True)
sr_flash_eff = gr.Number(value=0.02, label="Thermal Mass Eff (0-1)", step=0.01, interactive=True)
# ── Section 3: Downstream / Fill Mode (always visible) ──
gr.HTML('<hr style="border:none;border-top:1px solid #a0a09a;margin:6px 0">')
with gr.Row():
sr_fill_type = gr.Dropdown(choices=["Fixed Pexit", "CHSS Fill", "RO Vent"],
value="Fixed Pexit", label="Fill Type", interactive=True)
sr_num_cycles = gr.Number(value=1, label="No. cycles", step=1,
minimum=1, maximum=50, precision=0, interactive=True)
with gr.Row():
sr_vsnubber = gr.Number(value=5.0, label="Snubber (L)", step=0.1, interactive=True)
sr_vchss = gr.Number(value=50.0, label="CHSS (L)", step=1, interactive=True)
with gr.Row():
sr_aov140f = gr.Number(value=1.0, label="AOV140 (0-1)", step=0.01,
minimum=0, maximum=1, interactive=True)
sr_rodia = gr.Number(value=0.94, label="RO Dia (mm)", step=0.01, interactive=True)
# RIGHT COLUMN — outputs (scale=3, ~75% width)
with gr.Column(scale=3):
sr_metrics = gr.HTML(label="Results")
sr_hist_selector = gr.CheckboxGroup(
choices=HISTORY_CHOICES,
value=DEFAULT_HISTORY_SELECTION,
label="Plot Variables (max 4)",
)
sr_params_html = gr.HTML("")
sr_plot = gr.Plot(label="Cycle Plots")
with gr.Accordion("Pump Cycle Animation", open=True):
sr_animation = gr.HTML()
sr_diag = gr.HTML()
# Save Run — above results table
with gr.Row():
sr_save_name = gr.Textbox(label="Run Name",
placeholder="e.g. Baseline 900 barg",
scale=3)
sr_save_btn = gr.Button("Save Run", scale=1)
sr_save_status = gr.HTML("")
with gr.Accordion("Full Results Table", open=False):
sr_table = gr.Dataframe()
# Wire all 42 inputs (plot variables are decoupled)
sr_all_inputs = [
sr_pexit, sr_speed, sr_ptank, sr_psat, sr_fluid,
sr_icv_port, sr_icv_mass, sr_icv_travel, sr_icv_dparea,
sr_icv_Fs, sr_icv_SC, sr_icv_leak, sr_icv_ceff, sr_icv_npts,
sr_dcv_port, sr_dcv_mass, sr_dcv_travel, sr_dcv_dparea,
sr_dcv_Fs, sr_dcv_SC, sr_dcv_leak, sr_dcv_ceff, sr_dcv_npts,
sr_bore, sr_stroke, sr_hOD, sr_cLen, sr_emH, sr_emS, sr_kvoid, sr_kH,
sr_Vfv, sr_vac, sr_cpm, sr_dvf,
sr_Tamb, sr_htc, sr_drive, sr_Fmult, sr_KvBB, sr_Pbb, sr_fric, sr_Exp,
sr_flash_eff,
sr_fill_type, sr_vsnubber, sr_vchss, sr_aov140f, sr_rodia,
sr_num_cycles,
sr_engine,
]
# Parameter accordions are self-contained — no toggle wiring needed
sr_run_btn.click(
fn=run_single_sim,
inputs=sr_all_inputs,
outputs=[sr_metrics, sr_params_html, sr_plot, sr_diag, sr_table, last_run_state, sr_animation],
)
# Rebuild plot instantly when plot variable selection changes
sr_hist_selector.change(
fn=_rebuild_plot,
inputs=[sr_hist_selector, last_run_state],
outputs=[sr_plot],
)
# Save btn wired after Compare Scenarios tab defines sr_compare_checks
# ====================== TAB 2: PARAMETER SWEEP (1D + 2D) ======================
with gr.Tab("Parameter Sweep"):
with gr.Row(equal_height=False):
# LEFT COLUMN — inputs (fixed width)
with gr.Column(scale=0, min_width=320, elem_classes=["sweep-input-col"]):
sw_mode = gr.Radio(choices=["1D Sweep", "2D Sweep"],
value="1D Sweep", label="Sweep Mode")
# Build tier-grouped choices (using module-level _sweep_name_map)
_sweep_choices = list(_sweep_name_map.keys())
sw_param_x = gr.Dropdown(choices=_sweep_choices,
value=_sweep_choices[0],
label="Sweep Parameter (X-axis)",
interactive=True)
sw_param_y = gr.Dropdown(choices=_sweep_choices,
value=_sweep_choices[1],
label="Sweep Parameter (Y-axis)",
visible=False, interactive=True)
with gr.Row():
sw_x_lo = gr.Number(label="X Min", interactive=True)
sw_x_hi = gr.Number(label="X Max", interactive=True)
sw_x_steps = gr.Slider(minimum=3, maximum=20, value=8,
step=1, label="X Steps")
sw_y_range_row = gr.Row(visible=False)
with sw_y_range_row:
sw_y_lo = gr.Number(label="Y Min", interactive=True)
sw_y_hi = gr.Number(label="Y Max", interactive=True)
sw_y_steps = gr.Slider(minimum=3, maximum=15, value=8,
step=1, label="Y Steps")
sw_default_info = gr.Markdown("")
gr.Markdown("<small>*Base parameters are taken from the "
"Simulation tab. Change them there.*</small>")
sw_run_btn = gr.Button("Run Sweep", variant="primary", size="lg")
sw_cache_html = gr.HTML("")
# RIGHT COLUMN — outputs
with gr.Column(scale=2):
sw_params_html = gr.HTML("")
sw_plot = gr.Plot(label="Sweep Results")
sw_summary = gr.HTML(label="Summary")
with gr.Accordion("Results Table", open=False):
sw_table = gr.Dataframe()
# Toggle 2D controls visibility (Gradio 5.x: return component instances)
def _toggle_sweep_mode(mode):
is_2d = mode == "2D Sweep"
return (gr.Dropdown(visible=is_2d), # param_y
gr.Row(visible=is_2d)) # y_range_row
sw_mode.change(
fn=_toggle_sweep_mode, inputs=[sw_mode],
outputs=[sw_param_y, sw_y_range_row],
)
# Auto-fill X range on param change
def _update_x_range(param_label):
name = _sweep_name_map.get(param_label, '')
if name in SWEEP_PARAMS:
p = SWEEP_PARAMS[name]
return p['range'][0], p['range'][1], f"Default: {p['default']}"
return 0, 1, ""
def _update_y_range(param_label):
name = _sweep_name_map.get(param_label, '')
if name in SWEEP_PARAMS:
p = SWEEP_PARAMS[name]
return p['range'][0], p['range'][1]
return 0, 1
sw_param_x.change(
fn=_update_x_range, inputs=[sw_param_x],
outputs=[sw_x_lo, sw_x_hi, sw_default_info],
)
sw_param_y.change(
fn=_update_y_range, inputs=[sw_param_y],
outputs=[sw_y_lo, sw_y_hi],
)
sw_run_btn.click(
fn=run_sweep_unified,
inputs=[sw_mode, sw_param_x, sw_param_y,
sw_x_lo, sw_x_hi, sw_x_steps,
sw_y_lo, sw_y_hi, sw_y_steps,
] + sr_all_inputs,
outputs=[sw_params_html, sw_plot, sw_summary, sw_table, sw_cache_html],
)
# ====================== TAB 3: COMPARE SAVED RUNS ======================
with gr.Tab("Compare Runs"):
gr.HTML('<div style="font-family: var(--font-ui); font-size: 13px; '
'color: var(--text-secondary); margin-bottom: 12px;">'
'Save runs from the Simulation tab, then select 2-5 to compare.</div>')
sr_compare_checks = gr.CheckboxGroup(choices=[],
label="Select runs to compare")
with gr.Row():
sr_compare_btn = gr.Button("Compare Selected", scale=2, variant="primary")
sr_clear_btn = gr.Button("Clear All", scale=1, variant="stop")
sr_compare_table = gr.Dataframe(label="Parameter & Output Diff")
sr_compare_html = gr.HTML("")
sr_compare_btn.click(
fn=compare_saved_runs,
inputs=[sr_compare_checks, saved_runs_state],
outputs=[sr_compare_table, sr_compare_html],
)
sr_clear_btn.click(
fn=clear_saved_runs,
inputs=[saved_runs_state],
outputs=[saved_runs_state, sr_save_status, sr_compare_checks],
)
# ====================== TAB 4: REAL PUMP CYCLES ======================
with gr.Tab("Real Pump Cycles"):
with gr.Row(equal_height=False):
# LEFT COLUMN — query + sim controls
with gr.Column(scale=1, min_width=320):
gr.HTML('<div style="font-family: var(--font-mono); font-size: 10px; '
'font-weight: 600; color: var(--text-secondary); text-transform: uppercase; '
'letter-spacing: 0.1em; margin-bottom: 4px;">Query Controls</div>')
rc_plo = gr.Number(value=100.0, label="Pressure Low (bar)", step=1, interactive=True)
rc_phi = gr.Number(value=1000.0, label="Pressure High (bar)", step=1, interactive=True)
rc_dur = gr.Number(value=60, label="Min Duration (s)", step=1, interactive=True)
rc_query_btn = gr.Button("Query Cycles", variant="primary", size="lg")
rc_select = gr.Dropdown(choices=[], label="Select Cycle",
interactive=True)
rc_info = gr.HTML()
gr.HTML('<div style="font-family: var(--font-mono); font-size: 10px; '
'font-weight: 600; color: var(--text-secondary); text-transform: uppercase; '
'letter-spacing: 0.1em; margin-top: 8px; margin-bottom: 4px;">Sim Conditions</div>')
rc_sim_pexit = gr.Number(value=500, label="Sim Pexit (barg)", step=1, interactive=True)
rc_sim_speed = gr.Number(value=0.2, label="Sim Speed Fraction", step=0.01, interactive=True)
with gr.Row():
rc_sim_ptank = gr.Number(value=7.0, label="Sim Ptank (barg)", step=0.1, interactive=True)
rc_sim_psat = gr.Number(value=2.0, label="Sim Psat (barg)", step=0.1, interactive=True)
rc_compare_btn = gr.Button("Run Comparison", variant="primary", size="lg")
# RIGHT COLUMN — outputs
with gr.Column(scale=2):
rc_status = gr.HTML()
with gr.Accordion("Cycle List", open=False):
rc_table = gr.Dataframe(label="Available Cycles")
rc_metrics = gr.HTML()
rc_plot = gr.Plot(label="Real vs Simulated")
with gr.Accordion("Sensor Summary", open=False):
rc_sensor_table = gr.Dataframe()
# Wire events
rc_query_btn.click(
fn=query_cycles,
inputs=[rc_plo, rc_phi, rc_dur],
outputs=[rc_status, rc_table, rc_select],
)
rc_select.change(
fn=get_cycle_conditions,
inputs=[rc_select],
outputs=[rc_sim_pexit, rc_sim_speed, rc_sim_ptank, rc_sim_psat, rc_info],
)
rc_compare_btn.click(
fn=run_cycle_comparison,
inputs=[rc_select, rc_sim_pexit, rc_sim_speed, rc_sim_ptank, rc_sim_psat],
outputs=[rc_metrics, rc_plot, rc_sensor_table],
)
# Save Run wiring (here because sr_compare_checks is in Compare Scenarios tab)
sr_save_btn.click(
fn=save_current_run,
inputs=[sr_save_name, last_run_state, saved_runs_state],
outputs=[sr_save_status, saved_runs_state, sr_compare_checks],
)
# Force light mode after Gradio hydration completes
demo.load(fn=None, inputs=None, outputs=None, js=_FORCE_LIGHT_JS)
# Load pump animation renderer + controller (global JS functions)
if _PUMP_ANIM_JS:
demo.load(fn=None, inputs=None, outputs=None, js=_PUMP_ANIM_JS)
return demo
if __name__ == '__main__':
import argparse as _ap
_p = _ap.ArgumentParser(description="CSH2 Scenario Analyzer Dashboard")
_p.add_argument('--port', type=int, default=7861, help='Server port (default: 7861)')
_p.add_argument('--share', action='store_true', help='Create public Gradio share link')
_args = _p.parse_args()
demo = build_app()
try:
demo.launch(share=_args.share, server_name="0.0.0.0", server_port=_args.port,
max_threads=40, css=CSS, js=_FORCE_LIGHT_JS)
except TypeError:
demo.launch(share=_args.share, server_name="0.0.0.0", server_port=_args.port,
max_threads=40)