Source code for complex_problems.coupled_oscillators.ui

"""UI dialog for configuring and solving coupled harmonic oscillators."""

from __future__ import annotations

import ast
import queue
import threading
import tkinter as tk
from tkinter import messagebox, ttk

import numpy as np

from complex_problems.coupled_oscillators.solver import solve_coupled_oscillators
from config import get_default_solver_method, get_env_from_schema
from config.constants import SOLVER_METHODS
from frontend.theme import get_contrast_foreground, get_font
from frontend.ui_dialogs.loading_dialog import LoadingDialog
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
from utils import build_eval_namespace, get_logger, safe_eval, validate_expression_ast

logger = get_logger(__name__)

_BOUNDARY_OPTIONS = ("Fixed ends", "Periodic")

_COUPLED_OSC_INFO = """
Configure a 1D chain of N coupled harmonic oscillators.

• Mass and k: Single number = constant. Comma-separated = list per oscillator/spring.
  Expression with "i" (e.g. 1.0+0.1*i) or containing "[" = function of index.

• Boundary: Fixed ends (x_{-1} = x_N = 0) or Periodic (ring).

• Initial conditions: Oscillators (x_i,v_i) or Modes (q_i,dq_i). Comma-separated values.
"""


def _auto_parse_mass_or_k(
    text: str, n: int, n_springs: int, name: str, default_const: float, is_mass: bool
):
    """Auto-detect: single value=constant, comma-separated=list, '[' or expr=function.

    Returns float, list[float], or callable(i)->float.
    """
    text = text.strip()
    if not text:
        return default_const

    # If "[" appears, treat as function of index
    if "[" in text:
        return _parse_function_of_index(text, name)

    parts = [p.strip() for p in text.split(",") if p.strip()]
    if len(parts) == 1:
        try:
            return float(parts[0])
        except ValueError:
            # Not a number — try as function (e.g. 1.0+0.1*i)
            return _parse_function_of_index(text, name)

    # Comma-separated list
    n_vals = n if is_mass else n_springs
    vals = []
    for i, p in enumerate(parts):
        if i >= n_vals:
            break
        try:
            vals.append(float(p))
        except ValueError:
            raise ValueError(f"{name}: invalid number at index {i}: '{p}'")
    if len(vals) < n_vals:
        last = vals[-1] if vals else default_const
        vals.extend([last] * (n_vals - len(vals)))
    return vals[:n_vals]


def _parse_function_of_index(expr: str, name: str):
    """Parse expression like '1.0 + 0.1*i' and return callable(i) -> float."""
    expr = expr.strip()
    if not expr:
        raise ValueError(f"{name} expression cannot be empty")
    try:
        tree = ast.parse(expr, mode="eval")
    except SyntaxError as e:
        raise ValueError(f"{name}: invalid expression: {e}")
    validate_expression_ast(tree)
    compiled = compile(tree, "<string>", "eval")
    ns = build_eval_namespace({})

    def _eval(i: int) -> float:
        ns["i"] = float(i)
        return float(safe_eval(compiled, ns))

    return _eval


