"""Parameters dialog — configure domain, ICs, method, and statistics."""
from __future__ import annotations
import queue
import re
import threading
import tkinter as tk
from tkinter import messagebox, ttk
from typing import Any
from config import (
AVAILABLE_STATISTICS,
SOLVER_METHOD_DESCRIPTIONS,
SOLVER_METHODS,
get_default_solver_method,
get_env_from_schema,
)
from frontend.theme import get_contrast_foreground, get_font
from frontend.ui_dialogs.keyboard_nav import setup_arrow_enter_navigation
from frontend.ui_dialogs.scrollable_frame import ScrollableFrame
from frontend.ui_dialogs.tooltip import ToolTip
from frontend.window_utils import bind_wraplength, fit_and_center, make_modal
from utils import DifferentialLabError, get_logger
logger = get_logger(__name__)
_MAX_PDE_GRID = 1000
[docs]
class ParametersDialog:
"""Dialog for configuring solver parameters, ICs, and statistics.
Args:
parent: Parent window.
expression: ODE expression string (optional).
function_name: Name of function in config.equations (optional).
order: ODE order.
parameters: Parameter name-value mapping.
equation_name: Display name.
default_y0: Default initial conditions.
default_domain: Default ``[x_min, x_max]``.
"""
def __init__(
self,
parent: tk.Tk | tk.Toplevel,
*,
expression: str | None = None,
function_name: str | None = None,
order: int,
parameters: dict[str, float],
equation_name: str,
default_y0: list[float],
default_domain: list[float],
parameters_schema: dict[str, dict[str, Any]] | None = None,
display_formula: str | None = None,
equation_type: str = "ode",
variables: list[str] | None = None,
vector_expressions: list[str] | None = None,
vector_components: int = 1,
pde_operator: str = "neg_laplacian",
component_orders: tuple[int, ...] | None = None,
) -> None:
self.parent = parent
self.expression = expression
self.function_name = function_name
self.order = order
self.parameters = parameters
self.equation_name = equation_name
self.display_formula = (
display_formula
if display_formula is not None
else (expression or f"<function:{function_name}>")
)
self.parameters_schema = parameters_schema or {}
self.equation_type = equation_type
self.variables = variables if variables else ["x"]
self.vector_expressions = vector_expressions
self.vector_components = vector_components
self.is_vector = (
vector_expressions is not None and len(vector_expressions) > 0
) or equation_type == "vector_ode"
self.pde_operator = pde_operator
self.is_pde = equation_type == "pde" or len(self.variables) > 1
self.component_orders = component_orders
self.win = tk.Toplevel(parent)
self.win.title(f"Parameters — {equation_name}")
bg: str = get_env_from_schema("UI_BACKGROUND")
self.win.configure(bg=bg)
self._y0_vars: list[tk.StringVar] = []
self._x0_vars: list[tk.StringVar] = []
self._eq_param_vars: dict[str, tk.StringVar] = {}
self._build_ui(default_y0, default_domain)
fit_and_center(self.win, min_width=1050, min_height=700)
make_modal(self.win, parent)
# ------------------------------------------------------------------
# UI
# ------------------------------------------------------------------
def _build_ui(self, default_y0: list[float], default_domain: list[float]) -> None:
pad: int = get_env_from_schema("UI_PADDING")
# ── Fixed bottom button bar ──
btn_frame = ttk.Frame(self.win)
btn_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=pad, pady=pad)
btn_inner = ttk.Frame(btn_frame)
btn_inner.pack()
btn_solve = ttk.Button(btn_inner, text="Solve", command=self._on_solve)
btn_solve.pack(side=tk.LEFT, padx=pad)
btn_cancel = ttk.Button(
btn_inner,
text="Cancel",
style="Cancel.TButton",
command=self.win.destroy,
)
btn_cancel.pack(side=tk.LEFT, padx=pad)
setup_arrow_enter_navigation([[btn_solve, btn_cancel]])
# ── Scrollable content ──
scroll = ScrollableFrame(self.win)
scroll.apply_bg(get_env_from_schema("UI_BACKGROUND"))
scroll.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
scroll_frame = scroll.inner
scroll_frame.configure(padding=pad)
# Equation summary
ttk.Label(
scroll_frame, text=f"Equation: {self.equation_name}", style="Subtitle.TLabel"
).pack(anchor=tk.W, pady=(0, pad))
formula_lbl = ttk.Label(
scroll_frame,
text=self.display_formula,
style="Small.TLabel",
justify=tk.LEFT,
)
formula_lbl.pack(anchor=tk.W, pady=(0, pad))
# Two-column layout: left = domain + ICs, right = solver + statistics
columns_frame = ttk.Frame(scroll_frame)
columns_frame.pack(fill=tk.BOTH, expand=True, pady=(0, pad))
left_col = ttk.Frame(columns_frame)
left_col.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, pad))
right_col = ttk.Frame(columns_frame)
right_col.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Domain (left column)
domain_label = (
"Domain (n\u2098\u1d62\u2099, n\u2098\u2090\u2093)" # n_min, n_max
if self.equation_type == "difference"
else "Domain"
)
domain_frame = ttk.LabelFrame(left_col, text=domain_label, padding=pad)
domain_frame.pack(fill=tk.X, pady=(0, pad))
# n_min/x_min, n_max/x_max
var0 = self.variables[0] if self.variables else "x"
if self.equation_type == "difference":
x_min_label = "n\u2098\u1d62\u2099:"
x_max_label = "n\u2098\u2090\u2093:"
elif self.is_pde:
x_min_label = "x[0]\u2098\u1d62\u2099:"
x_max_label = "x[0]\u2098\u2090\u2093:"
else:
x_min_label = f"{var0}\u2098\u1d62\u2099:"
x_max_label = f"{var0}\u2098\u2090\u2093:"
row_d = ttk.Frame(domain_frame)
row_d.pack(fill=tk.X)
ttk.Label(row_d, text=x_min_label).pack(side=tk.LEFT)
is_diff = self.equation_type == "difference"
xmin_val = int(default_domain[0]) if is_diff else default_domain[0]
self.xmin_var = tk.StringVar(value=str(xmin_val))
ttk.Entry(row_d, textvariable=self.xmin_var, width=12, font=get_font()).pack(
side=tk.LEFT, padx=pad
)
ttk.Label(row_d, text=x_max_label).pack(side=tk.LEFT)
xmax_val = int(default_domain[1]) if is_diff else default_domain[1]
self.xmax_var = tk.StringVar(value=str(xmax_val))
ttk.Entry(row_d, textvariable=self.xmax_var, width=12, font=get_font()).pack(
side=tk.LEFT, padx=pad
)
self.ymin_var: tk.StringVar | None = None
self.ymax_var: tk.StringVar | None = None
self.npoints_y_var: tk.StringVar | None = None
if self.is_pde and len(default_domain) >= 4:
pde_label_1 = "x[1]"
row_y = ttk.Frame(domain_frame)
row_y.pack(fill=tk.X, pady=(pad, 0))
ttk.Label(row_y, text=f"{pde_label_1}\u2098\u1d62\u2099:").pack(side=tk.LEFT)
self.ymin_var = tk.StringVar(value=str(default_domain[2]))
ttk.Entry(row_y, textvariable=self.ymin_var, width=12, font=get_font()).pack(
side=tk.LEFT, padx=pad
)
ttk.Label(row_y, text=f"{pde_label_1}\u2098\u2090\u2093:").pack(side=tk.LEFT)
self.ymax_var = tk.StringVar(value=str(default_domain[3]))
ttk.Entry(row_y, textvariable=self.ymax_var, width=12, font=get_font()).pack(
side=tk.LEFT, padx=pad
)
row_ny = ttk.Frame(domain_frame)
row_ny.pack(fill=tk.X, pady=(pad, 0))
ttk.Label(row_ny, text=f"Grid points ({pde_label_1}):").pack(side=tk.LEFT)
self.npoints_y_var = tk.StringVar(value="1000")
ttk.Entry(row_ny, textvariable=self.npoints_y_var, width=10, font=get_font()).pack(
side=tk.LEFT, padx=pad
)
# Equation parameters (ω, γ, etc.) — left column, 2 per row
if self.parameters:
_sub_digits = "\u2080\u2081\u2082\u2083\u2084\u2085\u2086\u2087\u2088\u2089"
def _subscript_n(n: int) -> str:
"""Return subscript digits for integer n (e.g. 12 → '₁₂')."""
return "".join(_sub_digits[int(d)] if d.isdigit() else d for d in str(n))
eq_params_frame = ttk.LabelFrame(left_col, text="Equation Parameters", padding=pad)
eq_params_frame.pack(fill=tk.X, pady=(0, pad))
params_items = list(self.parameters.items())
def _display_name_for(pname: str) -> str:
pinfo = self.parameters_schema.get(pname, {})
m = re.match(r"^(.+)\[(\d+)\]$", pname)
if m:
return f"{m.group(1)}{_subscript_n(int(m.group(2)))}"
return pinfo.get("display", pname)
label_width = max(len(_display_name_for(p)) for p in self.parameters) + 1
for i in range(0, len(params_items), 2):
row = ttk.Frame(eq_params_frame)
row.pack(fill=tk.X, pady=2)
for pname, val in params_items[i : i + 2]:
pinfo = self.parameters_schema.get(pname, {})
display_name = _display_name_for(pname)
# Detect list parameter
is_list_param = isinstance(val, list)
if is_list_param:
ttk.Label(row, text=f"{display_name}:", width=label_width).pack(
side=tk.LEFT,
)
default_csv = ", ".join(str(v) for v in val)
var = tk.StringVar(value=default_csv)
entry = ttk.Entry(row, textvariable=var, width=20, font=get_font())
entry.pack(side=tk.LEFT, padx=(pad, pad * 2))
self._eq_param_vars[pname] = var
m = re.match(r"^(.+)\[(\d+)\]$", pname)
n = int(m.group(2)) if m else len(val)
ToolTip(
entry,
pinfo.get("description", "")
or f"Comma-separated values ({n} components)",
)
else:
ttk.Label(row, text=f"{display_name}:", width=label_width).pack(
side=tk.LEFT,
)
var = tk.StringVar(value=str(val))
entry = ttk.Entry(row, textvariable=var, width=12, font=get_font())
entry.pack(side=tk.LEFT, padx=(pad, pad * 2))
self._eq_param_vars[pname] = var
ToolTip(entry, pinfo.get("description", ""))
if self.equation_type != "difference" and not self.is_pde:
row_n = ttk.Frame(domain_frame)
row_n.pack(fill=tk.X, pady=(pad, 0))
ttk.Label(row_n, text="Evaluation points:").pack(side=tk.LEFT)
self.npoints_var = tk.StringVar(value=str(get_env_from_schema("SOLVER_NUM_POINTS")))
npoints_entry = ttk.Entry(
row_n, textvariable=self.npoints_var, width=10, font=get_font()
)
npoints_entry.pack(side=tk.LEFT, padx=pad)
btn_decrease = ttk.Button(
row_n,
text="−",
width=3,
style="Small.TButton",
command=lambda: self._change_npoints(0.1),
)
btn_decrease.pack(side=tk.LEFT, padx=(0, 2))
btn_increase = ttk.Button(
row_n,
text="+",
width=3,
style="Small.TButton",
command=lambda: self._change_npoints(10),
)
btn_increase.pack(side=tk.LEFT)
elif self.is_pde:
row_n = ttk.Frame(domain_frame)
row_n.pack(fill=tk.X, pady=(pad, 0))
ttk.Label(row_n, text="Grid points (x[0]):").pack(side=tk.LEFT)
self.npoints_var = tk.StringVar(value="1000")
ttk.Entry(row_n, textvariable=self.npoints_var, width=10, font=get_font()).pack(
side=tk.LEFT, padx=pad
)
# Initial conditions (skip for PDE) — left column
self._bc_vars: list[tk.StringVar] = []
self._bc_type_vars: list[tk.StringVar] = []
self._domain_shape_var: tk.StringVar | None = None
self._mask_expr_var: tk.StringVar | None = None
self._contour_bc_expr_var: tk.StringVar | None = None
self._contour_bc_type_var: tk.StringVar | None = None
self._rect_bc_frame: ttk.LabelFrame | None = None
self._contour_bc_frame: ttk.LabelFrame | None = None
if self.is_pde:
# Domain shape selector
shape_frame = ttk.LabelFrame(left_col, text="Domain Shape", padding=pad)
shape_frame.pack(fill=tk.X, pady=(0, pad))
row_shape = ttk.Frame(shape_frame)
row_shape.pack(fill=tk.X)
ttk.Label(row_shape, text="Shape:").pack(side=tk.LEFT)
self._domain_shape_var = tk.StringVar(value="Rectangle")
shape_combo = ttk.Combobox(
row_shape,
textvariable=self._domain_shape_var,
values=["Rectangle", "Custom contour"],
state="readonly",
width=18,
font=get_font(),
)
shape_combo.pack(side=tk.LEFT, padx=pad)
shape_combo.bind("<<ComboboxSelected>>", self._on_domain_shape_change)
# Mask expression entry (hidden by default)
self._mask_row = ttk.Frame(shape_frame)
ttk.Label(self._mask_row, text="Mask expr:").pack(side=tk.LEFT)
self._mask_expr_var = tk.StringVar(value="x**2 + y**2 <= 1")
mask_entry = ttk.Entry(
self._mask_row,
textvariable=self._mask_expr_var,
width=30,
font=get_font(),
)
mask_entry.pack(side=tk.LEFT, padx=pad)
ToolTip(
mask_entry,
"Boolean expression defining the domain, e.g. x**2 + y**2 <= 1",
)
# Rectangular boundary conditions
self._rect_bc_frame = ttk.LabelFrame(
left_col,
text="Boundary Conditions",
padding=pad,
)
self._rect_bc_frame.pack(fill=tk.X, pady=(0, pad))
idx0 = "x[0]"
idx1 = "x[1]"
boundaries = [
(f"{idx1} = {idx1}\u2098\u1d62\u2099 (bottom)", idx0),
(f"{idx1} = {idx1}\u2098\u2090\u2093 (top)", idx0),
(f"{idx0} = {idx0}\u2098\u1d62\u2099 (left)", idx1),
(f"{idx0} = {idx0}\u2098\u2090\u2093 (right)", idx1),
]
for label_text, free_var in boundaries:
row = ttk.Frame(self._rect_bc_frame)
row.pack(fill=tk.X, pady=1)
ttk.Label(row, text=f"{label_text}:", width=24).pack(side=tk.LEFT)
bc_type_var = tk.StringVar(value="Dirichlet")
bc_type_combo = ttk.Combobox(
row,
textvariable=bc_type_var,
values=["Dirichlet", "Neumann"],
state="readonly",
width=10,
font=get_font(),
)
bc_type_combo.pack(side=tk.LEFT, padx=(pad, 2))
self._bc_type_vars.append(bc_type_var)
bc_var = tk.StringVar(value="0")
bc_entry = ttk.Entry(
row,
textvariable=bc_var,
width=16,
font=get_font(),
)
bc_entry.pack(side=tk.LEFT, padx=(2, 0))
ToolTip(
bc_entry,
f"Expression as a function of {free_var}, e.g. sin(pi*{free_var}). "
"For Dirichlet: value. For Neumann: normal derivative.",
)
self._bc_vars.append(bc_var)
# Contour boundary conditions (hidden by default)
self._contour_bc_frame = ttk.LabelFrame(
left_col,
text="Contour Boundary Conditions",
padding=pad,
)
row_cbc_type = ttk.Frame(self._contour_bc_frame)
row_cbc_type.pack(fill=tk.X, pady=1)
ttk.Label(row_cbc_type, text="BC type:").pack(side=tk.LEFT)
self._contour_bc_type_var = tk.StringVar(value="Dirichlet")
ttk.Combobox(
row_cbc_type,
textvariable=self._contour_bc_type_var,
values=["Dirichlet", "Neumann"],
state="readonly",
width=10,
font=get_font(),
).pack(side=tk.LEFT, padx=pad)
row_cbc_expr = ttk.Frame(self._contour_bc_frame)
row_cbc_expr.pack(fill=tk.X, pady=1)
ttk.Label(row_cbc_expr, text="Value:").pack(side=tk.LEFT)
self._contour_bc_expr_var = tk.StringVar(value="0")
contour_bc_entry = ttk.Entry(
row_cbc_expr,
textvariable=self._contour_bc_expr_var,
width=25,
font=get_font(),
)
contour_bc_entry.pack(side=tk.LEFT, padx=pad)
ToolTip(
contour_bc_entry,
"Expression for boundary value (Dirichlet) or normal derivative (Neumann), "
"as a function of x and y.",
)
else:
ic_frame = ttk.LabelFrame(left_col, text="Initial Conditions", padding=pad)
ic_frame.pack(fill=tk.X, pady=(0, pad))
_subscripts = "₀₁₂₃₄₅₆₇₈₉"
ic_labels = self._ic_labels()
if self.component_orders:
n_ic = sum(self.component_orders)
elif self.is_vector:
n_ic = self.order * self.vector_components
else:
n_ic = self.order
ic_label_width = max(len(label) for label in ic_labels) + 1
x0_label_width = (
max(
len(f"x{_subscripts[i] if i < len(_subscripts) else str(i)} =")
for i in range(n_ic)
)
+ 1
)
x0_val = int(default_domain[0]) if is_diff else default_domain[0]
default_x0_val = str(x0_val)
for i in range(n_ic):
row = ttk.Frame(ic_frame)
row.pack(fill=tk.X, pady=2)
default_val = default_y0[i] if i < len(default_y0) else 1.0
sub = _subscripts[i] if i < len(_subscripts) else str(i)
ttk.Label(row, text=f"{ic_labels[i]} =", width=ic_label_width).pack(side=tk.LEFT)
var = tk.StringVar(value=str(default_val))
ttk.Entry(row, textvariable=var, width=10, font=get_font()).pack(
side=tk.LEFT,
padx=(pad, pad * 2),
)
if self.equation_type != "difference":
ttk.Label(row, text=f"x{sub} =", width=x0_label_width).pack(side=tk.LEFT)
x_var = tk.StringVar(value=default_x0_val)
ttk.Entry(row, textvariable=x_var, width=10, font=get_font()).pack(
side=tk.LEFT,
padx=pad,
)
self._x0_vars.append(x_var)
else:
self._x0_vars.append(tk.StringVar(value=default_x0_val))
self._y0_vars.append(var)
# Solver method (ODE only) — right column
self.method_frame = ttk.LabelFrame(right_col, text="Solver Method", padding=pad)
self.method_frame.pack(fill=tk.X, pady=(0, pad))
self.method_var = tk.StringVar(value=get_default_solver_method())
combo = ttk.Combobox(
self.method_frame,
textvariable=self.method_var,
values=list(SOLVER_METHODS),
state="readonly",
width=15,
font=get_font(),
)
combo.pack(anchor=tk.W)
self.method_desc = ttk.Label(
self.method_frame,
text="",
style="Small.TLabel",
justify=tk.LEFT,
)
self.method_desc.pack(anchor=tk.W, pady=(2, 0))
combo.bind("<<ComboboxSelected>>", self._on_method_change)
self._on_method_change(None)
if self.equation_type == "difference" or self.is_pde:
self.method_frame.pack_forget()
# Statistics listbox (extended selection) — right column
stats_frame = ttk.LabelFrame(right_col, text="Statistics & Magnitudes", padding=pad)
stats_frame.pack(fill=tk.X, pady=(0, pad))
self._stat_keys = list(AVAILABLE_STATISTICS.keys())
stats_list_frame = ttk.Frame(stats_frame)
stats_list_frame.pack(fill=tk.X)
btn_bg: str = get_env_from_schema("UI_BUTTON_BG")
fg: str = get_env_from_schema("UI_FOREGROUND")
stats_select_bg: str = get_env_from_schema("UI_BUTTON_FG")
stats_select_fg: str = get_contrast_foreground(stats_select_bg)
stats_scrollbar = ttk.Scrollbar(stats_list_frame, orient=tk.VERTICAL)
self._stats_listbox = tk.Listbox(
stats_list_frame,
selectmode=tk.EXTENDED,
height=min(len(self._stat_keys), 6),
bg=btn_bg,
fg=fg,
selectbackground=stats_select_bg,
selectforeground=stats_select_fg,
font=get_font(),
exportselection=False,
yscrollcommand=stats_scrollbar.set,
)
stats_scrollbar.config(command=self._stats_listbox.yview)
self._stats_listbox.pack(side=tk.LEFT, fill=tk.X, expand=True)
stats_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
for key in self._stat_keys:
self._stats_listbox.insert(tk.END, key)
self._stats_listbox.select_set(0, tk.END)
self._stats_desc_label = ttk.Label(
stats_frame,
text="",
style="Small.TLabel",
justify=tk.LEFT,
)
self._stats_desc_label.pack(anchor=tk.W, pady=(4, 0))
self._stats_listbox.bind("<<ListboxSelect>>", self._on_stats_select)
bind_wraplength(scroll_frame, formula_lbl, pad=2 * pad, min_wrap=200)
bind_wraplength(
stats_frame,
[self.method_desc, self._stats_desc_label],
pad=2 * pad,
min_wrap=150,
)
scroll.bind_new_children()
btn_solve.focus_set()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _ic_labels(self) -> list[str]:
subscripts = "₀₁₂₃₄₅₆₇₈₉"
def _sub(i: int) -> str:
return subscripts[i] if i < len(subscripts) else str(i)
if self.equation_type == "difference":
return [f"f{_sub(i)}" for i in range(self.order)]
if self.is_vector:
labels: list[str] = []
orders = self.component_orders or tuple(
self.order for _ in range(self.vector_components)
)
for c, comp_order in enumerate(orders):
comp_sub = _sub(c)
for k in range(comp_order):
if k == 0:
labels.append(f"f{comp_sub}")
else:
primes = "\u2032" * k
labels.append(f"f{primes}{comp_sub}")
return labels
labels = [f"f(x{subscripts[0]})"]
for i in range(1, self.order):
primes = "\u2032" * i
sub = subscripts[i] if i < len(subscripts) else str(i)
labels.append(f"f{primes}(x{sub})")
return labels
def _change_npoints(self, factor: float) -> None:
"""Change evaluation points by an order of magnitude.
Args:
factor: Multiplication factor (10 to increase, 0.1 to decrease).
"""
try:
current = int(self.npoints_var.get())
new_value = max(10, int(current * factor))
self.npoints_var.set(str(new_value))
except ValueError:
# If invalid, reset to default
self.npoints_var.set(str(get_env_from_schema("SOLVER_NUM_POINTS")))
def _on_domain_shape_change(self, _event: Any) -> None:
"""Toggle visibility between rectangular and custom contour BC sections."""
is_custom = self._domain_shape_var and self._domain_shape_var.get() == "Custom contour"
if is_custom:
self._mask_row.pack(fill=tk.X, pady=(4, 0))
if self._rect_bc_frame:
self._rect_bc_frame.pack_forget()
if self._contour_bc_frame:
self._contour_bc_frame.pack(fill=tk.X, pady=(0, 4))
# Suggest symmetric domain for custom contours (e.g. circles)
if self.xmin_var and float(self.xmin_var.get() or 0) >= 0:
self.xmin_var.set("-1.0")
self.xmax_var.set("1.0")
if self.ymin_var and float(self.ymin_var.get() or 0) >= 0:
self.ymin_var.set("-1.0")
if self.ymax_var:
self.ymax_var.set("1.0")
else:
self._mask_row.pack_forget()
if self._contour_bc_frame:
self._contour_bc_frame.pack_forget()
if self._rect_bc_frame:
self._rect_bc_frame.pack(fill=tk.X, pady=(0, 4))
def _on_method_change(self, _event: Any) -> None:
method = self.method_var.get()
desc = SOLVER_METHOD_DESCRIPTIONS.get(method, "")
self.method_desc.config(text=desc)
def _on_stats_select(self, _event: Any) -> None:
indices = self._stats_listbox.curselection()
if not indices:
self._stats_desc_label.config(text="")
return
last_key = self._stat_keys[indices[-1]]
desc = AVAILABLE_STATISTICS.get(last_key, "")
self._stats_desc_label.config(text=desc)
# ------------------------------------------------------------------
# Solve
# ------------------------------------------------------------------
def _on_solve(self) -> None:
"""Parse inputs, run the solver pipeline, and open the result dialog."""
# Equation parameters
if self._eq_param_vars:
import numpy as _np
params: dict[str, Any] = {}
for pname, var in self._eq_param_vars.items():
raw = var.get().strip()
# Detect list parameter: name[n]
m = re.match(r"^(.+)\[(\d+)\]$", pname)
if m:
base_name = m.group(1)
try:
values = [float(v.strip()) for v in raw.split(",")]
except ValueError:
messagebox.showerror(
"Invalid Parameter",
f"Parameter '{pname}' must be comma-separated numbers.",
parent=self.win,
)
return
# Store under base name so name[i] works in expressions
params[base_name] = _np.array(values)
else:
try:
params[pname] = float(raw)
except ValueError:
messagebox.showerror(
"Invalid Parameter",
f"Parameter '{pname}' must be a number.",
parent=self.win,
)
return
self.parameters = params
try:
x_min = float(self.xmin_var.get())
x_max = float(self.xmax_var.get())
except ValueError:
domain_name = (
"n\u2098\u1d62\u2099 and n\u2098\u2090\u2093"
if self.equation_type == "difference"
else "x\u2098\u1d62\u2099 and x\u2098\u2090\u2093"
)
messagebox.showerror(
"Invalid Domain", f"{domain_name} must be numbers.", parent=self.win
)
return
if self.is_pde:
if self.ymin_var is None or self.ymax_var is None:
messagebox.showerror(
"Invalid PDE",
"y\u2098\u1d62\u2099 and y\u2098\u2090\u2093 required.",
parent=self.win,
)
return
try:
y_min = float(self.ymin_var.get())
y_max = float(self.ymax_var.get())
except ValueError:
messagebox.showerror(
"Invalid Domain",
"y\u2098\u1d62\u2099 and y\u2098\u2090\u2093 must be numbers.",
parent=self.win,
)
return
try:
n_points = int(self.npoints_var.get())
n_points_y = int(self.npoints_y_var.get()) if self.npoints_y_var else n_points
except (ValueError, AttributeError):
messagebox.showerror(
"Invalid Grid", "Grid points must be integers.", parent=self.win
)
return
if n_points > _MAX_PDE_GRID or n_points_y > _MAX_PDE_GRID:
messagebox.showerror(
"Grid too large",
f"PDE grid is limited to {_MAX_PDE_GRID} points per axis to avoid "
f"excessive memory use. You entered {n_points}×{n_points_y}.",
parent=self.win,
)
return
y0 = []
x0_list = None
method = "fdm"
elif self.equation_type == "difference":
n_points = int(x_max) - int(x_min) + 1
x0_list = None
method = "iteration"
y_min = None
y_max = None
n_points_y = None
else:
try:
n_points = int(self.npoints_var.get())
except ValueError:
messagebox.showerror(
"Invalid Grid", "Number of points must be an integer.", parent=self.win
)
return
subscripts = "₀₁₂₃₄₅₆₇₈₉"
x0_list = []
for i, x_var in enumerate(self._x0_vars):
sub = subscripts[i] if i < len(subscripts) else str(i)
try:
x0_list.append(float(x_var.get()))
except ValueError:
messagebox.showerror(
"Invalid IC Point",
f"x{sub} must be a number.",
parent=self.win,
)
return
method = self.method_var.get()
y_min = None
y_max = None
n_points_y = None
y0_list: list[float] = []
if not self.is_pde:
for i, var in enumerate(self._y0_vars):
try:
y0_list.append(float(var.get()))
except ValueError:
messagebox.showerror(
"Invalid IC",
f"Initial condition {i} must be a number.",
parent=self.win,
)
return
y0 = y0_list if not self.is_pde else []
selected_indices = self._stats_listbox.curselection()
selected_stats = {self._stat_keys[i] for i in selected_indices}
# Collect PDE boundary condition expressions and new parameters
bc_expressions: list[str] | None = None
bc_types: list[str] | None = None
mask_expression: str | None = None
contour_bc_expression: str | None = None
contour_bc_type: str | None = None
if self.is_pde:
is_custom_contour = (
self._domain_shape_var is not None
and self._domain_shape_var.get() == "Custom contour"
)
if is_custom_contour:
mask_expression = self._mask_expr_var.get().strip() if self._mask_expr_var else None
if not mask_expression:
messagebox.showerror(
"Missing Mask",
"Custom contour requires a mask expression.",
parent=self.win,
)
return
contour_bc_type = (
self._contour_bc_type_var.get().strip().lower()
if self._contour_bc_type_var
else "dirichlet"
)
contour_bc_expression = (
self._contour_bc_expr_var.get().strip() or "0"
if self._contour_bc_expr_var
else "0"
)
else:
# Rectangular: collect per-edge expressions and types
if self._bc_vars:
bc_expressions = [var.get().strip() or "0" for var in self._bc_vars]
if self._bc_type_vars:
bc_types = [var.get().strip().lower() for var in self._bc_type_vars]
result_queue: queue.Queue[tuple[str, Any]] = queue.Queue()
def _run_solver() -> None:
try:
from pipeline import run_solver_pipeline
result = run_solver_pipeline(
expression=self.expression,
function_name=self.function_name,
order=self.order,
parameters=self.parameters,
equation_name=self.equation_name,
x_min=x_min,
x_max=x_max,
y0=y0,
n_points=n_points,
method=method,
selected_stats=selected_stats,
x0_list=x0_list,
equation_type=self.equation_type,
variables=self.variables,
y_min=y_min,
y_max=y_max,
n_points_y=n_points_y,
vector_expressions=self.vector_expressions,
vector_components=self.vector_components,
pde_operator=self.pde_operator,
component_orders=self.component_orders,
bc_expressions=bc_expressions,
bc_types=bc_types,
mask_expression=mask_expression,
contour_bc_expression=contour_bc_expression,
contour_bc_type=contour_bc_type,
)
result_queue.put(("success", result))
except DifferentialLabError as exc:
logger.warning("Solver pipeline failed (user-facing): %s", exc)
result_queue.put(("error", ("DifferentialLabError", str(exc))))
except (MemoryError, OSError) as exc:
logger.error("Solver pipeline: memory/system error: %s", exc, exc_info=True)
result_queue.put(
(
"error",
(
"Memory Error",
f"Not enough memory to solve: {exc}\n\n"
"Try reducing the grid size (points per axis).",
),
)
)
except Exception as exc:
logger.exception("Solver pipeline: unexpected error")
result_queue.put(("error", ("Error", str(exc))))
from frontend.ui_dialogs.loading_dialog import LoadingDialog
loading = LoadingDialog(self.parent, message="Solving...")
self.win.destroy()
# Keep ParametersDialog alive until _check_result runs on the main thread.
# Otherwise GC may collect it from the worker thread when the solver finishes,
# causing "RuntimeError: main thread is not in main loop" in Variable.__del__.
dialog_ref = self
threading.Thread(target=_run_solver, daemon=True).start()
def _release_tk_vars() -> None:
"""Clear all tk.StringVar references so GC doesn't call __del__ off-thread.
Variables are collected into a list and dropped in a deferred after(0)
callback. This ensures they are garbage-collected on the main thread,
avoiding "RuntimeError: main thread is not in main loop" in Variable.__del__
when the worker thread triggers GC.
"""
d = dialog_ref
vars_to_discard: list[tk.StringVar] = []
vars_to_discard.extend(d._y0_vars)
vars_to_discard.extend(d._x0_vars)
vars_to_discard.extend(d._eq_param_vars.values())
vars_to_discard.extend(d._bc_vars)
vars_to_discard.extend(d._bc_type_vars)
for attr in (
"xmin_var",
"xmax_var",
"ymin_var",
"ymax_var",
"npoints_var",
"npoints_y_var",
"method_var",
"_domain_shape_var",
"_mask_expr_var",
"_contour_bc_expr_var",
"_contour_bc_type_var",
):
if hasattr(d, attr):
v = getattr(d, attr)
if v is not None:
vars_to_discard.append(v)
setattr(d, attr, None)
d._y0_vars.clear()
d._x0_vars.clear()
d._eq_param_vars.clear()
d._bc_vars.clear()
d._bc_type_vars.clear()
def _discard_on_main() -> None:
vars_to_discard.clear()
d.parent.after(0, _discard_on_main)
def _check_result() -> None:
_ = dialog_ref # Closure keeps dialog_ref alive until this callback runs
try:
status, data = result_queue.get_nowait()
try:
loading.destroy()
except tk.TclError:
pass
_release_tk_vars()
if not self.parent.winfo_exists():
return
if status == "success":
from frontend.ui_dialogs.result_dialog import ResultDialog
ResultDialog(self.parent, result=data)
else:
title, msg = data
messagebox.showerror(title, msg, parent=self.parent)
except queue.Empty:
if self.parent.winfo_exists():
self.parent.after(100, _check_result)
self.parent.after(100, _check_result)