pandeydigant31's picture
Phase 1 + Phase 2: rename Advanced/Raw Sweep, add Guide tab + per-tab taglines
cfac7e4 verified
Raw
History Blame Contribute Delete
31 kB
"""Tab 4 β€” Process Simulator: downstream piping transient simulation.
Pump β†’ Snubber β†’ Control Valve β†’ Pipe β†’ Atmosphere/Tank.
Supports manual CSV pump LUT upload and auto-generation from cycle2mdot.
"""
from __future__ import annotations
import math
import os
import gradio as gr
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from murphy_unified.process_sim.engine import (
Simulation,
SIM_MODE_VALVE,
SIM_MODE_FILL,
SIM_MODES,
MAX_STEPS,
)
from murphy_unified.process_sim.pump_lut import (
PumpLUT,
generate_pump_lut,
_load_csv,
_find_col,
DATA_DIR,
GENERATED_DIR,
)
from murphy_unified.theme import (
ACCENT,
ACCENT2,
WARN,
DANGER,
TEXT,
TEXT_BRIGHT,
SURFACE,
SURFACE2,
)
from murphy_unified.tab_help import tagline_html
try:
import anthropic
HAS_ANTHROPIC = True
except ImportError:
HAS_ANTHROPIC = False
# ── Parameter definitions ────────────────────────────────────────────────────
PARAM_DEFS = [
("pump_T_out", 60.0, "Pump outlet T (K)", "Pump"),
("pump_cpm_max", 500.0, "Pump max speed (cpm)", "Pump"),
("snub_V_L", 0.5, "Snubber volume (L)", "Snubber"),
("snub_P0_bar", 1.01325, "Snubber init P (bar)", "Snubber"),
("snub_T0_K", 60.0, "Snubber init T (K)", "Snubber"),
("v2_cv", 0.25, "Valve 2 max Cv", "Control Valve 2"),
("v2_pct", 50.0, "Valve 2 opening (%)", "Control Valve 2"),
("p2_id_in", 0.4645, "Pipe 2 ID (inch)", "Pipe 2"),
("p2_len_ft", 10.0, "Pipe 2 length (ft)", "Pipe 2"),
("tank_V_L", 450.0, "Tank volume (L)", "Tank (Fill mode)"),
("tank_P0_bar", 1.01325, "Tank init P (bar)", "Tank (Fill mode)"),
("tank_dT_K", 20.0, "Tank T offset above pump (K)", "Tank (Fill mode)"),
("roughness_mm", 0.015, "Pipe roughness (mm)", "General"),
("P_atm_bar", 1.01325, "Atmospheric P (bar)", "General"),
("motor_pct", 50.0, "Motor speed (%)", "Simulation"),
("dt", 0.001, "Time step (s)", "Simulation"),
("duration", 10.0, "Duration (s)", "Simulation"),
]
PARAM_KEYS = [d[0] for d in PARAM_DEFS]
# ── Helpers ──────────────────────────────────────────────────────────────────
def _file_path(file):
"""Handle both gradio 4 (obj with .name) and gradio 5+ (str path)."""
if file is None:
return None
return getattr(file, "name", file)
def _load_pump_lut(file):
fpath = _file_path(file)
if fpath is None:
return None, "No pump LUT loaded β€” upload a CSV."
try:
lut = PumpLUT.from_csv(fpath)
return lut, lut.summary()
except Exception as e:
return None, f"Error loading pump CSV: {e}"
def _load_profile_csv(file):
fpath = _file_path(file)
if fpath is None:
return None, None, None, "No profile file loaded."
try:
headers, hlower, data = _load_csv(fpath)
except Exception as e:
return None, None, None, f"CSV error: {e}"
t_col = _find_col(hlower, ["time", "seconds", "t_s", "t(s)"])
m_col = _find_col(hlower, ["motor", "speed", "rpm", "pump"])
v_col = _find_col(hlower, ["valve", "v2", "opening", "cv_pct"])
if t_col is None:
return None, None, None, f"No time column. Headers: {headers}"
if m_col is None and v_col is None:
return None, None, None, f"No motor or valve columns. Headers: {headers}"
t_data = data[t_col]
m_data = data.get(m_col) if m_col else None
v_data = data.get(v_col) if v_col else None
msg_parts = [f"Loaded {len(t_data)} rows (t={t_data[0]:.1f}-{t_data[-1]:.1f} s)"]
if m_data is not None:
msg_parts.append(f"Motor: {m_data.min():.0f}-{m_data.max():.0f}%")
if v_data is not None:
msg_parts.append(f"Valve: {v_data.min():.0f}-{v_data.max():.0f}%")
return t_data, m_data, v_data, "\n".join(msg_parts)
def _load_actual_csv(file):
fpath = _file_path(file)
if fpath is None:
return None, "No actual data loaded."
try:
headers, hlower, data = _load_csv(fpath)
except Exception as e:
return None, f"CSV error: {e}"
t_col = _find_col(hlower, ["time", "seconds", "t_s", "t(s)"])
p_col = _find_col(hlower, ["pt130", "pressure", "p_snub", "snubber", "psi", "bar"])
if t_col is None or p_col is None:
return None, f"Need time + pressure cols. Headers: {headers}"
actual = {
"time": data[t_col],
"pressure": data[p_col],
"filename": os.path.basename(fpath),
}
n = len(actual["time"])
msg = (
f"Loaded {actual['filename']} ({n} points, "
f"{actual['time'][0]:.1f}-{actual['time'][-1]:.1f} s)"
)
return actual, msg
# ── Plotting ─────────────────────────────────────────────────────────────────
def _build_plotly_figure(results, actual_data=None):
"""Build 3x2 Plotly subplot grid matching the HF Space layout."""
fill_mode = results.get("sim_mode") == SIM_MODE_FILL and "P_tank_bar" in results
t = results["time"]
fig = make_subplots(
rows=3, cols=2,
subplot_titles=[
"Snubber Pressure", "Pump Mass Flow Rate",
"Outlet Flow Rate", "Tank Pressure" if fill_mode else "Net Flow into Snubber",
"Motor Speed & Valve Opening", "Pump Discharge Temperature",
],
vertical_spacing=0.08,
horizontal_spacing=0.08,
)
# (1,1) Snubber Pressure
fig.add_trace(go.Scatter(
x=t, y=results["P_snub_bar"], mode="lines",
name="Simulated", line=dict(color=ACCENT, width=1),
), row=1, col=1)
if actual_data is not None:
fig.add_trace(go.Scatter(
x=actual_data["time"], y=actual_data["pressure"], mode="lines",
name="Actual (PT130)", line=dict(color=DANGER, width=1.5),
), row=1, col=1)
fig.update_yaxes(title_text="Pressure (bar)", row=1, col=1)
# (1,2) Pump Mass Flow
fig.add_trace(go.Scatter(
x=t, y=results["mdot_pump"], mode="lines",
name="Pump flow", line=dict(color=DANGER, width=1), showlegend=False,
), row=1, col=2)
fig.update_yaxes(title_text="Flow (kg/min)", row=1, col=2)
# (2,1) Outlet Flow
fig.add_trace(go.Scatter(
x=t, y=results["mdot_out"], mode="lines",
name="Outlet flow", line=dict(color=ACCENT2, width=1), showlegend=False,
), row=2, col=1)
fig.update_yaxes(title_text="Flow (kg/min)", row=2, col=1)
# (2,2) Net Flow or Tank Pressure
if fill_mode:
fig.add_trace(go.Scatter(
x=t, y=results["P_tank_bar"], mode="lines",
name="Tank P", line=dict(color=WARN, width=1), showlegend=False,
), row=2, col=2)
fig.update_yaxes(title_text="Pressure (bar)", row=2, col=2)
else:
net = results["mdot_net"]
fig.add_trace(go.Scatter(
x=t, y=net, mode="lines",
name="Net flow", line=dict(color=ACCENT, width=1), showlegend=False,
), row=2, col=2)
fig.update_yaxes(title_text="Net Flow (kg/min)", row=2, col=2)
# (3,1) Motor Speed & Valve Opening
fig.add_trace(go.Scatter(
x=t, y=results["motor_pct"], mode="lines",
name="Motor Speed", line=dict(color=TEXT_BRIGHT, width=1),
), row=3, col=1)
fig.add_trace(go.Scatter(
x=t, y=results["valve2_pct"], mode="lines",
name="Valve 2", line=dict(color=WARN, width=1, dash="dash"),
), row=3, col=1)
fig.update_yaxes(title_text="Percentage (%)", range=[-5, 105], row=3, col=1)
# (3,2) Pump Discharge Temperature
fig.add_trace(go.Scatter(
x=t, y=results["T_pump_K"], mode="lines",
name="T pump", line=dict(color=DANGER, width=1), showlegend=False,
), row=3, col=2)
fig.update_yaxes(title_text="Temperature (K)", row=3, col=2)
# Apply time axis labels to bottom row
fig.update_xaxes(title_text="Time (s)", row=3, col=1)
fig.update_xaxes(title_text="Time (s)", row=3, col=2)
fig.update_layout(
height=750,
template="plotly_dark",
paper_bgcolor=SURFACE,
plot_bgcolor=SURFACE2,
font=dict(family="JetBrains Mono, monospace", color=TEXT),
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
margin=dict(t=40, b=40, l=50, r=20),
)
return fig
# ── Results summary ──────────────────────────────────────────────────────────
def _results_summary(results, pump_lut):
pump_info = "Pump LUT loaded" if pump_lut is not None else "No pump LUT (zero flow)"
r = results
mode = r.get("sim_mode", SIM_MODE_VALVE)
lines = [
pump_info,
f"Mode: {mode}",
f"Final snubber P = {r['P_snub_bar'][-1]:.4f} bar",
f"Peak snubber P = {max(r['P_snub_bar']):.2f} bar",
f"Peak pump flow = {max(r['mdot_pump']):.4f} kg/min",
f"Final outlet = {r['mdot_out'][-1]:.4f} kg/min",
f"T_pump range = {min(r['T_pump_K']):.1f} - {max(r['T_pump_K']):.1f} K",
]
if "P_tank_bar" in r:
lines.append(
f"Tank T (fixed) = {r.get('T_tank_K', 0):.1f} K (= pump T_dis + offset)"
)
lines.append(
f"Final tank P = {r['P_tank_bar'][-1]:.4f} bar (peak {max(r['P_tank_bar']):.2f})"
)
lines.append(
f"Tank mass = {r['m_tank_kg'][0]:.3f} β†’ {r['m_tank_kg'][-1]:.3f} kg "
f"(Ξ” {r['m_tank_kg'][-1] - r['m_tank_kg'][0]:+.3f} kg)"
)
return "\n".join(lines)
def _comparison_metrics(results, actual):
if results is None or actual is None:
return ""
sim_interp = np.interp(actual["time"], results["time"], results["P_snub_bar"])
diff = sim_interp - actual["pressure"]
rmse = float(np.sqrt(np.mean(diff**2)))
mae = float(np.mean(np.abs(diff)))
max_err = float(np.max(np.abs(diff)))
bias = float(np.mean(diff))
corr = (
float(np.corrcoef(sim_interp, actual["pressure"])[0, 1]) if len(diff) > 2 else float("nan")
)
return (
f"=== Simulated vs Actual (PT130) ===\n"
f"Data points: {len(actual['time'])}\n"
f"Time range: {actual['time'][0]:.1f} - {actual['time'][-1]:.1f} s\n"
f"RMSE: {rmse:.2f} bar\n"
f"MAE: {mae:.2f} bar\n"
f"Max |error|: {max_err:.2f} bar\n"
f"Mean bias: {bias:+.2f} bar (sim-actual)\n"
f"Correlation: {corr:.4f}\n"
f"Sim peak: {max(results['P_snub_bar']):.1f} bar\n"
f"Actual peak: {max(actual['pressure']):.1f} bar"
)
# ── Claude inline chat ───────────────────────────────────────────────────────
_SYSTEM_PROMPT = (
"You are an expert cryogenic process engineer and simulation analyst. "
"You are embedded in a simulation tool for a cryogenic hydrogen piping system "
"with: reciprocating pump (2-D LUT) -> snubber volume -> control valve -> "
"vent pipe -> atmosphere or tank.\n\n"
"The simulation uses Euler integration with a single state variable:\n"
" m_snub (mass in snubber)\n"
"Pressure is computed from density via a 1-D CoolProp LUT at fixed T.\n\n"
"Provide concise, actionable engineering insights. Use numbers."
)
def _build_context(params_dict, pump_lut, results, actual, profile_state):
lines = ["=== SIMULATION CONFIGURATION ==="]
for key, val in params_dict.items():
lines.append(f" {key}: {val}")
if pump_lut is not None:
lines.append(f"\n=== PUMP LUT ===\n {pump_lut.summary()}")
if results is not None:
lines.append("\n=== RESULTS SUMMARY ===")
lines.append(f" Mode: {results.get('sim_mode', SIM_MODE_VALVE)}")
lines.append(
f" Snubber P: min={min(results['P_snub_bar']):.2f}, "
f"max={max(results['P_snub_bar']):.2f}, "
f"final={results['P_snub_bar'][-1]:.2f} bar"
)
lines.append(f" Pump flow max={max(results['mdot_pump']):.3f} kg/min")
lines.append(
f" Outlet flow max={max(results['mdot_out']):.3f}, "
f"final={results['mdot_out'][-1]:.3f} kg/min"
)
if "P_tank_bar" in results:
lines.append(
f" Tank P: final={results['P_tank_bar'][-1]:.2f}, "
f"peak={max(results['P_tank_bar']):.2f} bar"
)
t_samples = np.arange(0, results["time"][-1] + 0.01, 1.0)
P_samples = np.interp(t_samples, results["time"], results["P_snub_bar"])
lines.append("\n Snubber P at 1 Hz (t_s, P_bar):")
for ts, ps in zip(t_samples, P_samples):
lines.append(f" {ts:.0f}, {ps:.2f}")
if actual is not None:
lines.append(f"\n=== ACTUAL DATA ({actual.get('filename', '')}) ===")
lines.append(
f" Pressure range: {min(actual['pressure']):.2f} - "
f"{max(actual['pressure']):.2f} bar"
)
return "\n".join(lines)
def _chat_with_claude(user_message, history, pump_lut_state, results_state,
actual_data_state, profile_state, *param_values):
# history is list of (user_msg, bot_msg) tuples for Gradio 6.x Chatbot
if not HAS_ANTHROPIC:
history = history + [(user_message, "anthropic SDK not installed.")]
return history, ""
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
if not api_key:
history = history + [(user_message, "ANTHROPIC_API_KEY not set.")]
return history, ""
params_dict = {k: v for k, v in zip(PARAM_KEYS, param_values)}
context = _build_context(
params_dict, pump_lut_state, results_state, actual_data_state, profile_state
)
system = f"{_SYSTEM_PROMPT}\n\n{context}"
# Convert tuple history to API messages format
api_messages = []
for user_msg, bot_msg in history:
if user_msg:
api_messages.append({"role": "user", "content": user_msg})
if bot_msg:
api_messages.append({"role": "assistant", "content": bot_msg})
api_messages.append({"role": "user", "content": user_message})
try:
client = anthropic.Anthropic(api_key=api_key)
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
system=system,
messages=api_messages,
)
reply = response.content[0].text
except Exception as e:
reply = f"Error: {e}"
history = history + [(user_message, reply)]
return history, ""
def _auto_analyze(history, pump_lut_state, results_state, actual_data_state,
profile_state, *param_values):
if results_state is None:
history = history + [(None, "Run a simulation first, then click Auto-Analyze.")]
return history, ""
prompt = (
"Analyze the simulation results. Compare simulated vs actual snubber "
"pressure if actual data is loaded. Comment on pump flow vs outlet flow "
"balance and pressure buildup. Identify key differences, possible causes, "
"and suggest parameter tuning. Be specific and quantitative."
)
return _chat_with_claude(
prompt, history, pump_lut_state, results_state,
actual_data_state, profile_state, *param_values,
)
# ── Simulation runner ────────────────────────────────────────────────────────
def _run_simulation(
pump_lut_state, profile_enabled, profile_state, actual_data_state,
pulsation_enabled, sim_mode, *param_values, progress=gr.Progress(),
):
params = {}
try:
for key, val in zip(PARAM_KEYS, param_values):
params[key] = float(val)
except (ValueError, TypeError) as e:
raise gr.Error(f"Invalid parameter value: {e}")
if params["dt"] <= 0 or params["duration"] <= 0:
raise gr.Error("Time step and duration must be > 0")
n_steps = int(params["duration"] / params["dt"])
if n_steps > MAX_STEPS:
raise gr.Error(
f"Simulation would require {n_steps:,} steps (cap: {MAX_STEPS:,}). "
f"Increase dt or decrease duration."
)
params["pulsation"] = bool(pulsation_enabled)
params["pump_lut"] = pump_lut_state
params["sim_mode"] = sim_mode or SIM_MODE_VALVE
prof = profile_state or {}
params["use_profiles"] = bool(profile_enabled) and prof.get("times") is not None
params["profile_times"] = prof.get("times") if params["use_profiles"] else None
params["profile_motor"] = prof.get("motor") if params["use_profiles"] else None
params["profile_valve2"] = prof.get("valve") if params["use_profiles"] else None
def cb(frac):
progress(frac, desc="Building LUT..." if frac < 0.05 else "Integrating...")
sim = Simulation(params)
results = sim.run(cb=cb)
fig = _build_plotly_figure(results, actual_data_state)
summary = _results_summary(results, pump_lut_state)
compare = _comparison_metrics(results, actual_data_state)
return fig, summary, compare, results
# ── LUT generation runner ────────────────────────────────────────────────────
def _run_generate_lut(engine, spd_min, spd_max, spd_step, prs_min, prs_max,
prs_step, progress=gr.Progress()):
total = (
len(np.arange(spd_min, spd_max + spd_step * 0.5, spd_step))
* len(np.arange(prs_min, prs_max + prs_step * 0.5, prs_step))
)
engine_map = {"Lightspeed": "lightspeed", "Euler": "euler", "General-ODE": "general_ode"}
eng = engine_map.get(engine, "lightspeed")
def cb(frac):
progress(frac, desc=f"Generating LUT ({int(frac*total)}/{total} points)...")
lut = generate_pump_lut(
engine=eng,
speed_range=(spd_min, spd_max, spd_step),
pressure_range=(prs_min, prs_max, prs_step),
progress_cb=cb,
save=True,
)
return lut, lut.summary()
# ── Sweep payload ingestion ─────────────────────────────────────────────────
_REQUIRED_SWEEP_KEYS = (
"x_vals", "y_vals", "z_mdot_kgpm", "z_temp_K", "axis_x", "axis_y",
)
def _ingest_sweep_payload(payload):
"""Build a PumpLUT from a sweep bridge payload; save CSV; return UI updates.
Raises
------
gr.Error
If payload is None, missing required keys, or has axes other than
Pexit_barg Γ— speed_f.
"""
if payload is None:
raise gr.Error(
"No compatible sweep data β€” run a 2D sweep on "
"Pexit_barg vs speed_f first."
)
missing = [k for k in _REQUIRED_SWEEP_KEYS if k not in payload]
if missing:
raise gr.Error(
f"Sweep payload is missing required keys: {', '.join(missing)}. "
"This usually means the Sweep tab produced an incompatible "
"payload β€” please re-run the 2D Pexit_barg Γ— speed_f sweep."
)
axes = {payload["axis_x"], payload["axis_y"]}
if axes != {"Pexit_barg", "speed_f"}:
raise gr.Error(
"Process Sim ingest requires a 2D sweep on exactly "
f"(Pexit_barg, speed_f). Got ({payload['axis_x']}, "
f"{payload['axis_y']}). Run the 2D Pexit_barg vs speed_f sweep."
)
try:
lut = PumpLUT.from_sweep_grid(
x_vals=payload["x_vals"],
y_vals=payload["y_vals"],
z_mdot_kgpm=payload["z_mdot_kgpm"],
z_temp_K=payload["z_temp_K"],
axis_x=payload["axis_x"],
axis_y=payload["axis_y"],
)
except (ValueError, TypeError) as e:
raise gr.Error(f"Could not build pump LUT from sweep payload: {e}")
# Save CSV to data/generated/ with a unique timestamp. Tolerate a
# read-only filesystem (e.g., restricted HF Space deploy) β€” the LUT is
# still usable in-session even if the CSV side-effect fails.
import datetime as _dt
now = _dt.datetime.now()
ts = now.strftime("%Y-%m-%d_%H%M%S")
hw = payload.get("hw", "unknown")
csv_path = GENERATED_DIR / f"lut_{ts}_sweep_{hw}.csv"
save_note = f"Saved: {csv_path.name}"
try:
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
lut.save(str(csv_path))
except (OSError, PermissionError) as e:
save_note = f"Save skipped ({e.__class__.__name__})"
# Status + provenance strings.
n_speeds = len(lut.speeds)
n_press = len(lut.pressures)
status = (
f"Loaded from sweep ({n_speeds}x{n_press}, hw={hw}, "
f"{payload.get('timestamp', '')})\n"
f"{lut.summary()}\n"
f"{save_note}"
)
provenance = (
f"Source: 2D sweep (axes: Pexit_barg x speed_f)\n"
f"Grid: {n_speeds} speeds x {n_press} pressures\n"
f"Hardware: {hw}\n"
f"Timestamp: {payload.get('timestamp', '')}\n"
f"{save_note}"
)
mode_update = gr.update(value="From Sweep")
return lut, status, mode_update, provenance
# ── Tab builder ──────────────────────────────────────────────────────────────
def build_process_sim_tab():
"""Build the Process Sim tab. Must be called inside a gr.TabItem context.
Returns
-------
dict
Handles used by app.py to wire cross-tab events:
* ``pump_lut_state``
* ``pump_lut_status``
* ``lut_mode``
* ``sweep_provenance``
* ``ingest_fn`` (callable: bridge payload β†’ (lut, status, mode_update, provenance))
"""
gr.HTML(tagline_html("process_sim"))
pump_lut_state = gr.State(None)
results_state = gr.State(None)
actual_data_state = gr.State(None)
profile_state = gr.State({"times": None, "motor": None, "valve": None})
with gr.Row():
# ── Left column: inputs ──
with gr.Column(scale=1):
with gr.Accordion("LUT Source", open=True):
lut_mode = gr.Radio(
choices=["Upload CSV", "Auto-Generate from Engine", "From Sweep"],
value="Upload CSV",
label="Pump LUT Source",
)
# CSV upload mode
with gr.Group(visible=True) as csv_group:
pump_file = gr.File(
label="Pump CSV (motor_pct, back_pressure_bar (absolute), mdot_kgs, [T_discharge_K])",
file_types=[".csv"],
)
pump_lut_status = gr.Textbox(
label="Pump LUT status",
value="No pump LUT loaded β€” upload a CSV.",
interactive=False, lines=2,
)
# Auto-generate mode
with gr.Group(visible=False) as gen_group:
gen_engine = gr.Dropdown(
choices=["Lightspeed", "Euler", "General-ODE"],
value="Lightspeed",
label="Engine",
)
gen_btn = gr.Button("Generate LUT", variant="secondary")
gen_status = gr.Textbox(
label="Generation status", value="", interactive=False, lines=2,
)
with gr.Accordion("Override Grid", open=False):
spd_min = gr.Number(label="Speed min (%)", value=10)
spd_max = gr.Number(label="Speed max (%)", value=100)
spd_step = gr.Number(label="Speed step (%)", value=10)
prs_min = gr.Number(label="Pressure min (bar)", value=0)
prs_max = gr.Number(label="Pressure max (bar)", value=900)
prs_step = gr.Number(label="Pressure step (bar)", value=50)
# From-sweep mode (read-only provenance panel)
with gr.Group(visible=False) as sweep_group:
sweep_provenance = gr.Textbox(
label="Sweep provenance",
value="No sweep data ingested yet. Run a 2D sweep on "
"(Pexit_barg, speed_f) and click 'Send to Process Sim'.",
interactive=False, lines=5,
)
sim_mode_radio = gr.Radio(
choices=SIM_MODES,
value=SIM_MODE_VALVE,
label="Simulation Mode",
)
# Parameter accordions
param_inputs = {}
sections = {}
for key, default, label, section in PARAM_DEFS:
sections.setdefault(section, []).append((key, default, label))
for sec_name, items in sections.items():
open_default = "Tank" not in sec_name
with gr.Accordion(sec_name, open=open_default):
for key, default, label in items:
param_inputs[key] = gr.Number(
label=label, value=default, precision=None,
)
with gr.Accordion("Profiles", open=False):
profile_enabled = gr.Checkbox(
label="Enable time-varying profiles", value=False,
)
profile_file = gr.File(
label="Profile CSV (time, motor_speed, valve_opening)",
file_types=[".csv"],
)
profile_status = gr.Textbox(
label="Profile status", value="No profile loaded",
interactive=False, lines=2,
)
with gr.Accordion("Actual Data Overlay", open=False):
actual_file = gr.File(
label="Actual data CSV (time + PT130 pressure)",
file_types=[".csv"],
)
actual_status = gr.Textbox(
label="Actual data status", value="No data loaded",
interactive=False, lines=2,
)
pulsation_cb = gr.Checkbox(
label="Enable pump pulsation (half-sine on LUT avg)", value=True,
)
run_btn = gr.Button("Run Simulation", variant="primary")
# ── Right column: outputs ──
with gr.Column(scale=2):
plot_out = gr.Plot(label="Simulation Results")
summary_box = gr.Textbox(
label="Summary", value="", interactive=False, lines=7,
)
compare_box = gr.Textbox(
label="Comparison metrics", value="", interactive=False, lines=10,
)
gr.Markdown("### Claude AI Analysis")
chatbot = gr.Chatbot(label="Claude", height=300)
chat_input = gr.Textbox(
label="Message",
placeholder="Ask about the simulation results...",
)
with gr.Row():
send_btn = gr.Button("Send")
auto_btn = gr.Button("Auto-Analyze")
clear_chat_btn = gr.Button("Clear")
# ── Event wiring ─────────────────────────────────────────────────────
param_component_list = [param_inputs[k] for k in PARAM_KEYS]
# LUT mode toggle
def _toggle_lut_mode(mode):
return (
gr.update(visible=(mode == "Upload CSV")),
gr.update(visible=(mode == "Auto-Generate from Engine")),
gr.update(visible=(mode == "From Sweep")),
)
lut_mode.change(
fn=_toggle_lut_mode,
inputs=[lut_mode],
outputs=[csv_group, gen_group, sweep_group],
api_name=False,
)
# CSV upload
pump_file.change(
fn=_load_pump_lut,
inputs=[pump_file],
outputs=[pump_lut_state, pump_lut_status],
api_name=False,
)
# Auto-generate
gen_btn.click(
fn=_run_generate_lut,
inputs=[gen_engine, spd_min, spd_max, spd_step, prs_min, prs_max, prs_step],
outputs=[pump_lut_state, gen_status],
api_name=False,
)
# Profiles
def _on_profile_upload(file):
t, m, v, msg = _load_profile_csv(file)
state = {"times": t, "motor": m, "valve": v}
if t is not None and len(t) > 0:
new_duration = float(math.ceil(float(t[-1])))
msg = msg + f"\nDuration set to {new_duration:.0f} s"
return state, msg, gr.update(value=True), gr.update(value=new_duration)
return state, msg, gr.update(), gr.update()
profile_file.change(
fn=_on_profile_upload,
inputs=[profile_file],
outputs=[profile_state, profile_status, profile_enabled, param_inputs["duration"]],
api_name=False,
)
# Actual data
actual_file.change(
fn=_load_actual_csv,
inputs=[actual_file],
outputs=[actual_data_state, actual_status],
api_name=False,
)
# Run simulation
run_btn.click(
fn=_run_simulation,
inputs=[
pump_lut_state, profile_enabled, profile_state, actual_data_state,
pulsation_cb, sim_mode_radio, *param_component_list,
],
outputs=[plot_out, summary_box, compare_box, results_state],
api_name=False,
)
# Chat
chat_inputs = [
chat_input, chatbot, pump_lut_state, results_state,
actual_data_state, profile_state, *param_component_list,
]
send_btn.click(fn=_chat_with_claude, inputs=chat_inputs, outputs=[chatbot, chat_input], api_name=False)
chat_input.submit(fn=_chat_with_claude, inputs=chat_inputs, outputs=[chatbot, chat_input], api_name=False)
auto_inputs = [chatbot, pump_lut_state, results_state, actual_data_state, profile_state, *param_component_list]
auto_btn.click(fn=_auto_analyze, inputs=auto_inputs, outputs=[chatbot, chat_input], api_name=False)
clear_chat_btn.click(fn=lambda: ([], ""), inputs=[], outputs=[chatbot, chat_input], api_name=False)
return {
"pump_lut_state": pump_lut_state,
"pump_lut_status": pump_lut_status,
"lut_mode": lut_mode,
"sweep_provenance": sweep_provenance,
"ingest_fn": _ingest_sweep_payload,
}