Spaces:
Sleeping
Sleeping
| """ | |
| 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;">▶</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°</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} | |
| {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 < 960 barg, eff > 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}–{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}% × {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) | |