diff --git "a/em_embedded.py" "b/em_embedded.py" deleted file mode 100644--- "a/em_embedded.py" +++ /dev/null @@ -1,3798 +0,0 @@ -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 - - -# ============================================================================ -# DEFERRED STATE/CONTROLLER PROXY CLASSES -# These allow using state.update(), @state.change(), and ctrl.xxx at module -# load time, then applying them when set_server() is called. -# ============================================================================ - -class _DeferredStateProxy: - """ - A proxy that collects state defaults and change decorators at module load time, - then applies them to the real server.state when bind() is called. - """ - - def __init__(self): - self._state = None - self._defaults = {} - self._pending_changes = [] # list of (keys, func) - - def bind(self, real_state): - """Bind to the real state object and apply all pending operations.""" - self._state = real_state - # Apply all collected defaults - if self._defaults: - real_state.update(self._defaults) - self._defaults.clear() - # Apply all pending @state.change decorators - for keys, func in self._pending_changes: - real_state.change(*keys)(func) - self._pending_changes.clear() - - @property - def bound(self): - return self._state is not None - - def update(self, d): - """Collect defaults; apply immediately if bound.""" - if self._state is not None: - self._state.update(d) - else: - self._defaults.update(d) - - def change(self, *keys): - """ - Decorator factory that mimics @state.change("key1", "key2"). - If already bound, apply immediately. Otherwise, queue for later. - """ - def decorator(func): - if self._state is not None: - # Already bound - register directly - self._state.change(*keys)(func) - else: - # Queue for later - self._pending_changes.append((keys, func)) - return func - return decorator - - def __getattr__(self, name): - if name.startswith("_"): - raise AttributeError(name) - if self._state is None: - raise AttributeError(f"State not bound yet; cannot access '{name}'") - return getattr(self._state, name) - - def __setattr__(self, name, value): - if name.startswith("_"): - object.__setattr__(self, name, value) - elif self._state is not None: - setattr(self._state, name, value) - else: - # Store as a default - self._defaults[name] = value - - -class _DeferredControllerProxy: - """ - A proxy that collects controller attribute assignments at module load time, - then applies them when bind() is called. - """ - - def __init__(self): - self._ctrl = None - self._pending = {} - - def bind(self, real_ctrl): - """Bind to the real controller and apply pending attributes.""" - self._ctrl = real_ctrl - for name, value in self._pending.items(): - setattr(real_ctrl, name, value) - self._pending.clear() - - @property - def bound(self): - return self._ctrl is not None - - def __getattr__(self, name): - if name.startswith("_"): - raise AttributeError(name) - if self._ctrl is None: - raise AttributeError(f"Controller not bound yet; cannot access '{name}'") - return getattr(self._ctrl, name) - - def __setattr__(self, name, value): - if name.startswith("_"): - object.__setattr__(self, name, value) - elif self._ctrl is not None: - setattr(self._ctrl, name, value) - else: - self._pending[name] = value - - -# Module-level proxies (will be bound when set_server is called) -_server = None -state = _DeferredStateProxy() -ctrl = _DeferredControllerProxy() - - -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() - # Initialize preview after state is bound - try: - update_initial_state_preview() - except Exception: - pass # Ignore if preview can't be initialized yet - -# 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_sve(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 - -# Note: update_initial_state_preview() is called from init_state() after the server is bound \ No newline at end of file