Source code for frontend.ui_dialogs.result_dialog

"""Result dialog — left panel (stats, info, export) + right panel (interactive plots)."""

from __future__ import annotations

import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import TYPE_CHECKING, Any, Callable

import numpy as np

from config import generate_output_basename, get_env_from_schema, get_output_dir
from frontend.plot_embed import embed_animation_plot_in_tk, embed_plot_in_tk
from frontend.theme import get_contrast_foreground, get_font
from frontend.ui_dialogs.collapsible_section import CollapsibleSection
from frontend.ui_dialogs.keyboard_nav import setup_arrow_enter_navigation
from frontend.ui_dialogs.scrollable_frame import ScrollableFrame
from frontend.window_utils import center_window, make_modal
from solver.notation import FNotation, generate_derivative_labels, generate_phase_space_options
from utils import export_csv_to_path, export_json_to_path, get_logger

if TYPE_CHECKING:
    from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
    from matplotlib.figure import Figure

    from pipeline import SolverResult

logger = get_logger(__name__)

_MAGNITUDE_KEYS = {
    "mean",
    "rms",
    "std",
    "integral",
    "l2_norm",
    "half_life",
    "time_constant",
    "doubling_time",
    "angular_frequency",
}

_LEFT_MIN_WIDTH = 580


[docs] class ResultDialog: """Window showing the solution with interactive plot tabs. Plots are generated on-demand from the raw solver data. The user selects *what* to visualise (derivatives, phase-space axes, etc.) inside the result window rather than before solving. Args: parent: Parent window. result: A data-only ``SolverResult`` from the pipeline. """ def __init__( self, parent: tk.Tk | tk.Toplevel, *, result: SolverResult, ) -> None: self.parent = parent self._result = result self._notation: FNotation = result.notation or FNotation( kind="ode", order=result.vector_order ) self.win = tk.Toplevel(parent) self.win.title(f"Results — {result.metadata.get('equation_name', 'ODE')}") bg: str = get_env_from_schema("UI_BACKGROUND") self.win.configure(bg=bg) # Canvas references for cleanup self._canvases: list[FigureCanvasTkAgg] = [] self._build_ui() screen_w = self.win.winfo_screenwidth() screen_h = self.win.winfo_screenheight() win_w = int(screen_w * 0.94) win_h = min(int(screen_h * 0.85), 900) center_window(self.win, win_w, win_h, max_width_ratio=0.96, resizable=True) self.win.minsize(_LEFT_MIN_WIDTH + 500, 500) make_modal(self.win, parent) logger.info("Result dialog displayed") # ------------------------------------------------------------------ # UI construction # ------------------------------------------------------------------ def _build_ui(self) -> None: pad: int = get_env_from_schema("UI_PADDING") result = self._result # ── Fixed bottom button bar ── btn_frame = ttk.Frame(self.win) btn_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=pad, pady=pad) btn_close = ttk.Button( btn_frame, text="Close", style="Cancel.TButton", command=self.win.destroy, ) btn_close.pack() setup_arrow_enter_navigation([[btn_close]]) btn_close.focus_set() # ── Main content area (grid: left info | right plot) ── content = ttk.Frame(self.win) content.pack(fill=tk.BOTH, expand=True, padx=pad, pady=pad) content.columnconfigure(0, weight=0, minsize=_LEFT_MIN_WIDTH) content.columnconfigure(1, weight=1, minsize=400) content.rowconfigure(0, weight=1) # ── LEFT: scrollable info panel ── left_frame = ttk.Frame(content, width=_LEFT_MIN_WIDTH) left_frame.grid(row=0, column=0, sticky="nsew", padx=(0, pad)) left_frame.grid_propagate(False) left_scroll = ScrollableFrame(left_frame) left_scroll.apply_bg(get_env_from_schema("UI_BACKGROUND")) left_scroll.pack(fill=tk.BOTH, expand=True) left_inner = left_scroll.inner left_inner.configure(padding=pad) self._build_left_panel(left_inner, left_scroll, result.statistics, result.metadata, pad) left_scroll.bind_new_children() # ── RIGHT: plots ── right_frame = ttk.Frame(content) right_frame.grid(row=0, column=1, sticky="nsew") self._notebook = ttk.Notebook(right_frame) self._notebook.pack(fill=tk.BOTH, expand=True) self._build_plot_tabs() def _build_left_panel( self, inner: ttk.Frame, scroll: ScrollableFrame, statistics: dict[str, Any], metadata: dict[str, Any], pad: int, ) -> None: """Build magnitudes, statistics, solver info, and export sections.""" magnitudes = {k: v for k, v in statistics.items() if k in _MAGNITUDE_KEYS} other_stats = {k: v for k, v in statistics.items() if k not in _MAGNITUDE_KEYS} if magnitudes: mag_section = CollapsibleSection(inner, scroll, "Magnitudes", expanded=True, pad=pad) for key, val in magnitudes.items(): self._render_stat_entry(mag_section.content, key, val, pad) if other_stats: stat_section = CollapsibleSection(inner, scroll, "Statistics", expanded=True, pad=pad) for key, val in other_stats.items(): self._render_stat_entry(stat_section.content, key, val, pad) # Solver info info_section = CollapsibleSection(inner, scroll, "Solver Info", expanded=True, pad=pad) info_items: list[tuple[str, Any]] = [ ("Method", metadata.get("method", "?")), ("Success", "Yes" if metadata.get("solver_success") else "No"), ("Evaluations", metadata.get("n_evaluations", "?")), ("Points", metadata.get("num_points", "?")), ] if metadata.get("rtol") is not None: info_items.append(("rtol", metadata["rtol"])) if metadata.get("atol") is not None: info_items.append(("atol", metadata["atol"])) if metadata.get("residual_max") is not None: info_items.append(("Residual max", f"{metadata['residual_max']:.2e}")) if metadata.get("residual_mean") is not None: info_items.append(("Residual mean", f"{metadata['residual_mean']:.2e}")) if metadata.get("residual_rms") is not None: info_items.append(("Residual RMS", f"{metadata['residual_rms']:.2e}")) if metadata.get("n_jacobian_evals") is not None: info_items.append(("Jacobian evals", metadata["n_jacobian_evals"])) for label, value in info_items: row = ttk.Frame(info_section.content) row.pack(fill=tk.X, pady=1) ttk.Label(row, text=f"{label}:", width=16, anchor=tk.W).pack(side=tk.LEFT) ttk.Label(row, text=str(value), style="Small.TLabel").pack(side=tk.LEFT) # Export export_section = CollapsibleSection(inner, scroll, "Export Data", expanded=True, pad=pad) btn_row = ttk.Frame(export_section.content) btn_row.pack(fill=tk.X, pady=2) ttk.Button(btn_row, text="Save CSV...", command=self._on_save_csv).pack( side=tk.LEFT, padx=(0, pad) ) ttk.Button(btn_row, text="Save JSON...", command=self._on_save_json).pack(side=tk.LEFT) # ------------------------------------------------------------------ # Transform controls helper # ------------------------------------------------------------------ def _build_transform_controls( self, parent: ttk.Frame, callback: Any, prefix: str, *, label_style: str | None = None, ) -> None: """Add a transform dropdown to a tab's control bar. The ``StringVar`` is stored as ``self._transform_{prefix}_var``. """ from transforms import TransformKind sep = ttk.Separator(parent, orient=tk.VERTICAL) sep.pack(side=tk.LEFT, fill=tk.Y, padx=8, pady=2) label_kw = {"style": label_style} if label_style else {} ttk.Label(parent, text="Transform:", **label_kw).pack(side=tk.LEFT, padx=(0, 4)) var = tk.StringVar(value=TransformKind.ORIGINAL.value) setattr(self, f"_transform_{prefix}_var", var) combo = ttk.Combobox( parent, textvariable=var, values=[k.value for k in TransformKind], state="readonly", width=20, font=get_font(), ) combo.pack(side=tk.LEFT, padx=(0, 4)) combo.bind("<<ComboboxSelected>>", lambda _e: callback()) def _create_styled_listbox( self, parent: tk.Widget, labels: list[str], on_select: Callable[[], None], *, height: int = 4, width: int = 12, ) -> tk.Listbox: """Create a themed multi-select Listbox for derivative/component selection.""" bg: str = get_env_from_schema("UI_BUTTON_BG") fg: str = get_env_from_schema("UI_FOREGROUND") select_bg: str = get_env_from_schema("UI_BUTTON_FG") select_fg: str = get_contrast_foreground(select_bg) lb = tk.Listbox( parent, selectmode=tk.EXTENDED, height=min(len(labels), height), width=width, bg=bg, fg=fg, selectbackground=select_bg, selectforeground=select_fg, font=get_font(), exportselection=False, ) for lbl in labels: lb.insert(tk.END, lbl) lb.select_set(0) lb.pack(side=tk.LEFT, padx=4) lb.bind("<<ListboxSelect>>", lambda _e: on_select()) return lb # ------------------------------------------------------------------ # Plot tab construction # ------------------------------------------------------------------ def _build_plot_tabs(self) -> None: """Create the right-side tabs based on equation type.""" r = self._result eq_type = r.equation_type is_2d_pde = eq_type == "pde" and r.y_grid is not None if is_2d_pde: self._build_pde_tabs() elif eq_type == "vector_ode" or (r.is_vector and r.vector_components > 1): self._build_vector_ode_tabs() elif eq_type == "difference": self._build_ode_scalar_tabs() # same layout: solution + (no phase for 1st-order) else: self._build_ode_scalar_tabs() # ── ODE scalar / difference ────────────────────────────────────── def _build_ode_scalar_tabs(self) -> None: """Solution 2D (multi-select derivatives) + Phase Space (axis dropdowns).""" r = self._result notation = self._notation nb = self._notebook # --- Tab 1: Solution 2D --- sol_tab = ttk.Frame(nb) nb.add(sol_tab, text=" Solution f(x) ") ctrl = ttk.Frame(sol_tab) ctrl.pack(fill=tk.X, padx=4, pady=4) self._sol_labels = generate_derivative_labels(notation) ttk.Label(ctrl, text="Show:").pack(side=tk.LEFT, padx=(0, 4)) self._sol_listbox = self._create_styled_listbox( ctrl, self._sol_labels, self._update_solution_plot, height=4 ) self._build_transform_controls(ctrl, self._update_solution_plot, "sol") self._sol_plot_frame = ttk.Frame(sol_tab) self._sol_plot_frame.pack(fill=tk.BOTH, expand=True) self._sol_canvas: FigureCanvasTkAgg | None = None self._update_solution_plot() # --- Tab 2: Phase Space --- order = r.vector_order if order >= 2 or r.equation_type != "difference": phase_tab = ttk.Frame(nb) nb.add(phase_tab, text=" Phase Space ") phase_ctrl = ttk.Frame(phase_tab) phase_ctrl.pack(fill=tk.X, padx=4, pady=4) ps_options = generate_phase_space_options(notation) ps_labels = [lbl for lbl, _ in ps_options] ttk.Label(phase_ctrl, text="X-axis:").pack(side=tk.LEFT, padx=(0, 2)) # Default phase portrait: f vs f' for order>=2, x vs f for order 1 if order >= 2 and len(ps_labels) >= 3: default_x = ps_labels[1] # f default_y_ax = ps_labels[2] # f' elif len(ps_labels) >= 2: default_x = ps_labels[0] # x default_y_ax = ps_labels[1] # f else: default_x = ps_labels[0] if ps_labels else "f" default_y_ax = ps_labels[0] if ps_labels else "f" self._phase_x_var = tk.StringVar(value=default_x) phase_x_combo = ttk.Combobox( phase_ctrl, textvariable=self._phase_x_var, values=ps_labels, state="readonly", width=6, font=get_font(), ) phase_x_combo.pack(side=tk.LEFT, padx=(0, 8)) phase_x_combo.bind("<<ComboboxSelected>>", lambda _e: self._update_phase_plot()) ttk.Label(phase_ctrl, text="Y-axis:").pack(side=tk.LEFT, padx=(0, 2)) self._phase_y_var = tk.StringVar(value=default_y_ax) phase_y_combo = ttk.Combobox( phase_ctrl, textvariable=self._phase_y_var, values=ps_labels, state="readonly", width=6, font=get_font(), ) phase_y_combo.pack(side=tk.LEFT, padx=(0, 8)) phase_y_combo.bind("<<ComboboxSelected>>", lambda _e: self._update_phase_plot()) self._build_transform_controls(phase_ctrl, self._update_phase_plot, "phase") self._phase_options_map = {lbl: idx for lbl, idx in ps_options} self._phase_plot_frame = ttk.Frame(phase_tab) self._phase_plot_frame.pack(fill=tk.BOTH, expand=True) self._phase_canvas: FigureCanvasTkAgg | None = None self._update_phase_plot() def _apply_transform_multi( self, x: np.ndarray, y_2d: np.ndarray, selected: list[int], labels: list[str], kind: Any, ) -> tuple[np.ndarray, np.ndarray, list[str], str, str] | None: """Apply a transform to each selected row and align to a common x-axis. Returns ``(tx, ty_2d, trans_labels, txlabel, tylabel)`` or ``None`` if nothing could be computed. """ from scipy.interpolate import interp1d from transforms import apply_transform x_min_t, x_max_t = float(x[0]), float(x[-1]) raw: list[tuple[np.ndarray, np.ndarray, str]] = [] txlabel = tylabel = "" for idx in selected: if idx >= y_2d.shape[0]: continue func = interp1d(x, y_2d[idx], kind="cubic", fill_value="extrapolate") tx, ty, txlabel, tylabel = apply_transform( lambda arr, f=func: f(arr), kind, x_min_t, x_max_t, ) lbl = labels[idx] if idx < len(labels) else f"f[{idx}]" raw.append((tx, ty, lbl)) if not raw: return None # Use the longest x-axis as the common grid and interpolate the rest ref_tx = max(raw, key=lambda r: len(r[0]))[0] aligned_rows: list[np.ndarray] = [] trans_labels: list[str] = [] for tx_i, ty_i, lbl in raw: if len(tx_i) == len(ref_tx) and np.allclose(tx_i, ref_tx): aligned_rows.append(ty_i) else: f_interp = interp1d(tx_i, ty_i, kind="linear", bounds_error=False, fill_value=0.0) aligned_rows.append(f_interp(ref_tx)) trans_labels.append(lbl) return ref_tx, np.vstack(aligned_rows), trans_labels, txlabel, tylabel def _get_transform_kind(self, prefix: str) -> Any: """Return the current TransformKind for the given control prefix.""" from transforms import TransformKind var = getattr(self, f"_transform_{prefix}_var", None) if var is None: return TransformKind.ORIGINAL try: return TransformKind(var.get()) except ValueError: return TransformKind.ORIGINAL def _update_solution_plot(self) -> None: """Regenerate the solution f(x) plot with currently selected derivatives.""" from plotting import create_solution_plot from transforms import TransformKind r = self._result selected = list(self._sol_listbox.curselection()) if not selected: selected = [0] xlabel = "n" if r.equation_type == "difference" else "x" eq_name = r.metadata.get("equation_name", "f(x)") kind = self._get_transform_kind("sol") if kind == TransformKind.ORIGINAL: fig = create_solution_plot( r.x, r.y, title=eq_name, xlabel=xlabel, ylabel="f", selected_derivatives=selected, labels=self._sol_labels, ) else: y_2d = np.atleast_2d(r.y) if y_2d.shape[1] != len(r.x): y_2d = y_2d.T result = self._apply_transform_multi( r.x, y_2d, selected, self._sol_labels, kind, ) if result is None: return tx, ty_2d, trans_labels, txlabel, tylabel = result fig = create_solution_plot( tx, ty_2d, title=f"{eq_name} \u2014 {kind.value}", xlabel=txlabel, ylabel=tylabel, selected_derivatives=list(range(ty_2d.shape[0])), labels=trans_labels, ) self._replace_plot(self._sol_plot_frame, fig, "_sol_canvas") def _transform_phase_axes( self, x: np.ndarray, y_2d: np.ndarray, axis_specs: list[tuple[int | None, str]], kind: Any, ) -> list[tuple[np.ndarray, str]] | None: """Transform multiple axis data series for phase-space plots. Each element in *axis_specs* is ``(flat_index_or_None, label)``. Returns a list of ``(data_array, display_label)`` per axis, or ``None`` if nothing could be computed. """ # Collect all unique non-None indices that need transforming unique_indices: list[int] = [] for idx, _ in axis_specs: if idx is not None and idx not in unique_indices: unique_indices.append(idx) if not unique_indices: # All axes selected the independent variable — nothing to transform return None labels_for_transform = [ (f"y[{i}]" if i >= y_2d.shape[0] else f"row{i}") for i in unique_indices ] result = self._apply_transform_multi( x, y_2d, unique_indices, labels_for_transform, kind, ) if result is None: return None tx, ty_2d, _tlabels, txlabel, _tylabel = result # Build a lookup from original index to transformed row index idx_to_row = {orig: row_i for row_i, orig in enumerate(unique_indices)} output: list[tuple[np.ndarray, str]] = [] for idx, label in axis_specs: if idx is None: # Independent variable becomes the transform domain axis output.append((tx, txlabel)) elif idx in idx_to_row: output.append((ty_2d[idx_to_row[idx]], label)) else: output.append((tx, txlabel)) return output def _update_phase_plot(self) -> None: """Regenerate the phase portrait with selected axes.""" from plotting import create_phase_plot from transforms import TransformKind r = self._result eq_name = r.metadata.get("equation_name", "Phase") x_label = self._phase_x_var.get() y_label = self._phase_y_var.get() x_idx = self._phase_options_map.get(x_label) y_idx = self._phase_options_map.get(y_label) y_2d = np.atleast_2d(r.y) if y_2d.shape[1] != len(r.x): y_2d = y_2d.T kind = self._get_transform_kind("phase") if kind == TransformKind.ORIGINAL: # Build the two data arrays (None index means independent variable x) if x_idx is None: horiz = r.x elif x_idx < y_2d.shape[0]: horiz = y_2d[x_idx] else: horiz = r.x if y_idx is None: vert = r.x elif y_idx < y_2d.shape[0]: vert = y_2d[y_idx] else: vert = y_2d[0] disp_xlabel = x_label disp_ylabel = y_label title = f"{eq_name} \u2014 Phase" else: result = self._transform_phase_axes( r.x, y_2d, [(x_idx, x_label), (y_idx, y_label)], kind, ) if result is None: return horiz, disp_xlabel = result[0] vert, disp_ylabel = result[1] title = f"{eq_name} \u2014 Phase \u2014 {kind.value}" phase_data = np.vstack([horiz, vert]) fig = create_phase_plot( phase_data, title=title, xlabel=disp_xlabel, ylabel=disp_ylabel, ) self._replace_plot(self._phase_plot_frame, fig, "_phase_canvas") # ── Vector ODE ─────────────────────────────────────────────────── def _build_vector_ode_tabs(self) -> None: """Solution 2D + Phase Space 2D + Phase Space 3D + Animation + 3D for vector ODEs.""" r = self._result notation = self._notation nb = self._notebook # --- Tab 1: Solution 2D --- sol_tab = ttk.Frame(nb) nb.add(sol_tab, text=" Solution f(x) ") ctrl = ttk.Frame(sol_tab) ctrl.pack(fill=tk.X, padx=4, pady=4) self._vec_sol_labels = generate_derivative_labels(notation) ttk.Label(ctrl, text="Show:").pack(side=tk.LEFT, padx=(0, 4)) self._vec_sol_listbox = self._create_styled_listbox( ctrl, self._vec_sol_labels, self._update_vec_solution_plot, height=6 ) self._build_transform_controls(ctrl, self._update_vec_solution_plot, "vec_sol") self._vec_sol_plot_frame = ttk.Frame(sol_tab) self._vec_sol_plot_frame.pack(fill=tk.BOTH, expand=True) self._vec_sol_canvas: FigureCanvasTkAgg | None = None self._update_vec_solution_plot() # --- Tab 2: Phase Space 2D --- phase_tab = ttk.Frame(nb) nb.add(phase_tab, text=" Phase Space ") phase_ctrl = ttk.Frame(phase_tab) phase_ctrl.pack(fill=tk.X, padx=4, pady=4) ps_options = generate_phase_space_options(notation) ps_labels = [lbl for lbl, _ in ps_options] # Default: f₀ vs f′₀ (component 0 value vs its first derivative) # ps_labels[0] = "x", ps_labels[1] = "f₀", ps_labels[2] = "f′₀" (for order >= 2) if r.vector_order >= 2 and len(ps_labels) >= 3: default_x_phase = ps_labels[1] # f₀ default_y_phase = ps_labels[2] # f′₀ elif len(ps_labels) >= 2: default_x_phase = ps_labels[0] # x default_y_phase = ps_labels[1] # f₀ else: default_x_phase = ps_labels[0] if ps_labels else "f" default_y_phase = ps_labels[0] if ps_labels else "f" ttk.Label(phase_ctrl, text="X-axis:").pack(side=tk.LEFT, padx=(0, 2)) self._vec_phase_x_var = tk.StringVar(value=default_x_phase) vec_phase_x_combo = ttk.Combobox( phase_ctrl, textvariable=self._vec_phase_x_var, values=ps_labels, state="readonly", width=6, font=get_font(), ) vec_phase_x_combo.pack(side=tk.LEFT, padx=(0, 8)) vec_phase_x_combo.bind("<<ComboboxSelected>>", lambda _e: self._update_vec_phase_plot()) ttk.Label(phase_ctrl, text="Y-axis:").pack(side=tk.LEFT, padx=(0, 2)) self._vec_phase_y_var = tk.StringVar(value=default_y_phase) vec_phase_y_combo = ttk.Combobox( phase_ctrl, textvariable=self._vec_phase_y_var, values=ps_labels, state="readonly", width=6, font=get_font(), ) vec_phase_y_combo.pack(side=tk.LEFT, padx=(0, 8)) vec_phase_y_combo.bind("<<ComboboxSelected>>", lambda _e: self._update_vec_phase_plot()) self._build_transform_controls(phase_ctrl, self._update_vec_phase_plot, "vec_phase") self._vec_phase_options_map = {lbl: idx for lbl, idx in ps_options} self._vec_phase_plot_frame = ttk.Frame(phase_tab) self._vec_phase_plot_frame.pack(fill=tk.BOTH, expand=True) self._vec_phase_canvas: FigureCanvasTkAgg | None = None self._update_vec_phase_plot() # --- Tab 3: Phase Space 3D --- phase3d_tab = ttk.Frame(nb) nb.add(phase3d_tab, text=" Phase 3D ") phase3d_ctrl = ttk.Frame(phase3d_tab) phase3d_ctrl.pack(fill=tk.X, padx=4, pady=4) # Default axes: f₀, f₁, f₂ for 3+ components; x, f₀, f₁ otherwise n_comp = r.vector_components if n_comp >= 3 and len(ps_labels) >= 4: # ps_labels: x, f₀, f′₀, f₁, f′₁, f₂, f′₂, ... # Find the first 3 "base" component labels (derivative 0 of each) order = r.vector_order def_3d_x = ps_labels[1] # f₀ def_3d_y = ps_labels[1 + order] # f₁ def_3d_z = ps_labels[1 + 2 * order] # f₂ elif n_comp >= 2 and len(ps_labels) >= 3: order = r.vector_order def_3d_x = ps_labels[0] # x def_3d_y = ps_labels[1] # f₀ def_3d_z = ps_labels[1 + order] # f₁ else: def_3d_x = ps_labels[0] if ps_labels else "x" def_3d_y = ps_labels[1] if len(ps_labels) > 1 else def_3d_x def_3d_z = ps_labels[2] if len(ps_labels) > 2 else def_3d_y ttk.Label(phase3d_ctrl, text="X:").pack(side=tk.LEFT, padx=(0, 2)) self._vec_phase3d_x_var = tk.StringVar(value=def_3d_x) phase3d_x_combo = ttk.Combobox( phase3d_ctrl, textvariable=self._vec_phase3d_x_var, values=ps_labels, state="readonly", width=6, font=get_font(), ) phase3d_x_combo.pack(side=tk.LEFT, padx=(0, 6)) phase3d_x_combo.bind("<<ComboboxSelected>>", lambda _e: self._update_vec_phase_3d()) ttk.Label(phase3d_ctrl, text="Y:").pack(side=tk.LEFT, padx=(0, 2)) self._vec_phase3d_y_var = tk.StringVar(value=def_3d_y) phase3d_y_combo = ttk.Combobox( phase3d_ctrl, textvariable=self._vec_phase3d_y_var, values=ps_labels, state="readonly", width=6, font=get_font(), ) phase3d_y_combo.pack(side=tk.LEFT, padx=(0, 6)) phase3d_y_combo.bind("<<ComboboxSelected>>", lambda _e: self._update_vec_phase_3d()) ttk.Label(phase3d_ctrl, text="Z:").pack(side=tk.LEFT, padx=(0, 2)) self._vec_phase3d_z_var = tk.StringVar(value=def_3d_z) phase3d_z_combo = ttk.Combobox( phase3d_ctrl, textvariable=self._vec_phase3d_z_var, values=ps_labels, state="readonly", width=6, font=get_font(), ) phase3d_z_combo.pack(side=tk.LEFT, padx=(0, 6)) phase3d_z_combo.bind("<<ComboboxSelected>>", lambda _e: self._update_vec_phase_3d()) self._build_transform_controls(phase3d_ctrl, self._update_vec_phase_3d, "vec_phase3d") self._vec_phase3d_plot_frame = ttk.Frame(phase3d_tab) self._vec_phase3d_plot_frame.pack(fill=tk.BOTH, expand=True) self._vec_phase3d_canvas: FigureCanvasTkAgg | None = None self._update_vec_phase_3d() # --- Tab 4: Animation --- anim_tab = ttk.Frame(nb) nb.add(anim_tab, text=" Animation ") anim_ctrl = ttk.Frame(anim_tab) anim_ctrl.pack(fill=tk.X, padx=4, pady=4) ttk.Label(anim_ctrl, text="Derivative order:").pack(side=tk.LEFT, padx=(0, 4)) self._anim_order_var = tk.StringVar(value="0") orders = [str(k) for k in range(r.vector_order)] anim_order_combo = ttk.Combobox( anim_ctrl, textvariable=self._anim_order_var, values=orders, state="readonly", width=4, font=get_font(), ) anim_order_combo.pack(side=tk.LEFT, padx=(0, 8)) anim_order_combo.bind("<<ComboboxSelected>>", lambda _e: self._update_animation()) self._build_transform_controls(anim_ctrl, self._update_animation, "anim") self._anim_plot_frame = ttk.Frame(anim_tab) self._anim_plot_frame.pack(fill=tk.BOTH, expand=True) self._update_animation() # --- Tab 5: 3D Surface --- tab_3d = ttk.Frame(nb) nb.add(tab_3d, text=" 3D Surface ") ctrl_3d = ttk.Frame(tab_3d) ctrl_3d.pack(fill=tk.X, padx=4, pady=4) ttk.Label(ctrl_3d, text="Derivative order:").pack(side=tk.LEFT, padx=(0, 4)) self._3d_order_var = tk.StringVar(value="0") order_3d_combo = ttk.Combobox( ctrl_3d, textvariable=self._3d_order_var, values=orders, state="readonly", width=4, font=get_font(), ) order_3d_combo.pack(side=tk.LEFT, padx=(0, 8)) order_3d_combo.bind("<<ComboboxSelected>>", lambda _e: self._update_3d_plot()) self._build_transform_controls(ctrl_3d, self._update_3d_plot, "vec_3d") self._3d_plot_frame = ttk.Frame(tab_3d) self._3d_plot_frame.pack(fill=tk.BOTH, expand=True) self._3d_canvas: FigureCanvasTkAgg | None = None self._update_3d_plot() def _update_vec_solution_plot(self) -> None: """Regenerate vector ODE solution plot.""" from plotting import create_solution_plot from transforms import TransformKind r = self._result selected = list(self._vec_sol_listbox.curselection()) if not selected: selected = [0] eq_name = r.metadata.get("equation_name", "f(x)") kind = self._get_transform_kind("vec_sol") if kind == TransformKind.ORIGINAL: fig = create_solution_plot( r.x, r.y, title=eq_name, xlabel="x", ylabel="f", selected_derivatives=selected, labels=self._vec_sol_labels, ) else: y_2d = np.atleast_2d(r.y) if y_2d.shape[1] != len(r.x): y_2d = y_2d.T result = self._apply_transform_multi( r.x, y_2d, selected, self._vec_sol_labels, kind, ) if result is None: return tx, ty_2d, trans_labels, txlabel, tylabel = result fig = create_solution_plot( tx, ty_2d, title=f"{eq_name} \u2014 {kind.value}", xlabel=txlabel, ylabel=tylabel, selected_derivatives=list(range(ty_2d.shape[0])), labels=trans_labels, ) self._replace_plot(self._vec_sol_plot_frame, fig, "_vec_sol_canvas") def _update_vec_phase_plot(self) -> None: """Regenerate vector ODE phase portrait.""" from plotting import create_phase_plot from transforms import TransformKind r = self._result eq_name = r.metadata.get("equation_name", "Phase") x_label = self._vec_phase_x_var.get() y_label = self._vec_phase_y_var.get() x_idx = self._vec_phase_options_map.get(x_label) y_idx = self._vec_phase_options_map.get(y_label) y_2d = np.atleast_2d(r.y) if y_2d.shape[1] != len(r.x): y_2d = y_2d.T kind = self._get_transform_kind("vec_phase") if kind == TransformKind.ORIGINAL: if x_idx is None: horiz = r.x elif x_idx < y_2d.shape[0]: horiz = y_2d[x_idx] else: horiz = r.x if y_idx is None: vert = r.x elif y_idx < y_2d.shape[0]: vert = y_2d[y_idx] else: vert = y_2d[0] disp_xlabel = x_label disp_ylabel = y_label title = f"{eq_name} \u2014 Phase" else: result = self._transform_phase_axes( r.x, y_2d, [(x_idx, x_label), (y_idx, y_label)], kind, ) if result is None: return horiz, disp_xlabel = result[0] vert, disp_ylabel = result[1] title = f"{eq_name} \u2014 Phase \u2014 {kind.value}" phase_data = np.vstack([horiz, vert]) fig = create_phase_plot( phase_data, title=title, xlabel=disp_xlabel, ylabel=disp_ylabel, ) self._replace_plot(self._vec_phase_plot_frame, fig, "_vec_phase_canvas") def _update_vec_phase_3d(self) -> None: """Regenerate vector ODE 3D phase-space trajectory.""" from plotting import create_phase_3d_plot from transforms import TransformKind r = self._result eq_name = r.metadata.get("equation_name", "Phase 3D") x_label = self._vec_phase3d_x_var.get() y_label = self._vec_phase3d_y_var.get() z_label = self._vec_phase3d_z_var.get() y_2d = np.atleast_2d(r.y) if y_2d.shape[1] != len(r.x): y_2d = y_2d.T kind = self._get_transform_kind("vec_phase3d") if kind == TransformKind.ORIGINAL: def _get_data(label: str) -> np.ndarray: idx = self._vec_phase_options_map.get(label) if idx is None: return r.x if idx < y_2d.shape[0]: return y_2d[idx] return r.x data_x = _get_data(x_label) data_y = _get_data(y_label) data_z = _get_data(z_label) disp_xlabel = x_label disp_ylabel = y_label disp_zlabel = z_label title = f"{eq_name} \u2014 Phase 3D" else: x_idx = self._vec_phase_options_map.get(x_label) y_idx = self._vec_phase_options_map.get(y_label) z_idx = self._vec_phase_options_map.get(z_label) result = self._transform_phase_axes( r.x, y_2d, [(x_idx, x_label), (y_idx, y_label), (z_idx, z_label)], kind, ) if result is None: return data_x, disp_xlabel = result[0] data_y, disp_ylabel = result[1] data_z, disp_zlabel = result[2] title = f"{eq_name} \u2014 Phase 3D \u2014 {kind.value}" fig = create_phase_3d_plot( data_x, data_y, data_z, title=title, xlabel=disp_xlabel, ylabel=disp_ylabel, zlabel=disp_zlabel, ) self._replace_plot(self._vec_phase3d_plot_frame, fig, "_vec_phase3d_canvas") def _transform_vector_components( self, x: np.ndarray, y: np.ndarray, order: int, vector_components: int, deriv_offset: int, kind: Any, ) -> tuple[np.ndarray, np.ndarray, str] | None: """Transform each vector component independently over x. Returns ``(tx, transformed_y, txlabel)`` with the same shape convention as the original data, or ``None`` if transform fails. """ y_2d = np.atleast_2d(y) if y_2d.shape[1] != len(x): y_2d = y_2d.T # Extract the relevant rows for each component at the given derivative offset indices = [i * order + deriv_offset for i in range(vector_components)] labels = [f"f_{i}" for i in range(vector_components)] result = self._apply_transform_multi(x, y_2d, indices, labels, kind) if result is None: return None tx, ty_2d, _labels, txlabel, _tylabel = result # Rebuild a full-size y array with the transformed components in the right slots n_state = vector_components * order new_y = np.zeros((n_state, len(tx))) for comp_i, orig_idx in enumerate(indices): if comp_i < ty_2d.shape[0]: new_y[orig_idx] = ty_2d[comp_i] return tx, new_y, txlabel def _update_animation(self) -> None: """Regenerate the animation tab.""" from plotting import create_vector_animation_plot from transforms import TransformKind r = self._result eq_name = r.metadata.get("equation_name", "ODE") deriv_k = int(self._anim_order_var.get()) kind = self._get_transform_kind("anim") if kind == TransformKind.ORIGINAL: fig = create_vector_animation_plot( r.x, r.y, order=r.vector_order, vector_components=r.vector_components, title=f"{eq_name} \u2014 f_i(x) (k={deriv_k})", deriv_offset=deriv_k, ) else: result = self._transform_vector_components( r.x, r.y, r.vector_order, r.vector_components, deriv_k, kind, ) if result is None: return tx, new_y, txlabel = result fig = create_vector_animation_plot( tx, new_y, order=r.vector_order, vector_components=r.vector_components, title=f"{eq_name} \u2014 {kind.value} (k={deriv_k})", deriv_offset=deriv_k, ) # Clear existing widgets for w in self._anim_plot_frame.winfo_children(): w.destroy() def _export_cb(dur: float) -> None: self._on_export_animation_mp4(dur, deriv_k) embed_animation_plot_in_tk(fig, self._anim_plot_frame, on_export_mp4=_export_cb) def _update_3d_plot(self) -> None: """Regenerate the 3D surface tab.""" from plotting import create_vector_animation_3d from transforms import TransformKind r = self._result eq_name = r.metadata.get("equation_name", "ODE") deriv_k = int(self._3d_order_var.get()) kind = self._get_transform_kind("vec_3d") if kind == TransformKind.ORIGINAL: fig = create_vector_animation_3d( r.x, r.y, order=r.vector_order, vector_components=r.vector_components, title=f"{eq_name} \u2014 3D (k={deriv_k})", deriv_offset=deriv_k, ) else: result = self._transform_vector_components( r.x, r.y, r.vector_order, r.vector_components, deriv_k, kind, ) if result is None: return tx, new_y, txlabel = result fig = create_vector_animation_3d( tx, new_y, order=r.vector_order, vector_components=r.vector_components, title=f"{eq_name} \u2014 {kind.value} 3D (k={deriv_k})", deriv_offset=deriv_k, ) self._replace_plot(self._3d_plot_frame, fig, "_3d_canvas") # ── PDE ────────────────────────────────────────────────────────── def _build_pde_tabs(self) -> None: """Solution 3D (surface) + Solution 2D (contour) + Phase Space slice.""" nb = self._notebook xlabel, ylabel = self._pde_axis_labels() # --- Tab 1: 3D Surface --- surf_tab = ttk.Frame(nb) nb.add(surf_tab, text=" Solution 3D ") surf_ctrl = ttk.Frame(surf_tab) surf_ctrl.pack(fill=tk.X, padx=4, pady=4) ttk.Label(surf_ctrl, text="Transform along:").pack(side=tk.LEFT, padx=(0, 4)) self._pde_3d_axis_var = tk.StringVar(value=xlabel) ttk.Combobox( surf_ctrl, textvariable=self._pde_3d_axis_var, values=[xlabel, ylabel], state="readonly", width=4, font=get_font(), ).pack(side=tk.LEFT, padx=(0, 4)) self._build_transform_controls(surf_ctrl, self._update_pde_3d, "pde_3d") self._pde_3d_frame = ttk.Frame(surf_tab) self._pde_3d_frame.pack(fill=tk.BOTH, expand=True) self._pde_3d_canvas: FigureCanvasTkAgg | None = None self._update_pde_3d() # --- Tab 2: 2D Contour --- contour_tab = ttk.Frame(nb) nb.add(contour_tab, text=" Solution 2D ") contour_ctrl = ttk.Frame(contour_tab) contour_ctrl.pack(fill=tk.X, padx=4, pady=4) ttk.Label(contour_ctrl, text="Transform along:").pack(side=tk.LEFT, padx=(0, 4)) self._pde_2d_axis_var = tk.StringVar(value=xlabel) ttk.Combobox( contour_ctrl, textvariable=self._pde_2d_axis_var, values=[xlabel, ylabel], state="readonly", width=4, font=get_font(), ).pack(side=tk.LEFT, padx=(0, 4)) self._build_transform_controls(contour_ctrl, self._update_pde_2d, "pde_2d") self._pde_2d_frame = ttk.Frame(contour_tab) self._pde_2d_frame.pack(fill=tk.BOTH, expand=True) self._pde_2d_canvas: FigureCanvasTkAgg | None = None self._update_pde_2d() # --- Tab 3: Transform (1D slice) --- trans_tab = ttk.Frame(nb) nb.add(trans_tab, text=" Transform ") trans_ctrl = ttk.Frame(trans_tab) trans_ctrl.pack(fill=tk.X, padx=4, pady=4) xlabel, ylabel = self._pde_axis_labels() ttk.Label(trans_ctrl, text="Slice along:", style="Small.TLabel").pack( side=tk.LEFT, padx=(0, 4) ) self._pde_slice_var = tk.StringVar(value=xlabel) ttk.Combobox( trans_ctrl, textvariable=self._pde_slice_var, values=[xlabel, ylabel], state="readonly", width=2, font=get_font(), ).pack(side=tk.LEFT, padx=(0, 2)) r = self._result y_mid = ( float((r.y_grid[0] + r.y_grid[-1]) / 2) if r.y_grid is not None and len(r.y_grid) > 0 else 0.5 ) ttk.Label(trans_ctrl, text="at fixed value:", style="Small.TLabel").pack( side=tk.LEFT, padx=(0, 4) ) self._pde_slice_val_var = tk.StringVar(value=str(round(y_mid, 4))) ttk.Entry( trans_ctrl, textvariable=self._pde_slice_val_var, width=4, font=get_font(), ).pack(side=tk.LEFT, padx=(0, 2)) self._build_transform_controls( trans_ctrl, self._update_pde_transform, "pde", label_style="Small.TLabel", ) ttk.Button( trans_ctrl, text="Update", command=self._update_pde_transform, ).pack(side=tk.LEFT, padx=4) self._pde_trans_frame = ttk.Frame(trans_tab) self._pde_trans_frame.pack(fill=tk.BOTH, expand=True) self._pde_trans_canvas: FigureCanvasTkAgg | None = None self._update_pde_transform() def _pde_axis_labels(self) -> tuple[str, str]: """Return (xlabel, ylabel) from metadata variable names.""" variables = self._result.metadata.get("variables", ["x[0]", "x[1]"]) xlabel = variables[0] if len(variables) > 0 else "x[0]" ylabel = variables[1] if len(variables) > 1 else "x[1]" return xlabel, ylabel def _transform_pde_along_axis( self, x: np.ndarray, y_grid: np.ndarray, z: np.ndarray, axis_var: str, kind: Any, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, str, str] | None: """Transform PDE solution along one axis. Returns ``(new_x, new_y, new_z, new_xlabel, new_ylabel)`` or ``None`` if the transform cannot be applied. Because ``apply_transform`` trims spectra by amplitude and may return different-length arrays for each slice, we interpolate all results onto the domain grid from the first slice. """ from scipy.interpolate import interp1d from transforms import apply_transform xlabel, ylabel = self._pde_axis_labels() if axis_var == xlabel: # Transform along x (columns): for each row, transform f vs x raw: list[tuple[np.ndarray, np.ndarray]] = [] txlabel = "" for i in range(z.shape[0]): func = interp1d(x, z[i, :], kind="cubic", fill_value="extrapolate") tx, ty, txlabel, _tylabel = apply_transform( lambda arr, f=func: f(arr), kind, float(x[0]), float(x[-1]), ) raw.append((tx, ty)) if not raw: return None # Use first slice's domain as the common grid tx_common = raw[0][0] new_rows: list[np.ndarray] = [] for tx_i, ty_i in raw: if len(tx_i) == len(tx_common) and np.allclose(tx_i, tx_common): new_rows.append(ty_i) else: resamp = interp1d(tx_i, ty_i, kind="linear", fill_value=0.0, bounds_error=False) new_rows.append(resamp(tx_common)) new_z = np.array(new_rows) return tx_common, y_grid, new_z, txlabel, ylabel else: # Transform along y_grid (rows): for each column, transform f vs y raw_c: list[tuple[np.ndarray, np.ndarray]] = [] tylabel = "" for j in range(z.shape[1]): func = interp1d(y_grid, z[:, j], kind="cubic", fill_value="extrapolate") ty, tz, tylabel, _tzlabel = apply_transform( lambda arr, f=func: f(arr), kind, float(y_grid[0]), float(y_grid[-1]), ) raw_c.append((ty, tz)) if not raw_c: return None ty_common = raw_c[0][0] new_cols: list[np.ndarray] = [] for ty_j, tz_j in raw_c: if len(ty_j) == len(ty_common) and np.allclose(ty_j, ty_common): new_cols.append(tz_j) else: resamp = interp1d(ty_j, tz_j, kind="linear", fill_value=0.0, bounds_error=False) new_cols.append(resamp(ty_common)) new_z = np.column_stack(new_cols) return x, ty_common, new_z, xlabel, tylabel def _update_pde_3d(self) -> None: """Render the 3D surface plot for PDE.""" from plotting import create_surface_plot from transforms import TransformKind r = self._result xlabel, ylabel = self._pde_axis_labels() eq_name = r.metadata.get("equation_name", f"f({xlabel},{ylabel})") kind = self._get_transform_kind("pde_3d") if kind != TransformKind.ORIGINAL: axis_var = self._pde_3d_axis_var.get() result = self._transform_pde_along_axis( r.x, r.y_grid, r.y, axis_var, kind, ) if result is not None: px, py, pz, pxl, pyl = result fig = create_surface_plot( px, py, pz, title=f"{eq_name}{kind.value}", xlabel=pxl, ylabel=pyl, zlabel="|F|", ) self._replace_plot(self._pde_3d_frame, fig, "_pde_3d_canvas") return fig = create_surface_plot( r.x, r.y_grid, r.y, title=eq_name, xlabel=xlabel, ylabel=ylabel, zlabel="f", ) self._replace_plot(self._pde_3d_frame, fig, "_pde_3d_canvas") def _update_pde_2d(self) -> None: """Render the 2D contour plot for PDE.""" from plotting import create_contour_plot from transforms import TransformKind r = self._result xlabel, ylabel = self._pde_axis_labels() eq_name = r.metadata.get("equation_name", f"f({xlabel},{ylabel})") kind = self._get_transform_kind("pde_2d") if kind != TransformKind.ORIGINAL: axis_var = self._pde_2d_axis_var.get() result = self._transform_pde_along_axis( r.x, r.y_grid, r.y, axis_var, kind, ) if result is not None: px, py, pz, pxl, pyl = result fig = create_contour_plot( px, py, pz, title=f"{eq_name}{kind.value}", xlabel=pxl, ylabel=pyl, ) self._replace_plot(self._pde_2d_frame, fig, "_pde_2d_canvas") return fig = create_contour_plot( r.x, r.y_grid, r.y, title=eq_name, xlabel=xlabel, ylabel=ylabel, ) self._replace_plot(self._pde_2d_frame, fig, "_pde_2d_canvas") def _update_pde_transform(self) -> None: """Render a 1D transform of a slice through the PDE solution.""" from plotting import create_solution_plot from transforms import TransformKind, apply_transform r = self._result kind = self._get_transform_kind("pde") xlabel, ylabel = self._pde_axis_labels() slice_var = self._pde_slice_var.get() try: slice_val = float(self._pde_slice_val_var.get()) except ValueError: slice_val = 0.5 if slice_var == xlabel: # Slice along x[0] at a fixed x[1] value y_idx = int(np.argmin(np.abs(r.y_grid - slice_val))) data_1d = r.y[y_idx, :] x_1d = r.x slice_label = f"{ylabel}={slice_val:.3g}" axis_label = xlabel else: # Slice along x[1] at a fixed x[0] value x_idx = int(np.argmin(np.abs(r.x - slice_val))) data_1d = r.y[:, x_idx] x_1d = r.y_grid slice_label = f"{xlabel}={slice_val:.3g}" axis_label = ylabel eq_name = r.metadata.get("equation_name", "PDE") if kind == TransformKind.ORIGINAL: fig = create_solution_plot( x_1d, np.atleast_2d(data_1d), title=f"{eq_name} \u2014 slice at {slice_label}", xlabel=axis_label, ylabel="f", selected_derivatives=[0], labels=["f"], ) else: from scipy.interpolate import interp1d func = interp1d(x_1d, data_1d, kind="cubic", fill_value="extrapolate") x_min_t, x_max_t = float(x_1d[0]), float(x_1d[-1]) tx, ty, txlabel, tylabel = apply_transform( lambda arr: func(arr), kind, x_min_t, x_max_t, ) fig = create_solution_plot( tx, np.atleast_2d(ty), title=f"{eq_name} \u2014 {kind.value} [slice {slice_label}]", xlabel=txlabel, ylabel=tylabel, selected_derivatives=[0], labels=[tylabel], ) self._replace_plot(self._pde_trans_frame, fig, "_pde_trans_canvas") # ------------------------------------------------------------------ # Plot replacement helper # ------------------------------------------------------------------ def _replace_plot( self, frame: ttk.Frame, fig: Figure, canvas_attr: str, ) -> None: """Destroy the old canvas in *frame* and embed *fig* in its place.""" import matplotlib.pyplot as plt old_canvas: FigureCanvasTkAgg | None = getattr(self, canvas_attr, None) if old_canvas is not None: old_fig = old_canvas.figure old_canvas.get_tk_widget().destroy() plt.close(old_fig) for w in frame.winfo_children(): w.destroy() canvas = embed_plot_in_tk(fig, frame) setattr(self, canvas_attr, canvas) # ------------------------------------------------------------------ # Stat rendering # ------------------------------------------------------------------ def _render_stat_entry(self, parent: tk.Widget, key: str, val: Any, pad: int) -> None: if isinstance(val, dict): hdr = ttk.Frame(parent) hdr.pack(fill=tk.X, pady=(2, 0)) ttk.Label(hdr, text=f"{key}:", width=16, anchor=tk.W, style="Small.TLabel").pack( side=tk.LEFT ) for sub_key, sub_val in val.items(): sub_row = ttk.Frame(parent) sub_row.pack(fill=tk.X, pady=0) ttk.Label(sub_row, text=f" {sub_key}:", width=22, anchor=tk.W).pack(side=tk.LEFT) formatted = f"{sub_val:.6g}" if isinstance(sub_val, float) else str(sub_val) ttk.Label(sub_row, text=formatted, style="Small.TLabel").pack( side=tk.LEFT, padx=(2, 0) ) else: row = ttk.Frame(parent) row.pack(fill=tk.X, pady=1) ttk.Label(row, text=f"{key}:", width=16, anchor=tk.W).pack(side=tk.LEFT) ttk.Label(row, text=self._format_stat(val), style="Small.TLabel").pack(side=tk.LEFT) # ------------------------------------------------------------------ # Export # ------------------------------------------------------------------ def _save_export_file( self, export_fn, ext: str, filetypes: list[tuple[str, str]], prefix_log: str = "", ) -> None: default_path = get_output_dir() / f"{generate_output_basename()}{ext}" filepath = filedialog.asksaveasfilename( parent=self.win, defaultextension=ext, initialfile=default_path.name, initialdir=str(default_path.parent), filetypes=filetypes, ) if not filepath: return path = Path(filepath) try: export_fn(path) messagebox.showinfo( "Export Complete", f"{prefix_log} saved to:\n{path}", parent=self.win, ) except Exception as exc: logger.error(f"{prefix_log} export failed: %s", exc, exc_info=True) messagebox.showerror("Export Failed", str(exc), parent=self.win) def _on_save_csv(self) -> None: r = self._result def export_fn(path: str) -> None: export_csv_to_path(r.x, r.y, path, y_grid=r.y_grid) self._save_export_file( export_fn, ".csv", [("CSV files", "*.csv"), ("All files", "*.*")], "CSV", ) def _on_save_json(self) -> None: r = self._result def export_fn(path: str) -> None: export_json_to_path(r.statistics, r.metadata, path) self._save_export_file( export_fn, ".json", [("JSON files", "*.json"), ("All files", "*.*")], "JSON", ) def _on_export_animation_mp4(self, duration_seconds: float, deriv_k: int = 0) -> None: r = self._result default_path = get_output_dir() / f"{generate_output_basename(prefix='animation')}.mp4" filepath_str = filedialog.asksaveasfilename( parent=self.win, defaultextension=".mp4", initialfile=default_path.name, initialdir=str(default_path.parent), filetypes=[("MP4 video", "*.mp4"), ("All files", "*.*")], ) if not filepath_str: return filepath = Path(filepath_str) try: from plotting import export_animation_to_mp4 export_animation_to_mp4( r.x, r.y, r.vector_order, r.vector_components, filepath, title=f"{r.metadata.get('equation_name', 'ODE')} — f_i(x)", duration_seconds=duration_seconds, ) messagebox.showinfo( "Export Complete", f"Animation saved to:\n{filepath}", parent=self.win, ) except RuntimeError as exc: logger.warning("MP4 export failed (ffmpeg): %s", exc) messagebox.showerror( "Export Failed", str(exc) + "\n\nInstall ffmpeg and ensure it is in your PATH.", parent=self.win, ) except Exception as exc: logger.error("MP4 export failed: %s", exc, exc_info=True) messagebox.showerror("Export Failed", str(exc), parent=self.win) @staticmethod def _format_stat(value: Any) -> str: if value is None: return "N/A" if isinstance(value, dict): parts = [ f"{k}={v:.6g}" if isinstance(v, float) else f"{k}={v}" for k, v in value.items() ] return ", ".join(parts) if isinstance(value, float): return f"{value:.6g}" return str(value)