Source code for complex_problems.membrane_2d.ui

"""UI dialog for configuring a 2D nonlinear membrane simulation."""

from __future__ import annotations

import tkinter as tk
from tkinter import messagebox, ttk

import numpy as np

from complex_problems.common import (
    add_how_to_config_section,
    compile_scalar_expression,
    parse_float,
    parse_int,
    parse_positive_float,
    parse_positive_int,
    run_solver_with_loading,
)
from complex_problems.membrane_2d.model import build_initial_displacement
from complex_problems.membrane_2d.solver import solve_membrane_2d
from config import get_env_from_schema
from frontend.theme import get_contrast_foreground, get_font
from frontend.ui_dialogs.scrollable_frame import ScrollableFrame
from frontend.ui_dialogs.tooltip import ToolTip
from frontend.window_utils import fit_and_center, make_modal

_BOUNDARY_OPTIONS = ("fixed", "periodic")
_INTEGRATOR_OPTIONS = ("verlet", "rk45")
_IC_SHAPES = ("gaussian", "mode", "random", "custom")

_TERM_ALPHA = "Quadratic α(Δu)²"
_TERM_BETA = "Cubic β(Δu)³"
_TERM_HIGH = "Higher-order cₚ sign(Δu)|Δu|ᵖ"
_OPTIONAL_TERMS = (_TERM_ALPHA, _TERM_BETA, _TERM_HIGH)


