#!/usr/bin/env python
"""
Main Program - Data Fitting Application
Entry point for the curve fitting application with GUI interface.
"""
# Standard library
from tkinter import messagebox
from typing import Any, Callable, List, Optional, Union
# Local imports (kept lightweight at startup; heavy modules are loaded lazily)
from config import (
AVAILABLE_EQUATION_TYPES,
EXIT_SIGNAL,
__version__,
initialize_and_validate_config,
) # noqa: E402
from i18n import t, initialize_i18n # noqa: E402
from frontend import start_main_menu # noqa: E402
from fitting import get_fitting_function # noqa: E402
from utils import FittingError, setup_logging, get_logger # noqa: E402
# Initialize configuration validation, i18n and logging at module level
initialize_and_validate_config()
initialize_i18n()
setup_logging()
logger = get_logger(__name__)
# ============================================================================
# APPLICATION STATE
# ============================================================================
class _ApplicationState:
"""
Encapsulates application state instead of using global variables.
This class manages the current state of the application, particularly
tracking which equation type is currently selected and ready for use.
Using a class for state management (instead of module-level globals)
provides better:
- Testability: Easy to create fresh state for tests
- Encapsulation: State changes go through methods
- Clarity: All state in one place
Attributes:
menu_window: Reference to the main Tkinter menu window
current_equation: Name of the currently selected equation (e.g., 'linear_function')
current_fitter: The fitting function wrapped with visualization
"""
def __init__(self):
"""Initialize application state with default values."""
self.menu_window = None
self.current_equation: str = ""
self.current_fitter: Optional[Callable] = None
def set_equation(
self, equation_name: str, fitter_function: Optional[Callable]
) -> None:
"""
Set the current equation and its fitter function.
This is called after the user selects an equation type,
storing it so it can be reused for multiple datasets.
Args:
equation_name: Internal name of the equation
fitter_function: Function to perform fitting (with visualization)
"""
self.current_equation = equation_name
self.current_fitter = fitter_function
def reset_equation(self) -> None:
"""
Reset equation state to initial values.
Called when starting a new fitting operation or
when the user cancels equation selection.
"""
self.current_equation = ""
self.current_fitter = None
# Global application state instance
# This is the single source of truth for application state
_app_state = _ApplicationState()
# ============================================================================
# WORKFLOW FUNCTIONS - Main Menu Callbacks
# ============================================================================
def _get_menu_window() -> Optional[Any]:
"""Get the menu window from __main__ module."""
import __main__
return getattr(__main__, "menu", None)
def _equation_display_name(equation_name: str) -> str:
"""Convert internal equation name to display form (e.g. 'linear_function' -> 'Linear Function')."""
return equation_name.replace("_", " ").title()
def _resolve_multiple_x_variables(
menu: Any,
data: Any,
x_name: str,
num_independent_vars: int,
filter_uncertainty: bool = False,
) -> Optional[Union[str, List[str]]]:
"""
Ask for multiple x variables when equation has more than one independent variable.
Args:
menu: Parent window for dialogs.
data: Dataset (DataFrame or dict).
x_name: First x variable already selected.
num_independent_vars: Number of independent variables required.
filter_uncertainty: If True, exclude uncertainty columns from variable list.
Returns:
List of x variable names, or None if user cancels. For single-variable fits,
returns x_name unchanged.
"""
if num_independent_vars <= 1:
return x_name
from frontend import ask_multiple_x_variables
from loaders import get_variable_names
variable_names = get_variable_names(data, filter_uncertainty=filter_uncertainty)
x_names = ask_multiple_x_variables(
parent_window=menu,
variable_names=variable_names,
num_vars=num_independent_vars,
first_x_name=x_name,
)
return x_names if x_names else None
def _set_equation_helper(equation_name: str) -> None:
"""
Set the current equation and create a fitter with visualization.
This helper retrieves the backend fitting function for the given equation,
wraps it with frontend visualization, and stores both in the application state.
Args:
equation_name: Internal name of the equation (e.g., 'linear_function')
"""
base_fit = get_fitting_function(equation_name)
if base_fit:
fitter_with_ui = _wrap_with_visualization(
base_fit, _equation_display_name(equation_name)
)
_app_state.set_equation(equation_name, fitter_with_ui)
def _wrap_with_visualization(base_fit_function: Callable, fit_name: str) -> Callable:
"""
Wrap a backend fitting function with frontend visualization.
This decorator pattern keeps the separation between backend (calculations)
and frontend (UI), allowing backend functions to be tested independently
and reused in different contexts (GUI, CLI, web, etc.).
The wrapper:
1. Calls the backend function to get fit results
2. Extracts data and uncertainties from the dataset
3. Creates a plot with matplotlib
4. Displays results in a Tkinter window
Args:
base_fit_function: Backend function that returns (text, y_fitted, equation)
fit_name: Name of the fit for display in window title and plot
Returns:
Wrapped function that performs fitting and shows results
"""
# Lazy import heavy modules only when visualization is actually needed
from plotting import create_plot
from frontend import create_result_window
def wrapped_function(
data: Any,
x_name: Union[str, List[str]],
y_name: str,
plot_name: Optional[str] = None,
) -> None:
"""Execute fitting and display results."""
try:
# Backend: Perform the fitting calculation
# Returns: parameter text, fitted y values, formatted equation, fit_info (for prediction)
result = base_fit_function(data, x_name, y_name)
if len(result) == 4:
text, y_fitted, equation, fit_info = result
else:
text, y_fitted, equation = result
fit_info = None
num_indep = getattr(base_fit_function, "num_independent_vars", 1)
if isinstance(x_name, list):
num_indep = len(x_name)
x_key: str = x_name if isinstance(x_name, str) else x_name[0]
y = data[y_name]
uy = data.get("u%s" % y_name, [0.0] * len(y))
filename_base = plot_name if plot_name else fit_name
figure_3d = None
if num_indep == 1:
x = data[x_key]
ux = data.get("u%s" % x_key, [0.0] * len(x))
output_path = create_plot(
x,
y,
ux,
uy,
y_fitted,
filename_base,
x_key,
y_name,
fit_info=fit_info,
)
elif num_indep == 2:
# Two variables: 3D plot (interactive, rotatable with mouse)
from plotting import create_3d_plot
x1 = data[x_name[0]]
x2 = data[x_name[1]]
output_path, figure_3d = create_3d_plot(
x1,
x2,
y,
y_fitted,
filename_base,
x_name[0],
x_name[1],
y_name,
interactive=True,
fit_info=fit_info,
)
else:
# Multiple variables (>2): residual plot
from plotting import create_residual_plot
import numpy as np
residuals = np.array(y) - np.array(y_fitted)
point_indices = list(range(len(y)))
output_path = create_residual_plot(
residuals, point_indices, filename_base
)
# Display the results in a Tkinter window
window_title = plot_name if plot_name else fit_name
create_result_window(
window_title,
text,
equation,
output_path,
figure_3d=figure_3d,
fit_info=fit_info,
)
except FittingError as e:
# Show error message when fitting fails due to scipy convergence issues
messagebox.showerror(
t("error.fitting_error"),
t("error.fitting_failed_details", error=str(e)),
)
except Exception as e:
# Catch any other unexpected errors during fitting or visualization
messagebox.showerror(
t("error.fitting_error"),
t(
"error.fitting_failed_generic",
error_type=type(e).__name__,
error=str(e),
),
)
return wrapped_function
[docs]
def normal_fitting() -> None:
"""
Perform a normal fitting operation with optional loop mode.
This is the main fitting workflow that most users will use.
It fits a single equation type to a single dataset.
Workflow:
1. User selects equation type (predefined or custom)
2. User decides whether to enable loop mode
3. User selects data file and variables
4. Fit is performed and results displayed
5. If loop mode: user can modify data and refit without restarting
Loop mode is useful for:
- Exploring different data subsets
- Iteratively cleaning outliers
- Testing sensitivity to data modifications
"""
# Lazy imports to avoid loading heavy dependencies at application startup
from fitting import (
single_fit_with_loop,
coordinate_data_loading,
coordinate_equation_selection,
)
from frontend import (
open_load_dialog,
ask_variables,
ask_equation_type,
ask_num_parameters,
ask_parameter_names,
ask_custom_formula,
)
logger.info(t("log.normal_fitting_workflow"))
menu = _get_menu_window()
# Phase 1: Equation Selection
# Get the backend fitting function (returns calculation results only)
equation_name, base_fit_function = coordinate_equation_selection(
parent_window=menu,
ask_equation_type_func=ask_equation_type,
ask_num_parameters_func=ask_num_parameters,
ask_parameter_names_func=ask_parameter_names,
ask_custom_formula_func=ask_custom_formula,
get_fitting_function_func=get_fitting_function,
)
# User cancelled equation selection
if equation_name == EXIT_SIGNAL or base_fit_function is None:
logger.info(t("log.user_cancelled_equation"))
return
num_independent_vars = getattr(base_fit_function, "num_independent_vars", 1)
fitter_with_ui = _wrap_with_visualization(
base_fit_function, _equation_display_name(equation_name)
)
# Store current equation in app state
_app_state.set_equation(equation_name, fitter_with_ui)
# Phase 2: Loop Mode Selection
# Ask if user wants to enable loop mode (allows reloading and refitting)
loop_mode = messagebox.askyesno(
message=t("workflow.loop_question"), title=t("workflow.normal_fitting_title")
)
# Phase 3: Data Loading
# Load the dataset and get variable selections
(data, x_name, y_name, plot_name, data_file_path, data_file_type) = (
coordinate_data_loading(
parent_window=menu,
open_load_func=open_load_dialog,
ask_variables_func=ask_variables,
)
)
# Check if data was loaded successfully (empty string indicates cancellation)
if isinstance(data, str): # Empty result
logger.info(t("log.user_cancelled_data"))
return
# Phase 3.5: If multidimensional, ask for additional x variables
x_name = _resolve_multiple_x_variables(menu, data, x_name, num_independent_vars)
if x_name is None:
logger.info("User cancelled multiple x variables selection")
return
# Phase 4: Fitting Execution
if loop_mode:
# Loop mode: fit, show results, ask to continue, reload data, repeat
single_fit_with_loop(
fitter_function=fitter_with_ui,
data=data,
x_name=x_name,
y_name=y_name,
plot_name=plot_name,
data_file_path=data_file_path,
data_file_type=data_file_type,
)
else:
# Single mode: fit once and done
fitter_with_ui(data, x_name, y_name, plot_name)
[docs]
def single_fit_multiple_datasets() -> None:
"""
Perform multiple fitting operations with the same equation on different datasets.
Workflow:
1. Select equation type
2. Specify how many datasets to fit
3. Load each dataset
4. Perform fits on all datasets
5. Optionally reload and refit in loop
"""
# Lazy imports to avoid loading heavy dependencies at application startup
from fitting import (
multiple_fit_with_loop,
coordinate_data_loading,
coordinate_equation_selection,
)
from frontend import (
open_load_dialog,
ask_variables,
ask_equation_type,
ask_num_parameters,
ask_parameter_names,
ask_custom_formula,
ask_num_fits,
)
menu = _get_menu_window()
# Select equation (backend function)
equation_name, base_fit_function = coordinate_equation_selection(
parent_window=menu,
ask_equation_type_func=ask_equation_type,
ask_num_parameters_func=ask_num_parameters,
ask_parameter_names_func=ask_parameter_names,
ask_custom_formula_func=ask_custom_formula,
get_fitting_function_func=get_fitting_function,
)
if equation_name == EXIT_SIGNAL or base_fit_function is None:
return
num_independent_vars = getattr(base_fit_function, "num_independent_vars", 1)
fitter_with_ui = _wrap_with_visualization(
base_fit_function, _equation_display_name(equation_name)
)
_app_state.set_equation(equation_name, fitter_with_ui)
# Ask for number of datasets
num_datasets = ask_num_fits(menu)
if num_datasets is None:
return
# Ask if user wants loop mode
loop_mode = messagebox.askyesno(
message=t("workflow.loop_question"), title=t("workflow.multiple_fitting_title")
)
datasets: List[dict] = []
for _ in range(num_datasets):
if any(ds.get("data_file_type") == EXIT_SIGNAL for ds in datasets):
break
(data, x_name, y_name, plot_name, data_file_path, data_file_type) = (
coordinate_data_loading(
parent_window=menu,
open_load_func=open_load_dialog,
ask_variables_func=ask_variables,
)
)
if isinstance(data, str):
datasets.append({"data_file_type": EXIT_SIGNAL})
break
x_name = _resolve_multiple_x_variables(
menu, data, x_name, num_independent_vars, filter_uncertainty=True
)
if x_name is None:
datasets.append({"data_file_type": EXIT_SIGNAL})
break
datasets.append(
{
"data": data,
"x_name": x_name,
"y_name": y_name,
"plot_name": plot_name,
"data_file_path": data_file_path,
"data_file_type": data_file_type,
}
)
# Only proceed if all datasets were loaded successfully
if not any(ds.get("data_file_type") == EXIT_SIGNAL for ds in datasets):
if loop_mode:
# Execute multiple fittings with loop
multiple_fit_with_loop(fitter_function=fitter_with_ui, datasets=datasets)
else:
# Execute single fit for each dataset
for ds in datasets:
fitter_with_ui(ds["data"], ds["x_name"], ds["y_name"], ds["plot_name"])
[docs]
def multiple_fits_single_dataset() -> None:
"""
Test different equation types on the same dataset.
Workflow:
1. Load a dataset once
2. Try different equation types on it
3. Compare results without reloading the data
"""
# Lazy imports to avoid loading heavy dependencies at application startup
from fitting import coordinate_data_loading, coordinate_equation_selection
from frontend import (
open_load_dialog,
ask_variables,
ask_equation_type,
ask_num_parameters,
ask_parameter_names,
ask_custom_formula,
)
menu = _get_menu_window()
# Load data once
(data, x_name, y_name, plot_name, data_file_path, data_file_type) = (
coordinate_data_loading(
parent_window=menu,
open_load_func=open_load_dialog,
ask_variables_func=ask_variables,
)
)
if isinstance(data, str): # Empty result
return
continue_testing = True
while continue_testing:
# Select equation type (backend function)
equation_name, base_fit_function = coordinate_equation_selection(
parent_window=menu,
ask_equation_type_func=ask_equation_type,
ask_num_parameters_func=ask_num_parameters,
ask_parameter_names_func=ask_parameter_names,
ask_custom_formula_func=ask_custom_formula,
get_fitting_function_func=get_fitting_function,
)
if equation_name != EXIT_SIGNAL and base_fit_function is not None:
fitter_with_ui = _wrap_with_visualization(
base_fit_function, _equation_display_name(equation_name)
)
_app_state.set_equation(equation_name, fitter_with_ui)
# If custom multidimensional, ask for independent variables before fitting
num_independent_vars = getattr(base_fit_function, "num_independent_vars", 1)
fit_x_name = _resolve_multiple_x_variables(
menu, data, x_name, num_independent_vars
)
if fit_x_name is None:
logger.info("User cancelled multiple x variables selection")
continue
fitter_with_ui(data, fit_x_name, y_name, plot_name)
# Ask if user wants to try another equation
continue_testing = messagebox.askyesno(
message=t("workflow.continue_question"),
title=t("workflow.fitting_title", name=plot_name),
)
[docs]
def all_fits_single_dataset() -> None:
"""
Perform all available fitting types on the selected dataset.
This function loads data and sequentially applies all predefined equation types
to fit the data, generating results for each fitting method.
"""
# Lazy imports to avoid loading heavy dependencies at application startup
from fitting import apply_all_equations, coordinate_data_loading
from frontend import (
open_load_dialog,
ask_variables,
)
menu = _get_menu_window()
# Load data
(data, x_name, y_name, plot_name, data_file_path, data_file_type) = (
coordinate_data_loading(
parent_window=menu,
open_load_func=open_load_dialog,
ask_variables_func=ask_variables,
)
)
if isinstance(data, str): # Empty result
return
# Apply all equation types
apply_all_equations(
equation_setter=_set_equation_helper,
get_fitter=lambda: _app_state.current_fitter,
equation_types=AVAILABLE_EQUATION_TYPES,
data=data,
x_name=x_name,
y_name=y_name,
plot_name=plot_name,
)
[docs]
def watch_data() -> None:
"""
View data from a file without performing any fitting.
This function allows users to inspect loaded data.
"""
# Lazy imports to avoid loading heavy dependencies at application startup
from fitting import coordinate_data_viewing
from frontend import (
open_load_dialog,
show_data_dialog,
)
menu = _get_menu_window()
coordinate_data_viewing(
parent_window=menu,
open_load_func=open_load_dialog,
show_data_func=show_data_dialog,
)
[docs]
def show_help() -> None:
"""
Display help and information about the application.
Shows information about fitting modes, navigation, data locations, and output locations.
"""
# Lazy import to avoid loading help dialog module at startup
from frontend import show_help_dialog
menu = _get_menu_window()
show_help_dialog(parent_window=menu)
# ============================================================================
# APPLICATION ENTRY POINT
# ============================================================================
def _check_for_updates() -> None:
"""
Check for updates once a week. If a newer version is available and
CHECK_UPDATES is enabled, show a dialog asking if the user wants to update.
If yes, perform git pull --ff-only (preserves input/, output/, .env).
"""
from utils.update_checker import (
is_update_available,
perform_git_pull,
record_check_done,
should_run_check,
)
if not should_run_check():
logger.debug(
"Update check skipped (CHECK_UPDATES disabled or checked recently)"
)
return
latest = is_update_available(__version__)
record_check_done()
if latest:
logger.info("Update available: %s (current: %s)", latest, __version__)
else:
logger.debug("Update check done: no newer version (current: %s)", __version__)
if not latest:
return
wants_update = messagebox.askyesno(
t("update.title"),
t("update.message", latest=latest, current=__version__),
default=messagebox.YES,
)
if not wants_update:
return
success, msg = perform_git_pull()
if success:
# msg may be git output or i18n key
display_msg = t(msg) if msg.startswith("update.") else msg
messagebox.showinfo(t("update.title"), display_msg)
else:
display_msg = t(msg) if msg.startswith("update.") else msg
messagebox.showerror(t("update.title"), display_msg)
[docs]
def main() -> None:
"""Main entry point for the application."""
logger.info("=" * 60)
logger.info(t("log.application_starting"))
logger.info(t("log.version", version=__version__))
logger.info("=" * 60)
try:
_check_for_updates()
# Start the main menu
# The start_main_menu function stores the menu in __main__.menu
# Callbacks access it via _get_menu_window()
start_main_menu(
normal_fitting_callback=normal_fitting,
single_fit_multiple_datasets_callback=single_fit_multiple_datasets,
multiple_fits_single_dataset_callback=multiple_fits_single_dataset,
all_fits_single_dataset_callback=all_fits_single_dataset,
watch_data_callback=watch_data,
help_callback=show_help,
)
logger.info(t("log.application_closed"))
except Exception as e:
logger.critical(t("log.unexpected_error", error=str(e)), exc_info=True)
try:
messagebox.showerror(
t("error.critical_error"), t("error.unexpected_error", error=str(e))
)
except Exception:
# If tkinter itself is broken (e.g. missing Tcl/Tk), at least show in console.
print(t("error.critical_error"))
print(t("error.unexpected_error", error=str(e)))
raise
if __name__ == "__main__":
main()