"""Result window for displaying fitting results and plot."""
from pathlib import Path
from tkinter import Frame, Toplevel, Text, PhotoImage, ttk, Entry, StringVar
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import matplotlib.pyplot as plt
from config import PLOT_CONFIG, UI_STYLE
from frontend.window_utils import place_window_centered
from fitting.fitting_utils import format_scientific
from frontend.image_utils import (
load_image_scaled,
plot_display_path,
preview_path_to_remove_after_display,
)
from frontend.keyboard_nav import bind_enter_to_accept
from i18n import t
# Max size for result plot image so it fits in the window
_RESULT_PLOT_MAX_WIDTH = 920
_RESULT_PLOT_MAX_HEIGHT = 720
# Thin raised border for result frames
_RESULT_FRAME_BORDER = 1
_RESULT_FRAME_RELIEF = "raised"
def _predict_with_uncertainty(
fit_func: Any,
params: List[float],
cov: np.ndarray,
x_values: List[float],
num_indep: int,
) -> Tuple[float, Optional[float]]:
"""
Evaluate fitted function at x_values and propagate parameter uncertainty.
Args:
fit_func: The model function (x, *params) -> y
params: Fitted parameter values
cov: Covariance matrix from fit
x_values: Values for each independent variable
num_indep: Number of independent variables
Returns:
Tuple (y_pred, sigma_y). sigma_y is None if covariance invalid.
"""
x_arr = np.array(x_values, dtype=float)
if num_indep == 1:
x_point = x_arr.reshape(-1)
else:
x_point = x_arr.reshape(1, -1)
try:
y_pred = fit_func(x_point, *params)
y_pred = float(np.squeeze(y_pred))
except Exception:
return float("nan"), None
params_arr = np.array(params, dtype=float)
cov_arr = np.asarray(cov)
if cov_arr.size == 0 or not np.all(np.isfinite(cov_arr)):
return y_pred, None
eps = np.sqrt(np.finfo(float).eps) * np.maximum(np.abs(params_arr), 1.0)
J = np.zeros(len(params))
for i in range(len(params)):
p_plus = list(params)
p_minus = list(params)
p_plus[i] = params[i] + eps[i]
p_minus[i] = params[i] - eps[i]
try:
y_plus = float(np.squeeze(fit_func(x_point, *p_plus)))
y_minus = float(np.squeeze(fit_func(x_point, *p_minus)))
J[i] = (y_plus - y_minus) / (2.0 * eps[i])
except Exception:
return y_pred, None
var_y = J @ cov_arr @ J
sigma_y = float(np.sqrt(np.maximum(var_y, 0.0)))
return y_pred, sigma_y
def _show_prediction_dialog(parent: Toplevel, fit_info: Dict[str, Any]) -> None:
"""Open a prediction dialog for evaluating the fitted function at user-specified x values."""
fit_func = fit_info["fit_func"]
params = fit_info["params"]
cov = fit_info["cov"]
x_names = fit_info["x_names"]
num_indep = len(x_names)
pred_win = Toplevel(parent)
pred_win.title(t("dialog.prediction_title"))
pred_win.configure(background=UI_STYLE["bg"])
pred_win.transient(parent)
pred_win.grab_set()
_pad = UI_STYLE["padding"]
entry_vars: List[StringVar] = []
entries: List[Entry] = []
for i, name in enumerate(x_names):
lbl = ttk.Label(pred_win, text=f"{name}:")
lbl.grid(row=i, column=0, padx=_pad, pady=_pad, sticky="e")
var = StringVar(value="0")
entry_vars.append(var)
ent = ttk.Entry(pred_win, textvariable=var, width=15)
ent.grid(row=i, column=1, padx=_pad, pady=_pad, sticky="w")
entries.append(ent)
result_var = StringVar(value=t("dialog.prediction_result_placeholder"))
def _update_result(*_args: Any) -> None:
try:
x_vals = []
for var in entry_vars:
val = var.get().strip()
if not val:
result_var.set(t("dialog.prediction_result_placeholder"))
return
x_vals.append(float(val))
except ValueError:
result_var.set(t("dialog.prediction_invalid_input"))
return
y_pred, sigma_y = _predict_with_uncertainty(
fit_func, params, cov, x_vals, num_indep
)
if np.isnan(y_pred):
result_var.set(t("dialog.prediction_invalid_input"))
return
if sigma_y is not None and np.isfinite(sigma_y):
result_var.set(
t(
"dialog.prediction_result_with_uncertainty",
y=format_scientific(y_pred, ".6g"),
uy=format_scientific(sigma_y),
)
)
else:
result_var.set(
t("dialog.prediction_result", y=format_scientific(y_pred, ".6g"))
)
for var in entry_vars:
var.trace_add("write", _update_result)
result_label = ttk.Label(
pred_win,
textvariable=result_var,
font=(UI_STYLE["font_family"], UI_STYLE["font_size"], "bold"),
)
result_label.grid(row=num_indep, column=0, columnspan=2, padx=_pad, pady=_pad * 2)
ttk.Button(pred_win, text=t("dialog.accept"), command=pred_win.destroy).grid(
row=num_indep + 1, column=0, columnspan=2, padx=_pad, pady=_pad
)
if entries:
entries[0].focus_set()
_update_result()
place_window_centered(pred_win, preserve_size=True)
[docs]
def create_result_window(
fit_name: str,
text: str,
equation_str: str,
output_path: str,
figure_3d: Optional[Any] = None,
fit_info: Optional[Dict[str, Any]] = None,
) -> Toplevel:
"""
Create a Tkinter window to display the fitting results.
Creates a new ``Toplevel`` window showing the fitting results including
parameter values, uncertainties, statistics, and the fitted equation plot.
Args:
fit_name: Name of the fit for window title.
text: Formatted text with parameters, uncertainties, and statistics.
equation_str: Formatted equation string with parameter values.
output_path: Path to the plot image file to display.
figure_3d: Optional matplotlib Figure for 3D plot (embeds interactive canvas, rotatable with mouse).
fit_info: Optional dict with 'fit_func', 'params', 'cov', 'x_names' for prediction feature.
Returns:
The created ``Toplevel`` window instance.
"""
plot_level = Toplevel()
plot_level.title(fit_name)
plot_level.configure(background=UI_STYLE["bg"])
display_path = plot_display_path(output_path)
preview_to_remove = preview_path_to_remove_after_display(display_path, output_path)
def _on_close() -> None:
if (
hasattr(plot_level, "matplotlib_canvas")
and plot_level.matplotlib_canvas is not None
):
try:
fig = plot_level.matplotlib_canvas.figure
fig.savefig(output_path, bbox_inches="tight", dpi=PLOT_CONFIG["dpi"])
if Path(output_path).suffix.lower() == ".pdf":
preview_path = Path(output_path).parent / (
Path(output_path).stem + "_preview.png"
)
fig.savefig(
str(preview_path),
bbox_inches="tight",
dpi=PLOT_CONFIG["dpi"],
format="png",
)
plot_level.matplotlib_canvas.get_tk_widget().destroy()
plt.close(fig)
except Exception:
pass
elif preview_to_remove:
try:
Path(preview_to_remove).unlink(missing_ok=True)
except OSError:
pass
plot_level.destroy()
_pad = UI_STYLE["padding"]
equation_lines = equation_str.split("\n")
equation_height = max(1, len(equation_lines))
equation_width = (
max(len(line) for line in equation_lines) + 2 if equation_lines else 2
)
plot_level.equation_text = Text(
plot_level,
relief=_RESULT_FRAME_RELIEF,
borderwidth=_RESULT_FRAME_BORDER,
bg=UI_STYLE["bg"],
fg=UI_STYLE["fg"],
font=(UI_STYLE["font_family"], UI_STYLE["font_size_large"], "bold"),
height=equation_height,
width=equation_width,
wrap="none",
cursor="arrow",
)
plot_level.equation_text.insert("1.0", equation_str)
plot_level.equation_text.config(state="disabled")
text_lines = text.split("\n")
num_lines = len(text_lines)
max_line_length = max(len(line) for line in text_lines) if text_lines else 0
param_width = max_line_length + 2
plot_level.middle_frame = Frame(
plot_level,
relief=_RESULT_FRAME_RELIEF,
borderwidth=_RESULT_FRAME_BORDER,
bg=UI_STYLE["bg"],
)
plot_level.label_parameters = Text(
plot_level.middle_frame,
relief=_RESULT_FRAME_RELIEF,
borderwidth=_RESULT_FRAME_BORDER,
bg=UI_STYLE["bg"],
fg=UI_STYLE["fg"],
font=(UI_STYLE["font_family"], UI_STYLE["font_size"]),
height=num_lines,
width=param_width,
wrap="none",
cursor="arrow",
)
plot_level.label_parameters.insert("1.0", text)
plot_level.label_parameters.config(state="disabled")
plot_level.imagen = None
plot_level.matplotlib_canvas = None
if figure_3d is not None:
try:
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
plot_level.matplotlib_canvas = FigureCanvasTkAgg(
figure_3d, master=plot_level.middle_frame
)
plot_level.matplotlib_canvas.draw()
except Exception:
plot_level.matplotlib_canvas = None
if plot_level.matplotlib_canvas is None:
plot_level.imagen = load_image_scaled(
display_path, _RESULT_PLOT_MAX_WIDTH, _RESULT_PLOT_MAX_HEIGHT
)
if plot_level.imagen is None and Path(display_path).exists():
try:
plot_level.imagen = PhotoImage(file=display_path)
except Exception:
plot_level.imagen = None
if plot_level.matplotlib_canvas is not None:
plot_level.image = plot_level.matplotlib_canvas.get_tk_widget()
elif plot_level.imagen is not None:
plot_level.image = ttk.Label(
plot_level.middle_frame,
image=plot_level.imagen,
)
plot_level.image.image = plot_level.imagen # keep reference
else:
plot_level.image = ttk.Label(
plot_level.middle_frame,
text=t("dialog.plot_preview_unavailable"),
)
button_frame = Frame(plot_level, bg=UI_STYLE["bg"])
plot_level.accept_button = ttk.Button(
button_frame,
text=t("dialog.accept"),
command=_on_close,
style="Primary.TButton",
width=UI_STYLE["button_width"],
)
plot_level.accept_button.pack(side="left", padx=_pad, pady=_pad)
focusables = [
plot_level.equation_text,
plot_level.label_parameters,
plot_level.accept_button,
]
if fit_info is not None:
plot_level.prediction_button = ttk.Button(
button_frame,
text=t("dialog.prediction"),
command=lambda: _show_prediction_dialog(plot_level, fit_info),
width=UI_STYLE["button_width"],
)
plot_level.prediction_button.pack(side="left", padx=_pad, pady=_pad)
focusables.append(plot_level.prediction_button)
plot_level.equation_text.pack(padx=_pad, pady=_pad)
plot_level.middle_frame.pack(padx=_pad, pady=_pad)
plot_level.label_parameters.pack(
in_=plot_level.middle_frame, side="left", padx=_pad, pady=_pad
)
plot_level.image.pack(
in_=plot_level.middle_frame, side="left", padx=_pad, pady=_pad
)
button_frame.pack(padx=_pad, pady=_pad)
bind_enter_to_accept(focusables + [plot_level], _on_close)
plot_level.accept_button.focus_set()
plot_level.protocol("WM_DELETE_WINDOW", _on_close)
plot_level.resizable(
plot_level.matplotlib_canvas is not None,
plot_level.matplotlib_canvas is not None,
)
if plot_level.matplotlib_canvas is not None:
plot_level._figure_3d = figure_3d # keep reference to avoid garbage collection
place_window_centered(plot_level, preserve_size=True)
return plot_level