"""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, }