[docs] def resolve_optional_membrane_terms( selected_labels: set[str], *, alpha_text: str, beta_text: str, high_coeff_text: str, high_power_text: str, ) -> tuple[float, float, float, int]: """Resolve optional membrane nonlinear terms from selected labels.""" alpha = parse_float(alpha_text, name="α") if _TERM_ALPHA in selected_labels else 0.0 beta = parse_float(beta_text, name="β") if _TERM_BETA in selected_labels else 0.0 if _TERM_HIGH in selected_labels: high_coeff = parse_float(high_coeff_text, name="cₚ") high_power = parse_positive_int(high_power_text, name="p", min_value=2) else: high_coeff = 0.0 high_power = 5 return alpha, beta, high_coeff, high_power
[docs] class Membrane2DDialog: """Configuration dialog for the membrane problem.""" def __init__(self, parent: tk.Tk | tk.Toplevel) -> None: self.parent = parent self.win = tk.Toplevel(parent) self.win.title("2D Nonlinear Membrane") self.win.configure(bg=get_env_from_schema("UI_BACKGROUND")) self._build_ui() fit_and_center(self.win, min_width=980, min_height=760, padding=32, resizable=True) self.win.minsize(920, 700) make_modal(self.win, parent) def _build_ui(self) -> None: pad = int(get_env_from_schema("UI_PADDING")) root = ttk.Frame(self.win, padding=pad * 2) root.pack(fill=tk.BOTH, expand=True) scroll = ScrollableFrame(root) scroll.apply_bg(get_env_from_schema("UI_BACKGROUND")) scroll.pack(fill=tk.BOTH, expand=True) body = scroll.inner body.configure(padding=pad) ttk.Label(body, text="2D Nonlinear Membrane", style="Title.TLabel").pack( anchor=tk.W, pady=(0, pad) ) ttk.Label( body, text=( "Discrete membrane lattice with fixed or periodic boundaries.\n" "The linear Laplacian term is always active; optional nonlinear terms can be added." ), style="Small.TLabel", justify=tk.LEFT, ).pack(anchor=tk.W, pady=(0, pad)) add_how_to_config_section( body, scroll, problem_id="membrane_2d", pad=pad, wraplength=780, ) row = ttk.Frame(body) row.pack(fill=tk.X, pady=pad // 2) self._nx_var = tk.StringVar(value="32") self._ny_var = tk.StringVar(value="32") self._make_spinbox(row, "Nₓ", self._nx_var, from_=8, to=2048, width=7) self._make_spinbox(row, "Nᵧ", self._ny_var, from_=8, to=2048, width=7) ToolTip(row, "Grid size along x and y (integers).") row = ttk.Frame(body) row.pack(fill=tk.X, pady=pad // 2) self._boundary_var = tk.StringVar(value="fixed") self._integrator_var = tk.StringVar(value="verlet") self._make_combo(row, "Boundary", self._boundary_var, _BOUNDARY_OPTIONS, width=10) self._make_combo( row, "Integrator", self._integrator_var, _INTEGRATOR_OPTIONS, width=10 ) ToolTip( row, "Verlet is faster and more energy-stable for Hamiltonian-like dynamics. " "RK45 is provided for comparison.", ) row = ttk.Frame(body) row.pack(fill=tk.X, pady=pad // 2) self._mass_var = tk.StringVar(value="1.0") self._k_var = tk.StringVar(value="1.0") self._make_entry(row, "Mass m", self._mass_var, width=10) self._make_entry(row, "Linear k", self._k_var, width=10) btn_bg = get_env_from_schema("UI_BUTTON_BG") fg = get_env_from_schema("UI_FOREGROUND") select_bg = get_env_from_schema("UI_BUTTON_FG") select_fg = get_contrast_foreground(select_bg) row = ttk.Frame(body) row.pack(fill=tk.X, pady=pad // 2) ttk.Label(row, text="Optional nonlinear terms:").pack(side=tk.LEFT, padx=(0, pad)) self._optional_terms_listbox = tk.Listbox( row, selectmode=tk.EXTENDED, height=4, width=36, bg=btn_bg, fg=fg, selectbackground=select_bg, selectforeground=select_fg, font=get_font(), exportselection=False, ) for term in _OPTIONAL_TERMS: self._optional_terms_listbox.insert(tk.END, term) self._optional_terms_listbox.pack(side=tk.LEFT, padx=(0, pad)) self._optional_terms_listbox.bind( "<<ListboxSelect>>", lambda _e: self._update_optional_terms_visibility() ) ToolTip( self._optional_terms_listbox, "Linear k·Δu is always active. Select one or more optional nonlinear terms.", ) self._alpha_var = tk.StringVar(value="0.0") self._beta_var = tk.StringVar(value="0.0") self._high_coeff_var = tk.StringVar(value="0.0") self._high_power_var = tk.StringVar(value="5") self._alpha_frame = ttk.Frame(body) self._make_entry(self._alpha_frame, "α coefficient", self._alpha_var, width=10) self._beta_frame = ttk.Frame(body) self._make_entry(self._beta_frame, "β coefficient", self._beta_var, width=10) self._high_frame = ttk.Frame(body) self._make_entry(self._high_frame, "cₚ coefficient", self._high_coeff_var, width=10) self._make_entry(self._high_frame, "Power p", self._high_power_var, width=8) ToolTip( self._high_frame, "Higher-order contribution uses cₚ·sign(Δu)·|Δu|ᵖ with integer p ≥ 2.", ) row = ttk.Frame(body) row.pack(fill=tk.X, pady=pad // 2) self._t_min_var = tk.StringVar(value="0.0") self._t_max_var = tk.StringVar(value="20.0") self._dt_var = tk.StringVar(value="0.02") self._make_entry(row, "tₘᵢₙ", self._t_min_var, width=8) self._make_entry(row, "tₘₐₓ", self._t_max_var, width=8) self._make_entry(row, "Δt", self._dt_var, width=8) ttk.Separator(body).pack(fill=tk.X, pady=pad) ttk.Label(body, text="Initial condition", style="Small.TLabel").pack(anchor=tk.W) row = ttk.Frame(body) row.pack(fill=tk.X, pady=pad // 2) self._ic_shape_var = tk.StringVar(value="gaussian") shape_combo = self._make_combo( row, "Shape", self._ic_shape_var, _IC_SHAPES, width=12 ) shape_combo.bind("<<ComboboxSelected>>", lambda _e: self._update_ic_visibility()) self._amp_var = tk.StringVar(value="1.0") self._sigma_var = tk.StringVar(value="0.12") self._make_entry(row, "Amplitude", self._amp_var, width=8) self._make_entry(row, "σ", self._sigma_var, width=8) self._mode_row = ttk.Frame(body) self._mode_row.pack(fill=tk.X, pady=pad // 2) self._mode_x_var = tk.StringVar(value="1") self._mode_y_var = tk.StringVar(value="1") self._make_entry(self._mode_row, "Mode nₓ", self._mode_x_var, width=6) self._make_entry(self._mode_row, "Mode nᵧ", self._mode_y_var, width=6) self._custom_row = ttk.Frame(body) self._custom_row.pack(fill=tk.X, pady=pad // 2) ttk.Label(self._custom_row, text="u₀(x,y) =").pack(side=tk.LEFT, padx=(0, 4)) self._custom_expr_var = tk.StringVar(value="exp(-((x-0.5)**2 + (y-0.5)**2)/0.02)") self._custom_entry = ttk.Entry( self._custom_row, textvariable=self._custom_expr_var, width=54, font=get_font(), ) self._custom_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) ToolTip(self._custom_entry, "Custom expression using x and y in [0,1].") row = ttk.Frame(body) row.pack(fill=tk.X, pady=pad // 2) self._center_x_var = tk.StringVar(value="0.5") self._center_y_var = tk.StringVar(value="0.5") self._seed_var = tk.StringVar(value="0") self._make_entry(row, "Center x₀", self._center_x_var, width=8) self._make_entry(row, "Center y₀", self._center_y_var, width=8) self._make_entry(row, "Random seed", self._seed_var, width=8) self._btn_row = ttk.Frame(body) self._btn_row.pack(fill=tk.X, pady=(pad * 2, 0)) ttk.Button(self._btn_row, text="Solve", command=self._on_solve).pack( side=tk.LEFT, padx=(0, pad) ) ttk.Button( self._btn_row, text="Close", style="Cancel.TButton", command=self.win.destroy, ).pack( side=tk.LEFT ) self._update_optional_terms_visibility() self._update_ic_visibility() scroll.bind_new_children() def _make_entry( self, parent: ttk.Frame, label: str, var: tk.StringVar, *, width: int = 10, ) -> ttk.Entry: ttk.Label(parent, text=f"{label}:").pack(side=tk.LEFT, padx=(0, 4)) entry = ttk.Entry(parent, textvariable=var, width=width, font=get_font()) entry.pack(side=tk.LEFT, padx=(0, 12)) return entry def _make_spinbox( self, parent: ttk.Frame, label: str, var: tk.StringVar, *, from_: int, to: int, width: int = 8, ) -> ttk.Spinbox: ttk.Label(parent, text=f"{label}:").pack(side=tk.LEFT, padx=(0, 4)) spin = ttk.Spinbox( parent, textvariable=var, from_=from_, to=to, width=width, font=get_font(), ) spin.pack(side=tk.LEFT, padx=(0, 12)) return spin def _make_combo( self, parent: ttk.Frame, label: str, var: tk.StringVar, values: tuple[str, ...], *, width: int = 12, ) -> ttk.Combobox: ttk.Label(parent, text=f"{label}:").pack(side=tk.LEFT, padx=(0, 4)) combo = ttk.Combobox( parent, textvariable=var, values=list(values), state="readonly", width=width, font=get_font(), ) combo.pack(side=tk.LEFT, padx=(0, 12)) return combo def _update_optional_terms_visibility(self) -> None: selected = { self._optional_terms_listbox.get(i) for i in self._optional_terms_listbox.curselection() } if _TERM_ALPHA in selected: self._alpha_frame.pack(fill=tk.X, pady=4, before=self._btn_row) else: self._alpha_frame.pack_forget() if _TERM_BETA in selected: self._beta_frame.pack(fill=tk.X, pady=4, before=self._btn_row) else: self._beta_frame.pack_forget() if _TERM_HIGH in selected: self._high_frame.pack(fill=tk.X, pady=4, before=self._btn_row) else: self._high_frame.pack_forget() def _update_ic_visibility(self) -> None: shape = self._ic_shape_var.get() if shape == "mode": self._mode_row.pack(fill=tk.X, pady=4, before=self._btn_row) else: self._mode_row.pack_forget() if shape == "custom": self._custom_row.pack(fill=tk.X, pady=4, before=self._btn_row) else: self._custom_row.pack_forget() def _collect_inputs(self) -> dict[str, object]: nx = parse_positive_int(self._nx_var.get(), name="Nₓ", min_value=8) ny = parse_positive_int(self._ny_var.get(), name="Nᵧ", min_value=8) boundary = self._boundary_var.get() integrator = self._integrator_var.get() mass = parse_positive_float(self._mass_var.get(), name="Mass m") k_linear = parse_float(self._k_var.get(), name="Linear k") if k_linear < 0: raise ValueError("Linear k must be non-negative.") t_min = parse_float(self._t_min_var.get(), name="tₘᵢₙ") t_max = parse_float(self._t_max_var.get(), name="tₘₐₓ") dt = parse_positive_float(self._dt_var.get(), name="Δt") if t_max <= t_min: raise ValueError("tₘₐₓ must be greater than tₘᵢₙ.") selected_terms = { self._optional_terms_listbox.get(i) for i in self._optional_terms_listbox.curselection() } alpha, beta, high_coeff, high_power = resolve_optional_membrane_terms( selected_terms, alpha_text=self._alpha_var.get(), beta_text=self._beta_var.get(), high_coeff_text=self._high_coeff_var.get(), high_power_text=self._high_power_var.get(), ) shape = self._ic_shape_var.get() amp = parse_float(self._amp_var.get(), name="Amplitude") sigma = parse_positive_float(self._sigma_var.get(), name="σ") center_x = parse_float(self._center_x_var.get(), name="Center x₀") center_y = parse_float(self._center_y_var.get(), name="Center y₀") mode_x = parse_positive_int(self._mode_x_var.get(), name="Mode nₓ") mode_y = parse_positive_int(self._mode_y_var.get(), name="Mode nᵧ") seed = parse_int(self._seed_var.get(), name="Random seed") custom_fn = None if shape == "custom": custom_fn = compile_scalar_expression( self._custom_expr_var.get(), variables=("x", "y"), ) u0 = build_initial_displacement( nx=nx, ny=ny, shape=shape, amplitude=amp, sigma=sigma, mode_x=mode_x, mode_y=mode_y, center_x=center_x, center_y=center_y, custom_fn=custom_fn, random_seed=seed, boundary=boundary, ) v0 = np.zeros_like(u0) return { "u0": u0, "v0": v0, "t_min": t_min, "t_max": t_max, "dt": dt, "mass": mass, "k_linear": k_linear, "boundary": boundary, "integrator": integrator, "alpha": alpha, "beta": beta, "high_order_coeff": high_coeff, "high_order_power": high_power, } def _on_solve(self) -> None: try: params = self._collect_inputs() except ValueError as exc: messagebox.showerror("Invalid input", str(exc), parent=self.win) return self.win.destroy() def _task(): return solve_membrane_2d(**params) def _on_success(result) -> None: from complex_problems.membrane_2d.result_dialog import Membrane2DResultDialog Membrane2DResultDialog(self.parent, result=result) run_solver_with_loading( parent=self.parent, message="Solving 2D nonlinear membrane...", task=_task, on_success=_on_success, )