diff --git "a/em_embedded.py" "b/em_embedded.py" new file mode 100644--- /dev/null +++ "b/em_embedded.py" @@ -0,0 +1,3801 @@ +import numpy as np +import re +import pyvista as pv +import threading +import base64 +from collections import defaultdict +import tempfile +from pathlib import Path + +from trame_vuetify.widgets import vuetify3 +from pyvista.trame.ui import plotter_ui +import plotly.graph_objects as go +import plotly.io as pio +from trame_plotly.widgets import plotly as plotly_widgets +import os +from datetime import datetime +import time +from trame.widgets import html as trame_html + + +EXCITATION_SURFACE_COLORSCALE = [ + [0.0, "#001219"], + [0.25, "#005F73"], + [0.5, "#94D2BD"], + [0.75, "#EE9B00"], + [1.0, "#CA6702"], +] + +try: + # Prefer package import when running as a module: python -m quantum.em_trame + from quantum.utils.delta_impulse_generator import * + import quantum.utils.delta_impulse_generator as qutils +except ModuleNotFoundError: + # Fallback when running this file directly: python quantum/em_trame.py + from utils.delta_impulse_generator import * + import utils.delta_impulse_generator as qutils +# from utils.base_functions import * + + + +# Set PyVista to use off-screen rendering for Trame +pv.OFF_SCREEN = True + + +class PrefixedStateProxy: + """State wrapper that automatically namespaces EM fields with a prefix.""" + + __slots__ = ("_prefix", "_state", "_pending_updates", "_pending_changes", "_cache") + + def __init__(self, prefix: str): + object.__setattr__(self, "_prefix", prefix) + object.__setattr__(self, "_state", None) + object.__setattr__(self, "_pending_updates", {}) + object.__setattr__(self, "_pending_changes", []) + object.__setattr__(self, "_cache", {}) + + # --- Binding --- + def bind(self, state): + object.__setattr__(self, "_state", state) + if self._pending_updates: + prefixed = {f"{self._prefix}{k}": v for k, v in self._pending_updates.items()} + state.update(prefixed) + self._pending_updates.clear() + for name, value in self._cache.items(): + setattr(state, f"{self._prefix}{name}", value) + self._cache.clear() + for names, kwargs, func in self._pending_changes: + prefixed = tuple(f"{self._prefix}{n}" for n in names) + state.change(*prefixed, **kwargs)(func) + self._pending_changes.clear() + + @property + def bound(self) -> bool: + return self._state is not None + + # --- Core API --- + def update(self, values: dict): + if self._state is None: + self._pending_updates.update(values) + else: + prefixed = {f"{self._prefix}{k}": v for k, v in values.items()} + self._state.update(prefixed) + + def change(self, *names, **kwargs): + prefix = self._prefix + + def decorator(func): + # Wrap the user's callback to translate prefixed kwargs back to unprefixed + def wrapper(**kw): + translated = {} + for k, v in kw.items(): + if k.startswith(prefix): + translated[k[len(prefix):]] = v + else: + translated[k] = v + return func(**translated) + + if self._state is not None: + prefixed = tuple(f"{prefix}{n}" for n in names) + return self._state.change(*prefixed, **kwargs)(wrapper) + self._pending_changes.append((names, kwargs, wrapper)) + return func + + return decorator + + # --- Attribute proxying --- + def __getattr__(self, name): + if name in {"_prefix", "_state", "_pending_updates", "_pending_changes", "_cache", "bind", "bound", "update", "change"}: + return object.__getattribute__(self, name) + if self._state is not None: + return getattr(self._state, f"{self._prefix}{name}") + if name in self._pending_updates: + return self._pending_updates[name] + if name in self._cache: + return self._cache[name] + raise AttributeError(name) + + def __setattr__(self, name, value): + if name in {"_prefix", "_state", "_pending_updates", "_pending_changes", "_cache"}: + object.__setattr__(self, name, value) + elif self._state is not None: + setattr(self._state, f"{self._prefix}{name}", value) + else: + self._cache[name] = value + + +class ControllerProxy: + """Controller proxy that queues attribute bindings until a server is set.""" + + __slots__ = ("_ctrl", "_pending") + + def __init__(self): + object.__setattr__(self, "_ctrl", None) + object.__setattr__(self, "_pending", {}) + + def bind(self, ctrl): + object.__setattr__(self, "_ctrl", ctrl) + for name, value in self._pending.items(): + setattr(ctrl, name, value) + self._pending.clear() + + @property + def bound(self) -> bool: + return self._ctrl is not None + + def __getattr__(self, name): + if name in {"_ctrl", "_pending", "bind", "bound"}: + return object.__getattribute__(self, name) + if self._ctrl is None: + raise AttributeError(f"Controller not set; cannot access '{name}'") + return getattr(self._ctrl, name) + + def __setattr__(self, name, value): + if name in {"_ctrl", "_pending", "bind", "bound"}: + object.__setattr__(self, name, value) + elif self._ctrl is not None: + setattr(self._ctrl, name, value) + else: + self._pending[name] = value + + +_server = None +state = PrefixedStateProxy("em_") +ctrl = ControllerProxy() + + +def _noop(*_, **__): + pass + + +ctrl.qpu_ts_update = _noop +ctrl.qpu_ts_other_update = _noop +ctrl.view_update = _noop +ctrl.sim_ts_update = _noop +ctrl.geometry_preview_update = _noop +ctrl.excitation_preview_update = _noop +ctrl.qubit_plot_update = _noop + + +def set_server(server): + """Bind the embedded EM module to the shared Trame server.""" + + global _server + _server = server + state.bind(server.state) + ctrl.bind(server.controller) + + +def init_state(force: bool = False): + """Ensure EM defaults are applied; optionally force a reset.""" + + if not state.bound: + return + if force: + reset_to_defaults() + +# Cache for QPU Plotly export/selection (Python-side) +qpu_ts_cache = { + "times": None, + "series_map": None, + "field": None, + "fig": None, + "positions_by_field": {"All": []}, + "key_to_label": {}, + "label_to_keys": {}, + "nx": None, +} + +# --- Application State --- +state.update({ + "problem_selection": None, + "geometry_options": ["None", "Square Metallic Body", "Square Domain", "Geometry 2", "Add"], + "dist_type": None, "impulse_x": 0.5, "impulse_y": 0.5, + "peak_pair": "(0.5, 0.5)", + "mu_x": 0.5, "mu_y": 0.5, "sigma_x": 0.25, "sigma_y": 0.15, + "mu_pair": "(0.5, 0.5)", # Gaussian Mu as normalized pair string + "sigma_pair": "(0.25, 0.15)", # Gaussian Sigma as normalized pair string + "nx": None, "T": 1.0, "time_val": 0.0, + "L": 1.0, # Side length used for coordinate conversion + "output_type": "Surface Plot", + "surface_field": "Ez", + "timeseries_field": "Ez", + "timeseries_points": "(0.5, 0.5)", + "timeseries_gridpoints": "", + "timeseries_point_info": "", + "error_message": "", + "is_running": False, + "simulation_has_run": False, + "geometry_selection": None, + "show_upload_dialog": False, + "uploaded_file_info": None, + "show_upload_status": False, + "upload_status_message": "", + "coeff_permittivity": 1.0, # Relative permittivity (ε_r) + "coeff_permeability": 1.0, # Relative permeability (μ_r) + "run_button_text": "RUN!", + "backend_type": None, + "selected_simulator": "IBM Qiskit simulator", + "selected_qpu": "IBM QPU", + "stop_button_disabled": True, # Stop button is initially disabled + "export_format": "vtk", # Dummy export format for Surface Plot + "nx_slider_index": None, # No selection until user chooses on the slider + "show_export_status": False, + "export_status_message": "", + "logo_src": None, + # Dummy geometry-hole controls (UI only) + "hole_size_edge": 0.2, # edge length in [0, 1] + "hole_center_x": 0.5, # center X in (0, 1) + "hole_center_y": 0.5, # center Y in (0, 1) + "hole_center_pair": "(0.5, 0.5)", # bracket-format center for dropdown + "hole_error_message": "", # validation message + "excitation_error_message": "", # parse/validation for Gaussian pair inputs + "excitation_info_message": "", # Snapping info for excitation coordinates + "excitation_config_open": False, + "dt_user": 0.1, # Dummy Δt input from user (seconds) + "temporal_warning": "", # Warning message for invalid Δt + "qpu_field_components": "Ez", # Dummy field component for QPU + "qpu_monitor_gridpoints": "", # Derived gridpoints for QPU monitors + "qpu_monitor_samples": "(0.5, 0.5)", # User-facing normalized sample input + "qpu_monitor_sample_info": "", + "console_output": "Console initialized.\n", + # Square aspect for initial preview + "pyvista_view_style": "aspect-ratio: 1 / 1; width: 100%;", + "qpu_ts_fig": None, + "qpu_ts_selected_time": None, + "qpu_ts_ready": False, + # Hide Plotly widget until data; enlarge Y using a taller min-height + "qpu_plot_style": "display: none; width: 900px; height: 660px; margin: 0 auto;", + # Second Plotly chart for other components + "qpu_ts_other_ready": False, + "qpu_other_plot_style": "display: none; width: 900px; height: 660px; margin: 0 auto;", + # NEW: multiple QPU monitor configurations (list of { field, points }) + "qpu_monitor_configs": [], # unused when using primitives + # NEW: dropdown filter for which component to plot + "qpu_plot_filter": "All", + "qpu_plot_field_options": ["All"], + "qpu_plot_position_filter": "All positions", + "qpu_plot_position_options": ["All positions"], + # Primitive slots for additional monitors (2..5) + "qpu_monitor_count": 0, # number of additional monitors enabled + "qpu_field_components_2": "Ez", + "qpu_monitor_gridpoints_2": "", + "qpu_monitor_samples_2": "", + "qpu_monitor_sample_info_2": "", + "qpu_field_components_3": "Ez", + "qpu_monitor_gridpoints_3": "", + "qpu_monitor_samples_3": "", + "qpu_monitor_sample_info_3": "", + "qpu_field_components_4": "Ez", + "qpu_monitor_gridpoints_4": "", + "qpu_monitor_samples_4": "", + "qpu_monitor_sample_info_4": "", + "qpu_field_components_5": "Ez", + "qpu_monitor_gridpoints_5": "", + "qpu_monitor_samples_5": "", + "qpu_monitor_sample_info_5": "", + # Status window + "status_visible": False, + "status_message": "Ready", + "status_type": "info", # info, success, warning, error + "simulation_progress": 0, + "show_progress": False, + "console_logs": "Console initialized...\n", +}) + +# Ensure hole snap state exists +state.hole_snap = True + +_WORKFLOW_CARD_KEYS = [ + "overview_card_style", + "geometry_card_style", + "excitation_card_style", + "meshing_card_style", + "backend_card_style", + "output_card_style", +] + + +def _workflow_highlight_style(active: bool) -> str: + base = "font-size: 0.8rem; border: 1px solid transparent; transition: box-shadow 0.2s ease;" + accent = "border: 2px solid #5F259F; box-shadow: 0 0 12px rgba(95,37,159,0.45); background-color: rgba(95,37,159,0.02);" + return f"{base} {accent}" if active else base + + +def _determine_workflow_step() -> int: + if not state.problem_selection: + return 0 + if not state.geometry_selection: + return 1 + if not state.dist_type: + return 2 + if state.nx is None: + return 3 + if not state.backend_type: + return 4 + return 5 + + +def _apply_workflow_highlights(step_index: int): + state.workflow_step = step_index + for idx, key in enumerate(_WORKFLOW_CARD_KEYS): + setattr(state, key, _workflow_highlight_style(idx == step_index)) + + +_apply_workflow_highlights(0) + +# --- Load Synopsys logo (from quantum folder) as data URI --- +def load_logo_data_uri(): + base_dir = os.path.dirname(__file__) + candidates = [ + os.path.join(base_dir, "ansys-part-of-synopsys-logo.svg"), + os.path.join(base_dir, "synopsys-logo-color-rgb.svg"), + os.path.join(base_dir, "synopsys-logo-color-rgb.png"), + os.path.join(base_dir, "synopsys-logo-color-rgb.jpg"), + ] + for p in candidates: + if os.path.exists(p): + ext = os.path.splitext(p)[1].lower() + mime = "image/svg+xml" if ext == ".svg" else ("image/png" if ext == ".png" else "image/jpeg") + with open(p, "rb") as f: + b64 = base64.b64encode(f.read()).decode("ascii") + return f"data:{mime};base64,{b64}" + return None + +state.logo_src = load_logo_data_uri() + +# --- Global PyVista and Data Variables --- +plotter = pv.Plotter() +simulation_data = None +current_mesh = None +data_frames = None +z_scale = 1.0 +X_grids, Y_grids = {}, {} +surface_clims = {} +stop_simulation = False # Flag to stop running simulation +snapshot_times = None # Times corresponding to saved frames (user snapshots) + +# Stable id generator for QPU monitor config rows +qpu_cfg_id_counter = 1 + +def _new_monitor_cfg(field: str = "Ez", points: str = "(8, 8)") -> dict: + """Create a new monitor config with a unique id for proper v-for keying.""" + global qpu_cfg_id_counter + qpu_cfg_id_counter += 1 + return {"id": qpu_cfg_id_counter, "field": field, "points": points} + +# --- Constants --- +GRID_SIZES = ["16", "32", "64", "128", "256", "512"] + +DEFAULT_AXIS_TICKS = (0.0, 0.25, 0.5, 0.75, 1.0) + +# --- Plotting and Simulation Logic --- + +# New: Build a PyVista time series chart for QPU results (same look as simulator) + +# Build Plotly time series for QPU and helpers +import plotly.graph_objects as go # ensure present + +# NEW: multi-config Plotly builder for QPU + +from matplotlib import cm as _mpl_cm + +# Helper to choose cmap per field (Ez→Reds, Hx→Greens, Hy/default→Blues) +def _cmap_for_field(field: str): + f = str(field) + if f == 'Ez': + return _mpl_cm.Reds + if f == 'Hx': + return _mpl_cm.Greens + return _mpl_cm.Blues + + +def _normalized_position_label(px: int, py: int, gw: int, gh: int) -> str: + px_i, py_i = int(px), int(py) + denom_x = float(max(gw - 1, 1)) + denom_y = float(max(gh - 1, 1)) + x_norm = px_i / denom_x + y_norm = py_i / denom_y + return f"Position ({x_norm:.3f}, {y_norm:.3f})" + + +def _format_grid_label(px: int, py: int, field: str | None = None) -> str: + px_i, py_i = int(px), int(py) + label_map = qpu_ts_cache.get("key_to_label") or {} + if field: + label = label_map.get((str(field), px_i, py_i)) + if label: + return label + for (fld, gx, gy), label in label_map.items(): + if gx == px_i and gy == py_i: + return label + nx_val = getattr(state, "nx", None) + if nx_val: + denom = float(max(int(nx_val) - 1, 1)) + return f"Position ({px_i / denom:.3f}, {py_i / denom:.3f})" + return f"Position ({px_i}, {py_i})" + + +def _update_qpu_position_options(current_field: str = "All"): + try: + field_key = (current_field or "All").strip() or "All" + pos_map = qpu_ts_cache.get("positions_by_field") or {} + entries = pos_map.get(field_key) or pos_map.get("All") or [] + labels = [] + for entry in entries: + label = None + if isinstance(entry, dict): + label = entry.get("label") + if not label: + coords = entry.get("coords") or (None, None) + fld = entry.get("field") + label = _format_grid_label(coords[0], coords[1], fld) + elif isinstance(entry, (list, tuple)) and len(entry) >= 2: + lbl_field = entry[2] if len(entry) >= 3 else (field_key if field_key not in ("", "All") else None) + label = _format_grid_label(entry[0], entry[1], lbl_field) + if label: + labels.append(label) + # Preserve order while removing duplicates + labels = list(dict.fromkeys(labels)) + options = ["All positions"] + labels if labels else ["All positions"] + state.qpu_plot_position_options = options + if state.qpu_plot_position_filter not in options: + state.qpu_plot_position_filter = options[0] + except Exception: + pass + + +def _filter_series_keys(series_map, field_filter: str, position_filter: str): + keys = list(series_map.keys()) + ff = (field_filter or "All").strip() + pf = (position_filter or "All positions").strip() + if ff not in ("", "All"): + keys = [k for k in keys if str(k[0]) == ff] + if pf not in ("", "All", "All positions"): + label_map = qpu_ts_cache.get("label_to_keys") or {} + label_keys = label_map.get(pf) + if not label_keys: + return [] + allowed = {(str(fld), int(px), int(py)) for (fld, px, py) in label_keys} + keys = [k for k in keys if (str(k[0]), int(k[1]), int(k[2])) in allowed] + return keys + + +def _refresh_qpu_plot_figures(): + try: + field_filter = (state.qpu_plot_filter or "All").strip() + except Exception: + field_filter = "All" + try: + position_filter = (state.qpu_plot_position_filter or "All positions").strip() + except Exception: + position_filter = "All positions" + + fig_all = qpu_ts_cache.get("fig") + times = qpu_ts_cache.get("times") or [] + series_map = qpu_ts_cache.get("series_map") or {} + if fig_all is None or not times or not series_map: + return + + _update_qpu_position_options(field_filter) + fig_primary = _rebuild_qpu_fig_filtered(field_filter, position_filter) + if fig_primary is None: + fig_primary = fig_all + + if fig_primary is not None: + try: + ctrl.qpu_ts_update(fig_primary) + except Exception: + pass + state.qpu_ts_ready = True + state.qpu_plot_style = "width: 900px; height: 660px; margin: 0 auto;" + else: + state.qpu_ts_ready = False + state.qpu_plot_style = "display: none; width: 900px; height: 660px; margin: 0 auto;" + + if field_filter not in ("", "All") and position_filter in ("", "All", "All positions"): + fig_oth = _rebuild_qpu_fig_others(field_filter, position_filter) + if fig_oth is not None and getattr(fig_oth, "data", None): + try: + ctrl.qpu_ts_other_update(fig_oth) + except Exception: + pass + state.qpu_ts_other_ready = True + state.qpu_other_plot_style = "width: 900px; height: 660px; margin: 0 auto;" + else: + state.qpu_ts_other_ready = False + state.qpu_other_plot_style = "display: none; width: 900px; height: 660px; margin: 0 auto;" + else: + state.qpu_ts_other_ready = False + state.qpu_other_plot_style = "display: none; width: 900px; height: 660px; margin: 0 auto;" + +def _build_qpu_timeseries_plotly_multi(configs, nx: int, T: float, snapshot_dt: float, impulse_pos, progress_callback=None, print_callback=None): + times = qutils.create_time_frames(T, snapshot_dt) + fig = go.Figure() + # Gather all validated positions across configs (after expanding 'All') to compute color normalization + all_triplets = [] # (field, px, py) + cfg_expanded = [] # list of (field, [(px,py), ...]) + for cfg in (configs or []): + field_type = (cfg.get("field") or "Ez").strip() + pts_str = str(cfg.get("points") or "").strip() + # Expand 'All' into Ez, Hx, Hy + fields = ('Ez', 'Hx', 'Hy') if field_type == 'All' else (field_type,) + # Parse positions string once + raw_pts = [tuple(map(int, m)) for m in re.findall(r"\((\d+)\s*,\s*(\d+)\)", pts_str)] or [impulse_pos] + for f in fields: + if f == 'Ez': + gw, gh = nx, nx + elif f == 'Hx': + gw, gh = nx, nx - 1 + else: + gw, gh = nx - 1, nx + valid = [] + for (px, py) in raw_pts: + if 0 <= px < gw and 0 <= py < gh: + valid.append((int(px), int(py))) + if not valid: + continue + cfg_expanded.append((f, valid)) + all_triplets.extend((f, px, py) for (px, py) in valid) + # Normalize color by max(px+py) across all selected points + max_sum = max(((px + py) for (_, px, py) in all_triplets), default=1) + if max_sum <= 0: + max_sum = 1 + + series_map = {} + positions_by_field = defaultdict(dict) + key_to_label = {} + label_to_keys = defaultdict(set) + max_abs = 0.0 + dashes = ["solid", "dash", "dot", "dashdot"] + markers = ["circle", "square", "diamond", "triangle-up", "x"] + + total_configs = len(cfg_expanded) + for idx, (field_type, valid_positions) in enumerate(cfg_expanded): + # Create a sub-callback for this config's portion of progress + def _sub_progress(p): + if progress_callback: + # Map p (0-100) to the slice for this config + base = (idx / total_configs) * 100 + fraction = (1 / total_configs) * 100 + total_p = base + (p / 100.0) * fraction + progress_callback(total_p) + + # Fetch time series from QPU for this field and the validated positions + try: + series_map_field = qutils.run_qpu(field_type, valid_positions, None, float(T), float(snapshot_dt), int(nx), None, impulse_pos, progress_callback=_sub_progress, print_callback=print_callback) + except Exception as e: + msg = f"QPU error for {field_type} positions {valid_positions}: {e}" + if print_callback: + print_callback(msg) + else: + print(msg) + continue + cmap = _cmap_for_field(field_type) + num_pts = len(valid_positions) + for i, (px, py) in enumerate(valid_positions): + ys = (series_map_field or {}).get((px, py), []) + if not ys or len(ys) != len(times): + continue + series_map[(field_type, px, py)] = list(ys) + try: + max_abs = max(max_abs, max((abs(float(v)) for v in ys))) + except Exception: + pass + + # Color keyed by index to ensure distinctness + if num_pts > 1: + # Distribute from 0.3 (light) to 0.9 (dark) + s_index = i / (num_pts - 1) + s_light = 0.3 + 0.6 * s_index + else: + s_light = 0.6 # Medium intensity for single point + + rgba = cmap(s_light) + color_hex = f"#{int(rgba[0]*255):02x}{int(rgba[1]*255):02x}{int(rgba[2]*255):02x}" + label = _normalized_position_label(px, py, gw, gh) + key = (str(field_type), int(px), int(py)) + key_to_label[key] = label + label_to_keys[label].add(key) + positions_by_field[str(field_type)][(int(px), int(py))] = { + "coords": (int(px), int(py)), + "label": label, + "field": str(field_type), + } + fig.add_trace( + go.Scatter( + x=times, + y=ys, + mode='lines+markers', + name=label, + line=dict(color=color_hex, width=2.5, dash=dashes[i % len(dashes)]), + marker=dict(size=7, symbol=markers[i % len(markers)], color=color_hex), + hovertemplate=f"{field_type} | t=%{{x:.3f}}s
Value=%{{y:.6g}}{label}", + ) + ) + # Layout: fixed size, doubled label fonts, refined grid/lines + unique_fields = sorted({f for (f, _, _) in series_map.keys()}) + fig.update_layout( + title=f"Time Series ({', '.join(unique_fields) if unique_fields else '—'})", + height=660, width=900, + margin=dict(l=50, r=30, t=50, b=50), + hovermode="x unified", + legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1, title_text=""), + paper_bgcolor="#FFFFFF", + plot_bgcolor="#FFFFFF", + ) + fig.update_xaxes(title_text="Time (s)", title_font=dict(size=22), tickfont=dict(size=16), showgrid=True, gridcolor="rgba(0,0,0,.06)", zeroline=False, showline=True, linewidth=1, linecolor="rgba(0,0,0,.3)", showspikes=True, spikemode='across', spikesnap='cursor') + fig.update_yaxes(title_text="Field Value", title_font=dict(size=22), tickfont=dict(size=16), showgrid=True, gridcolor="rgba(0,0,0,.06)", zeroline=True, zerolinecolor="rgba(0,0,0,.25)", showline=True, linewidth=1, linecolor="rgba(0,0,0,.3)") + if max_abs > 0: + pad = 0.12 * max_abs + fig.update_yaxes(range=[-max_abs - pad, max_abs + pad]) + + # Cache and field options + qpu_ts_cache["times"] = list(times) + qpu_ts_cache["series_map"] = series_map + qpu_ts_cache["field"] = ",".join(unique_fields) if len(unique_fields) == 1 else ("multi" if unique_fields else "") + qpu_ts_cache["fig"] = fig + qpu_ts_cache["unique_fields"] = list(unique_fields) + try: + positions_map_sorted = {} + all_entries = {} + for field_name, entry_map in positions_by_field.items(): + entries = [entry_map[key] for key in sorted(entry_map.keys(), key=lambda xy: (xy[0], xy[1]))] + positions_map_sorted[field_name] = entries + for entry in entries: + all_entries.setdefault(entry["label"], entry) + positions_map_sorted["All"] = sorted(all_entries.values(), key=lambda entry: (entry["coords"][0], entry["coords"][1])) + qpu_ts_cache["positions_by_field"] = positions_map_sorted + qpu_ts_cache["key_to_label"] = key_to_label + qpu_ts_cache["label_to_keys"] = {lbl: sorted(list(vals)) for lbl, vals in label_to_keys.items()} + qpu_ts_cache["nx"] = int(nx) + except Exception: + qpu_ts_cache["positions_by_field"] = {"All": []} + qpu_ts_cache["key_to_label"] = {} + qpu_ts_cache["label_to_keys"] = {} + try: + state.qpu_plot_field_options = ["All"] + list(unique_fields) + state.qpu_plot_filter = "All" + _update_qpu_position_options("All") + except Exception: + pass + return fig + +def _rebuild_qpu_fig_filtered(filter_value: str, position_filter: str = "All positions"): + try: + fv = (filter_value or "All").strip() + pf = (position_filter or "All positions").strip() + fig_all = qpu_ts_cache.get("fig") + times = qpu_ts_cache.get("times") or [] + series_map = qpu_ts_cache.get("series_map") or {} + if fig_all is None or not times or not series_map: + return fig_all + use_base = fv in ("", "All") and pf in ("", "All", "All positions") + if use_base: + return fig_all + keys = _filter_series_keys(series_map, fv, pf) + if not keys: + return None + import plotly.graph_objects as go + max_sum = max(((k[1] + k[2]) for k in keys), default=1) + if max_sum <= 0: + max_sum = 1 + fig = go.Figure() + dashes = ["solid", "dash", "dot", "dashdot"] + markers = ["circle", "square", "diamond", "triangle-up", "x"] + max_abs = 0.0 + label_map = qpu_ts_cache.get("key_to_label") or {} + sorted_keys = sorted(keys, key=lambda x: (str(x[0]), x[1], x[2])) + num_keys = len(sorted_keys) + for i, k in enumerate(sorted_keys): + field_name, px, py = k + ys = series_map.get(k) or [] + if not ys or len(ys) != len(times): + continue + try: + max_abs = max(max_abs, max((abs(float(v)) for v in ys))) + except Exception: + pass + cmap = _cmap_for_field(field_name) + + # Color keyed by index to ensure distinctness + if num_keys > 1: + s_index = i / (num_keys - 1) + s_light = 0.3 + 0.6 * s_index + else: + s_light = 0.6 + + rgba = cmap(s_light) + color_hex = f"#{int(rgba[0]*255):02x}{int(rgba[1]*255):02x}{int(rgba[2]*255):02x}" + label = label_map.get((str(field_name), int(px), int(py))) or _format_grid_label(px, py, field_name) + fig.add_trace(go.Scatter( + x=times, + y=ys, + mode='lines+markers', + name=label, + line=dict(color=color_hex, width=2.5, dash=dashes[i % len(dashes)]), + marker=dict(size=7, symbol=markers[i % len(markers)], color=color_hex), + hovertemplate=f"{field_name} | t=%{{x:.3f}}s
Value=%{{y:.6g}}{label}", + )) + title_parts = [] + if fv not in ("", "All"): + title_parts.append(fv) + if pf not in ("", "All", "All positions"): + title_parts.append(pf) + suffix = " - ".join(title_parts) if title_parts else "Filtered" + fig.update_layout(title=f"IBM QPU Time Series ({suffix})", height=660, width=900, margin=dict(l=50, r=30, t=50, b=50), hovermode="x unified", legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1, title_text="")) + fig.update_xaxes(title_text="Time (s)", title_font=dict(size=22), tickfont=dict(size=16), showgrid=True, gridcolor="rgba(0,0,0,.06)", zeroline=False, showline=True, linewidth=1, linecolor="rgba(0,0,0,.3)") + fig.update_yaxes(title_text="Field Value", title_font=dict(size=22), tickfont=dict(size=16), showgrid=True, gridcolor="rgba(0,0,0,.06)", zeroline=True, zerolinecolor="rgba(0,0,0,.25)", showline=True, linewidth=1, linecolor="rgba(0,0,0,.3)") + if max_abs > 0: + pad = 0.12 * max_abs + fig.update_yaxes(range=[-max_abs - pad, max_abs + pad]) + return fig + except Exception: + return qpu_ts_cache.get("fig") + +# Plotly click selection and CSV export + +def on_qpu_ts_click(evt): + try: + if not evt or "points" not in evt or not evt["points"]: + return + x = float(evt["points"][0].get("x")) + times = qpu_ts_cache.get("times") or [] + fig = qpu_ts_cache.get("fig") + if not times or fig is None: + return + import numpy as _np + idx = int(_np.argmin(_np.abs(_np.asarray(times) - x))) + sel_t = float(times[idx]) + # Update selection line + fig.update_layout(shapes=[dict(type="line", x0=sel_t, x1=sel_t, y0=0, y1=1, xref="x", yref="paper", line=dict(color="#5F259F", width=2, dash="dot"))]) + qpu_ts_cache["fig"] = fig + try: + ctrl.qpu_ts_update(fig) + except Exception: + pass + state.qpu_ts_selected_time = sel_t + except Exception: + pass + +def on_qpu_ts_clear(): + try: + fig = qpu_ts_cache.get("fig") + if fig is None: + return + fig.update_layout(shapes=[]) + qpu_ts_cache["fig"] = fig + try: + ctrl.qpu_ts_update(fig) + except Exception: + pass + state.qpu_ts_selected_time = None + except Exception: + pass + +ctrl.on_qpu_ts_click = on_qpu_ts_click +ctrl.on_qpu_ts_clear = on_qpu_ts_clear + + +@state.change("problem_selection") +def on_problem_change(problem_selection, **kwargs): + if problem_selection == "Propagation in a given medium (no bodies)": + state.geometry_options = ["None", "Square Domain", "Geometry 2", "Add"] + elif problem_selection == "Scattering from a perfectly conducting body": + state.geometry_options = ["None", "Square Metallic Body", "Geometry 2", "Add"] + else: + state.geometry_options = ["None", "Square Metallic Body", "Square Domain", "Geometry 2", "Add"] + _apply_workflow_highlights(_determine_workflow_step()) + + # Reset geometry selection when options change to avoid invalid state + if state.geometry_selection not in state.geometry_options: + state.geometry_selection = None + + +def export_qpu_timeseries_csv(): + try: + times = qpu_ts_cache.get("times") + series_map = qpu_ts_cache.get("series_map") + field = qpu_ts_cache.get("field") or "Ez" + if not times or not series_map: + raise ValueError("No QPU time series to export.") + + nx_val = int(state.nx or 0) + from datetime import datetime as _dt + filename = f"qpu_timeseries_{field}_nx{nx_val}_{_dt.now().strftime('%Y%m%d_%H%M%S')}.csv" + + import csv + with tempfile.NamedTemporaryFile(mode='w', newline='', suffix=".csv", delete=False) as tmp: + writer = csv.writer(tmp) + # Detect key layout: (px,py) legacy or (field,px,py) + keys = list(series_map.keys()) + if keys and len(keys[0]) == 3: + keys.sort(key=lambda k: (k[0], k[1], k[2])) + writer.writerow(["time_s"] + [f"{k[0]}({k[1]},{k[2]})" for k in keys]) + for i, t in enumerate(times): + row = [t] + [ (series_map.get(k) or [None])[i] if i < len(series_map.get(k) or []) else None for k in keys ] + writer.writerow(row) + else: + # Legacy single-field + pts = [(k[0], k[1]) for k in keys] + pts.sort(key=lambda p: (p[0], p[1])) + writer.writerow(["time_s"] + [f"({px},{py})" for (px, py) in pts]) + for i, t in enumerate(times): + row = [t] + [ (series_map.get((px,py)) or [None])[i] if i < len(series_map.get((px,py)) or []) else None for (px,py) in pts ] + writer.writerow(row) + temp_path = tmp.name + + content = Path(temp_path).read_bytes() + # Encode content as base64 for browser download + content_b64 = base64.b64encode(content).decode("ascii") + _server.js_call("utils", "download", filename, f"data:text/csv;base64,{content_b64}") + Path(temp_path).unlink() + + state.export_status_message = f"Exported QPU CSV to {filename}" + except Exception as e: + state.export_status_message = f"QPU CSV export failed: {e}" + finally: + state.show_export_status = True + +# NEW: Export QPU Plotly figure as PNG (requires kaleido) + +def export_qpu_timeseries_png(): + try: + fig = qpu_ts_cache.get("fig") + field = qpu_ts_cache.get("field") or "Ez" + if fig is None: + raise ValueError("No QPU time series to export.") + + nx_val = int(state.nx or 0) + filename = f"qpu_timeseries_{field}_nx{nx_val}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + temp_path = tmp.name + + fig.write_image(temp_path, scale=2, width=900, height=660) + + content = Path(temp_path).read_bytes() + # Encode content as base64 for browser download + content_b64 = base64.b64encode(content).decode("ascii") + _server.js_call("utils", "download", filename, f"data:image/png;base64,{content_b64}") + Path(temp_path).unlink() + + state.export_status_message = f"Exported QPU PNG to {filename}" + except Exception as e: + msg = str(e) + if "kaleido" in msg.lower(): + state.export_status_message = "QPU PNG export failed: kaleido is not installed. Run `pip install -U kaleido`." + else: + state.export_status_message = f"QPU PNG export failed: {e}" + finally: + state.show_export_status = True + +# NEW: Export QPU Plotly figure as standalone HTML + +def export_qpu_timeseries_html(): + try: + fig = qpu_ts_cache.get("fig") + field = qpu_ts_cache.get("field") or "Ez" + if fig is None: + raise ValueError("No QPU time series to export.") + + nx_val = int(state.nx or 0) + filename = f"qpu_timeseries_{field}_nx{nx_val}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html" + + with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp: + temp_path = tmp.name + + pio.write_html(fig, file=temp_path, include_plotlyjs="cdn", full_html=True) + + content = Path(temp_path).read_bytes() + # Encode content as base64 for browser download + content_b64 = base64.b64encode(content).decode("ascii") + _server.js_call("utils", "download", filename, f"data:text/html;base64,{content_b64}") + Path(temp_path).unlink() + + state.export_status_message = f"Exported QPU HTML to {filename}" + except Exception as e: + state.export_status_message = f"QPU HTML export failed: {e}" + finally: + state.show_export_status = True + +# --- Cache for Simulator Time Series (for export) --- +sim_ts_cache = { + "fig": None, + "field": None, +} + +# --- Export functions for Simulator Time Series --- + +def export_sim_timeseries_csv(): + """Export simulator time series data to CSV.""" + try: + global simulation_data, snapshot_times + if simulation_data is None or snapshot_times is None: + raise ValueError("No simulator time series to export.") + + field = state.timeseries_field or "Ez" + nx_val = int(state.nx or 0) + filename = f"sim_timeseries_{field}_nx{nx_val}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + # Parse positions from state + positions = [] + pts_str = state.timeseries_points or "(0.5, 0.5)" + for m in _SAMPLE_PAIR_RE.finditer(pts_str): + x_norm, y_norm = float(m.group(1)), float(m.group(2)) + px = int(round(x_norm * (nx_val - 1))) + py = int(round(y_norm * (nx_val - 1))) + positions.append((px, py)) + + if not positions: + positions = [(nx_val // 2, nx_val // 2)] + + import csv + with tempfile.NamedTemporaryFile(mode='w', newline='', suffix=".csv", delete=False) as tmp: + writer = csv.writer(tmp) + header = ["time_s"] + [f"({px},{py})" for (px, py) in positions] + writer.writerow(header) + + times = np.asarray(snapshot_times) + n_frames = simulation_data.shape[0] + for i, t in enumerate(times): + if i >= n_frames: + break + row = [t] + for (px, py) in positions: + if field == 'Ez': + val = simulation_data[i, py * nx_val + px] + elif field == 'Hx': + gw, gh = nx_val, nx_val - 1 + if 0 <= px < gw and 0 <= py < gh: + block = simulation_data[i, 2*nx_val*nx_val : 3*nx_val*nx_val-nx_val].reshape(gh, gw) + val = block[py, px] + else: + val = 0.0 + else: # Hy + mask = np.arange(1, nx_val * nx_val + 1) % nx_val != 0 + raw_block = simulation_data[i, -nx_val*nx_val:] + gw, gh = nx_val - 1, nx_val + if 0 <= px < gw and 0 <= py < gh: + val = raw_block[mask].reshape(nx_val, nx_val - 1)[py, px] + else: + val = 0.0 + row.append(val) + writer.writerow(row) + temp_path = tmp.name + + content = Path(temp_path).read_bytes() + content_b64 = base64.b64encode(content).decode("ascii") + _server.js_call("utils", "download", filename, f"data:text/csv;base64,{content_b64}") + Path(temp_path).unlink() + + state.export_status_message = f"Exported Simulator CSV to {filename}" + except Exception as e: + state.export_status_message = f"Simulator CSV export failed: {e}" + finally: + state.show_export_status = True + + +def export_sim_timeseries_png(): + """Export simulator time series Plotly figure as PNG.""" + try: + fig = sim_ts_cache.get("fig") + field = state.timeseries_field or "Ez" + if fig is None: + raise ValueError("No simulator time series to export.") + + nx_val = int(state.nx or 0) + filename = f"sim_timeseries_{field}_nx{nx_val}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + temp_path = tmp.name + + fig.write_image(temp_path, scale=2, width=900, height=660) + + content = Path(temp_path).read_bytes() + content_b64 = base64.b64encode(content).decode("ascii") + _server.js_call("utils", "download", filename, f"data:image/png;base64,{content_b64}") + Path(temp_path).unlink() + + state.export_status_message = f"Exported Simulator PNG to {filename}" + except Exception as e: + msg = str(e) + if "kaleido" in msg.lower(): + state.export_status_message = "Simulator PNG export failed: kaleido is not installed. Run `pip install -U kaleido`." + else: + state.export_status_message = f"Simulator PNG export failed: {e}" + finally: + state.show_export_status = True + + +def export_sim_timeseries_html(): + """Export simulator time series Plotly figure as standalone HTML.""" + try: + fig = sim_ts_cache.get("fig") + field = state.timeseries_field or "Ez" + if fig is None: + raise ValueError("No simulator time series to export.") + + nx_val = int(state.nx or 0) + filename = f"sim_timeseries_{field}_nx{nx_val}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html" + + with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp: + temp_path = tmp.name + + pio.write_html(fig, file=temp_path, include_plotlyjs="cdn", full_html=True) + + content = Path(temp_path).read_bytes() + content_b64 = base64.b64encode(content).decode("ascii") + _server.js_call("utils", "download", filename, f"data:text/html;base64,{content_b64}") + Path(temp_path).unlink() + + state.export_status_message = f"Exported Simulator HTML to {filename}" + except Exception as e: + state.export_status_message = f"Simulator HTML export failed: {e}" + finally: + state.show_export_status = True + + +def setup_surface_plot_data(simulation_data, nx): + global data_frames, z_scale, X_grids, Y_grids, surface_clims + mask = np.arange(1, nx * nx + 1) % nx != 0 + data_frames = {'Ez': [], 'Hx': [], 'Hy': []} + surface_clims = {'Ez': [np.inf, -np.inf], 'Hx': [np.inf, -np.inf], 'Hy': [np.inf, -np.inf]} + for u in simulation_data: + ez, hx, hy = u[:nx*nx].reshape(nx,nx), u[2*nx*nx:3*nx*nx-nx].reshape(nx-1,nx), u[-nx*nx:][mask].reshape(nx,nx-1) + data_frames['Ez'].append(ez); data_frames['Hx'].append(hx); data_frames['Hy'].append(hy) + if ez.size > 0: surface_clims['Ez'][0], surface_clims['Ez'][1] = min(surface_clims['Ez'][0], ez.min()), max(surface_clims['Ez'][1], ez.max()) + if hx.size > 0: surface_clims['Hx'][0], surface_clims['Hx'][1] = min(surface_clims['Hx'][0], hx.min()), max(surface_clims['Hx'][1], hx.max()) + if hy.size > 0: surface_clims['Hy'][0], surface_clims['Hy'][1] = min(surface_clims['Hy'][0], hy.min()), max(surface_clims['Hy'][1], hy.max()) + for key in surface_clims: + if surface_clims[key][0] == surface_clims[key][1]: surface_clims[key][0] -= 1e-9; surface_clims[key][1] += 1e-9 + # Revert to integer grid coordinates (like app.py) to keep output plots in grids + x, y, x_m1, y_m1 = np.arange(nx), np.arange(nx), np.arange(nx-1), np.arange(nx-1) + X_grids['Ez'], Y_grids['Ez'] = np.meshgrid(x, y) + X_grids['Hx'], Y_grids['Hx'] = np.meshgrid(x, y_m1) + X_grids['Hy'], Y_grids['Hy'] = np.meshgrid(x_m1, y) + # Fix: compute max_abs across finite values in surface_clims + finite_vals = [abs(float(v)) for pair in surface_clims.values() for v in pair if np.isfinite(v)] + max_abs = max(finite_vals) if finite_vals else 1e-9 + z_scale = (nx / 2) / max(max_abs, 1e-9) + +def run_simulation_only(): + global simulation_data, current_mesh, snapshot_times, stop_simulation + # Require selections before running + if not state.geometry_selection: + state.error_message = "Please select a geometry before running the simulation." + log_to_console("Error: Please select a geometry before running.") + state.status_visible = True + state.status_message = "Error: Please select a geometry before running." + state.status_type = "error" + state.show_progress = False + state.is_running = False + state.run_button_text = "RUN!" + return + if not state.dist_type: + state.error_message = "Please select an initial state before running the simulation." + log_to_console("Error: Please select an initial state before running.") + state.status_visible = True + state.status_message = "Error: Please select an initial state before running." + state.status_type = "error" + state.show_progress = False + state.is_running = False + state.run_button_text = "RUN!" + return + + # Show status: Starting simulation + state.status_visible = True + state.status_message = "Initializing simulation..." + log_to_console("Initializing simulation...") + state.status_type = "info" + state.show_progress = True + state.simulation_progress = 0 + + last_logged_percent = 0 + def _progress_callback(percent): + nonlocal last_logged_percent + state.simulation_progress = percent + # Log every 10% + if percent - last_logged_percent >= 10: + log_to_console(f"Simulation progress: {int(percent)}%") + last_logged_percent = percent + + # Reset stop flag and enable Stop button at start + stop_simulation = False + state.stop_button_disabled = False + + plotter.clear() + current_mesh = None + state.error_message = "" + state.is_running = True + state.simulation_has_run = False + state.run_button_text = "Running" + ctrl.view_update() + + nx, T = int(state.nx), float(state.T) + na, R = 1, 4 + + try: + state.status_message = "Creating initial state..." + state.simulation_progress = 10 + if state.dist_type == "Delta": + initial_state = create_impulse_state_from_pos((nx, nx), (float(state.impulse_x), float(state.impulse_y))) + else: + initial_state = create_gaussian_state_from_pos((nx, nx), (float(state.mu_x), float(state.mu_y)), (float(state.sigma_x), float(state.sigma_y))) + except ValueError as e: + state.error_message = f"Initial State Error: {e}" + state.status_message = f"Error: {e}" + state.status_type = "error" + state.show_progress = False + state.is_running = False + state.run_button_text = "RUN!" + state.stop_button_disabled = True + return + + # If QPU selected, build QPU time series chart and return + if state.backend_type == "QPU": + try: + print("Running QPU...") + state.status_message = "Running QPU simulation..." + state.simulation_progress = 20 + state.qpu_ts_ready = False + state.qpu_plot_style = "display: none; width: 900px; height: 660px; margin: 0 auto;" + state.qpu_ts_other_ready = False + state.qpu_other_plot_style = "display: none; width: 900px; height: 660px; margin: 0 auto;" + # Inputs for QPU + snapshot_dt = float(state.dt_user) + # Impulse index for QPU helper + ix_imp, iy_imp = _nearest_node_index(float(state.impulse_x), float(state.impulse_y), nx) + impulse_pos = (ix_imp, iy_imp) + # Build configs from primitive slots: primary + additional slots (2..1+count) + configs = [{ + "field": (state.qpu_field_components or "Ez"), + "points": (state.qpu_monitor_gridpoints or ""), + }] + try: + cnt = int(state.qpu_monitor_count or 0) + except Exception: + cnt = 0 + for slot_num in range(2, 2 + cnt): + f = getattr(state, f"qpu_field_components_{slot_num}", "Ez") or "Ez" + p = getattr(state, f"qpu_monitor_gridpoints_{slot_num}", "") or "" + configs.append({"field": f, "points": p}) + + state.status_message = "Building QPU time series..." + state.simulation_progress = 60 + # Build and render Plotly chart (multi-config) + fig = _build_qpu_timeseries_plotly_multi(configs, nx, T, snapshot_dt, impulse_pos, progress_callback=_progress_callback, print_callback=log_to_console) + qpu_ts_cache["fig"] = fig + try: + ctrl.qpu_ts_update(fig) + except Exception: + pass + state.simulation_has_run = True + state.run_button_text = "Successful!" + state.simulation_progress = 100 + state.status_message = "QPU simulation completed successfully!" + log_to_console("Simulation Completed") + state.status_type = "success" + state.show_progress = False + # Show chart only if it has data + ready = bool(getattr(fig, "data", None)) and len(fig.data) > 0 + state.qpu_ts_ready = ready + state.qpu_plot_style = ( + "width: 900px; height: 660px; margin: 0 auto;" + if ready else "display: none; width: 900px; height: 660px; margin: 0 auto;" + ) + # Secondary stays hidden after run; it will appear when a specific component is selected + state.qpu_ts_other_ready = False + state.qpu_other_plot_style = "display: none; width: 900px; height: 660px; margin: 0 auto;" + if not ready: + state.error_message = "No QPU time series generated. Check Δt, T, nx, and monitor points." + state.status_message = "Warning: No QPU time series generated." + state.status_type = "warning" + log_to_console("QPU complete.") + except Exception as e: + state.error_message = f"QPU run failed: {e}" + state.status_message = f"QPU Error: {e}" + state.status_type = "error" + state.show_progress = False + state.run_button_text = "RUN!" + state.qpu_ts_ready = False + log_to_console(f"QPU error: {e}") + finally: + state.is_running = False + state.stop_button_disabled = True + ctrl.view_update() + return + + log_to_console("Running simulation...") + state.status_message = "Running simulation... This may take a while." + state.simulation_progress = 30 + # Pass user-defined snapshot Δt; keep solver dt=0.1 inside run_sim + snapshot_dt = float(state.dt_user) + def _stop_check(): + return stop_simulation + + state.simulation_progress = 50 + simulation_data, snapshot_times = run_sim(nx, na, R, initial_state, T, snapshot_dt=snapshot_dt, stop_check=_stop_check, progress_callback=_progress_callback, print_callback=log_to_console) + log_to_console("Simulation complete.") + + state.simulation_progress = 80 + state.status_message = "Processing simulation results..." + + if simulation_data.size > 0: + setup_surface_plot_data(simulation_data, nx) + state.simulation_has_run = True + state.run_button_text = "Successful!" + state.simulation_progress = 100 + state.status_message = "Simulation completed successfully!" + state.status_type = "success" + state.show_progress = False + # Allow the view to use full area after run (remove strict square) + generate_plot() + else: + state.error_message = "Simulation produced no data. Check parameters (e.g., T > 0)." + state.status_message = "Error: Simulation produced no data." + state.status_type = "error" + state.show_progress = False + state.run_button_text = "RUN!" + + state.is_running = False + state.stop_button_disabled = True + +def reset_to_defaults(): + """Reset all parameters to their default values""" + global simulation_data, current_mesh, data_frames, stop_simulation, snapshot_times + + # Stop any running simulation + stop_simulation = True + + # Reset global variables + simulation_data = None + current_mesh = None + data_frames = None + snapshot_times = None + + # Reset state to default values + state.update({ + "dist_type": None, + "impulse_x": 0.5, + "impulse_y": 0.5, + "peak_pair": "(0.5, 0.5)", + "mu_x": 0.5, + "mu_y": 0.5, + "sigma_x": 0.25, + "sigma_y": 0.15, + "mu_pair": "(0.5, 0.5)", + "sigma_pair": "(0.25, 0.15)", + "nx": None, + "T": 10.0, + "time_val": 0.0, + "output_type": "Surface Plot", + "surface_field": "Ez", + "timeseries_field": "Ez", + "timeseries_points": "(0.5, 0.5)", + "timeseries_gridpoints": "", + "timeseries_point_info": "", + "error_message": "", + "excitation_info_message": "", + "excitation_config_open": False, + "is_running": False, + "simulation_has_run": False, + "geometry_selection": None, + "coeff_permittivity": 1.0, + "coeff_permeability": 1.0, + "run_button_text": "RUN!", + "backend_type": None, + "selected_simulator": "IBM Qiskit simulator", + "selected_qpu": "IBM QPU", + "stop_button_disabled": True, + "export_format": "vtk", + "nx_slider_index": None, + "dt_user": 0.1, + "temporal_warning": "", + "qpu_field_components": "Ez", + "qpu_monitor_gridpoints": "", + "qpu_monitor_samples": "(0.5, 0.5)", + "qpu_monitor_sample_info": "", + "qpu_monitor_count": 0, + "qpu_plot_filter": "All", + "qpu_plot_field_options": ["All"], + "qpu_plot_position_filter": "All positions", + "qpu_plot_position_options": ["All positions"], + "qpu_ts_ready": False, + "qpu_plot_style": "display: none; width: 900px; height: 660px; margin: 0 auto;", + "qpu_ts_other_ready": False, + "qpu_other_plot_style": "display: none; width: 900px; height: 660px; margin: 0 auto;", + # Restore square aspect for initial preview + "pyvista_view_style": "aspect-ratio: 1 / 1; width: 100%;", + "qpu_field_components_2": "Ez", + "qpu_monitor_gridpoints_2": "", + "qpu_monitor_samples_2": "", + "qpu_monitor_sample_info_2": "", + "qpu_field_components_3": "Ez", + "qpu_monitor_gridpoints_3": "", + "qpu_monitor_samples_3": "", + "qpu_monitor_sample_info_3": "", + "qpu_field_components_4": "Ez", + "qpu_monitor_gridpoints_4": "", + "qpu_monitor_samples_4": "", + "qpu_monitor_sample_info_4": "", + "qpu_field_components_5": "Ez", + "qpu_monitor_gridpoints_5": "", + "qpu_monitor_samples_5": "", + "qpu_monitor_sample_info_5": "", + # Workflow guidance styling + "workflow_step": 0, + "overview_card_style": "font-size: 0.8rem; border: 2px solid #5F259F; box-shadow: 0 0 12px rgba(95,37,159,0.4); transition: box-shadow 0.2s ease;", + "geometry_card_style": "font-size: 0.8rem; border: 1px solid transparent; transition: box-shadow 0.2s ease;", + "excitation_card_style": "font-size: 0.8rem; border: 1px solid transparent; transition: box-shadow 0.2s ease;", + "meshing_card_style": "font-size: 0.8rem; border: 1px solid transparent; transition: box-shadow 0.2s ease;", + "backend_card_style": "font-size: 0.8rem; border: 1px solid transparent; transition: box-shadow 0.2s ease;", + "output_card_style": "font-size: 0.8rem; border: 1px solid transparent; transition: box-shadow 0.2s ease;", + }) + + qpu_ts_cache.update({ + "times": None, + "series_map": None, + "field": None, + "fig": None, + "positions_by_field": {"All": []}, + "key_to_label": {}, + "label_to_keys": {}, + "nx": None, + }) + + # Ensure stop flag is cleared for next run + stop_simulation = False + + _refresh_all_qpu_sample_slots() + _update_sim_monitor_points() + _apply_workflow_highlights(0) + + # Update the preview with default values + update_initial_state_preview() + print("Reset to default settings") +@state.change("peak_pair") +def sync_peak_pair(peak_pair, **kwargs): + """Parse normalized pair (x, y) in [0,1] for Peak and update impulse_x/impulse_y.""" + try: + m = re.match(r"\(\s*([0-9]*\.?[0-9]+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)", str(peak_pair)) + if not m: + raise ValueError("Invalid format") + x = max(0.0, min(1.0, float(m.group(1)))) + y = max(0.0, min(1.0, float(m.group(2)))) + state.impulse_x = x + state.impulse_y = y + state.excitation_error_message = "" + except Exception: + state.excitation_error_message = "Invalid Peak. Use format (x, y) in [0,1]." + finally: + update_excitation_info_message() + +@state.change("mu_pair") +def sync_mu_pair(mu_pair, **kwargs): + """Parse normalized pair (x, y) in [0,1] for Mu and update mu_x/mu_y.""" + try: + m = re.match(r"\(\s*([-+]?[0-9]*\.?[0-9]+)\s*,\s*([-+]?[0-9]*\.?[0-9]+)\s*\)", str(mu_pair)) + if not m: + raise ValueError("Invalid format") + x = max(0.0, min(1.0, float(m.group(1)))) + y = max(0.0, min(1.0, float(m.group(2)))) + state.mu_x = x + state.mu_y = y + state.excitation_error_message = "" + except Exception: + state.excitation_error_message = "Invalid Mu. Use format (x, y) in [0,1]." + finally: + update_excitation_info_message() + +def stop_simulation_handler(): + """Stop the currently running simulation""" + global stop_simulation + stop_simulation = True + state.stop_button_disabled = True + print("Stopping simulation...") + +def _build_sim_timeseries_plotly(field_type: str, positions, nx: int, times, sim_data): + try: + import plotly.graph_objects as go + import numpy as _np + from matplotlib import cm as _cm + from matplotlib import colors as _mcolors + + def _rgba_to_hex(rgba): + r, g, b, a = rgba + return "#%02x%02x%02x" % (int(r*255), int(g*255), int(b*255)) + + n_frames = int(sim_data.shape[0]) if sim_data is not None else 0 + time_axis = _np.asarray(times) if times is not None else _np.arange(n_frames) + + # Helper to get grid dims per field + def _dims(f): + if f == 'Ez': + return nx, nx + if f == 'Hx': + return nx, nx - 1 + return nx - 1, nx # Hy + + # Validate and normalize positions against a given field + def _valid_positions(f, pts): + gw, gh = _dims(f) + out = [] + for (px, py) in pts: + if 0 <= px < gw and 0 <= py < gh: + out.append((int(px), int(py))) + return out + + fig = go.Figure() + + if not positions or sim_data is None or n_frames == 0: + fig.update_layout( + title="Time Series (Simulator)", + height=660, width=900, + margin=dict(l=50, r=30, t=50, b=50), + xaxis=dict(title="Time (s)", title_font=dict(size=22), tickfont=dict(size=16), showline=True, linewidth=1, linecolor="rgba(0,0,0,.3)", gridcolor="rgba(0,0,0,.06)", showspikes=True, spikemode='across', spikesnap='cursor'), + yaxis=dict(title="Field Amplitude", title_font=dict(size=22), tickfont=dict(size=16), showline=True, linewidth=1, linecolor="rgba(0,0,0,.3)", gridcolor="rgba(0,0,0,.06)", zeroline=True, zerolinecolor="rgba(0,0,0,.25)"), + hovermode="x unified", + legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1), + ) + return fig + + # Compute color normalization metric: s = (px + py) / max_sum + max_sum = max((px + py) for (px, py) in positions) if positions else 1 + if max_sum <= 0: + max_sum = 1 + + # Field → colormap mapping + cmap_map = { + 'Ez': _cm.Reds, + 'Hx': _cm.Greens, + 'Hy': _cm.Blues, + } + + # Add traces for a single field + def _add_field_traces(f_name: str, pts): + nonlocal fig + gw, gh = _dims(f_name) + valid_pts = _valid_positions(f_name, pts) + if not valid_pts: + return 0.0, 0 + max_abs_local = 0.0 + num_keys = len(valid_pts) + for i, (px, py) in enumerate(valid_pts): + # Extract values depending on field + if f_name == 'Ez': + values = sim_data[:, py * gw + px] + elif f_name == 'Hx': + block = sim_data[:, 2*nx*nx : 3*nx*nx-nx].reshape(n_frames, gh, gw) + values = block[:, py, px] + else: # Hy + mask = _np.arange(1, nx * nx + 1) % nx != 0 + raw_block = sim_data[:, -nx*nx:] + values = _np.array([raw_block[t, mask].reshape(nx, nx - 1)[py, px] for t in range(n_frames)]) + try: + max_abs_local = max(max_abs_local, float(_np.max(_np.abs(values)))) + except Exception: + pass + + # Color keyed by index to ensure distinctness + if num_keys > 1: + s_index = i / (num_keys - 1) + s_light = 0.3 + 0.6 * s_index + else: + s_light = 0.6 + + rgba = cmap_map.get(f_name, _cm.Blues)(s_light) + color_hex = _rgba_to_hex(rgba) + dash_styles = ["solid", "dash", "dot", "dashdot"] + marker_symbols = ["circle", "square", "diamond", "triangle-up", "x"] + label = _normalized_position_label(px, py, gw, gh) + fig.add_trace(go.Scatter( + x=time_axis, + y=values, + mode='lines+markers', + name=label, + line=dict(color=color_hex, width=2.5, dash=dash_styles[i % len(dash_styles)]), + marker=dict(size=7, symbol=marker_symbols[i % len(marker_symbols)], color=color_hex, line=dict(width=0)), + hovertemplate=f"{f_name} | t=%{{x:.3f}}s
Value=%{{y:.6g}}{label}", + )) + return max_abs_local, len(valid_pts) + + max_abs = 0.0 + total_traces = 0 + if str(field_type) == 'All': + for f in ('Ez', 'Hx', 'Hy'): + m, n_tr = _add_field_traces(f, positions) + max_abs = max(max_abs, m) + total_traces += n_tr + else: + m, n_tr = _add_field_traces(str(field_type), positions) + max_abs = max(max_abs, m) + total_traces += n_tr + + # Layout styling: doubled label fonts, refined gridlines + title_suffix = str(field_type) if str(field_type) != 'All' else 'Ez, Hx, Hy' + fig.update_layout( + title=f"Time Series (Simulator: {title_suffix})", + height=660, width=900, + margin=dict(l=50, r=30, t=50, b=50), + hovermode="x unified", + legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1, title_text=""), + paper_bgcolor="#FFFFFF", + plot_bgcolor="#FFFFFF", + ) + fig.update_xaxes( + title_text="Time (s)", title_font=dict(size=22), tickfont=dict(size=16), + showgrid=True, gridcolor="rgba(95,37,159,0.08)", zeroline=False, + showline=True, linewidth=1, linecolor="rgba(0,0,0,.2)", + showspikes=True, spikemode='across', spikesnap='cursor' + ) + fig.update_yaxes( + title_text="Field Amplitude", title_font=dict(size=22), tickfont=dict(size=16), + showgrid=True, gridcolor="rgba(95,37,159,0.08)", zeroline=True, zerolinecolor="rgba(0,0,0,.25)", + showline=True, linewidth=1, linecolor="rgba(0,0,0,.2)" + ) + if max_abs > 0: + pad = 0.12 * max_abs + fig.update_yaxes(range=[-max_abs - pad, max_abs + pad]) + return fig + except Exception as _e: + try: + import plotly.graph_objects as go + return go.Figure(layout=dict(height=660, width=900)) + except Exception: + return None + +def generate_plot(): + global current_mesh, sim_ts_cache + if not state.simulation_has_run: + return + + plotter.clear() + try: + plotter.disable_picking() + except: + pass + + nx, T = int(state.nx), float(state.T) + + if state.output_type == "Surface Plot": + redraw_surface_plot() + else: # Time Series -> Plotly for Simulator + try: + points_str = state.timeseries_gridpoints or "" + positions = [tuple(map(int, match)) for match in re.findall(r'\((\d+)\s*,\s*(\d+)\)', points_str)] + if not positions and (state.timeseries_points or "").strip(): + raise ValueError("No valid monitor positions found. Enter (x, y) pairs in [0,1] x [0,1].") + fig = _build_sim_timeseries_plotly(state.timeseries_field, positions, nx, snapshot_times, simulation_data) + if fig is not None: + # Cache the figure for export + sim_ts_cache["fig"] = fig + sim_ts_cache["field"] = state.timeseries_field + try: + ctrl.sim_ts_update(fig) + except Exception: + pass + except Exception as e: + state.error_message = f"Plotting Error: {e}" + + ctrl.view_update() + +def redraw_surface_plot(): + global current_mesh + plotter.clear() + field = state.surface_field + if data_frames is None or not data_frames.get(field): return + if snapshot_times is None or len(snapshot_times) == 0: return + + # Find nearest snapshot index to requested time and clamp to available frames + req_t = float(state.time_val) + times = np.asarray(snapshot_times) + idx = int(np.argmin(np.abs(times - req_t))) + max_idx = len(data_frames[field]) - 1 + idx = max(0, min(idx, max_idx)) + + z_data = data_frames[field][idx] + points = np.c_[X_grids[field].ravel(), Y_grids[field].ravel(), z_data.ravel() * z_scale] + poly = pv.PolyData(points) + mesh = poly.delaunay_2d() + mesh['scalars'] = z_data.ravel() + current_mesh = mesh + plotter.add_mesh(mesh, scalars='scalars', clim=surface_clims[field], cmap="Blues", show_scalar_bar=False, show_edges=True, edge_color='grey', line_width=0.5) + plotter.add_scalar_bar(title=f"{field} Amplitude") + try: + plotter.disable_picking() + except Exception: + pass + plotter.enable_point_picking(callback=update_value_display, show_message=False) + plotter.add_axes() + plotter.view_isometric() + try: + plotter.camera.parallel_projection = True + except Exception: + pass + ctrl.view_update() + +# Helper: add a dotted unit grid (0..1) overlay in light Synopsys purple +def _add_dotted_unit_grid(plotter, ticks=(0.0, 0.25, 0.5, 0.75, 1.0), segments=48, gap_ratio=0.4, color="#AE8BD8", line_width=0.2): + try: + step = 1.0 / float(max(segments, 1)) + seg_len = step * float(max(0.0, min(1.0, 1.0 - gap_ratio))) + pts = [] + lines = [] + # Horizontal dotted lines at given y=tick + for y in ticks: + pos = 0.0 + while pos < 1.0 - 1e-9: + y0, y1 = pos, min(pos + seg_len, 1.0) + pts.extend([(0.0, y, 0.0), (1.0, y, 0.0)]) # end points along x (we'll segment via multiple x positions) + # Replace with segmented along X + pts[-2] = (pos, y, 0.0) + pts[-1] = (y1 if seg_len > 0 else pos, y, 0.0) + i0 = len(pts) - 2 + lines.extend([2, i0, i0 + 1]) + pos += step + # Vertical dotted lines at given x=tick + for x in ticks: + pos = 0.0 + while pos < 1.0 - 1e-9: + y0, y1 = pos, min(pos + seg_len, 1.0) + pts.extend([(x, pos, 0.0), (x, y1 if seg_len > 0 else pos, 0.0)]) + i0 = len(pts) - 2 + lines.extend([2, i0, i0 + 1]) + pos += step + if pts and lines: + poly = pv.PolyData(np.array(pts)) + poly.lines = np.array(lines) + plotter.add_mesh(poly, color=color, line_width=line_width, name="dotted_unit_grid", pickable=False) + except Exception: + pass + +# Scaled dotted unit grid overlay for integer-coordinate previews (Delta/Gaussian) +def _add_dotted_unit_grid_scaled(plotter, denom, ticks=(0.0, 0.25, 0.5, 0.75, 1.0), segments=48, gap_ratio=0.6, color="#AE8BD8", line_width=1.0, name="dotted_unit_grid_preview"): + """Overlay a 0–1 dotted grid scaled to [0, denom] on the XY plane without changing mesh coordinates.""" + try: + step = 1.0 / float(max(segments, 1)) + seg_len = step * float(max(0.0, min(1.0, 1.0 - gap_ratio))) + # Set a z slightly below mesh to avoid z-fighting + try: + z0 = float(current_mesh.points[:, 2].min()) - 1e-6 if current_mesh is not None else 0.0 + except Exception: + z0 = 0.0 + pts, lines = [], [] + # Vertical lines at x = t * denom + for t in ticks: + x = float(t) * float(denom) + pos = 0.0 + while pos < 1.0 - 1e-9: + y0 = pos * denom + y1 = min(pos + seg_len, 1.0) * denom + pts.extend([(x, y0, z0), (x, y1, z0)]) + i0 = len(pts) - 2 + lines.extend([2, i0, i0 + 1]) + pos += step + # Horizontal lines at y = t * denom + for t in ticks: + y = float(t) * float(denom) + pos = 0.0 + while pos < 1.0 - 1e-9: + x0 = pos * denom + x1 = min(pos + seg_len, 1.0) * denom + pts.extend([(x0, y, z0), (x1, y, z0)]) + i0 = len(pts) - 2 + lines.extend([2, i0, i0 + 1]) + pos += step + try: + plotter.remove_actor(name) + except Exception: + pass + if pts and lines: + poly = pv.PolyData(np.array(pts)) + poly.lines = np.array(lines) + plotter.add_mesh(poly, color=color, line_width=line_width, name=name, pickable=False) + except Exception: + pass + +# Helpers for square hole edge computation/alignment on a normalized [0,1] grid + +def _nearest_gridline(val: float, nx: int) -> float: + denom = float(max(int(nx) - 1, 1)) + return round(float(val) * denom) / denom + + +def _compute_hole_edges(nx: int, cx: float, cy: float, a: float, snap: bool = True): + """Compute square hole edges (xL, xR, yB, yT) in [0,1]. + + - nx: points per direction; grid lines at k/(nx-1). + - (cx, cy): center in (0,1). + - a: edge length in (0,1]. + - snap: if True, snap edges to nearest grid lines; else require exact alignment. + + Returns tuple or None when invalid/out-of-bounds/misaligned. + """ + try: + nx = int(nx) + cx = float(cx) + cy = float(cy) + a = float(a) + except Exception: + return None + + if not (a > 0.0): + return None + + half = a / 2.0 + xL, xR = cx - half, cx + half + yB, yT = cy - half, cy + half + + # Must be strictly inside domain to allow removing interior cells safely + if not (0.0 < xL < xR < 1.0 and 0.0 < yB < yT < 1.0): + return None + + if snap: + xL_s = _nearest_gridline(xL, nx) + xR_s = _nearest_gridline(xR, nx) + yB_s = _nearest_gridline(yB, nx) + yT_s = _nearest_gridline(yT, nx) + # Ensure non-degenerate after snapping; attempt a minimal adjustment if equal + if xL_s >= xR_s or yB_s >= yT_s: + step = 1.0 / float(max(nx - 1, 1)) + if xL_s >= xR_s: + if xL_s - step > 0.0: + xL_s -= step + elif xR_s + step < 1.0: + xR_s += step + if yB_s >= yT_s: + if yB_s - step > 0.0: + yB_s -= step + elif yT_s + step < 1.0: + yT_s += step + if xL_s >= xR_s or yB_s >= yT_s: + return None + return (xL_s, xR_s, yB_s, yT_s) + else: + # Require edges to already lie on grid lines + denom = float(max(nx - 1, 1)) + tol = 1e-9 + def _aligned(v: float) -> bool: + return abs(v * denom - round(v * denom)) < tol + if all(_aligned(v) for v in (xL, xR, yB, yT)): + return (xL, xR, yB, yT) + return None + + +def _build_geometry_placeholder(message: str) -> go.Figure: + fig = go.Figure() + fig.add_annotation( + text=message, + x=0.5, + y=0.5, + showarrow=False, + font=dict(size=18, color="#5F259F"), + ) + fig.update_xaxes(visible=False) + fig.update_yaxes(visible=False) + fig.update_layout( + template="plotly_white", + margin=dict(l=20, r=20, t=40, b=20), + paper_bgcolor="#ffffff", + plot_bgcolor="#ffffff", + height=460, + showlegend=False, + ) + return fig + + +def _build_square_domain_plot( + nx: int, + title: str, + hole_edges=None, + *, + show_edges: bool = True, + dense_grid: bool = False, +) -> go.Figure: + nx = max(int(nx), 3) + grid = np.linspace(0.0, 1.0, nx) + X, Y = np.meshgrid(grid, grid, indexing="xy") + Z = np.zeros_like(X, dtype=float) + color_field = np.full_like(Z, 0.85) + + if hole_edges is not None: + xL, xR, yB, yT = hole_edges + mask = (X >= xL) & (X <= xR) & (Y >= yB) & (Y <= yT) + Z = np.where(mask, np.nan, Z) + color_field = np.where(mask, np.nan, color_field) + + fig = go.Figure() + fig.add_trace( + go.Surface( + x=X, + y=Y, + z=Z, + surfacecolor=np.where(np.isnan(color_field), 0.15, color_field), + colorscale=EXCITATION_SURFACE_COLORSCALE, + cmin=0.0, + cmax=1.0, + showscale=False, + opacity=0.98, + lighting=dict(ambient=0.85, diffuse=0.55, specular=0.1), + hovertemplate="x=%{x:.3f}
y=%{y:.3f}", + ) + ) + + if show_edges: + base_z = -0.012 + grid_vals = ( + np.linspace(0.0, 1.0, max(int(nx), 2)) + if dense_grid + else np.asarray(DEFAULT_AXIS_TICKS) + ) + line_x, line_y, line_z = [], [], [] + x_min_val, x_max_val = float(grid[0]), float(grid[-1]) + for val in grid_vals: + val_f = float(val) + line_x.extend([val_f, val_f, np.nan]) + line_y.extend([x_min_val, x_max_val, np.nan]) + line_z.extend([base_z, base_z, np.nan]) + for val in grid_vals: + val_f = float(val) + line_x.extend([x_min_val, x_max_val, np.nan]) + line_y.extend([val_f, val_f, np.nan]) + line_z.extend([base_z, base_z, np.nan]) + fig.add_trace( + go.Scatter3d( + x=line_x, + y=line_y, + z=line_z, + mode="lines", + line=dict(color="rgba(174,139,216,0.65)", width=1.6), + showlegend=False, + hoverinfo="skip", + ) + ) + + scale_ticks = list(DEFAULT_AXIS_TICKS) + tick_text = [f"{t:.2f}" for t in scale_ticks] + tick_plane = -0.02 + fig.add_trace( + go.Scatter3d( + x=scale_ticks, + y=[-0.018] * len(scale_ticks), + z=[tick_plane] * len(scale_ticks), + mode="text", + text=tick_text, + textfont=dict(color="#5F259F", size=12), + showlegend=False, + hoverinfo="skip", + ) + ) + fig.add_trace( + go.Scatter3d( + x=[-0.018] * len(scale_ticks), + y=scale_ticks, + z=[tick_plane] * len(scale_ticks), + mode="text", + text=tick_text, + textfont=dict(color="#5F259F", size=12), + showlegend=False, + hoverinfo="skip", + ) + ) + + if hole_edges is not None: + xL, xR, yB, yT = hole_edges + fig.add_trace( + go.Scatter3d( + x=[xL, xR, xR, xL, xL], + y=[yB, yB, yT, yT, yB], + z=[0.0] * 5, + mode="lines", + line=dict(color="#FFFFFF", width=5), + hoverinfo="skip", + showlegend=False, + ) + ) + + fig.update_layout( + title=title, + margin=dict(l=8, r=8, t=44, b=8), + height=620, + template="plotly_white", + scene=dict( + xaxis=dict(range=[-0.05, 1.05], visible=False, backgroundcolor="#f7f3ff"), + yaxis=dict(range=[-0.05, 1.05], visible=False, backgroundcolor="#f7f3ff"), + zaxis=dict(range=[0.1, 0.1], visible=False, backgroundcolor="#f7f3ff"), + aspectmode="cube", + camera=dict(eye=dict(x=1.25, y=1.25, z=0.85)), + ), + dragmode="orbit", + uirevision="geometry_surface", + ) + return fig + + +def _push_geometry_plot(fig: go.Figure): + try: + if hasattr(ctrl, "geometry_preview_update"): + ctrl.geometry_preview_update(fig) + except Exception: + pass + + +def _refresh_pyvista_view(): + try: + if hasattr(ctrl, "view_update"): + ctrl.view_update() + except Exception: + pass + + +def _build_excitation_placeholder(message: str = "Select an excitation to preview.") -> go.Figure: + fig = go.Figure() + fig.add_annotation(text=message, x=0.5, y=0.5, showarrow=False, font=dict(size=18, color="#5F259F")) + fig.update_xaxes(visible=False) + fig.update_yaxes(visible=False) + fig.update_layout( + template="plotly_white", + margin=dict(l=20, r=20, t=40, b=20), + paper_bgcolor="#ffffff", + plot_bgcolor="#ffffff", + height=520, + showlegend=False, + ) + return fig + + +def _build_excitation_surface_plot( + X: np.ndarray, + Y: np.ndarray, + field: np.ndarray, + title: str, + *, + show_grid_lines: bool = False, + grid_line_resolution: int | None = None, +) -> go.Figure: + max_abs = float(np.max(np.abs(field))) if field.size else 1.0 + if max_abs < 1e-12: + max_abs = 1.0 + height_scale = 0.1 + Z = (field / max_abs) * height_scale + fig = go.Figure() + + fig.add_trace( + go.Surface( + x=X, + y=Y, + z=Z, + surfacecolor=field, + colorscale=EXCITATION_SURFACE_COLORSCALE, + cmin=-max_abs, + cmax=max_abs, + showscale=False, + opacity=0.98, + lighting=dict(ambient=0.6, diffuse=0.6, specular=0.15), + hovertemplate="x=%{x:.3f}
y=%{y:.3f}
z=%{z:.3f}", + ) + ) + + x_vals = np.unique(np.round(X[0], decimals=6)) + y_vals = np.unique(np.round(Y[:, 0], decimals=6)) + x_min, x_max = float(np.min(x_vals)), float(np.max(x_vals)) + y_min, y_max = float(np.min(y_vals)), float(np.max(y_vals)) + base_z = -0.02 + + if show_grid_lines: + span_x = x_max - x_min if x_max != x_min else 1.0 + span_y = y_max - y_min if y_max != y_min else 1.0 + if grid_line_resolution is not None and grid_line_resolution >= 2: + res = int(grid_line_resolution) + norm_vals = np.linspace(0.0, 1.0, res) + else: + norm_vals = np.asarray(DEFAULT_AXIS_TICKS) + x_grid_vals = x_min + span_x * norm_vals + y_grid_vals = y_min + span_y * norm_vals + + line_x, line_y, line_z = [], [], [] + for val in x_grid_vals: + val_f = float(val) + line_x.extend([val_f, val_f, np.nan]) + line_y.extend([y_min, y_max, np.nan]) + line_z.extend([base_z, base_z, np.nan]) + for val in y_grid_vals: + val_f = float(val) + line_x.extend([x_min, x_max, np.nan]) + line_y.extend([val_f, val_f, np.nan]) + line_z.extend([base_z, base_z, np.nan]) + + fig.add_trace( + go.Scatter3d( + x=line_x, + y=line_y, + z=line_z, + mode="lines", + line=dict(color="rgba(174,139,216,0.3)", width=1), + showlegend=False, + hoverinfo="skip", + ) + ) + + tick_positions_x = x_min + span_x * np.asarray(DEFAULT_AXIS_TICKS) + tick_positions_y = y_min + span_y * np.asarray(DEFAULT_AXIS_TICKS) + tick_labels = [f"{t:.2f}" for t in DEFAULT_AXIS_TICKS] + x_offset = x_min - 0.03 * span_x + y_offset = y_min - 0.03 * span_y + tick_plane = base_z - 0.01 + fig.add_trace( + go.Scatter3d( + x=tick_positions_x, + y=[y_offset] * len(tick_positions_x), + z=[tick_plane] * len(tick_positions_x), + mode="text", + text=tick_labels, + textfont=dict(color="#5F259F", size=12), + showlegend=False, + hoverinfo="skip", + ) + ) + fig.add_trace( + go.Scatter3d( + x=[x_offset] * len(tick_positions_y), + y=tick_positions_y, + z=[tick_plane] * len(tick_positions_y), + mode="text", + text=tick_labels, + textfont=dict(color="#5F259F", size=12), + showlegend=False, + hoverinfo="skip", + ) + ) + + fig.add_trace( + go.Scatter3d( + x=[x_min, x_max, x_max, x_min, x_min], + y=[y_min, y_min, y_max, y_max, y_min], + z=[base_z - 0.005] * 5, + mode="lines", + line=dict(color="rgba(255,192,203,0.9)", width=2.5), + showlegend=False, + hoverinfo="skip", + ) + ) + + pad_x = 0.05 * (x_max - x_min if x_max != x_min else 1.0) + pad_y = 0.05 * (y_max - y_min if y_max != y_min else 1.0) + + fig.update_layout( + title=title, + margin=dict(l=10, r=10, t=36, b=10), + height=620, + template="plotly_white", + scene=dict( + xaxis=dict(range=[x_min - pad_x, x_max + pad_x], showgrid=False, showline=False, showticklabels=False, zeroline=False, visible=False, showbackground=False), + yaxis=dict(range=[y_min - pad_y, y_max + pad_y], showgrid=False, showline=False, showticklabels=False, zeroline=False, visible=False, showbackground=False), + zaxis=dict(range=[-0.3, 0.3], showgrid=False, showline=False, showticklabels=False, zeroline=False, visible=False, showbackground=False), + aspectmode="cube", + camera=dict(eye=dict(x=1.2, y=1.2, z=1.0)), + ), + dragmode="orbit", + uirevision="excitation_surface", + ) + return fig + + +def _push_excitation_plot(fig: go.Figure | None): + render_fig = fig or _build_excitation_placeholder() + try: + if hasattr(ctrl, "excitation_preview_update"): + ctrl.excitation_preview_update(render_fig) + except Exception: + pass + +# --- Plain Square Domain Preview --- +def update_geometry_preview(): + """Render the geometry preview using Plotly for the square domain option.""" + global current_mesh + if state.is_running or state.simulation_has_run: + return + plotter.clear() + current_mesh = None + dense_grid = state.nx is not None + nx = int(state.nx) if state.nx is not None else 32 + fig = _build_square_domain_plot(nx, "Square Domain", dense_grid=dense_grid) + _push_geometry_plot(fig) + + +# --- Plain Square Domain (Hole) Preview --- +def update_geometry_hole_preview(): + """Render the geometry preview with a square metallic body using Plotly.""" + global current_mesh + if state.is_running or state.simulation_has_run: + return + plotter.clear() + current_mesh = None + dense_grid = state.nx is not None + nx = int(state.nx) if state.nx is not None else 32 + + try: + a = float(state.hole_size_edge) + cx = float(state.hole_center_x) + cy = float(state.hole_center_y) + except Exception: + a, cx, cy = 0.2, 0.5, 0.5 + + mode_snap = bool(state.hole_snap) + edges = _compute_hole_edges(nx, cx, cy, a, snap=mode_snap) + fig = _build_square_domain_plot(nx, "Square Metallic Body", edges, dense_grid=dense_grid) + _push_geometry_plot(fig) + +# Helper: map normalized [0,1] to nearest node index on an nx×ny grid +def _nearest_node_index(x: float, y: float, nx: int, ny: int | None = None): + ny = ny or nx + i = int(round(float(x) * (nx - 1))) + j = int(round(float(y) * (ny - 1))) + i = max(0, min(nx - 1, i)) + j = max(0, min(ny - 1, j)) + return i, j + +_SAMPLE_PAIR_RE = re.compile(r"\(\s*([-+]?\d*\.?\d+)\s*,\s*([-+]?\d*\.?\d+)\s*\)") + + +def _snap_samples_to_grid(sample_str: str, nx: int): + """Convert normalized sample positions to nearest integer grid indices. + + Returns (grid_points_string, message) where message is empty when no snapping occurred.""" + if not sample_str or not str(sample_str).strip(): + return "", "" + matches = _SAMPLE_PAIR_RE.findall(str(sample_str)) + if not matches: + return "", "Enter sample position(s) as (x, y) pairs in [0,1] x [0,1]." + + nx_int = int(nx) + denom = float(max(nx_int - 1, 1)) + tokens = [] + info_lines = [] + def _normalize_value(val: float) -> float: + threshold = 1.0 + 1e-9 + if abs(val) <= threshold: + return max(0.0, min(1.0, val)) + max_index = float(nx_int - 1) + return max(0.0, min(max_index, val)) / denom if denom else 0.0 + for raw_x, raw_y in matches: + try: + x_val = float(raw_x) + y_val = float(raw_y) + except ValueError: + continue + x_norm = _normalize_value(x_val) + y_norm = _normalize_value(y_val) + ix, iy = _nearest_node_index(x_norm, y_norm, nx_int) + tokens.append(f"({ix}, {iy})") + snapped_x = ix / denom + snapped_y = iy / denom + changed = ( + abs(x_norm - snapped_x) > 1e-9 + or abs(y_norm - snapped_y) > 1e-9 + ) + descriptor = "adjusted" if changed else "aligned" + info_lines.append( + f"Input ({x_val:.3f}, {y_val:.3f}) {descriptor} to nearest Position ({snapped_x:.3f}, {snapped_y:.3f})." + ) + grid_str = ", ".join(tokens) + message = "\n".join(info_lines) + return grid_str, message + + +def _update_sim_monitor_points(): + sample_value = state.timeseries_points + if not sample_value or not str(sample_value).strip(): + state.timeseries_gridpoints = "" + state.timeseries_point_info = "" + return + nx_val = state.nx + if nx_val is None: + state.timeseries_gridpoints = "" + state.timeseries_point_info = "Select a grid size (nx) to compute the nearest monitor positions." + return + snapped, message = _snap_samples_to_grid(sample_value, int(nx_val)) + state.timeseries_gridpoints = snapped + state.timeseries_point_info = message or "" + + +def _update_qpu_sample_slot(slot_index: int): + suffix = "" if slot_index == 1 else f"_{slot_index}" + sample_attr = f"qpu_monitor_samples{suffix}" + grid_attr = f"qpu_monitor_gridpoints{suffix}" + info_attr = f"qpu_monitor_sample_info{suffix}" + sample_value = getattr(state, sample_attr, "") + nx_val = state.nx + if not sample_value or not str(sample_value).strip(): + setattr(state, grid_attr, "") + setattr(state, info_attr, "") + return + if nx_val is None: + setattr(state, info_attr, "Select a grid size (nx) to compute the nearest grid position.") + setattr(state, grid_attr, "") + return + snapped, message = _snap_samples_to_grid(sample_value, int(nx_val)) + setattr(state, grid_attr, snapped) + setattr(state, info_attr, message or "") + if state.backend_type == "QPU": + _hide_qpu_plots() + + +def _hide_qpu_plots(): + state.qpu_ts_ready = False + state.qpu_plot_style = "display: none; width: 900px; height: 660px; margin: 0 auto;" + state.qpu_ts_other_ready = False + state.qpu_other_plot_style = "display: none; width: 900px; height: 660px; margin: 0 auto;" + state.qpu_plot_position_options = ["All positions"] + state.qpu_plot_position_filter = "All positions" + +def update_initial_state_preview(): + global current_mesh + # Don't render any preview while running + if state.is_running: + plotter.clear() + _refresh_pyvista_view() + return + # If no geometry selected, clear and stop + if not state.geometry_selection: + plotter.clear() + _push_geometry_plot(_build_geometry_placeholder("Select a geometry to preview.")) + _push_excitation_plot(None) + _refresh_pyvista_view() + return + # Geometry-only previews before initial state selection + if not state.simulation_has_run and not state.dist_type: + if state.geometry_selection == "Square Domain": + update_geometry_preview() + _push_excitation_plot(None) + return + if state.geometry_selection == "Square Metallic Body": + update_geometry_hole_preview() + _push_excitation_plot(None) + return + _push_geometry_plot(_build_geometry_placeholder("Preview not available for this geometry yet.")) + _push_excitation_plot(None) + return + + # Plotly preview for excitation surface before any simulation run + if not state.simulation_has_run: + plotter.clear() + current_mesh = None + state.error_message = "" + preview_n = 128 + nx_sel = state.nx + try: + slider_n = None + if nx_sel is not None: + slider_n = int(nx_sel) + grid_n = slider_n if slider_n is not None else preview_n + if slider_n is None: + show_grid_lines = True + grid_line_resolution = None + else: + show_grid_lines = True + grid_line_resolution = max(slider_n, 2) + grid_n = max(grid_n, 8) + if state.dist_type == "Delta": + ix, iy = _nearest_node_index(float(state.impulse_x), float(state.impulse_y), grid_n) + full_state = create_impulse_state((grid_n, grid_n), (ix, iy)) + title = "Delta Excitation" + elif state.dist_type == "Gaussian": + ix, iy = _nearest_node_index(float(state.mu_x), float(state.mu_y), grid_n) + sx = max(float(state.sigma_x) * (grid_n - 1), 1e-9) + sy = max(float(state.sigma_y) * (grid_n - 1), 1e-9) + full_state = create_gaussian_state((grid_n, grid_n), (ix, iy), (sx, sy)) + title = "Gaussian Excitation" + else: + _push_excitation_plot(None) + _refresh_pyvista_view() + return + + initial_grid = full_state[: grid_n * grid_n].reshape(grid_n, grid_n) + denom = float(max(grid_n - 1, 1)) + coords = np.arange(grid_n) / denom + X, Y = np.meshgrid(coords, coords) + fig = _build_excitation_surface_plot( + X, + Y, + initial_grid, + f"{title} (nx={grid_n})", + show_grid_lines=show_grid_lines, + grid_line_resolution=grid_line_resolution, + ) + _push_excitation_plot(fig) + except ValueError as e: + state.error_message = f"Parameter Error: {e}" + _push_excitation_plot(_build_excitation_placeholder("Check excitation parameters.")) + except Exception as e: + state.error_message = f"An unexpected error occurred: {e}" + _push_excitation_plot(_build_excitation_placeholder("Unable to render preview.")) + finally: + _refresh_pyvista_view() + return + + # Use PyVista preview only after simulations have produced data + plotter.clear() + state.error_message = "" + # Default to a high-resolution 128x128 preview grid + preview_n = 128 + nx_sel = state.nx + # Show grid edges only when a mesh size is selected + show_grid_edges = nx_sel is not None + + try: + grid_n = int(nx_sel) if nx_sel is not None else preview_n + + if state.dist_type == "Delta": + ix, iy = _nearest_node_index(float(state.impulse_x), float(state.impulse_y), grid_n) + full_state = create_impulse_state((grid_n, grid_n), (ix, iy)) + elif state.dist_type == "Gaussian": + ix, iy = _nearest_node_index(float(state.mu_x), float(state.mu_y), grid_n) + sx = max(float(state.sigma_x) * (grid_n - 1), 1e-9) + sy = max(float(state.sigma_y) * (grid_n - 1), 1e-9) + full_state = create_gaussian_state((grid_n, grid_n), (ix, iy), (sx, sy)) + else: + return + + # Build preview mesh using the correct grid size + initial_grid = full_state[: grid_n * grid_n].reshape(grid_n, grid_n) + denom = float(max(grid_n - 1, 1)) + x, y = np.arange(grid_n) / denom, np.arange(grid_n) / denom + X, Y = np.meshgrid(x, y) + max_abs = float(np.max(np.abs(initial_grid))) if initial_grid.size else 1.0 + if max_abs < 1e-12: + max_abs = 1.0 + height_scale = 0.15 + Z = (initial_grid / max_abs) * height_scale + mesh = pv.StructuredGrid() + mesh.points = np.c_[X.ravel(), Y.ravel(), Z.ravel()] + mesh.dimensions = (grid_n, grid_n, 1) + mesh['scalars'] = initial_grid.ravel() + current_mesh = mesh + + plotter.add_mesh( + mesh, + scalars='scalars', + cmap="Blues", + show_scalar_bar=False, + show_edges=show_grid_edges, + edge_color='grey', + line_width=0.5, + ) + # Mirror the geometry plotter's pink scaling grid so users can gauge coordinates + plotter.show_grid( + bounds=(0.0, 1.0, 0.0, 1.0, 0.0, 0.0), + xtitle="x (0–1)", + ytitle="y (0–1)", + ztitle=" ", + color="#AE8BD8", + ) + _add_dotted_unit_grid( + plotter, + ticks=(0.0, 0.25, 0.5, 0.75, 1.0), + segments=48, + gap_ratio=0.6, + color="#AE8BD8", + line_width=1, + ) + plotter.add_axes() + # No scalar bar, axes, or grid overlays in excitation preview; picking only + try: + plotter.disable_picking() + except Exception: + pass + plotter.enable_point_picking(callback=update_value_display, show_message=False) + plotter.view_isometric() + try: + plotter.camera.parallel_projection = True + except Exception: + pass + _refresh_pyvista_view() + + except ValueError as e: + state.error_message = f"Parameter Error: {e}" + except Exception as e: + state.error_message = f"An unexpected error occurred: {e}" + +@state.change("geometry_selection") +def handle_geometry_add(geometry_selection, **kwargs): + # Normalize unselect options to None + if geometry_selection in (None, "", "None"): + state.geometry_selection = None + update_initial_state_preview() + _apply_workflow_highlights(_determine_workflow_step()) + return + if (geometry_selection == "Add"): + state.show_upload_dialog = True + state.geometry_selection = None + _apply_workflow_highlights(_determine_workflow_step()) + return + # Update preview on any geometry change (e.g., show Square Domain flat mesh) + update_initial_state_preview() + _apply_workflow_highlights(_determine_workflow_step()) + +@state.change("uploaded_file_info") +def handle_file_upload(uploaded_file_info, **kwargs): + if uploaded_file_info: + file_name = uploaded_file_info.get("name", "unknown file") + print(f"File selected (dummy upload): {file_name}") + state.show_upload_dialog = False + state.upload_status_message = f"File '{file_name}' uploaded." + state.show_upload_status = True + +def log_message(message, level="INFO"): + """Append a message to the console log with a timestamp and level.""" + timestamp = datetime.now().strftime("%H:%M:%S") + new_entry = f"[{timestamp}] [{level}] {message}\n" + state.console_logs += new_entry + # Optional: Limit log size to prevent performance issues + if len(state.console_logs) > 50000: + state.console_logs = state.console_logs[-50000:] + +def log_to_console(message): + timestamp = datetime.now().strftime("%H:%M:%S") + new_line = f"[{timestamp}] {message}\n" + state.console_output = (state.console_output or "") + new_line + +def update_excitation_info_message(): + """Calculates and displays the coordinate snapping message.""" + if state.nx is None or state.dist_type is None: + state.excitation_info_message = "" + return + + try: + nx = int(state.nx) + denom = float(max(nx - 1, 1)) + + if state.dist_type == "Delta": + x_in, y_in = float(state.impulse_x), float(state.impulse_y) + elif state.dist_type == "Gaussian": + x_in, y_in = float(state.mu_x), float(state.mu_y) + else: + state.excitation_info_message = "" + return + + ix, iy = _nearest_node_index(x_in, y_in, nx) + x_snapped, y_snapped = ix / denom, iy / denom + changed = ( + abs(x_in - x_snapped) > 1e-9 + or abs(y_in - y_snapped) > 1e-9 + ) + descriptor = "adjusted" if changed else "aligned" + state.excitation_info_message = ( + f"Input ({x_in:.3f}, {y_in:.3f}) {descriptor} to nearest Position " + f"({x_snapped:.3f}, {y_snapped:.3f})." + ) + except Exception: + state.excitation_info_message = "" + +@state.change("nx_slider_index") +def on_slider_index_change(nx_slider_index, **kwargs): + if nx_slider_index is None: + state.nx = None + else: + try: + state.nx = int(GRID_SIZES[int(nx_slider_index)]) + except Exception: + state.nx = None + update_excitation_info_message() + +@state.change("nx", "T", "dist_type", "impulse_x", "impulse_y", "mu_x", "mu_y", "sigma_x", "sigma_y", "coeff_permittivity", "coeff_permeability") +def on_input_parameter_change(**kwargs): + # Do nothing while running + if state.is_running: + return + + update_excitation_info_message() + + if state.backend_type == "QPU": + state.qpu_ts_ready = False + state.qpu_plot_style = "display: none; width: 900px; height: 660px; margin: 0 auto;" + state.qpu_ts_other_ready = False + state.qpu_other_plot_style = "display: none; width: 900px; height: 660px; margin: 0 auto;" + + changed_keys = set(kwargs.keys()) + + # If a simulation has already run, keep current results and only indicate re-run is needed + if state.simulation_has_run: + state.run_button_text = "Re-run!" + return + + # Before a run, update the initial preview only when relevant preview params changed + preview_params = {"nx", "dist_type", "impulse_x", "impulse_y", "mu_x", "mu_y", "sigma_x", "sigma_y"} + if changed_keys & preview_params: + update_initial_state_preview() + +@state.change("output_type", "timeseries_field", "timeseries_points") +def on_output_config_change(**kwargs): + _update_sim_monitor_points() + if state.simulation_has_run: + generate_plot() + + +@state.change("timeseries_points") +def on_timeseries_points_text_change(**kwargs): + _update_sim_monitor_points() + +@state.change("surface_field") +def on_surface_field_change(surface_field, **kwargs): + if state.simulation_has_run and state.output_type == "Surface Plot": + redraw_surface_plot() + +@state.change("time_val") +def on_time_change(time_val, **kwargs): + if not state.simulation_has_run or state.output_type != "Surface Plot": + return + redraw_surface_plot() + +def update_value_display(point): + if current_mesh is None: + return + try: + plotter.remove_actor("value_text") + except Exception: + pass + + closest_id = current_mesh.find_closest_point(point) + if closest_id == -1: + return + + # Sample value and coordinates at closest vertex + value = current_mesh['scalars'][closest_id] if 'scalars' in current_mesh.array_names else 0.0 + px, py, pz = current_mesh.points[closest_id] + px = float(px); py = float(py) + + # Determine if current mesh is on unit square [0,1] (initial preview/geometry) or integer grid (output plots) + xmin, xmax, ymin, ymax, _, _ = current_mesh.bounds + is_unit_square = (xmax <= 1.00001 and ymax <= 1.00001) + + if not state.simulation_has_run and is_unit_square: + # Disable updating inputs based on point picking + text = f"Position: ({px:.3f}, {py:.3f})\nValue: {value:.3e}" + else: + # Output configuration or integer-grid context: keep grid indices visible like app.py + nx_val = int(state.nx) + denom = max(float(nx_val - 1), 1.0) + if is_unit_square: + ix = int(round(px * denom)); iy = int(round(py * denom)) + x_code = max(0.0, min(1.0, px)); y_code = max(0.0, min(1.0, py)) + else: + ix = int(round(px)); iy = int(round(py)) + x_code = max(0.0, min(1.0, px / denom)); y_code = max(0.0, min(1.0, py / denom)) + ix = max(0, min(ix, nx_val - 1)); iy = max(0, min(iy, nx_val - 1)) + if state.simulation_has_run: + time = float(state.time_val) + text = f"Index: ({ix}, {iy}) | Position: ({x_code:.3f}, {y_code:.3f})\nTime: {time:.2f}s\nValue: {value:.3e}" + else: + text = f"Index: ({ix}, {iy}) | Position: ({x_code:.3f}, {y_code:.3f})\nValue: {value:.3e}" + + plotter.add_text(text, name="value_text", position="lower_left", color="black", font_size=10) + ctrl.view_update() + +try: + plotter.disable_picking() +except Exception: + pass +plotter.enable_point_picking(callback=update_value_display, show_message=False) + +def export_vtk(): + """Export current surface mesh to user's Downloads as .vtp and notify via snackbar.""" + global current_mesh + if current_mesh is None: + state.export_status_message = "No mesh to export." + state.show_export_status = True + return + try: + field = state.surface_field or "Ez" + nx = int(state.nx) + suffix = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"surface_{field}_nx{nx}_{suffix}.vtp" + + # Write to a temporary file first + with tempfile.NamedTemporaryFile(suffix=".vtp", delete=False) as tmp: + current_mesh.save(tmp.name) + content = Path(tmp.name).read_bytes() + # Encode content as base64 for browser download + content_b64 = base64.b64encode(content).decode("ascii") + # Trigger browser download via JavaScript + _server.js_call("utils", "download", filename, f"data:application/octet-stream;base64,{content_b64}") + Path(tmp.name).unlink() # Clean up + + state.export_status_message = f"Exported VTK to {filename}" + except Exception as e: + state.export_status_message = f"Export failed: {e}" + state.show_export_status = True + +def export_vtk_all_frames(): + """Export a .vtp file for each time frame of the selected component into a zip file.""" + global data_frames, X_grids, z_scale, snapshot_times + try: + if not state.simulation_has_run: + raise ValueError("Run a simulation before exporting all frames.") + field = state.surface_field or "Ez" + frames = data_frames.get(field) + if not frames: + raise ValueError(f"No frames available for {field}.") + if snapshot_times is None: + raise ValueError("Snapshot times are unavailable.") + + nx = int(state.nx) + suffix = datetime.now().strftime("%Y%m%d_%H%M%S") + zip_filename = f"vtk_sequence_{field}_nx{nx}_{suffix}.zip" + + import zipfile + + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp_zip: + temp_zip_path = tmp_zip.name + + with zipfile.ZipFile(temp_zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: + times = np.asarray(snapshot_times) + for i, (z_data, t) in enumerate(zip(frames, times)): + points = np.c_[X_grids[field].ravel(), Y_grids[field].ravel(), z_data.ravel() * z_scale] + poly = pv.PolyData(points) + mesh = poly.delaunay_2d() + mesh["scalars"] = z_data.ravel() + + fname = f"{field}_frame_{i:04d}_t{t:.3f}s.vtp" + + with tempfile.NamedTemporaryFile(suffix=".vtp", delete=False) as tmp_vtp: + tmp_vtp_path = tmp_vtp.name + + mesh.save(tmp_vtp_path) + zf.write(tmp_vtp_path, arcname=fname) + Path(tmp_vtp_path).unlink() + + content = Path(temp_zip_path).read_bytes() + # Encode content as base64 for browser download + content_b64 = base64.b64encode(content).decode("ascii") + _server.js_call("utils", "download", zip_filename, f"data:application/zip;base64,{content_b64}") + Path(temp_zip_path).unlink() + + state.export_status_message = f"Exported {len(frames)} frames to {zip_filename}" + except Exception as e: + state.export_status_message = f"Export failed: {e}" + finally: + state.show_export_status = True + +def export_mp4(): + """Export the surface plot time slider animation to MP4 using a dedicated off-screen plotter.""" + global data_frames + try: + if not state.simulation_has_run: + raise ValueError("Run a simulation before exporting MP4.") + field = state.surface_field or "Ez" + frames = data_frames.get(field) + if not frames: + raise ValueError(f"No frames available for {field}.") + if len(frames) < 2: + raise ValueError("Only one frame available; increase T or simulation steps.") + + nx = int(state.nx) + suffix = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"surface_anim_{field}_nx{nx}_{suffix}.mp4" + + # Create a temporary file for the MP4 + with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp: + temp_path = tmp.name + + # Build with a dedicated off-screen plotter at a macro-block friendly size + movie_plotter = pv.Plotter(off_screen=True, window_size=(1280, 720)) + + # Initial mesh from first frame + X = X_grids[field] + Y = Y_grids[field] + first = frames[0] + points = np.c_[X.ravel(), Y.ravel(), first.ravel() * z_scale] + poly = pv.PolyData(points) + mesh = poly.delaunay_2d() + mesh['scalars'] = first.ravel() + actor = movie_plotter.add_mesh( + mesh, + scalars='scalars', + clim=surface_clims[field], + cmap="RdBu", + show_scalar_bar=False, + show_edges=True, + edge_color='grey', + line_width=0.5, + ) + movie_plotter.add_axes() + # Use similar camera if available, else default + try: + if hasattr(plotter, 'camera_position') and plotter.camera_position: + movie_plotter.camera_position = plotter.camera_position + else: + movie_plotter.view_isometric() + except Exception: + movie_plotter.view_isometric() + + movie_plotter.open_movie(temp_path, framerate=20) + n_frames = len(frames) + for z_data in frames: + if mesh.n_points != z_data.size: + # Rebuild mesh if topology changes (unlikely here) + points = np.c_[X.ravel(), Y.ravel(), z_data.ravel() * z_scale] + poly = pv.PolyData(points) + mesh = poly.delaunay_2d() + mesh['scalars'] = z_data.ravel() + movie_plotter.clear() + actor = movie_plotter.add_mesh( + mesh, + scalars='scalars', + clim=surface_clims[field], + cmap="RdBu", + show_scalar_bar=False, + show_edges=True, + edge_color='grey', + line_width=0.5, + ) + else: + mesh.points[:, 2] = z_data.ravel() * z_scale + mesh['scalars'] = z_data.ravel() + movie_plotter.render() + movie_plotter.write_frame() + movie_plotter.close() + + # Read the file content and trigger download + content = Path(temp_path).read_bytes() + # Encode content as base64 for browser download + content_b64 = base64.b64encode(content).decode("ascii") + _server.js_call("utils", "download", filename, f"data:video/mp4;base64,{content_b64}") + Path(temp_path).unlink() # Clean up + + state.export_status_message = f"Exported MP4 to {filename}" + except Exception as e: + state.export_status_message = f"Export failed: {e}" + finally: + state.show_export_status = True + +# --- Small Plot under Meshing: Qubit requirement vs Grid Size --- +def build_qubit_plot(grid_size: int): + x_sizes = np.array([16, 32, 64, 128, 256, 512]) + y_qubits = 2 * np.ceil(np.log2(x_sizes)).astype(int) + 4 + current_nq = int(2 * np.ceil(np.log2(max(1, int(grid_size)))) + 4) + + fig = go.Figure() + # Match app.py: x = grid size, y = total qubits + fig.add_trace(go.Scatter(x=x_sizes, y=y_qubits, mode='lines', name='Total Qubits', line=dict(color='#7A3DB5', width=3))) + fig.add_trace(go.Scatter(x=[grid_size], y=[current_nq], mode='markers', marker=dict(size=10, color='#5F259F'), name='Current Selection')) + + x_min = int(x_sizes.min()); x_max = int(x_sizes.max()) + y_min = int(y_qubits.min()); y_max = int(max(y_qubits.max(), current_nq)) + fig.update_xaxes(range=[x_min - 8, x_max + 8], tickmode='array', tickvals=x_sizes, ticktext=[str(v) for v in x_sizes], title_text="Grid Size (nx)", gridcolor='rgba(95,37,159,0.1)', zerolinecolor='rgba(95,37,159,0.3)') + fig.update_yaxes(range=[y_min - 1, y_max + 1], dtick=1, title_text="Total Qubits (nq)", gridcolor='rgba(95,37,159,0.1)', zerolinecolor='rgba(95,37,159,0.3)') + fig.update_layout( + margin=dict(l=30, r=10, t=10, b=30), + autosize=True, + legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1), + font=dict(color='#1A1A1A'), + paper_bgcolor='#FFFFFF', + plot_bgcolor='#FFFFFF', + colorway=['#5F259F', '#7A3DB5', '#AE8BD8', '#5F259F'], + ) + return fig + +# --- Plotly Theme (Synopsys-aligned) --- +def _install_synopsys_plotly_theme(): + base = go.layout.Template(pio.templates["plotly_white"]) # build from existing template + base.layout.update( + font=dict( + family="Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif", + size=13, + color="#1A1A1A", + ), + paper_bgcolor="#FFFFFF", + plot_bgcolor="#FFFFFF", + colorway=["#5F259F", "#7A3DB5", "#AE8BD8", "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"], + hoverlabel=dict(bgcolor="#FFFFFF", bordercolor="#5F259F", font=dict(color="#1A1A1A")), + legend=dict(orientation="h", x=1, xanchor="right", y=1.02, yanchor="bottom", title_text=""), + margin=dict(l=40, r=20, t=40, b=40), + ) + base.layout.xaxis.update( + showgrid=True, + gridcolor="rgba(95,37,159,0.1)", + zeroline=False, + linecolor="rgba(0,0,0,.2)", + ticks="outside", + tickformat=".2f", + ) + base.layout.yaxis.update( + showgrid=True, + gridcolor="rgba(95,37,159,0.1)", + zeroline=True, + zerolinecolor="rgba(0,0,0,.25)", + linecolor="rgba(0,0,0,.2)", + ticks="outside", + tickformat=".3g", + ) + pio.templates["syn_white"] = base + pio.templates.default = "syn_white" + +_install_synopsys_plotly_theme() + +# NEW: controller to add a QPU monitor config row (moved before UI so it's defined at layout build time) + +def qpu_add_monitor_config(): + try: + cfgs = list(state.qpu_monitor_configs or []) + cfgs.append(_new_monitor_cfg("Ez", "(8, 8)")) + state.qpu_monitor_configs = cfgs + except Exception: + pass + +# NEW: controller to remove a QPU monitor config row by index + +def qpu_remove_monitor_config(index): + try: + i = int(index) + cfgs = list(state.qpu_monitor_configs or []) + if 0 <= i < len(cfgs): + cfgs.pop(i) + if not cfgs: + cfgs = [{"id": 1, "field": "Ez", "points": "(8, 8)"}] + # ensure counter is at least the last id + try: + global qpu_cfg_id_counter + qpu_cfg_id_counter = max(qpu_cfg_id_counter, 1) + except Exception: + pass + state.qpu_monitor_configs = cfgs + except Exception: + pass + +# Expose remover on controller for use with template args +ctrl.qpu_remove_monitor_config = qpu_remove_monitor_config + +# NEW: controllers to update per-row Field and Gridpoints selections + +def qpu_set_monitor_field(index, value): + try: + i = int(index) + v = str(value).strip() if value is not None else "Ez" + cfgs = list(state.qpu_monitor_configs or []) + if 0 <= i < len(cfgs): + cfgs[i]["field"] = v + state.qpu_monitor_configs = cfgs + except Exception: + pass + +def qpu_set_monitor_points(index, value): + try: + i = int(index) + v = str(value) if value is not None else "" + cfgs = list(state.qpu_monitor_configs or []) + if 0 <= i < len(cfgs): + cfgs[i]["points"] = v + state.qpu_monitor_configs = cfgs + except Exception: + pass + +ctrl.qpu_set_monitor_field = qpu_set_monitor_field +ctrl.qpu_set_monitor_points = qpu_set_monitor_points + +# NEW: controller to immediately set the component filter and refresh the chart + +def qpu_set_plot_filter(value): + try: + v = str(value) if value is not None else "All" + state.qpu_plot_filter = v + _refresh_qpu_plot_figures() + except Exception: + pass + +ctrl.qpu_set_plot_filter = qpu_set_plot_filter + + +def qpu_set_plot_position_filter(value): + try: + v = str(value) if value is not None else "All positions" + state.qpu_plot_position_filter = v + _refresh_qpu_plot_figures() + except Exception: + pass + +ctrl.qpu_set_plot_position_filter = qpu_set_plot_position_filter + +# Handlers for primitive additional monitor slots (2..5) + +def qpu_add_monitor_slot(): + try: + cnt = int(state.qpu_monitor_count or 0) + if cnt < 4: + new_cnt = cnt + 1 + state.qpu_monitor_count = new_cnt + slot_index = 1 + new_cnt + setattr(state, f"qpu_field_components_{slot_index}", "Ez") + setattr(state, f"qpu_monitor_samples_{slot_index}", "") + setattr(state, f"qpu_monitor_sample_info_{slot_index}", "") + setattr(state, f"qpu_monitor_gridpoints_{slot_index}", "") + _hide_qpu_plots() + except Exception: + pass + + +def qpu_remove_monitor_slot(slot_index): + try: + s = int(slot_index) + if s < 2 or s > 5: + return + cnt = int(state.qpu_monitor_count or 0) + if cnt <= 0: + return + last_slot = 1 + cnt + for k in range(s, last_slot): + setattr(state, f"qpu_field_components_{k}", getattr(state, f"qpu_field_components_{k+1}", "Ez")) + setattr(state, f"qpu_monitor_gridpoints_{k}", getattr(state, f"qpu_monitor_gridpoints_{k+1}", "")) + setattr(state, f"qpu_monitor_samples_{k}", getattr(state, f"qpu_monitor_samples_{k+1}", "")) + setattr(state, f"qpu_monitor_sample_info_{k}", getattr(state, f"qpu_monitor_sample_info_{k+1}", "")) + setattr(state, f"qpu_field_components_{last_slot}", "Ez") + setattr(state, f"qpu_monitor_gridpoints_{last_slot}", "") + setattr(state, f"qpu_monitor_samples_{last_slot}", "") + setattr(state, f"qpu_monitor_sample_info_{last_slot}", "") + state.qpu_monitor_count = max(0, cnt - 1) + _hide_qpu_plots() + except Exception: + pass + +ctrl.qpu_add_monitor_slot = qpu_add_monitor_slot +ctrl.qpu_remove_monitor_slot = qpu_remove_monitor_slot + +# NEW: helper to build Plotly figure for all components except the selected one + +def _rebuild_qpu_fig_others(selected_field: str, position_filter: str = "All positions"): + """Build a Plotly figure containing all series except the selected component. + Returns a new go.Figure or None if not applicable. + """ + try: + sel = (selected_field or "All").strip() + if sel in ("", "All"): + return None + if position_filter not in ("", "All", "All positions"): + return None + times = qpu_ts_cache.get("times") or [] + series_map = qpu_ts_cache.get("series_map") or {} + keys = [k for k in series_map.keys() if str(k[0]) == sel] + if not keys: + return None + import plotly.graph_objects as go + cmap = _cmap_for_field(sel) + max_sum = max(((k[1] + k[2]) for k in keys), default=1) + if max_sum <= 0: + max_sum = 1 + fig = go.Figure() + dashes = ["solid", "dash", "dot", "dashdot"] + markers = ["circle", "square", "diamond", "triangle-up", "x"] + max_abs = 0.0 + sorted_keys = sorted(keys, key=lambda x: (x[1], x[2])) + num_keys = len(sorted_keys) + for i, k in enumerate(sorted_keys): + field_name, px, py = k + ys = series_map.get(k) or [] + if not ys or len(ys) != len(times): + continue + try: + max_abs = max(max_abs, max((abs(float(v)) for v in ys))) + except Exception: + pass + + # Color keyed by index to ensure distinctness + if num_keys > 1: + s_index = i / (num_keys - 1) + s_light = 0.3 + 0.6 * s_index + else: + s_light = 0.6 + + rgba = cmap(s_light) + color_hex = f"#{int(rgba[0]*255):02x}{int(rgba[1]*255):02x}{int(rgba[2]*255):02x}" + fig.add_trace(go.Scatter( + x=times, y=ys, mode='lines+markers', name=f"({px}, {py})", + line=dict(color=color_hex, width=2.5, dash=dashes[i % len(dashes)]), + marker=dict(size=7, symbol=markers[i % len(markers)], color=color_hex), + hovertemplate=f"{sel} | t=%{{x:.3f}}s
Value=%{{y:.6g}}({px}, {py})", + )) + fig.update_layout(title=f"IBM QPU Time Series (Other components: {sel})", height=660, width=900, margin=dict(l=50, r=30, t=50, b=50), hovermode="x unified", legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1, title_text="")) + fig.update_xaxes(title_text="Time (s)", title_font=dict(size=22), tickfont=dict(size=16), showgrid=True, gridcolor="rgba(0,0,0,.06)", zeroline=False, showline=True, linewidth=1, linecolor="rgba(0,0,0,.3)") + fig.update_yaxes(title_text="Field Value", title_font=dict(size=22), tickfont=dict(size=16), showgrid=True, gridcolor="rgba(0,0,0,.06)", zeroline=True, zerolinecolor="rgba(0,0,0,.25)", showline=True, linewidth=1, linecolor="rgba(0,0,0,.3)") + if max_abs > 0: + pad = 0.12 * max_abs + fig.update_yaxes(range=[-max_abs - pad, max_abs + pad]) + return fig + except Exception: + return None + +# --- UI Layout --- + +def build_ui(): + """Render the EM UI inside the host layout.""" + if _server is None or not state.bound or not ctrl.bound: + raise RuntimeError('Call set_server(server) before build_ui().') + with vuetify3.VContainer(fluid=True, classes="pa-0 fill-height"): + with vuetify3.VDialog(v_model=("show_upload_dialog", False), max_width="500px"): + with vuetify3.VCard(): + vuetify3.VCardTitle("Upload Geometry") + with vuetify3.VCardText(classes="py-1 px-2"): + vuetify3.VFileInput( + show_size=True, + label="Select geometry file", + accept=".vtp,.vtk,.glb,.stl", + update_binary=("uploaded_file_info", 1), + ) + with vuetify3.VCardActions(): + vuetify3.VSpacer() + vuetify3.VBtn("Cancel", click="show_upload_dialog = false") + + vuetify3.VSnackbar( + v_model=("show_upload_status", False), + children=["{{ upload_status_message }}"], + timeout=4000, + location="bottom right", + color="primary", + variant="tonal", + ) + vuetify3.VSnackbar( + v_model=("show_export_status", False), + children=["{{ export_status_message }}"], + timeout=4000, + location="bottom right", + color="primary", + variant="tonal", + ) + + with vuetify3.VRow(no_gutters=True, classes="fill-height"): + with vuetify3.VCol(cols=5, classes="pa-1 d-flex flex-column"): + # Cell 1: Introduction + with vuetify3.VCard(classes="mb-1", style=("overview_card_style", "font-size: 0.8rem;")): + with vuetify3.VCardTitle("Overview", classes="text-subtitle-1 text-primary", style="font-size: 0.9rem; padding: 6px 10px;"): + pass + with vuetify3.VCardText(classes="py-1 px-2"): + vuetify3.VSelect( + label="Select a problem", + v_model=("problem_selection", None), + items=( + "problem_options", + [ + "Propagation in a given medium (no bodies)", + "Scattering from a perfectly conducting body", + ], + ), + placeholder="Select a problem", + density="compact", + color="primary", + ) + vuetify3.VDivider(classes="my-0") + vuetify3.VCardSubtitle("Governing Equations", classes="text-caption font-weight-bold mt-0", style="font-size: 0.7rem;") + vuetify3.VDivider(classes="mb-0") + vuetify3.VListItemTitle("Maxwell’s time-domain, 2D, TEz polarized.", classes="text-caption", style="font-size: 0.7rem;") + vuetify3.VCardSubtitle("Inputs", classes="text-caption font-weight-bold mt-0", style="font-size: 0.7rem;") + vuetify3.VDivider(classes="mb-0") + vuetify3.VListItemTitle("Geometry, excitation, medium, output visualization preferences.", classes="text-caption", style="font-size: 0.7rem;") + vuetify3.VCardSubtitle("Outputs", classes="text-caption font-weight-bold mt-0", style="font-size: 0.7rem;") + vuetify3.VDivider(classes="mb-0") + vuetify3.VListItemTitle("Surface plots of field components OR time evolution of field components at specified points.", classes="text-caption", style="font-size: 0.7rem;") + # Cell 2: Geometry + with vuetify3.VCard(classes="mb-1", style=("geometry_card_style", "font-size: 0.8rem;")): + with vuetify3.VCardTitle("Geometry", classes="text-subtitle-1 text-primary", style="font-size: 0.9rem; padding: 6px 10px;"): + pass + with vuetify3.VCardText(classes="py-1 px-2"): + vuetify3.VSelect( + label="Select", + v_model=("geometry_selection", None), + items=("geometry_options",), + placeholder="Select", + density="compact", + color="primary", + ) + with vuetify3.VContainer(v_if="geometry_selection === 'Square Metallic Body'", classes="pa-0 mt-2"): + with vuetify3.VRow(dense=True): + with vuetify3.VCol(): + with vuetify3.VTooltip("Square hole edge length s in domain units [0,1]. Must be ≤ 1. UI-only.", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VTextField( + v_bind="props", + v_model=("hole_size_edge", 0.2), + label="Hole Edge Length [0 - 1]", + type="number", + step=0.05, + min=0, + density="compact", + color="primary", + ) + with vuetify3.VCol(): + with vuetify3.VTooltip("Hole center as (x, y). Both x and y must be strictly within (0,1). Example: (0.5, 0.5). UI-only.", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VTextField( + v_bind="props", + v_model=("hole_center_pair", "(0.5, 0.5)"), + label="Hole Center (X, Y)", + density="compact", + color="primary", + ) + with vuetify3.VRow(dense=True, classes="mt-1"): + with vuetify3.VCol(cols=12): + vuetify3.VSwitch( + v_model=("hole_snap", True), + label="Snap edges to nearest grid lines", + color="primary", + inset=True, + density="compact", + ) + vuetify3.VAlert( + v_if="hole_error_message", + type="error", + variant="tonal", + density="compact", + children=["{{ hole_error_message }}"], + classes="mt-1", + ) + # Cell 3: Excitation + with vuetify3.VCard(classes="mb-1", style=("excitation_card_style", "font-size: 0.8rem;")): + with vuetify3.VCardTitle("Excitation: Initial State", classes="text-subtitle-1 text-primary", style="font-size: 0.9rem; padding: 6px 10px;"): + pass + with vuetify3.VCardText(classes="py-1 px-2"): + with vuetify3.VRow(classes="d-flex align-center", dense=True, no_gutters=True): + with vuetify3.VCol(classes="flex-grow-1"): + vuetify3.VSelect( + label="Select", + v_model=("dist_type", None), + items=("dist_type_options", ["None", "Delta", "Gaussian"]), + placeholder="Select", + density="compact", + color="primary", + ) + with vuetify3.VCol(cols="auto", classes="ml-1"): + with vuetify3.VTooltip("Show or hide configuration for the selected excitation", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + with vuetify3.VBtn( + icon=True, + density="compact", + variant="text", + click="excitation_config_open = !excitation_config_open", + disabled=("!dist_type", False), + v_bind="props", + ): + vuetify3.VIcon("mdi-cog", color=("excitation_config_open ? 'primary' : 'grey'",)) + trame_html.Span("Toggle parameter inputs for the chosen excitation") + with vuetify3.VExpandTransition(): + with vuetify3.VSheet( + v_if="excitation_config_open && dist_type === 'Delta'", + classes="pa-2 mt-1 rounded-lg", + style="background-color: rgba(95,37,159,0.03); border: 1px solid rgba(95,37,159,0.15);", + ): + with vuetify3.VTooltip("Impulse position (x, y) in [0,1]. Example: (0.6, 0.6).", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VTextField( + v_bind="props", + v_model=("peak_pair", "(0.5, 0.5)"), + label="Peak (x, y) in [0,1]", + density="compact", + color="primary", + ) + with vuetify3.VExpandTransition(): + with vuetify3.VSheet( + v_if="excitation_config_open && dist_type === 'Gaussian'", + classes="pa-2 mt-1 rounded-lg", + style="background-color: rgba(95,37,159,0.03); border: 1px solid rgba(95,37,159,0.15);", + ): + with vuetify3.VRow(dense=True): + with vuetify3.VCol(): + with vuetify3.VTooltip("Gaussian center μ (x, y) in [0,1]. Example: (0.5, 0.5).", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VTextField( + v_bind="props", + v_model=("mu_pair", "(0.5, 0.5)"), + label="Mu (x, y) in [0,1]", + density="compact", + color="primary", + ) + # Separate Sigma inputs (normalized) + with vuetify3.VRow(dense=True, classes="mt-1"): + with vuetify3.VCol(): + with vuetify3.VTooltip("Gaussian spread σx in [0,1] of domain length.", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VTextField( + v_bind="props", + v_model=("sigma_x", 0.25), + label="Sigma X (0–1)", + type="number", + step="0.01", + density="compact", + color="primary", + ) + with vuetify3.VCol(): + with vuetify3.VTooltip("Gaussian spread σy in [0,1] of domain length.", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VTextField( + v_bind="props", + v_model=("sigma_y", 0.15), + label="Sigma Y (0–1)", + type="number", + step="0.01", + density="compact", + color="primary", + ) + vuetify3.VAlert(v_if="excitation_error_message", type="error", variant="tonal", density="compact", children=["{{ excitation_error_message }}"], classes="mt-1") + vuetify3.VAlert( + v_if="excitation_info_message", + type="info", + variant="tonal", + density="compact", + children=["{{ excitation_info_message }}"], + classes="mt-1", + style="white-space: pre-line;", + ) + + # Cell 4: Medium + with vuetify3.VCard(classes="mb-1", style="font-size: 0.8rem;"): + with vuetify3.VCardTitle("Material Properties (Medium)", classes="text-subtitle-1 text-primary", style="font-size: 0.9rem; padding: 6px 10px;"): + pass + with vuetify3.VCardText(classes="py-1 px-2"): + with vuetify3.VRow(dense=True): + with vuetify3.VCol(cols="6"): + with vuetify3.VTooltip("Relative permittivity. ε_r = ε / ε₀. Default 1.0 (free space).", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VTextField(v_bind="props", v_model=("coeff_permittivity", 1.0), label="Permittivity (εr)", type="number", step="0.1", density="compact", color="primary") + with vuetify3.VCol(cols="6"): + with vuetify3.VTooltip("Relative permeability. μ_r = μ / μ₀. Default 1.0 (non-magnetic).", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VTextField(v_bind="props", v_model=("coeff_permeability", 1.0), label="Permeability (μr)", type="number", step="0.1", density="compact", color="primary") + # New Time card between Material and Backend sections + with vuetify3.VCard(classes="mb-1", style="font-size: 0.8rem;"): + with vuetify3.VCardTitle("Time", classes="text-subtitle-1 text-primary", style="font-size: 0.9rem; padding: 6px 10px;"): + pass + with vuetify3.VCardText(classes="py-1 px-2"): + with vuetify3.VTooltip("Sets the total duration for the simulation to run.", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VTextField( + v_bind="props", + v_model=("T", 1.0), + label="Total Time (T)", + type="number", + step="0.1", + density="compact", + color="primary", + ) + # Moved Meshing card: now under Temporal Settings and before Backends + with vuetify3.VCard(classes="mb-1", style=("meshing_card_style", "font-size: 0.8rem;")): + with vuetify3.VCardTitle("Meshing", classes="text-subtitle-1 text-primary", style="font-size: 0.9rem; padding: 6px 10px;"): + pass + with vuetify3.VCardText(classes="py-1 px-2"): + # Show the qubit graph only while hovering over the slider (like app.py) + with vuetify3.VMenu(open_on_hover=True, close_on_content_click=True, location="end"): + with vuetify3.Template(v_slot_activator="{ props }"): + with vuetify3.VSlider( + v_bind="props", + v_model=("nx_slider_index", None), + label="No. of points per direction:", + min=0, + max=5, + step=1, + show_ticks="always", + thumb_label="always", + density="compact", + color="primary", + ): + vuetify3.Template(v_slot_thumb_label="{ modelValue }", children=["{{ modelValue === null ? 'Select' : [16, 32, 64, 128, 256, 512][modelValue] }}"]) + # Hover content: enlarged Plotly graph with app.py axes (x=nx, y=nq) + with vuetify3.VSheet(classes="pa-2", elevation=6, rounded=True, style="width: 644px;"): + qubit_fig_widget = plotly_widgets.Figure( + figure=build_qubit_plot(int(state.nx or 16)), + responsive=True, + style="width: 616px; height: 364px; min-height: 364px;", + ) + ctrl.qubit_plot_update = qubit_fig_widget.update + # Cell: Backends (from app.py) + with vuetify3.VCard(classes="mb-1", style=("backend_card_style", "font-size: 0.8rem;")): + with vuetify3.VCardTitle("Backends", classes="text-subtitle-1 text-primary", style="font-size: 0.9rem; padding: 6px 10px;"): + pass + with vuetify3.VCardText(classes="py-1 px-2"): + with vuetify3.VRow(dense=True, classes="mb-2"): + with vuetify3.VCol(): + vuetify3.VAlert( + type="info", + color="primary", + variant="tonal", + density="compact", + children=[ + "Selected: ", + "{{ backend_type || '—' }}", + " - ", + "{{ backend_type === 'Simulator' ? selected_simulator : (backend_type === 'QPU' ? selected_qpu : '—') }}", + ], + ) + with vuetify3.VMenu(open_on_hover=True, close_on_content_click=True, location="end"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VBtn(v_bind="props", text="Choose Backend", color="primary", variant="tonal", block=True) + with vuetify3.VList(density="compact"): + with vuetify3.VMenu(open_on_hover=True, close_on_content_click=True, location="end", offset=8): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VListItem(v_bind="props", title="Simulator", prepend_icon="mdi-robot-outline", append_icon="mdi-chevron-right") + with vuetify3.VList(density="compact"): + vuetify3.VListItem(title="IBM Qiskit simulator", click="backend_type = 'Simulator'; selected_simulator = 'IBM Qiskit simulator'") + with vuetify3.VMenu(open_on_hover=True, close_on_content_click=True, location="end", offset=8): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VListItem(v_bind="props", title="QPU", prepend_icon="mdi-chip", append_icon="mdi-chevron-right") + with vuetify3.VList(density="compact"): + vuetify3.VListItem(title="IBM QPU", click="backend_type = 'QPU'; selected_qpu = 'IBM QPU'") + vuetify3.VListItem(title="IonQ QPU", click="backend_type = 'QPU'; selected_qpu = 'IonQ QPU'") + + # New Card: Output Preferences (appears after backend selection) + with vuetify3.VCard(v_if="backend_type", classes="mb-0", style=("output_card_style", "font-size: 0.8rem;")): + with vuetify3.VCardTitle("Output Preferences", classes="text-subtitle-1 text-primary", style="font-size: 0.9rem; padding: 6px 10px;"): + pass + with vuetify3.VCardText(classes="py-1 px-2"): + # Small bold heading before Δt input + vuetify3.VCardSubtitle("Select Δt intervals for plotting output snapshots", classes="text-caption font-weight-bold mt-1", style="font-size: 0.75rem;") + with vuetify3.VTooltip("Snapshot interval (Δt). Solver runs at fixed 0.1 s; frames are saved every Δt. Values < 0.1 or not multiples of 0.1 are unsupported.", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VTextField(v_bind="props", v_model=("dt_user", 0.1), label="Δt", type="number", step="0.1", density="compact", color="primary", classes="mt-1") + vuetify3.VAlert(v_if="temporal_warning", type="warning", variant="tonal", density="compact", children=["{{ temporal_warning }}"], classes="mt-1") + + # Updated QPU monitor options (IBM / IonQ) + with vuetify3.VContainer(v_if="backend_type === 'QPU'", classes="pa-0 mt-2"): + with vuetify3.VRow(dense=True, classes="mb-1 align-center"): + with vuetify3.VCol(cols=4, sm=3, md=3): + vuetify3.VSelect( + label="Field", + v_model=("qpu_field_components", "Ez"), + items=("qpu_field_options", ["All", "Ez", "Hx", "Hy"]), + density="compact", + color="primary", + hide_details=True, + style="max-width: 160px;", + ) + with vuetify3.VCol(cols=8, sm=9, md=9): + vuetify3.VTextField( + label="Sample position(s) (x, y) in [0,1]", + v_model=("qpu_monitor_samples", "(0.5, 0.5)"), + density="compact", + color="primary", + hide_details=True, + style="max-width: 320px;", + ) + vuetify3.VAlert( + v_if="qpu_monitor_sample_info", + type="info", + variant="tonal", + density="compact", + children=["{{ qpu_monitor_sample_info }}"], + classes="mb-1", + style="white-space: pre-line;", + ) + + # Run Simulation and Stop Buttons Row + with vuetify3.VRow(dense=True, classes="mb-2"): + with vuetify3.VCol(cols=9): + with vuetify3.VTooltip("Starts the quantum simulation with the specified parameters. This may take some time.", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VBtn( + v_bind="props", + text=("run_button_text", "RUN!"), + click=run_simulation_only, + color="primary", + block=True, + disabled=("is_running || run_button_text === 'Successful!' || !geometry_selection || !dist_type || !!temporal_warning || nx === null || !backend_type", False), + ) + with vuetify3.VCol(cols=3): + with vuetify3.VTooltip("Stop the running simulation", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VBtn( + v_bind="props", + text="Stop", + click=stop_simulation_handler, + color="error", + block=True, + disabled=("stop_button_disabled", True), + ) + + vuetify3.VSpacer() + + # Reset Button + with trame_html.Div(style="flex: 0 0 auto;"): + with vuetify3.VTooltip("Reset all parameters to their default values", location="bottom", color="primary"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VBtn( + v_bind="props", + text="Reset", + click=reset_to_defaults, + color="secondary", + block=True, + ) + + # Main graph column + with vuetify3.VCol(cols=7, classes="pa-1 d-flex flex-column"): + # Output Configuration (appears after simulation) – hidden for QPU + with vuetify3.VCard(v_if="simulation_has_run && backend_type !== 'QPU'", classes="mb-1", style="font-size: 0.8rem;"): + with vuetify3.VCardSubtitle("Output Configuration", classes="text-subtitle-1 text-primary", style="font-size: 0.9rem; padding: 6px 10px; font-weight: 600; color: #1A1A1A;"): + with vuetify3.VCardText(classes="py-1 px-2", style="color: #1A1A1A;"): + with vuetify3.VRadioGroup(v_model=("output_type", "Surface Plot"), row=True, density="compact", color="primary"): + vuetify3.VRadio(label="Surface", value="Surface Plot", style="font-weight: 500;") + vuetify3.VRadio(label="Time Series", value="Time Series Plot", style="font-weight: 500;") + with vuetify3.VContainer(v_if="output_type === 'Surface Plot'", classes="pa-0"): + vuetify3.VSelect(v_model=("surface_field", "Ez"), items=("surface_field_options", ["Ez", "Hx", "Hy"]), label="Field Component", density="compact", color="primary", style="font-weight: 500;") + # Replace export format dropdown and individual buttons with a single Export menu + with vuetify3.VMenu(open_on_hover=True, close_on_content_click=True, location="end"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VBtn(v_bind="props", text="DOWNLOAD", color="primary", variant="tonal", block=True, classes="mt-1") + with vuetify3.VList(density="compact"): + vuetify3.VListSubheader("VTK") + vuetify3.VListItem(title="Current frame (VTK)", prepend_icon="mdi-download", click=export_vtk) + vuetify3.VListItem(title="All frames (VTK sequence)", prepend_icon="mdi-download-multiple", click=export_vtk_all_frames) + vuetify3.VDivider() + vuetify3.VListItem(title="Animation (MP4)", prepend_icon="mdi-movie", click=export_mp4) + with vuetify3.VContainer(v_if="output_type === 'Time Series Plot'", classes="pa-0"): + vuetify3.VSelect(v_model=("timeseries_field", "Ez"), items=("timeseries_field_options", ["All", "Ez", "Hx", "Hy"]), label="Field Component", density="compact", color="primary", style="font-weight: 500;") + vuetify3.VTextarea( + v_model=("timeseries_points", "(0.5, 0.5)"), + label="Monitor Position(s) (x, y) in [0,1]", + hint="e.g., (0.50, 0.50) or multiple comma-separated pairs", + rows=2, + auto_grow=True, + color="primary", + style="font-weight: 500;", + ) + vuetify3.VAlert( + v_if="timeseries_point_info", + type="info", + variant="tonal", + density="compact", + children=["{{ timeseries_point_info }}"], + classes="mt-1", + style="white-space: pre-line;", + ) + # DOWNLOAD menu for Simulator Time Series + with vuetify3.VMenu(open_on_hover=True, close_on_content_click=True, location="end"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VBtn(v_bind="props", text="DOWNLOAD", color="primary", variant="tonal", block=True, classes="mt-1") + with vuetify3.VList(density="compact"): + vuetify3.VListItem(title="Download CSV", prepend_icon="mdi-download", click=export_sim_timeseries_csv) + vuetify3.VListItem(title="Download PNG", prepend_icon="mdi-image", click=export_sim_timeseries_png) + vuetify3.VListItem(title="Download HTML", prepend_icon="mdi-file-html", click=export_sim_timeseries_html) + + # Main plot area (PyVista) – keep visible for QPU until data replaces it + with vuetify3.VCard( + v_if="geometry_selection && (backend_type !== 'QPU' || !qpu_ts_ready)", + classes="mb-1 flex-grow-1 d-flex flex-column", + style="min-height: 0;", + ): + with vuetify3.VContainer(v_if="is_running", fluid=True, classes="fill-height d-flex flex-column align-center justify-center"): + vuetify3.VProgressCircular(indeterminate=True, size=64, color="primary") + vuetify3.VCardSubtitle("Running simulation...", classes="mt-4") + with vuetify3.VContainer( + v_if="!is_running && geometry_selection && !dist_type", + fluid=True, + classes="pa-0 flex-grow-1 d-flex align-center justify-center", + style="width: 100%; min-height: 560px;", + ): + geometry_preview_widget = plotly_widgets.Figure( + figure=_build_geometry_placeholder("Select a geometry to preview."), + responsive=True, + style="width: 100%; height: 100%;", + ) + ctrl.geometry_preview_update = geometry_preview_widget.update + with vuetify3.VContainer( + v_if="!is_running && dist_type && !simulation_has_run", + fluid=True, + classes="pa-0 flex-grow-1 d-flex align-center justify-center", + style="width: 100%; min-height: 580px;", + ): + excitation_preview_widget = plotly_widgets.Figure( + figure=_build_excitation_placeholder("Select an excitation to preview."), + responsive=True, + style="width: 100%; height: 100%;", + ) + ctrl.excitation_preview_update = excitation_preview_widget.update + # Surface Plot: PyVista view + with vuetify3.VContainer(v_if="!is_running && simulation_has_run && output_type === 'Surface Plot'", fluid=True, classes="pa-0", style=("pyvista_view_style", "aspect-ratio: 1 / 1; width: 100%;")): + view = plotter_ui(plotter) + ctrl.view_update = view.update + # Time Series: Plotly figure with fixed size + with vuetify3.VContainer(v_if="!is_running && simulation_has_run && output_type === 'Time Series Plot'", fluid=True, classes="d-flex align-center justify-center pa-2", style="overflow: hidden;"): + sim_ts = plotly_widgets.Figure( + figure=go.Figure(layout=dict(width=900, height=660)), + responsive=False, + style="width: 900px; height: 660px;", + ) + ctrl.sim_ts_update = sim_ts.update + # Time slider for surface plot (moved inside VCard) + with vuetify3.VContainer(v_if="simulation_has_run && output_type === 'Surface Plot' && backend_type !== 'QPU'", fluid=True, classes="pa-0 mt-2"): + vuetify3.VSlider(v_model=("time_val", 0.0), label="Time", min=0, max=("T", 10.0), step=("dt_user", 0.1), thumb_label="always", density="compact", color="primary") + + # Main plot area for QPU (Plotly) + with vuetify3.VCard(v_if="geometry_selection && backend_type === 'QPU' && (is_running || qpu_ts_ready)", classes="flex-grow-1", style="min-height: 0;"): + with vuetify3.VContainer(v_if="is_running", fluid=True, classes="fill-height d-flex flex-column align-center justify-center"): + vuetify3.VProgressCircular(indeterminate=True, size=64, color="primary") + vuetify3.VCardSubtitle("Running QPU...", classes="mt-4") + # Compact toolbar + plot directly below (no extra vertical stretch) + with vuetify3.VContainer(v_if="!is_running && qpu_ts_ready", fluid=True, classes="pa-2"): + with vuetify3.VToolbar(density="compact", flat=True, color="transparent", classes="px-0"): + vuetify3.VSelect( + label="Component", + v_model=("qpu_plot_filter", "All"), + items=("qpu_plot_field_options", ["All"]), + density="compact", + color="primary", + hide_details=True, + style="max-width: 180px; margin-right: 8px;", + disabled=("!qpu_ts_ready", True), + update_modelValue=(ctrl.qpu_set_plot_filter, "[$event]") + ) + vuetify3.VSelect( + label="Position", + v_model=("qpu_plot_position_filter", "All positions"), + items=("qpu_plot_position_options", ["All positions"]), + density="compact", + color="primary", + hide_details=True, + style="max-width: 200px; margin-right: 8px;", + disabled=("!qpu_ts_ready", True), + update_modelValue=(ctrl.qpu_set_plot_position_filter, "[$event]") + ) + vuetify3.VSpacer() + vuetify3.VBtn( + text="Clear Selection", + color="secondary", + variant="text", + click=ctrl.on_qpu_ts_clear, + disabled=("!qpu_ts_ready", True), + ) + with vuetify3.VMenu(open_on_hover=True, close_on_content_click=True, location="end", offset=8): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VBtn( + v_bind="props", + text="DOWNLOAD", + color="primary", + variant="tonal", + disabled=("!qpu_ts_ready", True), + ) + with vuetify3.VList(density="compact"): + vuetify3.VListItem(title="Download CSV", prepend_icon="mdi-download", click=export_qpu_timeseries_csv, disabled=("!qpu_ts_ready", True)) + vuetify3.VListItem(title="Download PNG", prepend_icon="mdi-image", click=export_qpu_timeseries_png, disabled=("!qpu_ts_ready", True)) + vuetify3.VListItem(title="Download HTML", prepend_icon="mdi-file-html", click=export_qpu_timeseries_html, disabled=("!qpu_ts_ready", True)) + # Tiny spacer under toolbar + trame_html.Div(style="height: 6px;") + # Plotly figure directly under toolbar + qpu_ts_widget = plotly_widgets.Figure( + figure=go.Figure(layout=dict(width=900, height=660)), + responsive=False, + style=("qpu_plot_style", "display: none; width: 900px; height: 660px; margin: 0 auto;"), + click=ctrl.on_qpu_ts_click, + ) + ctrl.qpu_ts_update = qpu_ts_widget.update + + # Placeholder when no geometry selected + with vuetify3.VContainer(v_if="!geometry_selection", fluid=True, classes="flex-grow-1 d-flex align-center justify-center text-medium-emphasis"): + vuetify3.VCardText("Select a geometry to display the preview and results.") + + # Console Window + with vuetify3.VCard(classes="mt-2", style="font-size: 0.8rem;"): + with vuetify3.VCardTitle("Status", classes="text-subtitle-1 text-primary", style="font-size: 0.9rem; padding: 6px 10px;"): + pass + with vuetify3.VCardText(classes="py-1 px-2", style="height: 150px; overflow-y: auto; background-color: #f5f5f5; font-family: monospace;"): + vuetify3.VTextarea( + v_model=("console_output", ""), + readonly=True, + auto_grow=False, + rows=6, + variant="plain", + hide_details=True, + style="font-family: monospace; width: 100%; height: 100%;" + ) + + # Status Window - Fixed at bottom right + with vuetify3.VCard( + v_if="status_visible", + style="position: fixed; bottom: 16px; right: 16px; z-index: 1000; min-width: 320px; max-width: 450px;", + elevation=8 + ): + with vuetify3.VCardTitle(classes="d-flex align-center", style="font-size: 0.95rem; padding: 8px 12px;"): + vuetify3.VIcon("mdi-information-outline", size="small", classes="mr-2") + trame_html.Span("Simulation Status") + vuetify3.VSpacer() + vuetify3.VBtn( + icon="mdi-close", + size="x-small", + variant="text", + click="status_visible = false" + ) + vuetify3.VDivider() + with vuetify3.VCardText(classes="py-2 px-3"): + # Status message + vuetify3.VAlert( + type=("status_type", "info"), + variant="tonal", + density="compact", + children=["{{ status_message }}"] + ) + # Progress bar (shown when simulation is running) + with vuetify3.VContainer(v_if="show_progress", classes="pa-0 mt-2"): + vuetify3.VProgressLinear( + model_value=("simulation_progress", 0), + color="primary", + height=6, + striped=True + ) + trame_html.Div( + "{{ simulation_progress }}% complete", + classes="text-caption text-center mt-1", + style="font-size: 0.75rem;" + ) + + +@state.change("nx") +def update_qubit_plot(nx, **kwargs): + try: + ctrl.qubit_plot_update(build_qubit_plot(int(nx))) + except Exception: + pass + +@state.change("hole_size_edge", "hole_center_x", "hole_center_y", "geometry_selection", "hole_snap") +def validate_hole_inputs(**kwargs): + # Only validate when Square Metallic Body is selected + if state.geometry_selection != "Square Metallic Body": + state.hole_error_message = "" + return + try: + s = float(state.hole_size_edge) + cx = float(state.hole_center_x) + cy = float(state.hole_center_y) + except Exception: + state.hole_error_message = "Hole size and center must be numeric." + return + + # Use selected nx, fall back to a safe default when not selected yet + try: + nx = int(state.nx or 32) + except Exception: + nx = 32 + + if s > 1.0: + state.hole_error_message = "Hole edge length must be <= 1." + return + if not (0.0 < cx < 1.0) or not (0.0 < cy < 1.0): + state.hole_error_message = "Hole center must be strictly within (0, 1) for both X and Y." + return + + # Alignment check (strict vs snap) + mode_snap = bool(state.hole_snap) + edges = _compute_hole_edges(nx, cx, cy, s, snap=mode_snap) + if not mode_snap and edges is None: + state.hole_error_message = "Hole edges must align with grid lines; enable Snap to auto-align." + return + + # Inputs valid; clear error and refresh preview (which builds the mesh and renders) + state.hole_error_message = "" + update_geometry_hole_preview() + +@state.change("hole_center_pair") +def sync_hole_center_pair(hole_center_pair, **kwargs): + """Parse bracket-format pair (x, y) from dropdown into numeric center fields.""" + try: + m = re.match(r"\(\s*([-+]?[0-9]*\.?[0-9]+)\s*,\s*([-+]?[0-9]*\.?[0-9]+)\s*\)", str(hole_center_pair)) + if not m: + raise ValueError("Invalid format") + state.hole_center_x = float(m.group(1)) + state.hole_center_y = float(m.group(2)) + state.hole_error_message = "" + except Exception: + state.hole_error_message = "Invalid hole center. Use format (x, y)." + +@state.change("sigma_pair") +def sync_sigma_pair(sigma_pair, **kwargs): + """Parse bracket-format pair (x, y) for Sigma and update sigma_x/sigma_y.""" + try: + m = re.match(r"\(\s*([-+]?[0-9]*\.?[0-9]+)\s*,\s*([-+]?[0-9]*\.?[0-9]+)\s*\)", str(sigma_pair)) + if not m: + raise ValueError("Invalid format") + x = max(0.0, min(1.0, float(m.group(1)))) + y = max(0.0, min(1.0, float(m.group(2)))) + state.sigma_x = x + state.sigma_y = y + state.excitation_error_message = "" + except Exception: + state.excitation_error_message = "Invalid Sigma. Use format (x, y) in [0,1]." + +@state.change("dist_type") +def normalize_dist_type(dist_type, **kwargs): + # Allow unselecting via 'None' + if dist_type in (None, "", "None"): + state.dist_type = None + update_initial_state_preview() + _apply_workflow_highlights(_determine_workflow_step()) + return + update_excitation_info_message() + _apply_workflow_highlights(_determine_workflow_step()) + + +@state.change("qpu_monitor_samples") +def on_qpu_monitor_samples_change(**kwargs): + _update_qpu_sample_slot(1) + + +@state.change("qpu_monitor_samples_2") +def on_qpu_monitor_samples_2_change(**kwargs): + _update_qpu_sample_slot(2) + + +@state.change("qpu_monitor_samples_3") +def on_qpu_monitor_samples_3_change(**kwargs): + _update_qpu_sample_slot(3) + + +@state.change("qpu_monitor_samples_4") +def on_qpu_monitor_samples_4_change(**kwargs): + _update_qpu_sample_slot(4) + + +@state.change("qpu_monitor_samples_5") +def on_qpu_monitor_samples_5_change(**kwargs): + _update_qpu_sample_slot(5) + + +def _refresh_all_qpu_sample_slots(): + for slot in range(1, 6): + _update_qpu_sample_slot(slot) + + +@state.change("nx") +def on_nx_change_refresh_qpu_samples(nx, **kwargs): + _refresh_all_qpu_sample_slots() + _update_sim_monitor_points() + _apply_workflow_highlights(_determine_workflow_step()) + +@state.change("dt_user") +def validate_dt_user(dt_user, **kwargs): + """Validate snapshot Δt: must be >= 0.1 (solver dt) and a multiple of 0.1.""" + try: + dt_val = float(dt_user) + except Exception: + state.temporal_warning = "Δt must be numeric. Frames are captured every Δt." + return + tol = 1e-9 + if dt_val < 0.1 - tol: + state.temporal_warning = "Δt < 0.1 is unsupported (solver dt = 0.1 s)." + elif abs((dt_val / 0.1) - round(dt_val / 0.1)) > 1e-9: + state.temporal_warning = "Δt must be a multiple of 0.1 s." + else: + state.temporal_warning = "" + +# Reset QPU chart visibility when switching backend +@state.change("backend_type") +def on_backend_change(backend_type, **kwargs): + if backend_type == "QPU": + _hide_qpu_plots() + _apply_workflow_highlights(_determine_workflow_step()) + +@state.change("selected_qpu") +def on_selected_qpu_change(selected_qpu, **kwargs): + if state.backend_type == "QPU": + _hide_qpu_plots() + +# NEW: react to component filter changes by rebuilding the figure +@state.change("qpu_plot_filter") +def on_qpu_plot_filter_change(qpu_plot_filter, **kwargs): + # No-op: updates handled by controller bound to the VSelect to avoid double refresh + return + +# --- Initial Setup Call --- +update_initial_state_preview() \ No newline at end of file