""" 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'''
0.0°
''' # 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"""
{f['check']} {f['message']} {f['rationale']}
""" else: findings_html = '
All checks passed — within normal operating bounds.
' return f"""
Diagnostics
{findings_html}
Physics Interpretation
{interp_text}
""" 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 ( '
' '{label}
' f'Operating: Pexit={_f(Pexit)} barg, speed={_f(speed_f)}, ' f'Ptank={_f(Ptank)}, Psat={_f(Psat)}, fluid={fluid}
' f'ICV: port={_f(icv_port)}mm, mass={_f(icv_mass)}g, ' f'Fs={_f(icv_Fs)}N, SC={_f(icv_SC)}, leakKv={_f(icv_leakKv)}
' f'DCV: port={_f(dcv_port)}mm, mass={_f(dcv_mass)}g, ' f'Fs={_f(dcv_Fs)}N, SC={_f(dcv_SC)}, leakKv={_f(dcv_leakKv)}
' f'Pump: bore={_f(bore)}mm, stroke={_f(stroke)}mm, ' f'dvf={_f(dvf)}, cpm={_f(cpm)}
' f'Process: Kv_BB={_f(KvBB)}, fric={_f(fric)}, ' f'Exp_eff={_f(Exp)}, flash_eff={_f(flash_eff)}
' f'Downstream: mode={fill_type}, snubber={_f(vsnubber)}L, ' f'CHSS={_f(vchss)}L, AOV140={_f(aov140f)}, RO_dia={_f(rodia)}mm' '
' ) # =========================================================================== # 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'{badge_label[d["overall_status"]]}' metrics_html = f"""
{s['mdot_kgpm']:.4f}
Mass Flow (kg/min)
{s['mass_eff']:.4f}
Mass Efficiency
{s['Tc_peak_K']:.1f}
Peak Temp (K)
{s['pc_peak_barg']:.1f}
Peak Pressure (barg)
{s['DCV_ct_s']*1000:.2f}
DCV Close (ms)
{s.get('kWh_extend', 0):.4f}
kWh/kg (extend)
{s['ICVmax_open_frac']:.3f}
ICV Max Open
{r['wall_time_s']:.2f}
CPU Time (s)
{status_badge}   {r['wall_time_s']:.2f}s wall · {r['steps']} steps
""" # 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'
' f'{sr["n_runs"]} runs · {sr["n_failures"]} failures · ' f'{sr["total_time_s"]:.1f}s total
'] # Sensitivity from sweep_analysis insights_text = sweep_insights(sr) if insights_text: parts.append(f'
{insights_text}
') if sr['optimal']: o = sr['optimal'] parts.append( f'
' f'BEST BY FLOW' f'{param_name} = {o["by_mdot"]["value"]:.4g} ' f'→ mdot = {o["by_mdot"]["mdot_kgpm"]:.4f} kg/min
') parts.append( f'
' f'BEST BY EFFICIENCY' f'{param_name} = {o["by_efficiency"]["value"]:.4g} ' f'→ eff = {o["by_efficiency"]["mass_eff"]:.4f}
') opt = _sweep.find_optimal(sr, objective='mdot_kgpm', constraints={'pc_peak_barg': ('<', 960), 'mass_eff': ('>', 0.3)}) if opt['found']: parts.append( f'
' f'CONSTRAINED OPTIMAL' f'{param_name} = {opt["optimal_value"]:.4g} ' f'→ mdot = {opt["objective_value"]:.4f} kg/min' f'Constraints: pc < 960 barg, eff > 0.3
') else: pexit = sr['operating_conditions']['Pexit_barg'] parts.append( f'
' f'NO FEASIBLE POINT' f'All points exceed MAWP at ' f'Pexit={pexit:.0f} barg
') 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'
' f'{tag}' f'{param_name} = {val:.4g}
') 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}}
{param_y}: %{{y:.4g}}
' f'{label}: %{{z:.4f}}'), ), 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'
' f'{n_total} grid points · {n_ok} successful · {elapsed:.1f}s total
'] insights_text = sweep_2d_insights(sr) if insights_text: parts.append(f'
{insights_text}
') return "\n".join(parts) def _cache_stats_html(): """Return a small HTML snippet with cache hit/miss stats.""" if _cache is None: return 'Cache: disabled' s = _cache.stats return (f'' f'Cache: {s["total_cached"]} entries · ' f'{s["session_hits"]} hits / {s["session_misses"]} misses ' f'({s["hit_rate"]:.0%} hit rate)') 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, '
' 'ERROR' 'X and Y parameters must be different
', 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'
' f'ERROR' f'{e}
') 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 ('No simulation result to save. ' 'Run a simulation first.', saved_runs, gr.CheckboxGroup(choices=[r['name'] for r in saved_runs])) if len(saved_runs) >= _MAX_SAVED: return (f'Max {_MAX_SAVED} saved runs. ' f'Clear some first.', 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'Saved "{name}" ' f'({len(saved_runs)} total)', 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'
' f'BEST FLOW' f'{best["name"]} — ' f'{best["scalars"]["mdot_kgpm"]:.4f} kg/min
') return df, summary def clear_saved_runs(saved_runs): """Clear all saved runs.""" return ([], 'Cleared all saved runs.', 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'
' f'BEST FLOW RATE' f'{winner_name} — {winner_mdot["result"]["scalars"]["mdot_kgpm"]:.4f} kg/min' f'
') 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'
' f'Found {len(df)} cycles in {pressure_lo:.0f}–{pressure_hi:.0f} bar range
') 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'
' f'Cycle {cid}: Peak P = {pmax:.0f} bar, Motor = {speed_pct:.0f}%, Ptank = {ptank:.1f} bar
' f'speed_f = {speed_pct:.0f}% × {H2_SPEED_SCALE:.4f} = ' f'{speed_f:.4f}
') 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"""
Cycle {cid} vs Simulation
MetricRealSimulated
Peak Pressure (bar){pmax_real:.1f}{s['pc_peak_barg']:.1f}
Peak Temperature (K){tmax_real:.1f}{s['Tc_peak_K']:.1f}
Min Temperature (K){tmin_real:.1f}
Motor Speed (%){speed_real:.0f}speed_f = {sim_speed:.4f}
Mass Flow (kg/min){s['mdot_kgpm']:.4f}
Duration (s){dur_real:.0f}{r['wall_time_s']:.2f} (wall)
Mass Efficiency{s['mass_eff']:.4f}
FT140 has known calibration issues (~11.4x high for LH2). Sim models a single pump stroke; real data is a multi-minute fill.
""" # 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"") # 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( '
' '
' '
DR MURPHY
' '
Not everything has to go wrong
' '
' '
' ' ' ' RUNNING' '
' '
' ) 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('
') 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("*Base parameters are taken from the " "Simulation tab. Change them there.*") 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('
' 'Save runs from the Simulation tab, then select 2-5 to compare.
') 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('
Query Controls
') 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('
Sim Conditions
') 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)