Spaces:
Sleeping
Sleeping
| """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, | |
| } | |