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