[docs] class CoupledOscillatorsDialog: """Dialog for configuring coupled harmonic oscillators parameters. Args: parent: Parent window. """ def __init__(self, parent: tk.Tk | tk.Toplevel) -> None: self.parent = parent self.win = tk.Toplevel(parent) self.win.title("Coupled Harmonic Oscillators") bg: str = get_env_from_schema("UI_BACKGROUND") self.win.configure(bg=bg) self._build_ui() fit_and_center( self.win, min_width=1000, min_height=750, padding=48, resizable=True, ) self.win.minsize(1000, 750) make_modal(self.win, parent) logger.info("Coupled oscillators dialog opened") def _build_ui(self) -> None: """Construct the dialog layout.""" pad: int = get_env_from_schema("UI_PADDING") main_frame = ttk.Frame(self.win, padding=pad * 2) main_frame.pack(fill=tk.BOTH, expand=True) self._scroll = ScrollableFrame(main_frame) self._scroll.apply_bg(get_env_from_schema("UI_BACKGROUND")) self._scroll.pack(fill=tk.BOTH, expand=True) inner = self._scroll.inner inner.configure(padding=pad) row = ttk.Frame(inner) row.pack(fill=tk.X, pady=pad) ttk.Label(row, text="Number of oscillators:").pack(side=tk.LEFT, padx=(0, pad)) self._n_var = tk.StringVar(value="32") n_spin = ttk.Spinbox( row, textvariable=self._n_var, from_=2, to=100, width=6, font=get_font(), ) n_spin.pack(side=tk.LEFT) ToolTip(n_spin, "Number of oscillators in the chain (2–100).") # Info section from frontend.ui_dialogs.collapsible_section import CollapsibleSection info_section = CollapsibleSection( inner, self._scroll, "How to configure", expanded=False, pad=pad ) info_lbl = ttk.Label( info_section.content, text=_COUPLED_OSC_INFO.strip(), style="Small.TLabel", justify=tk.LEFT, wraplength=520, ) info_lbl.pack(anchor=tk.W) self._scroll.bind_new_children() # Mass and k in same row (auto-detect: constant, list, or function) row = ttk.Frame(inner) row.pack(fill=tk.X, pady=pad) ttk.Label(row, text="Mass:").pack(side=tk.LEFT, padx=(0, pad)) self._mass_entry_var = tk.StringVar(value="1.0") mass_entry = ttk.Entry( row, textvariable=self._mass_entry_var, width=24, font=get_font() ) mass_entry.pack(side=tk.LEFT, padx=(0, pad * 2)) ttk.Label(row, text="Coupling k:").pack(side=tk.LEFT, padx=(0, pad)) self._k_entry_var = tk.StringVar(value="1.0") k_entry = ttk.Entry( row, textvariable=self._k_entry_var, width=24, font=get_font() ) k_entry.pack(side=tk.LEFT) ToolTip( row, "Auto-detect: single number=constant, comma-separated=list, " "contains [ or expression with i=function of index.", ) # Boundary row = ttk.Frame(inner) row.pack(fill=tk.X, pady=pad) ttk.Label(row, text="Boundary:").pack(side=tk.LEFT, padx=(0, pad)) self._boundary_var = tk.StringVar(value="Fixed ends") boundary_combo = ttk.Combobox( row, textvariable=self._boundary_var, values=_BOUNDARY_OPTIONS, state="readonly", width=12, font=get_font(), ) boundary_combo.pack(side=tk.LEFT) ToolTip(boundary_combo, "Fixed ends: x_{-1}=x_N=0. Periodic: chain forms a ring.") # Coupling types (multi-select Listbox + Equations button) row = ttk.Frame(inner) row.pack(fill=tk.X, pady=pad) ttk.Label(row, text="Coupling types:").pack(side=tk.LEFT, padx=(0, pad)) 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) self._coupling_listbox = tk.Listbox( row, selectmode=tk.EXTENDED, height=8, width=24, bg=btn_bg, fg=fg, selectbackground=select_bg, selectforeground=select_fg, font=get_font(), exportselection=False, ) for item in ( "2nd neighbor", "3rd neighbor", "4th neighbor", "FPUT-α", "Nonlinear (cubic)", "Nonlinear (quartic)", "Nonlinear (quintic)", "External force", ): self._coupling_listbox.insert(tk.END, item) self._coupling_listbox.pack(side=tk.LEFT, padx=(0, pad)) ttk.Button( row, text="Equations", command=self._show_coupling_equations, ).pack(side=tk.LEFT) self._coupling_listbox.bind("<<ListboxSelect>>", self._on_coupling_selection_change) # Long-range params: one row per selected neighbor (only its own k) self._k_2nn_frame = ttk.Frame(inner) ttk.Label(self._k_2nn_frame, text="k₂ (2nd neighbor):").pack( side=tk.LEFT, padx=(0, pad) ) self._k_2nn_var = tk.StringVar(value="25") ttk.Entry( self._k_2nn_frame, textvariable=self._k_2nn_var, width=6, font=get_font() ).pack(side=tk.LEFT) self._k_3nn_frame = ttk.Frame(inner) ttk.Label(self._k_3nn_frame, text="k₃ (3rd neighbor):").pack( side=tk.LEFT, padx=(0, pad) ) self._k_3nn_var = tk.StringVar(value="15") ttk.Entry( self._k_3nn_frame, textvariable=self._k_3nn_var, width=6, font=get_font() ).pack(side=tk.LEFT) self._k_4nn_frame = ttk.Frame(inner) ttk.Label(self._k_4nn_frame, text="k₄ (4th neighbor):").pack( side=tk.LEFT, padx=(0, pad) ) self._k_4nn_var = tk.StringVar(value="10") ttk.Entry( self._k_4nn_frame, textvariable=self._k_4nn_var, width=6, font=get_font() ).pack(side=tk.LEFT) # Nonlinear params: one row per selected (only its own ε) self._fput_alpha_frame = ttk.Frame(inner) ttk.Label(self._fput_alpha_frame, text="α (FPUT-α):").pack( side=tk.LEFT, padx=(0, pad) ) self._fput_alpha_var = tk.StringVar(value="0.25") ttk.Entry( self._fput_alpha_frame, textvariable=self._fput_alpha_var, width=6, font=get_font(), ).pack(side=tk.LEFT) self._cubic_frame = ttk.Frame(inner) ttk.Label(self._cubic_frame, text="ε₃ (cubic):").pack(side=tk.LEFT, padx=(0, pad)) self._nonlinear_coeff_var = tk.StringVar(value="80") ttk.Entry( self._cubic_frame, textvariable=self._nonlinear_coeff_var, width=6, font=get_font(), ).pack(side=tk.LEFT) self._quartic_frame = ttk.Frame(inner) ttk.Label(self._quartic_frame, text="ε₄ (quartic):").pack( side=tk.LEFT, padx=(0, pad) ) self._nonlinear_quartic_var = tk.StringVar(value="150") ttk.Entry( self._quartic_frame, textvariable=self._nonlinear_quartic_var, width=6, font=get_font(), ).pack(side=tk.LEFT) self._quintic_frame = ttk.Frame(inner) ttk.Label(self._quintic_frame, text="ε₅ (quintic):").pack( side=tk.LEFT, padx=(0, pad) ) self._nonlinear_quintic_var = tk.StringVar(value="5") ttk.Entry( self._quintic_frame, textvariable=self._nonlinear_quintic_var, width=6, font=get_font(), ).pack(side=tk.LEFT) # External force params (shown only when "External force" is selected) self._external_params_frame = ttk.Frame(inner) self._external_params_frame.pack(fill=tk.X, pady=pad) row_ext = ttk.Frame(self._external_params_frame) row_ext.pack(fill=tk.X) ttk.Label(row_ext, text="External F:").pack(side=tk.LEFT, padx=(0, pad)) self._external_amp_var = tk.StringVar(value="50") ttk.Entry( row_ext, textvariable=self._external_amp_var, width=8, font=get_font(), ).pack(side=tk.LEFT, padx=(0, pad * 2)) ttk.Label(row_ext, text="External Ω:").pack(side=tk.LEFT, padx=(0, pad)) self._external_freq_var = tk.StringVar(value="1.0") ttk.Entry( row_ext, textvariable=self._external_freq_var, width=8, font=get_font(), ).pack(side=tk.LEFT) # Domain (store ref for packing extra params before it) self._domain_row = ttk.Frame(inner) self._domain_row.pack(fill=tk.X, pady=pad) row = self._domain_row ttk.Label(row, text="Time domain:").pack(side=tk.LEFT, padx=(0, pad)) self._t_min_var = tk.StringVar(value="0.0") self._t_max_var = tk.StringVar(value="200.0") ttk.Entry( row, textvariable=self._t_min_var, width=8, font=get_font() ).pack(side=tk.LEFT, padx=(0, 4)) ttk.Label(row, text="to").pack(side=tk.LEFT, padx=4) ttk.Entry( row, textvariable=self._t_max_var, width=8, font=get_font() ).pack(side=tk.LEFT) ToolTip(row, "Integration time interval [t_min, t_max].") # Resolution points and solver method row_res = ttk.Frame(inner) row_res.pack(fill=tk.X, pady=pad) ttk.Label(row_res, text="Resolution points:").pack(side=tk.LEFT, padx=(0, pad)) default_n_points = max(2000, int(get_env_from_schema("SOLVER_NUM_POINTS"))) self._n_points_var = tk.StringVar(value=str(default_n_points)) ttk.Entry( row_res, textvariable=self._n_points_var, width=10, font=get_font() ).pack(side=tk.LEFT, padx=(0, pad * 2)) ttk.Label(row_res, text="Solver:").pack(side=tk.LEFT, padx=(pad * 2, pad)) self._method_var = tk.StringVar(value=get_default_solver_method()) method_combo = ttk.Combobox( row_res, textvariable=self._method_var, values=list(SOLVER_METHODS), state="readonly", width=10, font=get_font(), ) method_combo.pack(side=tk.LEFT) ToolTip(row_res, "Number of output points and ODE solver method.") self._update_extra_params_visibility() # Initial conditions: Oscillators or Modes ic_frame = ttk.Frame(inner) ic_frame.pack(fill=tk.X, pady=pad) ic_row1 = ttk.Frame(ic_frame) ic_row1.pack(fill=tk.X) ttk.Label(ic_row1, text="Initial conditions in:").pack(side=tk.LEFT, padx=(0, pad)) self._ic_space_var = tk.StringVar(value="Modes") ic_space_combo = ttk.Combobox( ic_row1, textvariable=self._ic_space_var, values=("Oscillators", "Modes"), state="readonly", width=12, font=get_font(), ) ic_space_combo.pack(side=tk.LEFT, padx=(0, pad * 2)) ic_space_combo.bind("<<ComboboxSelected>>", self._on_ic_space_change) ToolTip( ic_space_combo, "Oscillators: xᵢ, vᵢ. Modes: qᵢ, dqᵢ (converted to oscillator space for solving).", ) ic_row2 = ttk.Frame(ic_frame) ic_row2.pack(fill=tk.X, pady=(pad // 2, 0)) self._ic_pos_label = ttk.Label(ic_row2, text="Positions (q₁,q₂,...):") self._ic_pos_label.pack(side=tk.LEFT, padx=(0, pad)) self._ic_pos_var = tk.StringVar(value="5," + ",".join("0" for _ in range(31))) self._ic_pos_entry = ttk.Entry( ic_row2, textvariable=self._ic_pos_var, width=48, font=get_font(), ) self._ic_pos_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, pad)) ToolTip(self._ic_pos_entry, "Comma-separated values. Default: 1 for first, 0 for rest.") ic_row3 = ttk.Frame(ic_frame) ic_row3.pack(fill=tk.X, pady=(pad // 2, 0)) self._ic_vel_label = ttk.Label(ic_row3, text="Velocities (dq₁,dq₂,...):") self._ic_vel_label.pack(side=tk.LEFT, padx=(0, pad)) self._ic_vel_var = tk.StringVar(value=",".join("0" for _ in range(32))) self._ic_vel_entry = ttk.Entry( ic_row3, textvariable=self._ic_vel_var, width=48, font=get_font(), ) self._ic_vel_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, pad)) ToolTip(self._ic_vel_entry, "Comma-separated values. Default: all zeros.") self._n_var.trace_add("write", lambda *a: self._on_n_change()) # Solve button btn_frame = ttk.Frame(inner) btn_frame.pack(fill=tk.X, pady=pad * 2) btn_solve = ttk.Button( btn_frame, text="Solve", command=self._on_solve, ) btn_solve.pack(side=tk.LEFT, padx=(0, pad)) btn_close = ttk.Button( btn_frame, text="Close", style="Cancel.TButton", command=self.win.destroy, ) btn_close.pack(side=tk.LEFT) self._scroll.bind_new_children() def _on_ic_space_change(self, _event: tk.Event | None = None) -> None: """Update labels when Oscillators/Modes selection changes.""" space = self._ic_space_var.get() if space == "Oscillators": self._ic_pos_label.config(text="Positions (x₀,x₁,...):") self._ic_vel_label.config(text="Velocities (v₀,v₁,...):") else: # Physics convention: q₁ = Mode 1 (fundamental), q₂ = Mode 2, etc. self._ic_pos_label.config(text="Positions (q₁,q₂,...):") self._ic_vel_label.config(text="Velocities (dq₁,dq₂,...):") def _on_n_change(self) -> None: """Update default IC length when number of oscillators changes.""" try: n = int(self._n_var.get()) if n < 2 or n > 100: return except ValueError: return # Build default: mode 1 excited with amplitude 5 (visible nonlinear effect) pos_default = "5," + ",".join("0" for _ in range(n - 1)) vel_default = ",".join("0" for _ in range(n)) self._ic_pos_var.set(pos_default) self._ic_vel_var.set(vel_default) def _on_coupling_selection_change(self, _event: tk.Event) -> None: """Update visibility of coefficient fields based on listbox selection.""" self._update_extra_params_visibility() def _update_extra_params_visibility(self) -> None: """Show/hide params based on listbox selection.""" pad = int(get_env_from_schema("UI_PADDING")) selected = { self._coupling_listbox.get(i) for i in self._coupling_listbox.curselection() } # Show only the k field for each selected neighbor (pack 4th first so 2nd ends on top) for label, frame in ( ("4th neighbor", self._k_4nn_frame), ("3rd neighbor", self._k_3nn_frame), ("2nd neighbor", self._k_2nn_frame), ): if label in selected: if not frame.winfo_manager(): frame.pack(fill=tk.X, pady=pad, before=self._domain_row) else: frame.pack_forget() # Show only the ε field for each selected nonlinear for label, frame in ( ("Nonlinear (quintic)", self._quintic_frame), ("Nonlinear (quartic)", self._quartic_frame), ("Nonlinear (cubic)", self._cubic_frame), ("FPUT-α", self._fput_alpha_frame), ): if label in selected: if not frame.winfo_manager(): frame.pack(fill=tk.X, pady=pad, before=self._domain_row) else: frame.pack_forget() if "External force" in selected: if not self._external_params_frame.winfo_manager(): self._external_params_frame.pack( fill=tk.X, pady=pad, before=self._domain_row ) else: self._external_params_frame.pack_forget() self._scroll.refresh_scroll_region() def _show_coupling_equations(self) -> None: """Show equations for each coupling type in a formatted help window.""" pad = int(get_env_from_schema("UI_PADDING")) bg = get_env_from_schema("UI_BACKGROUND") txt_bg = get_env_from_schema("UI_BUTTON_BG") txt_fg = get_env_from_schema("UI_FOREGROUND") sections: list[tuple[str, str]] = [ ("Linear (always active)", "k·(xᵢ₊₁+xᵢ₋₁-2xᵢ)"), ("2nd neighbor", "k₂·(xᵢ₊₂+xᵢ₋₂-2xᵢ)"), ("3rd neighbor", "k₃·(xᵢ₊₃+xᵢ₋₃-2xᵢ)"), ("4th neighbor", "k₄·(xᵢ₊₄+xᵢ₋₄-2xᵢ)"), ("FPUT-α", "α·(xᵢ₊₁+xᵢ₋₁-2xᵢ)·(xᵢ₊₁-xᵢ₋₁)"), ("Cubic", "ε₃·(xᵢ₊₁+xᵢ₋₁-2xᵢ)³"), ("Quartic", "ε₄·sign(L)·|L|⁴ with L = xᵢ₊₁+xᵢ₋₁-2xᵢ"), ("Quintic", "ε₅·(xᵢ₊₁+xᵢ₋₁-2xᵢ)⁵"), ("External force", "F·cos(Ωt)"), ] dlg = tk.Toplevel(self.win) dlg.title("Equations — Coupled Oscillators") dlg.transient(self.win) dlg.configure(bg=bg) main = ttk.Frame(dlg, padding=pad * 2) main.pack(fill=tk.BOTH, expand=True) btn_frame = ttk.Frame(main) btn_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=(pad, 0)) ttk.Button( btn_frame, text="Close", style="Cancel.TButton", command=dlg.destroy, ).pack(side=tk.RIGHT) ttk.Label( main, text="Equations of motion", style="Title.TLabel", ).pack(anchor=tk.W, pady=(0, pad)) txt = tk.Text( main, wrap=tk.WORD, width=48, height=14, font=get_font(), bg=txt_bg, fg=txt_fg, relief=tk.FLAT, ) scroll = ttk.Scrollbar(main, orient=tk.VERTICAL, command=txt.yview) txt.configure(yscrollcommand=scroll.set) txt.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, pady=(0, pad)) scroll.pack(side=tk.RIGHT, fill=tk.Y, pady=(0, pad)) for i, (title, body) in enumerate(sections): if i > 0: txt.insert(tk.END, "\n\n") txt.insert(tk.END, f"▸ {title}\n", "heading") txt.insert(tk.END, f"{body}\n", "body") txt.tag_configure("heading", font=(get_font()[0], get_font()[1], "bold")) txt.tag_configure("body", font=get_font()) txt.config(state=tk.DISABLED) fit_and_center(dlg, min_width=380, min_height=340, padding=32) make_modal(dlg, self.win) def _resolve_masses(self, n: int): """Resolve mass spec from UI (auto-detect constant, list, or function).""" text = self._mass_entry_var.get().strip() n_springs = n - 1 if self._boundary_var.get() == "Fixed ends" else n return _auto_parse_mass_or_k(text, n, n_springs, "Mass", 1.0, is_mass=True) def _resolve_k_coupling(self, n: int): """Resolve k spec from UI (auto-detect constant, list, or function).""" text = self._k_entry_var.get().strip() n_springs = n - 1 if self._boundary_var.get() == "Fixed ends" else n return _auto_parse_mass_or_k( text, n, n_springs, "Coupling k", 0.3, is_mass=False ) def _on_solve(self) -> None: """Start the solver in a background thread.""" try: n = int(self._n_var.get()) if n < 2 or n > 100: messagebox.showerror( "Invalid input", "Number of oscillators must be between 2 and 100.", parent=self.win, ) return except ValueError: messagebox.showerror( "Invalid input", "Number of oscillators must be an integer.", parent=self.win, ) return try: masses = self._resolve_masses(n) if isinstance(masses, (list, tuple)): if any(m <= 0 for m in masses): raise ValueError("All masses must be positive") elif isinstance(masses, (int, float)): if masses <= 0: raise ValueError("Mass must be positive") except ValueError as e: messagebox.showerror("Invalid input", str(e), parent=self.win) return try: k_coupling = self._resolve_k_coupling(n) if isinstance(k_coupling, (list, tuple)): if any(k < 0 for k in k_coupling): raise ValueError("All coupling constants must be non-negative") elif isinstance(k_coupling, (int, float)): if k_coupling < 0: raise ValueError("Coupling k must be non-negative") except ValueError as e: messagebox.showerror("Invalid input", str(e), parent=self.win) return try: t_min = float(self._t_min_var.get()) t_max = float(self._t_max_var.get()) if t_min >= t_max: raise ValueError("t_max must be greater than t_min") except ValueError as e: messagebox.showerror("Invalid input", str(e), parent=self.win) return try: n_points = int(self._n_points_var.get()) if n_points < 2: raise ValueError("Resolution points must be at least 2") except ValueError as e: messagebox.showerror( "Invalid input", f"Resolution points: {e}" if str(e) else "Resolution points must be an integer.", parent=self.win, ) return method = self._method_var.get() if method not in SOLVER_METHODS: method = get_default_solver_method() # Parse initial conditions try: pos_str = self._ic_pos_var.get().strip() vel_str = self._ic_vel_var.get().strip() pos_vals = [float(p.strip()) for p in pos_str.split(",") if p.strip()] vel_vals = [float(v.strip()) for v in vel_str.split(",") if v.strip()] if len(pos_vals) < n or len(vel_vals) < n: raise ValueError( f"Initial conditions need at least {n} values each. " f"Got {len(pos_vals)} positions, {len(vel_vals)} velocities." ) pos_vals = pos_vals[:n] vel_vals = vel_vals[:n] except ValueError as e: messagebox.showerror("Invalid input", str(e), parent=self.win) return ic_space = self._ic_space_var.get() # Linear coupling is always active; add nonlinear/external/long-range from listbox selected_labels = { self._coupling_listbox.get(i) for i in self._coupling_listbox.curselection() } _label_to_type = { "FPUT-α": "nonlinear_fput_alpha", "Nonlinear (cubic)": "nonlinear", "Nonlinear (quartic)": "nonlinear_quartic", "Nonlinear (quintic)": "nonlinear_quintic", "External force": "external_force", } coupling_types: list[str] = ["linear"] for label in selected_labels: if label in _label_to_type: coupling_types.append(_label_to_type[label]) # Long-range: only use k values for selected neighbors try: k_2nn = ( max(0.0, float(self._k_2nn_var.get())) if "2nd neighbor" in selected_labels else 0.0 ) except ValueError: k_2nn = 25.0 if "2nd neighbor" in selected_labels else 0.0 try: k_3nn = ( max(0.0, float(self._k_3nn_var.get())) if "3rd neighbor" in selected_labels else 0.0 ) except ValueError: k_3nn = 15.0 if "3rd neighbor" in selected_labels else 0.0 try: k_4nn = ( max(0.0, float(self._k_4nn_var.get())) if "4th neighbor" in selected_labels else 0.0 ) except ValueError: k_4nn = 10.0 if "4th neighbor" in selected_labels else 0.0 try: nonlinear_fput_alpha = ( float(self._fput_alpha_var.get()) if "nonlinear_fput_alpha" in coupling_types else 0.0 ) except ValueError: nonlinear_fput_alpha = 0.25 try: nonlinear_coeff = ( float(self._nonlinear_coeff_var.get()) if "nonlinear" in coupling_types else 0.0 ) except ValueError: nonlinear_coeff = 80.0 try: nonlinear_quartic = ( float(self._nonlinear_quartic_var.get()) if "nonlinear_quartic" in coupling_types else 0.0 ) except ValueError: nonlinear_quartic = 15.0 try: nonlinear_quintic = ( float(self._nonlinear_quintic_var.get()) if "nonlinear_quintic" in coupling_types else 0.0 ) except ValueError: nonlinear_quintic = 5.0 try: external_amp = ( float(self._external_amp_var.get()) if "external_force" in coupling_types else 0.0 ) except ValueError: external_amp = 50.0 try: external_freq = ( float(self._external_freq_var.get()) if "external_force" in coupling_types else 1.0 ) except ValueError: external_freq = 1.0 boundary = "periodic" if self._boundary_var.get() == "Periodic" else "fixed" # Build initial conditions: [x_0, ..., x_{N-1}, v_0, ..., v_{N-1}] if ic_space == "Oscillators": y0 = list(pos_vals) + list(vel_vals) else: # Modes: convert (q, dq) to (x, v) via x = M_modes @ q, v = M_modes @ dq from complex_problems.coupled_oscillators.model import compute_normal_modes M_modes, _ = compute_normal_modes( n, masses, k_coupling, boundary, k_2nn=k_2nn, k_3nn=k_3nn, k_4nn=k_4nn, ) q = np.array(pos_vals, dtype=float) dq = np.array(vel_vals, dtype=float) x = M_modes @ q v = M_modes @ dq y0 = list(x) + list(v) result_queue: queue.Queue = queue.Queue() def _run_solver() -> None: try: result = solve_coupled_oscillators( n_oscillators=n, masses=masses, k_coupling=k_coupling, boundary=boundary, coupling_types=coupling_types, nonlinear_coeff=nonlinear_coeff, nonlinear_fput_alpha=nonlinear_fput_alpha, nonlinear_quartic=nonlinear_quartic, nonlinear_quintic=nonlinear_quintic, k_2nn=k_2nn, k_3nn=k_3nn, k_4nn=k_4nn, external_amplitude=external_amp, external_frequency=external_freq, t_min=t_min, t_max=t_max, n_points=n_points, y0=y0, method=method, ) result_queue.put(("success", result)) except Exception as exc: logger.exception("Coupled oscillators solver failed") result_queue.put(("error", ("Solver Error", str(exc)))) thread = threading.Thread(target=_run_solver, daemon=True) thread.start() loading = LoadingDialog(self.parent, message="Solving coupled oscillators...") self.win.destroy() def _check_result() -> None: try: status, data = result_queue.get_nowait() except queue.Empty: self.parent.after(100, _check_result) return loading.destroy() if status == "success": from complex_problems.coupled_oscillators.result_dialog import ( CoupledOscillatorsResultDialog, ) CoupledOscillatorsResultDialog(self.parent, result=data) else: title, msg = data messagebox.showerror(title, msg, parent=self.parent) self.parent.after(100, _check_result)