diff --git a/src/petab_gui/controllers/file_io_controller.py b/src/petab_gui/controllers/file_io_controller.py
new file mode 100644
index 0000000..49032dd
--- /dev/null
+++ b/src/petab_gui/controllers/file_io_controller.py
@@ -0,0 +1,724 @@
+"""File I/O Controller for PEtab GUI.
+
+This module contains the FileIOController class, which handles all file input/output
+operations for PEtab models, including:
+- Opening and saving PEtab YAML files
+- Opening and saving COMBINE archives (OMEX)
+- Opening and saving individual tables
+- Exporting SBML models
+- Loading example datasets
+- File validation
+"""
+
+import logging
+import os
+import tempfile
+import zipfile
+from io import BytesIO
+from pathlib import Path
+
+import petab.v1 as petab
+import yaml
+from petab.versions import get_major_version
+from PySide6.QtWidgets import QFileDialog, QMessageBox
+
+from ..settings_manager import settings_manager
+from ..utils import process_file
+
+
+class FileIOController:
+ """Controller for file I/O operations.
+
+ Handles all file input/output operations for the PEtab GUI, including
+ loading and saving PEtab problems from various formats (YAML, OMEX, TSV).
+
+ Attributes
+ ----------
+ main : MainController
+ Reference to the main controller for access to models, views, and other controllers.
+ model : PEtabModel
+ The PEtab model being managed.
+ view : MainWindow
+ The main application window.
+ logger : LoggerController
+ The logger for user feedback.
+ """
+
+ def __init__(self, main_controller):
+ """Initialize the FileIOController.
+
+ Parameters
+ ----------
+ main_controller : MainController
+ The main controller instance.
+ """
+ self.main = main_controller
+ self.model = main_controller.model
+ self.view = main_controller.view
+ self.logger = main_controller.logger
+
+ def save_model(self):
+ """Save the entire PEtab model.
+
+ Opens a dialog to select the save format and location, then saves the model
+ as either a COMBINE archive (OMEX), ZIP file, or folder structure.
+
+ Returns
+ -------
+ bool
+ True if saved successfully, False otherwise.
+ """
+ options = QFileDialog.Options()
+ file_name, filtering = QFileDialog.getSaveFileName(
+ self.view,
+ "Save Project",
+ "",
+ "COMBINE Archive (*.omex);;Zip Files (*.zip);;Folder",
+ options=options,
+ )
+ if not file_name:
+ return False
+
+ if filtering == "COMBINE Archive (*.omex)":
+ self.model.save_as_omex(file_name)
+ elif filtering == "Folder":
+ if file_name.endswith("."):
+ file_name = file_name[:-1]
+ target = Path(file_name)
+ target.mkdir(parents=True, exist_ok=True)
+ self.model.save(str(target))
+ file_name = str(target)
+ else:
+ if not file_name.endswith(".zip"):
+ file_name += ".zip"
+
+ # Create a temporary directory to save the model's files
+ with tempfile.TemporaryDirectory() as temp_dir:
+ self.model.save(temp_dir)
+
+ # Create a bytes buffer to hold the zip file in memory
+ buffer = BytesIO()
+ with zipfile.ZipFile(buffer, "w") as zip_file:
+ # Add files to zip archive
+ for root, _, files in os.walk(temp_dir):
+ for file in files:
+ file_path = os.path.join(root, file)
+ with open(file_path, "rb") as f:
+ zip_file.writestr(file, f.read())
+ with open(file_name, "wb") as f:
+ f.write(buffer.getvalue())
+
+ QMessageBox.information(
+ self.view,
+ "Save Project",
+ f"Project saved successfully to {file_name}",
+ )
+
+ # Show next steps panel if not disabled
+ dont_show = settings_manager.get_value(
+ "next_steps/dont_show_again", False, bool
+ )
+ if not dont_show:
+ self.main.next_steps_panel.show_panel()
+
+ return True
+
+ def save_single_table(self):
+ """Save the currently active table to a TSV file.
+
+ Returns
+ -------
+ bool or None
+ True if saved successfully, False if cancelled, None if no active table.
+ """
+ active_controller = self.main.active_controller()
+ if not active_controller:
+ QMessageBox.warning(
+ self.view,
+ "Save Table",
+ "No active table to save.",
+ )
+ return None
+ file_name, _ = QFileDialog.getSaveFileName(
+ self.view,
+ "Save Table (as *.tsv)",
+ f"{active_controller.model.table_type}.tsv",
+ "TSV Files (*.tsv)",
+ )
+ if not file_name:
+ return False
+ active_controller.save_table(file_name)
+ return True
+
+ def save_sbml_model(self):
+ """Export the SBML model to an XML file.
+
+ Returns
+ -------
+ bool
+ True if exported successfully, False otherwise.
+ """
+ if not self.model.sbml or not self.model.sbml.sbml_text:
+ QMessageBox.warning(
+ self.view,
+ "Export SBML Model",
+ "No SBML model to export.",
+ )
+ return False
+
+ file_name, _ = QFileDialog.getSaveFileName(
+ self.view,
+ "Export SBML Model",
+ f"{self.model.sbml.model_id}.xml",
+ "SBML Files (*.xml *.sbml);;All Files (*)",
+ )
+ if not file_name:
+ return False
+
+ try:
+ with open(file_name, "w") as f:
+ f.write(self.model.sbml.sbml_text)
+ self.logger.log_message(
+ "SBML model exported successfully to file.", color="green"
+ )
+ return True
+ except Exception as e:
+ QMessageBox.critical(
+ self.view,
+ "Export SBML Model",
+ f"Failed to export SBML model: {e}",
+ )
+ return False
+
+ def open_file(self, file_path=None, mode=None):
+ """Determine appropriate course of action for a given file.
+
+ Course of action depends on file extension, separator and header
+ structure. Opens the file in the appropriate controller.
+
+ Parameters
+ ----------
+ file_path : str, optional
+ Path to the file to open. If None, shows a file dialog.
+ mode : str, optional
+ Opening mode: "overwrite" or "append". If None, prompts the user.
+ """
+ if not file_path:
+ file_path, _ = QFileDialog.getOpenFileName(
+ self.view,
+ "Open File",
+ "",
+ "All supported (*.yaml *.yml *.xml *.sbml *.tsv *.csv *.txt "
+ "*.omex);;"
+ "PEtab Problems (*.yaml *.yml);;SBML Files (*.xml *.sbml);;"
+ "PEtab Tables or Data Matrix (*.tsv *.csv *.txt);;"
+ "COMBINE Archive (*.omex);;"
+ "All files (*)",
+ )
+ if not file_path:
+ return
+ # handle file appropriately
+ from .utils import prompt_overwrite_or_append
+
+ actionable, sep = process_file(file_path, self.logger)
+ if actionable in ["yaml", "omex"] and mode == "append":
+ self.logger.log_message(
+ f"Append mode is not supported for *.{actionable} files.",
+ color="red",
+ )
+ return
+ if actionable in ["sbml"] and mode == "append":
+ self.logger.log_message(
+ "Append mode is not supported for SBML models.",
+ color="orange",
+ )
+ return
+ if not actionable:
+ return
+ if mode is None:
+ if actionable in ["yaml", "sbml", "omex"]:
+ mode = "overwrite"
+ else:
+ mode = prompt_overwrite_or_append(self.main)
+ if mode is None:
+ return
+ self.main.recent_files_manager.add_file(file_path)
+ self._open_file(actionable, file_path, sep, mode)
+
+ def _open_file(self, actionable, file_path, sep, mode):
+ """Overwrite the file in the appropriate controller.
+
+ Actionable dictates which controller to use.
+
+ Parameters
+ ----------
+ actionable : str
+ Type of file: "yaml", "omex", "sbml", "measurement", "observable",
+ "parameter", "condition", "visualization", "simulation", "data_matrix".
+ file_path : str
+ Path to the file.
+ sep : str
+ Separator used in the file (for TSV/CSV).
+ mode : str
+ Opening mode: "overwrite" or "append".
+ """
+ if actionable == "yaml":
+ self.open_yaml_and_load_files(file_path)
+ elif actionable == "omex":
+ self.open_omex_and_load_files(file_path)
+ elif actionable == "sbml":
+ self.main.sbml_controller.overwrite_sbml(file_path)
+ elif actionable == "measurement":
+ self.main.measurement_controller.open_table(file_path, sep, mode)
+ elif actionable == "observable":
+ self.main.observable_controller.open_table(file_path, sep, mode)
+ elif actionable == "parameter":
+ self.main.parameter_controller.open_table(file_path, sep, mode)
+ elif actionable == "condition":
+ self.main.condition_controller.open_table(file_path, sep, mode)
+ elif actionable == "visualization":
+ self.main.visualization_controller.open_table(file_path, sep, mode)
+ elif actionable == "simulation":
+ self.main.simulation_table_controller.open_table(
+ file_path, sep, mode
+ )
+ elif actionable == "data_matrix":
+ self.main.measurement_controller.process_data_matrix_file(
+ file_path, mode, sep
+ )
+
+ def _validate_yaml_structure(self, yaml_content):
+ """Validate PEtab YAML structure before attempting to load files.
+
+ Parameters
+ ----------
+ yaml_content : dict
+ The parsed YAML content.
+
+ Returns
+ -------
+ tuple
+ (is_valid: bool, errors: list[str])
+ """
+ errors = []
+
+ # Check format version
+ if "format_version" not in yaml_content:
+ errors.append("Missing 'format_version' field")
+
+ # Check problems array
+ if "problems" not in yaml_content:
+ errors.append("Missing 'problems' field")
+ return False, errors
+
+ if (
+ not isinstance(yaml_content["problems"], list)
+ or not yaml_content["problems"]
+ ):
+ errors.append("'problems' must be a non-empty list")
+ return False, errors
+
+ problem = yaml_content["problems"][0]
+
+ # Optional but recommended fields
+ if (
+ "visualization_files" not in problem
+ or not problem["visualization_files"]
+ ):
+ errors.append("Warning: No visualization_files specified")
+
+ # Required fields in problem
+ for field in [
+ "sbml_files",
+ "measurement_files",
+ "observable_files",
+ "condition_files",
+ ]:
+ if field not in problem or not problem[field]:
+ errors.append("Problem must contain at least one SBML file")
+
+ # Check parameter_file (at root level)
+ if "parameter_file" not in yaml_content:
+ errors.append("Missing 'parameter_file' at root level")
+
+ return len([e for e in errors if "Warning" not in e]) == 0, errors
+
+ def _validate_files_exist(self, yaml_dir, yaml_content):
+ """Validate that all files referenced in YAML exist.
+
+ Parameters
+ ----------
+ yaml_dir : Path
+ The directory containing the YAML file.
+ yaml_content : dict
+ The parsed YAML content.
+
+ Returns
+ -------
+ tuple
+ (all_exist: bool, missing_files: list[str])
+ """
+ missing_files = []
+ problem = yaml_content["problems"][0]
+
+ # Check SBML files
+ for sbml_file in problem.get("sbml_files", []):
+ if not (yaml_dir / sbml_file).exists():
+ missing_files.append(str(sbml_file))
+
+ # Check measurement files
+ for meas_file in problem.get("measurement_files", []):
+ if not (yaml_dir / meas_file).exists():
+ missing_files.append(str(meas_file))
+
+ # Check observable files
+ for obs_file in problem.get("observable_files", []):
+ if not (yaml_dir / obs_file).exists():
+ missing_files.append(str(obs_file))
+
+ # Check condition files
+ for cond_file in problem.get("condition_files", []):
+ if not (yaml_dir / cond_file).exists():
+ missing_files.append(str(cond_file))
+
+ # Check parameter file
+ if "parameter_file" in yaml_content:
+ param_file = yaml_content["parameter_file"]
+ if not (yaml_dir / param_file).exists():
+ missing_files.append(str(param_file))
+
+ # Check visualization files (optional)
+ for vis_file in problem.get("visualization_files", []):
+ if not (yaml_dir / vis_file).exists():
+ missing_files.append(str(vis_file))
+
+ return len(missing_files) == 0, missing_files
+
+ def _load_file_list(self, controller, file_list, file_type, yaml_dir):
+ """Load multiple files for a given controller.
+
+ Parameters
+ ----------
+ controller : object
+ The controller to load files into (e.g., measurement_controller).
+ file_list : list[str]
+ List of file names to load.
+ file_type : str
+ Human-readable file type for logging (e.g., "measurement").
+ yaml_dir : Path
+ The directory containing the YAML and data files.
+ """
+ for i, file_name in enumerate(file_list):
+ file_mode = "overwrite" if i == 0 else "append"
+ controller.open_table(yaml_dir / file_name, mode=file_mode)
+ self.logger.log_message(
+ f"Loaded {file_type} file ({i + 1}/{len(file_list)}): {file_name}",
+ color="blue",
+ )
+
+ def open_yaml_and_load_files(self, yaml_path=None, mode="overwrite"):
+ """Open files from a YAML configuration.
+
+ Opens a dialog to upload YAML file. Creates a PEtab problem and
+ overwrites the current PEtab model with the new problem.
+
+ Parameters
+ ----------
+ yaml_path : str, optional
+ Path to the YAML file. If None, shows a file dialog.
+ mode : str, optional
+ Opening mode (currently only "overwrite" is supported).
+ """
+ if not yaml_path:
+ yaml_path, _ = QFileDialog.getOpenFileName(
+ self.view, "Open YAML File", "", "YAML Files (*.yaml *.yml)"
+ )
+ if not yaml_path:
+ return
+ try:
+ for controller in self.main.controllers:
+ if controller == self.main.sbml_controller:
+ continue
+ controller.release_completers()
+
+ # Load the YAML content
+ with open(yaml_path, encoding="utf-8") as file:
+ yaml_content = yaml.safe_load(file)
+
+ # Validate PEtab version
+ if (major := get_major_version(yaml_content)) != 1:
+ raise ValueError(
+ f"Only PEtab v1 problems are currently supported. "
+ f"Detected version: {major}.x."
+ )
+
+ # Validate YAML structure
+ is_valid, errors = self._validate_yaml_structure(yaml_content)
+ if not is_valid:
+ error_msg = "Invalid YAML structure:\n - " + "\n - ".join(
+ [e for e in errors if "Warning" not in e]
+ )
+ self.logger.log_message(error_msg, color="red")
+ QMessageBox.critical(
+ self.view, "Invalid PEtab YAML", error_msg
+ )
+ return
+
+ # Log warnings but continue
+ warnings = [e for e in errors if "Warning" in e]
+ for warning in warnings:
+ self.logger.log_message(warning, color="orange")
+
+ # Resolve the directory of the YAML file to handle relative paths
+ yaml_dir = Path(yaml_path).parent
+
+ # Validate file existence
+ all_exist, missing_files = self._validate_files_exist(
+ yaml_dir, yaml_content
+ )
+ if not all_exist:
+ error_msg = (
+ "The following files referenced in the YAML are missing:\n - "
+ + "\n - ".join(missing_files)
+ )
+ self.logger.log_message(error_msg, color="red")
+ QMessageBox.critical(self.view, "Missing Files", error_msg)
+ return
+
+ problem = yaml_content["problems"][0]
+
+ # Load SBML model (required, single file)
+ sbml_files = problem.get("sbml_files", [])
+ if sbml_files:
+ sbml_file_path = yaml_dir / sbml_files[0]
+ self.main.sbml_controller.overwrite_sbml(sbml_file_path)
+ self.logger.log_message(
+ f"Loaded SBML file: {sbml_files[0]}", color="blue"
+ )
+
+ # Load measurement files (multiple allowed)
+ measurement_files = problem.get("measurement_files", [])
+ if measurement_files:
+ self._load_file_list(
+ self.main.measurement_controller,
+ measurement_files,
+ "measurement",
+ yaml_dir,
+ )
+
+ # Load observable files (multiple allowed)
+ observable_files = problem.get("observable_files", [])
+ if observable_files:
+ self._load_file_list(
+ self.main.observable_controller,
+ observable_files,
+ "observable",
+ yaml_dir,
+ )
+
+ # Load condition files (multiple allowed)
+ condition_files = problem.get("condition_files", [])
+ if condition_files:
+ self._load_file_list(
+ self.main.condition_controller,
+ condition_files,
+ "condition",
+ yaml_dir,
+ )
+
+ # Load parameter file (required, single file at root level)
+ if "parameter_file" in yaml_content:
+ param_file = yaml_content["parameter_file"]
+ self.main.parameter_controller.open_table(
+ yaml_dir / param_file
+ )
+ self.logger.log_message(
+ f"Loaded parameter file: {param_file}", color="blue"
+ )
+
+ # Load visualization files (optional, multiple allowed)
+ visualization_files = problem.get("visualization_files", [])
+ if visualization_files:
+ self._load_file_list(
+ self.main.visualization_controller,
+ visualization_files,
+ "visualization",
+ yaml_dir,
+ )
+ else:
+ self.main.visualization_controller.clear_table()
+
+ # Simulation should be cleared
+ self.main.simulation_table_controller.clear_table()
+
+ self.logger.log_message(
+ "All files opened successfully from the YAML configuration.",
+ color="green",
+ )
+ self.main.validation.check_model()
+
+ # Rerun the completers
+ for controller in self.main.controllers:
+ if controller == self.main.sbml_controller:
+ continue
+ controller.setup_completers()
+ self.main.unsaved_changes_change(False)
+
+ except FileNotFoundError as e:
+ error_msg = f"File not found: {e.filename if hasattr(e, 'filename') else str(e)}"
+ self.logger.log_message(error_msg, color="red")
+ QMessageBox.warning(self.view, "File Not Found", error_msg)
+ except KeyError as e:
+ error_msg = f"Missing required field in YAML: {str(e)}"
+ self.logger.log_message(error_msg, color="red")
+ QMessageBox.warning(self.view, "Invalid YAML", error_msg)
+ except ValueError as e:
+ error_msg = f"Invalid YAML structure: {str(e)}"
+ self.logger.log_message(error_msg, color="red")
+ QMessageBox.warning(self.view, "Invalid YAML", error_msg)
+ except yaml.YAMLError as e:
+ error_msg = f"YAML parsing error: {str(e)}"
+ self.logger.log_message(error_msg, color="red")
+ QMessageBox.warning(self.view, "YAML Parsing Error", error_msg)
+ except Exception as e:
+ error_msg = f"Unexpected error loading YAML: {str(e)}"
+ self.logger.log_message(error_msg, color="red")
+ logging.exception("Full traceback for YAML loading error:")
+ QMessageBox.critical(self.view, "Error", error_msg)
+
+ def open_omex_and_load_files(self, omex_path=None):
+ """Open a PEtab problem from a COMBINE Archive.
+
+ Parameters
+ ----------
+ omex_path : str, optional
+ Path to the OMEX file. If None, shows a file dialog.
+ """
+ if not omex_path:
+ omex_path, _ = QFileDialog.getOpenFileName(
+ self.view,
+ "Open COMBINE Archive",
+ "",
+ "COMBINE Archive (*.omex);;All files (*)",
+ )
+ if not omex_path:
+ return
+ try:
+ combine_archive = petab.problem.Problem.from_combine(omex_path)
+ except Exception as e:
+ self.logger.log_message(
+ f"Failed to open files from OMEX: {str(e)}", color="red"
+ )
+ return
+ # overwrite current model
+ self.main.measurement_controller.overwrite_df(
+ combine_archive.measurement_df
+ )
+ self.main.observable_controller.overwrite_df(
+ combine_archive.observable_df
+ )
+ self.main.condition_controller.overwrite_df(
+ combine_archive.condition_df
+ )
+ self.main.parameter_controller.overwrite_df(
+ combine_archive.parameter_df
+ )
+ self.main.visualization_controller.overwrite_df(
+ combine_archive.visualization_df
+ )
+ self.main.sbml_controller.overwrite_sbml(
+ sbml_model=combine_archive.model
+ )
+
+ def new_file(self):
+ """Empty all tables.
+
+ In case of unsaved changes, asks the user whether to save.
+ """
+ if self.main.unsaved_changes:
+ reply = QMessageBox.question(
+ self.view,
+ "Unsaved Changes",
+ "You have unsaved changes. Do you want to save them?",
+ QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
+ QMessageBox.Save,
+ )
+ if reply == QMessageBox.Save:
+ self.save_model()
+ for controller in self.main.controllers:
+ if controller == self.main.sbml_controller:
+ controller.clear_model()
+ continue
+ controller.clear_table()
+ self.view.plot_dock.plot_it()
+ self.main.unsaved_changes_change(False)
+
+ def load_example(self, example_name):
+ """Load an internal example PEtab problem.
+
+ Parameters
+ ----------
+ example_name : str
+ Name of the example subdirectory (e.g., "Boehm", "Simple_Conversion").
+
+ Notes
+ -----
+ Finds and loads the example dataset from the package directory.
+ No internet connection required - the example is bundled with the package.
+ """
+ try:
+ # Use importlib.resources to access packaged example files
+ from importlib.resources import as_file, files
+
+ example_files = files("petab_gui.example")
+
+ # Check if the example package exists
+ if not example_files.is_dir():
+ error_msg = (
+ "Could not find the example dataset. "
+ "The example folder may not be properly installed."
+ )
+ self.logger.log_message(error_msg, color="red")
+ QMessageBox.warning(self.view, "Example Not Found", error_msg)
+ return
+
+ # Get the problem.yaml file path for the specified example
+ yaml_file = example_files.joinpath(example_name, "problem.yaml")
+
+ with as_file(yaml_file) as yaml_path:
+ if not yaml_path.exists():
+ error_msg = f"Example '{example_name}' not found or problem.yaml file is missing."
+ self.logger.log_message(error_msg, color="red")
+ QMessageBox.warning(
+ self.view, "Example Invalid", error_msg
+ )
+ return
+
+ # Load the example
+ self.logger.log_message(
+ f"Loading '{example_name}' example dataset...",
+ color="blue",
+ )
+ self.open_yaml_and_load_files(str(yaml_path))
+
+ except ModuleNotFoundError as e:
+ error_msg = (
+ "Example dataset not found. It may not be installed properly. "
+ f"Error: {str(e)}"
+ )
+ self.logger.log_message(error_msg, color="red")
+ QMessageBox.warning(self.view, "Example Not Found", error_msg)
+ except Exception as e:
+ error_msg = f"Failed to load example: {str(e)}"
+ self.logger.log_message(error_msg, color="red")
+ QMessageBox.critical(self.view, "Error Loading Example", error_msg)
+
+ def get_current_problem(self):
+ """Get the current PEtab problem from the model.
+
+ Returns
+ -------
+ petab.Problem
+ The current PEtab problem.
+ """
+ return self.model.current_petab_problem
diff --git a/src/petab_gui/controllers/mother_controller.py b/src/petab_gui/controllers/mother_controller.py
index 881ac3e..31a6da9 100644
--- a/src/petab_gui/controllers/mother_controller.py
+++ b/src/petab_gui/controllers/mother_controller.py
@@ -42,9 +42,12 @@
process_file,
)
from ..views import TaskBar
-from ..views.other_views import NextStepsPanel
+from ..views.dialogs import NextStepsPanel
+from .file_io_controller import FileIOController
from .logger_controller import LoggerController
+from .plot_coordinator import PlotCoordinator
from .sbml_controller import SbmlController
+from .simulation_controller import SimulationController
from .table_controllers import (
ConditionController,
MeasurementController,
@@ -56,8 +59,8 @@
RecentFilesManager,
_WhatsThisClickHelp,
filtered_error,
- prompt_overwrite_or_append,
)
+from .validation_controller import ValidationController
class MainController:
@@ -119,7 +122,7 @@ def __init__(self, view, model: PEtabModel):
self.undo_stack,
self,
)
- self.simulation_controller = MeasurementController(
+ self.simulation_table_controller = MeasurementController(
self.view.simulation_dock,
self.model.simulation,
self.logger,
@@ -136,8 +139,16 @@ def __init__(self, view, model: PEtabModel):
self.condition_controller,
self.sbml_controller,
self.visualization_controller,
- self.simulation_controller,
+ self.simulation_table_controller,
]
+ # File I/O Controller
+ self.file_io = FileIOController(self)
+ # Plot Coordinator
+ self.plot_coordinator = PlotCoordinator(self)
+ # Validation Controller
+ self.validation = ValidationController(self)
+ # Simulation Controller
+ self.simulation = SimulationController(self)
# Recent Files
self.recent_files_manager = RecentFilesManager(max_files=10)
# Checkbox states for Find + Replace
@@ -151,9 +162,6 @@ def __init__(self, view, model: PEtabModel):
}
self.sbml_checkbox_states = {"sbml": False, "antimony": False}
self.unsaved_changes = False
- # Selection synchronization flags to prevent redundant updates
- self._updating_from_plot = False
- self._updating_from_table = False
# Next Steps Panel
self.next_steps_panel = NextStepsPanel(self.view)
self.next_steps_panel.dont_show_again_changed.connect(
@@ -164,8 +172,9 @@ def __init__(self, view, model: PEtabModel):
self.actions = self.setup_actions()
self.view.setup_toolbar(self.actions)
- self.plotter = None
- self.init_plotter()
+ # Initialize plotter through plot coordinator
+ self.plot_coordinator.init_plotter()
+ self.plotter = self.plot_coordinator.plotter
self.setup_connections()
self.setup_task_bar()
self.setup_context_menu()
@@ -237,12 +246,12 @@ def setup_connections(self):
if z == "condition"
else None
)
- # Maybe Move to a Plot Model
+ # Plot selection synchronization
self.view.measurement_dock.table_view.selectionModel().selectionChanged.connect(
- self._on_table_selection_changed
+ self.plot_coordinator._on_table_selection_changed
)
self.view.simulation_dock.table_view.selectionModel().selectionChanged.connect(
- self._on_simulation_selection_changed
+ self.plot_coordinator._on_simulation_selection_changed
)
# Unsaved Changes
self.model.measurement.something_changed.connect(
@@ -268,7 +277,7 @@ def setup_connections(self):
self.sync_visibility_with_actions()
# Recent Files
self.recent_files_manager.open_file.connect(
- partial(self.open_file, mode="overwrite")
+ partial(self.file_io.open_file, mode="overwrite")
)
# Settings logging
settings_manager.new_log_message.connect(self.logger.log_message)
@@ -276,18 +285,16 @@ def setup_connections(self):
self.sbml_controller.overwritten_model.connect(
self.parameter_controller.update_handler_sbml
)
- # Plotting update. Regulated through a Timer
- self._plot_update_timer = QTimer()
- self._plot_update_timer.setSingleShot(True)
- self._plot_update_timer.setInterval(0)
- self._plot_update_timer.timeout.connect(self.init_plotter)
+ # Plotting update connections
for controller in [
self.measurement_controller,
self.condition_controller,
self.visualization_controller,
- self.simulation_controller,
+ self.simulation_table_controller,
]:
- controller.overwritten_df.connect(self._schedule_plot_update)
+ controller.overwritten_df.connect(
+ self.plot_coordinator._schedule_plot_update
+ )
def setup_actions(self):
"""Setup actions for the main controller."""
@@ -302,20 +309,20 @@ def setup_actions(self):
qta.icon("mdi6.file-document"), "&New", self.view
)
actions["new"].setShortcut(QKeySequence.New)
- actions["new"].triggered.connect(self.new_file)
+ actions["new"].triggered.connect(self.file_io.new_file)
# Open File
actions["open"] = QAction(
qta.icon("mdi6.folder-open"), "&Open...", self.view
)
actions["open"].setShortcut(QKeySequence.Open)
actions["open"].triggered.connect(
- partial(self.open_file, mode="overwrite")
+ partial(self.file_io.open_file, mode="overwrite")
)
# Add File
actions["add"] = QAction(qta.icon("mdi6.table-plus"), "Add", self.view)
actions["add"].setShortcut("Ctrl+Shift+O")
actions["add"].triggered.connect(
- partial(self.open_file, mode="append")
+ partial(self.file_io.open_file, mode="append")
)
# Load Examples
actions["load_example_boehm"] = QAction(
@@ -324,7 +331,7 @@ def setup_actions(self):
self.view,
)
actions["load_example_boehm"].triggered.connect(
- partial(self.load_example, "Boehm")
+ partial(self.file_io.load_example, "Boehm")
)
actions["load_example_simple"] = QAction(
qta.icon("mdi6.book-open-page-variant"),
@@ -332,22 +339,24 @@ def setup_actions(self):
self.view,
)
actions["load_example_simple"].triggered.connect(
- partial(self.load_example, "Simple_Conversion")
+ partial(self.file_io.load_example, "Simple_Conversion")
)
# Save
actions["save"] = QAction(
qta.icon("mdi6.content-save-all"), "&Save As...", self.view
)
actions["save"].setShortcut(QKeySequence.Save)
- actions["save"].triggered.connect(self.save_model)
+ actions["save"].triggered.connect(self.file_io.save_model)
actions["save_single_table"] = QAction(
qta.icon("mdi6.table-arrow-down"), "Save This Table", self.view
)
- actions["save_single_table"].triggered.connect(self.save_single_table)
+ actions["save_single_table"].triggered.connect(
+ self.file_io.save_single_table
+ )
actions["save_sbml"] = QAction(
qta.icon("mdi6.file-code"), "Export SBML Model", self.view
)
- actions["save_sbml"].triggered.connect(self.save_sbml_model)
+ actions["save_sbml"].triggered.connect(self.file_io.save_sbml_model)
# Find + Replace
actions["find"] = QAction(qta.icon("mdi6.magnify"), "Find", self.view)
actions["find"].setShortcut(QKeySequence.Find)
@@ -399,7 +408,7 @@ def setup_actions(self):
"Check PEtab",
self.view,
)
- actions["check_petab"].triggered.connect(self.check_model)
+ actions["check_petab"].triggered.connect(self.validation.check_model)
actions["reset_model"] = QAction(
qta.icon("mdi6.restore"), "Reset SBML Model", self.view
)
@@ -413,7 +422,7 @@ def setup_actions(self):
actions["simulate"] = QAction(
qta.icon("mdi6.play"), "Simulate", self.view
)
- actions["simulate"].triggered.connect(self.simulate)
+ actions["simulate"].triggered.connect(self.simulation.simulate)
# Filter widget
filter_widget = QWidget()
@@ -586,690 +595,6 @@ def sync_visibility_with_actions(self):
# Connect menu action to widget visibility
sbml_action.toggled.connect(sbml_widget.setVisible)
- def save_model(self):
- options = QFileDialog.Options()
- file_name, filtering = QFileDialog.getSaveFileName(
- self.view,
- "Save Project",
- "",
- "COMBINE Archive (*.omex);;Zip Files (*.zip);;Folder",
- options=options,
- )
- if not file_name:
- return False
-
- if filtering == "COMBINE Archive (*.omex)":
- self.model.save_as_omex(file_name)
- elif filtering == "Folder":
- if file_name.endswith("."):
- file_name = file_name[:-1]
- target = Path(file_name)
- target.mkdir(parents=True, exist_ok=True)
- self.model.save(str(target))
- file_name = str(target)
- else:
- if not file_name.endswith(".zip"):
- file_name += ".zip"
-
- # Create a temporary directory to save the model's files
- with tempfile.TemporaryDirectory() as temp_dir:
- self.model.save(temp_dir)
-
- # Create a bytes buffer to hold the zip file in memory
- buffer = BytesIO()
- with zipfile.ZipFile(buffer, "w") as zip_file:
- # Add files to zip archive
- for root, _, files in os.walk(temp_dir):
- for file in files:
- file_path = os.path.join(root, file)
- with open(file_path, "rb") as f:
- zip_file.writestr(file, f.read())
- with open(file_name, "wb") as f:
- f.write(buffer.getvalue())
-
- QMessageBox.information(
- self.view,
- "Save Project",
- f"Project saved successfully to {file_name}",
- )
-
- # Show next steps panel if not disabled
- dont_show = settings_manager.get_value(
- "next_steps/dont_show_again", False, bool
- )
- if not dont_show:
- self.next_steps_panel.show_panel()
-
- return True
-
- def save_single_table(self):
- """Save the currently active table to a tsv-file."""
- active_controller = self.active_controller()
- if not active_controller:
- QMessageBox.warning(
- self.view,
- "Save Table",
- "No active table to save.",
- )
- return None
- file_name, _ = QFileDialog.getSaveFileName(
- self.view,
- "Save Table (as *.tsv)",
- f"{active_controller.model.table_type}.tsv",
- "TSV Files (*.tsv)",
- )
- if not file_name:
- return False
- active_controller.save_table(file_name)
- return True
-
- def save_sbml_model(self):
- """Export the SBML model to an XML file."""
- if not self.model.sbml or not self.model.sbml.sbml_text:
- QMessageBox.warning(
- self.view,
- "Export SBML Model",
- "No SBML model to export.",
- )
- return False
-
- file_name, _ = QFileDialog.getSaveFileName(
- self.view,
- "Export SBML Model",
- f"{self.model.sbml.model_id}.xml",
- "SBML Files (*.xml *.sbml);;All Files (*)",
- )
- if not file_name:
- return False
-
- try:
- with open(file_name, "w") as f:
- f.write(self.model.sbml.sbml_text)
- self.logger.log_message(
- "SBML model exported successfully to file.", color="green"
- )
- return True
- except Exception as e:
- QMessageBox.critical(
- self.view,
- "Export SBML Model",
- f"Failed to export SBML model: {e}",
- )
- return False
-
- def handle_selection_changed(self):
- """Update the plot when selection in the measurement table changes."""
- self.update_plot()
-
- def handle_data_changed(self, top_left, bottom_right, roles):
- """Update the plot when the data in the measurement table changes."""
- if not roles or Qt.DisplayRole in roles:
- self.update_plot()
-
- def update_plot(self):
- """Update the plot with the selected measurement data.
-
- Extracts the selected data points from the measurement table and
- updates the plot visualization with this data.
- """
- selection_model = (
- self.view.measurement_dock.table_view.selectionModel()
- )
- indexes = selection_model.selectedIndexes()
- if not indexes:
- return
-
- selected_points = {}
- for index in indexes:
- if index.row() == self.model.measurement.get_df().shape[0]:
- continue
- row = index.row()
- observable_id = self.model.measurement._data_frame.iloc[row][
- "observableId"
- ]
- if observable_id not in selected_points:
- selected_points[observable_id] = []
- selected_points[observable_id].append(
- {
- "x": self.model.measurement._data_frame.iloc[row]["time"],
- "y": self.model.measurement._data_frame.iloc[row][
- "measurement"
- ],
- }
- )
- if selected_points == {}:
- return
-
- measurement_data = self.model.measurement._data_frame
- plot_data = {"all_data": [], "selected_points": selected_points}
- for observable_id in selected_points:
- observable_data = measurement_data[
- measurement_data["observableId"] == observable_id
- ]
- plot_data["all_data"].append(
- {
- "observable_id": observable_id,
- "x": observable_data["time"].tolist(),
- "y": observable_data["measurement"].tolist(),
- }
- )
-
- self.view.plot_dock.update_visualization(plot_data)
-
- def open_file(self, file_path=None, mode=None):
- """Determines appropriate course of action for a given file.
-
- Course of action depends on file extension, separator and header
- structure. Opens the file in the appropriate controller.
- """
- if not file_path:
- file_path, _ = QFileDialog.getOpenFileName(
- self.view,
- "Open File",
- "",
- "All supported (*.yaml *.yml *.xml *.sbml *.tsv *.csv *.txt "
- "*.omex);;"
- "PEtab Problems (*.yaml *.yml);;SBML Files (*.xml *.sbml);;"
- "PEtab Tables or Data Matrix (*.tsv *.csv *.txt);;"
- "COMBINE Archive (*.omex);;"
- "All files (*)",
- )
- if not file_path:
- return
- # handle file appropriately
- actionable, sep = process_file(file_path, self.logger)
- if actionable in ["yaml", "omex"] and mode == "append":
- self.logger.log_message(
- f"Append mode is not supported for *.{actionable} files.",
- color="red",
- )
- return
- if actionable in ["sbml"] and mode == "append":
- self.logger.log_message(
- "Append mode is not supported for SBML models.",
- color="orange",
- )
- return
- if not actionable:
- return
- if mode is None:
- if actionable in ["yaml", "sbml", "omex"]:
- mode = "overwrite"
- else:
- mode = prompt_overwrite_or_append(self)
- if mode is None:
- return
- self.recent_files_manager.add_file(file_path)
- self._open_file(actionable, file_path, sep, mode)
-
- def _open_file(self, actionable, file_path, sep, mode):
- """Overwrites the File in the appropriate controller.
-
- Actionable dictates which controller to use.
- """
- if actionable == "yaml":
- self.open_yaml_and_load_files(file_path)
- elif actionable == "omex":
- self.open_omex_and_load_files(file_path)
- elif actionable == "sbml":
- self.sbml_controller.overwrite_sbml(file_path)
- elif actionable == "measurement":
- self.measurement_controller.open_table(file_path, sep, mode)
- elif actionable == "observable":
- self.observable_controller.open_table(file_path, sep, mode)
- elif actionable == "parameter":
- self.parameter_controller.open_table(file_path, sep, mode)
- elif actionable == "condition":
- self.condition_controller.open_table(file_path, sep, mode)
- elif actionable == "visualization":
- self.visualization_controller.open_table(file_path, sep, mode)
- elif actionable == "simulation":
- self.simulation_controller.open_table(file_path, sep, mode)
- elif actionable == "data_matrix":
- self.measurement_controller.process_data_matrix_file(
- file_path, mode, sep
- )
-
- def _validate_yaml_structure(self, yaml_content):
- """Validate PEtab YAML structure before attempting to load files.
-
- Parameters
- ----------
- yaml_content : dict
- The parsed YAML content.
-
- Returns
- -------
- tuple
- (is_valid: bool, errors: list[str])
- """
- errors = []
-
- # Check format version
- if "format_version" not in yaml_content:
- errors.append("Missing 'format_version' field")
-
- # Check problems array
- if "problems" not in yaml_content:
- errors.append("Missing 'problems' field")
- return False, errors
-
- if (
- not isinstance(yaml_content["problems"], list)
- or not yaml_content["problems"]
- ):
- errors.append("'problems' must be a non-empty list")
- return False, errors
-
- problem = yaml_content["problems"][0]
-
- # Optional but recommended fields
- if (
- "visualization_files" not in problem
- or not problem["visualization_files"]
- ):
- errors.append("Warning: No visualization_files specified")
-
- # Required fields in problem
- for field in [
- "sbml_files",
- "measurement_files",
- "observable_files",
- "condition_files",
- ]:
- if field not in problem or not problem[field]:
- errors.append("Problem must contain at least one SBML file")
-
- # Check parameter_file (at root level)
- if "parameter_file" not in yaml_content:
- errors.append("Missing 'parameter_file' at root level")
-
- return len([e for e in errors if "Warning" not in e]) == 0, errors
-
- def _validate_files_exist(self, yaml_dir, yaml_content):
- """Validate that all files referenced in YAML exist.
-
- Parameters
- ----------
- yaml_dir : Path
- The directory containing the YAML file.
- yaml_content : dict
- The parsed YAML content.
-
- Returns
- -------
- tuple
- (all_exist: bool, missing_files: list[str])
- """
- missing_files = []
- problem = yaml_content["problems"][0]
-
- # Check SBML files
- for sbml_file in problem.get("sbml_files", []):
- if not (yaml_dir / sbml_file).exists():
- missing_files.append(str(sbml_file))
-
- # Check measurement files
- for meas_file in problem.get("measurement_files", []):
- if not (yaml_dir / meas_file).exists():
- missing_files.append(str(meas_file))
-
- # Check observable files
- for obs_file in problem.get("observable_files", []):
- if not (yaml_dir / obs_file).exists():
- missing_files.append(str(obs_file))
-
- # Check condition files
- for cond_file in problem.get("condition_files", []):
- if not (yaml_dir / cond_file).exists():
- missing_files.append(str(cond_file))
-
- # Check parameter file
- if "parameter_file" in yaml_content:
- param_file = yaml_content["parameter_file"]
- if not (yaml_dir / param_file).exists():
- missing_files.append(str(param_file))
-
- # Check visualization files (optional)
- for vis_file in problem.get("visualization_files", []):
- if not (yaml_dir / vis_file).exists():
- missing_files.append(str(vis_file))
-
- return len(missing_files) == 0, missing_files
-
- def _load_file_list(self, controller, file_list, file_type, yaml_dir):
- """Load multiple files for a given controller.
-
- Parameters
- ----------
- controller : object
- The controller to load files into (e.g., measurement_controller).
- file_list : list[str]
- List of file names to load.
- file_type : str
- Human-readable file type for logging (e.g., "measurement").
- yaml_dir : Path
- The directory containing the YAML and data files.
- """
- for i, file_name in enumerate(file_list):
- file_mode = "overwrite" if i == 0 else "append"
- controller.open_table(yaml_dir / file_name, mode=file_mode)
- self.logger.log_message(
- f"Loaded {file_type} file ({i + 1}/{len(file_list)}): {file_name}",
- color="blue",
- )
-
- def open_yaml_and_load_files(self, yaml_path=None, mode="overwrite"):
- """Open files from a YAML configuration.
-
- Opens a dialog to upload yaml file. Creates a PEtab problem and
- overwrites the current PEtab model with the new problem.
- """
- if not yaml_path:
- yaml_path, _ = QFileDialog.getOpenFileName(
- self.view, "Open YAML File", "", "YAML Files (*.yaml *.yml)"
- )
- if not yaml_path:
- return
- try:
- for controller in self.controllers:
- if controller == self.sbml_controller:
- continue
- controller.release_completers()
-
- # Load the YAML content
- with open(yaml_path, encoding="utf-8") as file:
- yaml_content = yaml.safe_load(file)
-
- # Validate PEtab version
- if (major := get_major_version(yaml_content)) != 1:
- raise ValueError(
- f"Only PEtab v1 problems are currently supported. "
- f"Detected version: {major}.x."
- )
-
- # Validate YAML structure
- is_valid, errors = self._validate_yaml_structure(yaml_content)
- if not is_valid:
- error_msg = "Invalid YAML structure:\n - " + "\n - ".join(
- [e for e in errors if "Warning" not in e]
- )
- self.logger.log_message(error_msg, color="red")
- QMessageBox.critical(
- self.view, "Invalid PEtab YAML", error_msg
- )
- return
-
- # Log warnings but continue
- warnings = [e for e in errors if "Warning" in e]
- for warning in warnings:
- self.logger.log_message(warning, color="orange")
-
- # Resolve the directory of the YAML file to handle relative paths
- yaml_dir = Path(yaml_path).parent
-
- # Validate file existence
- all_exist, missing_files = self._validate_files_exist(
- yaml_dir, yaml_content
- )
- if not all_exist:
- error_msg = (
- "The following files referenced in the YAML are missing:\n - "
- + "\n - ".join(missing_files)
- )
- self.logger.log_message(error_msg, color="red")
- QMessageBox.critical(self.view, "Missing Files", error_msg)
- return
-
- problem = yaml_content["problems"][0]
-
- # Load SBML model (required, single file)
- sbml_files = problem.get("sbml_files", [])
- if sbml_files:
- sbml_file_path = yaml_dir / sbml_files[0]
- self.sbml_controller.overwrite_sbml(sbml_file_path)
- self.logger.log_message(
- f"Loaded SBML file: {sbml_files[0]}", color="blue"
- )
-
- # Load measurement files (multiple allowed)
- measurement_files = problem.get("measurement_files", [])
- if measurement_files:
- self._load_file_list(
- self.measurement_controller,
- measurement_files,
- "measurement",
- yaml_dir,
- )
-
- # Load observable files (multiple allowed)
- observable_files = problem.get("observable_files", [])
- if observable_files:
- self._load_file_list(
- self.observable_controller,
- observable_files,
- "observable",
- yaml_dir,
- )
-
- # Load condition files (multiple allowed)
- condition_files = problem.get("condition_files", [])
- if condition_files:
- self._load_file_list(
- self.condition_controller,
- condition_files,
- "condition",
- yaml_dir,
- )
-
- # Load parameter file (required, single file at root level)
- if "parameter_file" in yaml_content:
- param_file = yaml_content["parameter_file"]
- self.parameter_controller.open_table(yaml_dir / param_file)
- self.logger.log_message(
- f"Loaded parameter file: {param_file}", color="blue"
- )
-
- # Load visualization files (optional, multiple allowed)
- visualization_files = problem.get("visualization_files", [])
- if visualization_files:
- self._load_file_list(
- self.visualization_controller,
- visualization_files,
- "visualization",
- yaml_dir,
- )
- else:
- self.visualization_controller.clear_table()
-
- # Simulation should be cleared
- self.simulation_controller.clear_table()
-
- self.logger.log_message(
- "All files opened successfully from the YAML configuration.",
- color="green",
- )
- self.check_model()
-
- # Rerun the completers
- for controller in self.controllers:
- if controller == self.sbml_controller:
- continue
- controller.setup_completers()
- self.unsaved_changes_change(False)
-
- except FileNotFoundError as e:
- error_msg = f"File not found: {e.filename if hasattr(e, 'filename') else str(e)}"
- self.logger.log_message(error_msg, color="red")
- QMessageBox.warning(self.view, "File Not Found", error_msg)
- except KeyError as e:
- error_msg = f"Missing required field in YAML: {str(e)}"
- self.logger.log_message(error_msg, color="red")
- QMessageBox.warning(self.view, "Invalid YAML", error_msg)
- except ValueError as e:
- error_msg = f"Invalid YAML structure: {str(e)}"
- self.logger.log_message(error_msg, color="red")
- QMessageBox.warning(self.view, "Invalid YAML", error_msg)
- except yaml.YAMLError as e:
- error_msg = f"YAML parsing error: {str(e)}"
- self.logger.log_message(error_msg, color="red")
- QMessageBox.warning(self.view, "YAML Parsing Error", error_msg)
- except Exception as e:
- error_msg = f"Unexpected error loading YAML: {str(e)}"
- self.logger.log_message(error_msg, color="red")
- logging.exception("Full traceback for YAML loading error:")
- QMessageBox.critical(self.view, "Error", error_msg)
-
- def open_omex_and_load_files(self, omex_path=None):
- """Opens a petab problem from a COMBINE Archive."""
- if not omex_path:
- omex_path, _ = QFileDialog.getOpenFileName(
- self.view,
- "Open COMBINE Archive",
- "",
- "COMBINE Archive (*.omex);;All files (*)",
- )
- if not omex_path:
- return
- try:
- combine_archive = petab.problem.Problem.from_combine(omex_path)
- except Exception as e:
- self.logger.log_message(
- f"Failed to open files from OMEX: {str(e)}", color="red"
- )
- return
- # overwrite current model
- self.measurement_controller.overwrite_df(
- combine_archive.measurement_df
- )
- self.observable_controller.overwrite_df(combine_archive.observable_df)
- self.condition_controller.overwrite_df(combine_archive.condition_df)
- self.parameter_controller.overwrite_df(combine_archive.parameter_df)
- self.visualization_controller.overwrite_df(
- combine_archive.visualization_df
- )
- self.sbml_controller.overwrite_sbml(sbml_model=combine_archive.model)
-
- def new_file(self):
- """Empty all tables. In case of unsaved changes, ask to save."""
- if self.unsaved_changes:
- reply = QMessageBox.question(
- self.view,
- "Unsaved Changes",
- "You have unsaved changes. Do you want to save them?",
- QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
- QMessageBox.Save,
- )
- if reply == QMessageBox.Save:
- self.save_model()
- for controller in self.controllers:
- if controller == self.sbml_controller:
- controller.clear_model()
- continue
- controller.clear_table()
- self.view.plot_dock.plot_it()
- self.unsaved_changes_change(False)
-
- def load_example(self, example_name):
- """Load an internal example PEtab problem.
-
- Parameters
- ----------
- example_name : str
- Name of the example subdirectory (e.g., "Boehm", "Simple_Conversion").
-
- Finds and loads the example dataset from the package directory.
- No internet connection required - the example is bundled with the package.
- """
- try:
- # Use importlib.resources to access packaged example files
- from importlib.resources import as_file, files
-
- example_files = files("petab_gui.example")
-
- # Check if the example package exists
- if not example_files.is_dir():
- error_msg = (
- "Could not find the example dataset. "
- "The example folder may not be properly installed."
- )
- self.logger.log_message(error_msg, color="red")
- QMessageBox.warning(self.view, "Example Not Found", error_msg)
- return
-
- # Get the problem.yaml file path for the specified example
- yaml_file = example_files.joinpath(example_name, "problem.yaml")
-
- with as_file(yaml_file) as yaml_path:
- if not yaml_path.exists():
- error_msg = f"Example '{example_name}' not found or problem.yaml file is missing."
- self.logger.log_message(error_msg, color="red")
- QMessageBox.warning(
- self.view, "Example Invalid", error_msg
- )
- return
-
- # Load the example
- self.logger.log_message(
- f"Loading '{example_name}' example dataset...",
- color="blue",
- )
- self.open_yaml_and_load_files(str(yaml_path))
-
- except ModuleNotFoundError as e:
- error_msg = (
- "Example dataset not found. It may not be installed properly. "
- f"Error: {str(e)}"
- )
- self.logger.log_message(error_msg, color="red")
- QMessageBox.warning(self.view, "Example Not Found", error_msg)
- except Exception as e:
- error_msg = f"Failed to load example: {str(e)}"
- self.logger.log_message(error_msg, color="red")
- QMessageBox.critical(self.view, "Error Loading Example", error_msg)
-
- def check_model(self):
- """Check the consistency of the model. And log the results."""
- capture_handler = CaptureLogHandler()
- logger_lint = logging.getLogger("petab.v1.lint")
- logger_vis = logging.getLogger("petab.v1.visualize.lint")
- logger_lint.addHandler(capture_handler)
- logger_vis.addHandler(capture_handler)
-
- try:
- # Run the consistency check
- failed = self.model.test_consistency()
-
- # Process captured logs
- if capture_handler.records:
- captured_output = "
".join(
- capture_handler.get_formatted_messages()
- )
- self.logger.log_message(
- f"Captured petab lint logs:
"
- f" {captured_output}",
- color="purple",
- )
-
- # Log the consistency check result
- if not failed:
- self.logger.log_message(
- "PEtab problem has no errors.", color="green"
- )
- for model in self.model.pandas_models.values():
- model.reset_invalid_cells()
- else:
- self.logger.log_message(
- "PEtab problem has errors.", color="red"
- )
- except Exception as e:
- msg = f"PEtab linter failed at some point: {filtered_error(e)}"
- self.logger.log_message(msg, color="red")
- finally:
- # Always remove the capture handler
- logger_lint.removeHandler(capture_handler)
- logger_vis.removeHandler(capture_handler)
-
def unsaved_changes_change(self, unsaved_changes: bool):
self.unsaved_changes = unsaved_changes
if unsaved_changes:
@@ -1289,7 +614,7 @@ def maybe_close(self):
QMessageBox.Save,
)
if reply == QMessageBox.Save:
- saved = self.save_model()
+ saved = self.file_io.save_model()
self.view.allow_close = saved
elif reply == QMessageBox.Discard:
self.view.allow_close = True
@@ -1321,7 +646,7 @@ def active_controller(self):
if active_widget == self.view.visualization_dock.table_view:
return self.visualization_controller
if active_widget == self.view.simulation_dock.table_view:
- return self.simulation_controller
+ return self.simulation_table_controller
return None
def delete_rows(self):
@@ -1385,7 +710,7 @@ def open_settings(self):
"measurement": self.measurement_controller.get_columns(),
"condition": self.condition_controller.get_columns(),
"visualization": self.visualization_controller.get_columns(),
- "simulation": self.simulation_controller.get_columns(),
+ "simulation": self.simulation_table_controller.get_columns(),
}
settings_dialog = SettingsDialog(table_columns, self.view)
settings_dialog.exec()
@@ -1402,234 +727,6 @@ def replace(self):
self.view.create_find_replace_bar()
self.view.toggle_replace()
- def init_plotter(self):
- """(Re-)initialize the plotter."""
- self.view.plot_dock.initialize(
- self.measurement_controller.proxy_model,
- self.simulation_controller.proxy_model,
- self.condition_controller.proxy_model,
- self.visualization_controller.proxy_model,
- self.model,
- )
- self.plotter = self.view.plot_dock
- self.plotter.highlighter.click_callback = self._on_plot_point_clicked
-
- def _floats_match(self, a, b, epsilon=1e-9):
- """Check if two floats match within epsilon tolerance."""
- return abs(a - b) < epsilon
-
- def _on_plot_point_clicked(self, x, y, label, data_type):
- """Handle plot point clicks and select corresponding table row.
-
- Uses epsilon tolerance for floating-point comparison to avoid
- precision issues.
- """
- # Check for None label
- if label is None:
- self.logger.log_message(
- "Cannot select table row: plot point has no label.",
- color="orange",
- )
- return
-
- # Extract observable ID from label
- proxy = self.measurement_controller.proxy_model
- view = self.measurement_controller.view.table_view
- if data_type == "simulation":
- proxy = self.simulation_controller.proxy_model
- view = self.simulation_controller.view.table_view
- obs = label
-
- x_axis_col = "time"
- y_axis_col = data_type
- observable_col = "observableId"
-
- # Get column indices with error handling
- def column_index(name):
- for col in range(proxy.columnCount()):
- if proxy.headerData(col, Qt.Horizontal) == name:
- return col
- raise ValueError(f"Column '{name}' not found.")
-
- try:
- x_col = column_index(x_axis_col)
- y_col = column_index(y_axis_col)
- obs_col = column_index(observable_col)
- except ValueError as e:
- self.logger.log_message(
- f"Table selection failed: {e}",
- color="red",
- )
- return
-
- # Search for matching row using epsilon tolerance for floats
- matched = False
- for row in range(proxy.rowCount()):
- row_obs = proxy.index(row, obs_col).data()
- row_x = proxy.index(row, x_col).data()
- row_y = proxy.index(row, y_col).data()
- try:
- row_x, row_y = float(row_x), float(row_y)
- except ValueError:
- continue
-
- # Use epsilon tolerance for float comparison
- if (
- row_obs == obs
- and self._floats_match(row_x, x)
- and self._floats_match(row_y, y)
- ):
- # Manually update highlight BEFORE selecting row
- # This ensures the circle appears even though we skip the signal handler
- if data_type == "measurement":
- self.plotter.highlight_from_selection([row])
- else:
- self.plotter.highlight_from_selection(
- [row],
- proxy=self.simulation_controller.proxy_model,
- y_axis_col="simulation",
- )
-
- # Set flag to prevent redundant highlight update from signal
- self._updating_from_plot = True
- try:
- view.selectRow(row)
- matched = True
- finally:
- self._updating_from_plot = False
- break
-
- # Provide feedback if no match found
- if not matched:
- self.logger.log_message(
- f"No matching row found for plot point (obs={obs}, x={x:.4g}, y={y:.4g})",
- color="orange",
- )
-
- def _handle_table_selection_changed(
- self, table_view, proxy=None, y_axis_col="measurement"
- ):
- """Common handler for table selection changes.
-
- Skips update if selection was triggered by plot click to prevent
- redundant highlight updates.
-
- Args:
- table_view: The table view with selection to highlight
- proxy: Optional proxy model for simulation data
- y_axis_col: Column name for y-axis data (default: "measurement")
- """
- # Skip if selection was triggered by plot point click
- if self._updating_from_plot:
- return
-
- # Set flag to prevent infinite loop if highlight triggers selection
- self._updating_from_table = True
- try:
- selected_rows = get_selected(table_view)
- if proxy:
- self.plotter.highlight_from_selection(
- selected_rows, proxy=proxy, y_axis_col=y_axis_col
- )
- else:
- self.plotter.highlight_from_selection(selected_rows)
- finally:
- self._updating_from_table = False
-
- def _on_table_selection_changed(self, selected, deselected):
- """Highlight the cells selected in measurement table."""
- self._handle_table_selection_changed(
- self.measurement_controller.view.table_view
- )
-
- def _on_simulation_selection_changed(self, selected, deselected):
- """Highlight the cells selected in simulation table."""
- self._handle_table_selection_changed(
- self.simulation_controller.view.table_view,
- proxy=self.simulation_controller.proxy_model,
- y_axis_col="simulation",
- )
-
- def simulate(self):
- """Simulate the model."""
- # obtain petab problem
- petab_problem = self.model.current_petab_problem
-
- # Check if nominalValue column exists, if not add it from SBML model
- parameter_df = petab_problem.parameter_df.copy()
- if (
- parameter_df is not None
- and not parameter_df.empty
- and petab.C.NOMINAL_VALUE not in parameter_df.columns
- ):
- self.logger.log_message(
- "nominalValue column missing in parameter table. "
- "Extracting nominal values from SBML model...",
- color="orange",
- )
- # Extract parameter values from SBML model
- sbml_model = self.model.sbml.get_current_sbml_model()
- if sbml_model is not None:
- nominal_values = []
- for param_id in parameter_df.index:
- try:
- value = sbml_model.get_parameter_value(param_id)
- nominal_values.append(value)
- except Exception:
- # If parameter not found in SBML, use default value of 1
- nominal_values.append(1.0)
-
- # Add nominalValue column to parameter_df
- parameter_df[petab.C.NOMINAL_VALUE] = nominal_values
- self.logger.log_message(
- f"Successfully extracted {len(nominal_values)} "
- f"nominal values from SBML model. Add nominalValue "
- f"column to parameter table to set values manually.",
- color="green",
- )
-
- # Update the petab problem with the modified parameter_df
- petab_problem = petab.Problem(
- condition_df=petab_problem.condition_df,
- measurement_df=petab_problem.measurement_df,
- observable_df=petab_problem.observable_df,
- parameter_df=parameter_df,
- visualization_df=petab_problem.visualization_df,
- model=petab_problem.model,
- )
-
- # import petabsimualtor
- import basico
- from basico.petab import PetabSimulator
-
- # report current basico / COPASI version
- self.logger.log_message(
- f"Simulate with basico: {basico.__version__}, COPASI: {basico.COPASI.__version__}",
- color="green",
- )
-
- import tempfile
-
- # create temp directory in temp folder:
- with tempfile.TemporaryDirectory() as temp_dir:
- # settings is only current solution statistic for now:
- settings = {"method": {"name": basico.PE.CURRENT_SOLUTION}}
- # create simulator
- simulator = PetabSimulator(
- petab_problem, settings=settings, working_dir=temp_dir
- )
-
- # simulate
- sim_df = simulator.simulate()
-
- # assign to simulation table
- self.simulation_controller.overwrite_df(sim_df)
- self.simulation_controller.model.reset_invalid_cells()
-
- def _schedule_plot_update(self):
- """Start the plot schedule timer."""
- self._plot_update_timer.start()
-
def _toggle_whats_this_mode(self, on: bool):
"""Enable/disable click-to-help mode by installing/removing the global filter.
@@ -1716,7 +813,3 @@ def _handle_next_steps_dont_show_again(self, dont_show: bool):
dont_show: Whether to suppress the panel on future saves
"""
settings_manager.set_value("next_steps/dont_show_again", dont_show)
-
- def get_current_problem(self):
- """Get the current PEtab problem from the model."""
- return self.model.current_petab_problem
diff --git a/src/petab_gui/controllers/plot_coordinator.py b/src/petab_gui/controllers/plot_coordinator.py
new file mode 100644
index 0000000..9bf01d5
--- /dev/null
+++ b/src/petab_gui/controllers/plot_coordinator.py
@@ -0,0 +1,342 @@
+"""Plot Coordinator for PEtab GUI.
+
+This module contains the PlotCoordinator class, which handles all plotting
+and selection synchronization operations, including:
+- Initializing and updating plot visualizations
+- Synchronizing selections between tables and plots
+- Handling plot point click interactions
+- Managing plot update debouncing
+"""
+
+from PySide6.QtCore import Qt, QTimer
+
+from ..utils import get_selected
+
+
+class PlotCoordinator:
+ """Coordinator for plotting and selection synchronization.
+
+ Manages the bidirectional synchronization between table selections and plot
+ highlights, handles plot interactions, and coordinates plot updates across
+ multiple data views.
+
+ Attributes
+ ----------
+ main : MainController
+ Reference to the main controller for access to models, views, and other controllers.
+ model : PEtabModel
+ The PEtab model being visualized.
+ view : MainWindow
+ The main application window.
+ logger : LoggerController
+ The logger for user feedback.
+ plotter : PlotDock
+ The plot widget for data visualization.
+ """
+
+ def __init__(self, main_controller):
+ """Initialize the PlotCoordinator.
+
+ Parameters
+ ----------
+ main_controller : MainController
+ The main controller instance.
+ """
+ self.main = main_controller
+ self.model = main_controller.model
+ self.view = main_controller.view
+ self.logger = main_controller.logger
+
+ # Plot widget reference (set by init_plotter)
+ self.plotter = None
+
+ # Selection synchronization flags to prevent redundant updates
+ self._updating_from_plot = False
+ self._updating_from_table = False
+
+ # Plot update timer for debouncing
+ self._plot_update_timer = QTimer()
+ self._plot_update_timer.setSingleShot(True)
+ self._plot_update_timer.setInterval(0)
+ self._plot_update_timer.timeout.connect(self.init_plotter)
+
+ def init_plotter(self):
+ """(Re-)initialize the plotter.
+
+ Sets up the plot widget with the current data models and configures
+ the click callback for interactive plot point selection.
+ """
+ self.view.plot_dock.initialize(
+ self.main.measurement_controller.proxy_model,
+ self.main.simulation_table_controller.proxy_model,
+ self.main.condition_controller.proxy_model,
+ self.main.visualization_controller.proxy_model,
+ self.model,
+ )
+ self.plotter = self.view.plot_dock
+ self.plotter.highlighter.click_callback = self._on_plot_point_clicked
+
+ def handle_selection_changed(self):
+ """Update the plot when selection in the measurement table changes.
+
+ This is a convenience method that delegates to update_plot().
+ """
+ self.update_plot()
+
+ def handle_data_changed(self, top_left, bottom_right, roles):
+ """Update the plot when the data in the measurement table changes.
+
+ Parameters
+ ----------
+ top_left : QModelIndex
+ Top-left index of the changed region.
+ bottom_right : QModelIndex
+ Bottom-right index of the changed region.
+ roles : list[int]
+ List of Qt item data roles that changed.
+ """
+ if not roles or Qt.DisplayRole in roles:
+ self.update_plot()
+
+ def update_plot(self):
+ """Update the plot with the selected measurement data.
+
+ Extracts the selected data points from the measurement table and
+ updates the plot visualization with this data. The plot shows all
+ data for the selected observables with the selected points highlighted.
+ """
+ selection_model = (
+ self.view.measurement_dock.table_view.selectionModel()
+ )
+ indexes = selection_model.selectedIndexes()
+ if not indexes:
+ return
+
+ selected_points = {}
+ for index in indexes:
+ if index.row() == self.model.measurement.get_df().shape[0]:
+ continue
+ row = index.row()
+ observable_id = self.model.measurement._data_frame.iloc[row][
+ "observableId"
+ ]
+ if observable_id not in selected_points:
+ selected_points[observable_id] = []
+ selected_points[observable_id].append(
+ {
+ "x": self.model.measurement._data_frame.iloc[row]["time"],
+ "y": self.model.measurement._data_frame.iloc[row][
+ "measurement"
+ ],
+ }
+ )
+ if selected_points == {}:
+ return
+
+ measurement_data = self.model.measurement._data_frame
+ plot_data = {"all_data": [], "selected_points": selected_points}
+ for observable_id in selected_points:
+ observable_data = measurement_data[
+ measurement_data["observableId"] == observable_id
+ ]
+ plot_data["all_data"].append(
+ {
+ "observable_id": observable_id,
+ "x": observable_data["time"].tolist(),
+ "y": observable_data["measurement"].tolist(),
+ }
+ )
+
+ self.view.plot_dock.update_visualization(plot_data)
+
+ def _schedule_plot_update(self):
+ """Start the plot schedule timer.
+
+ Debounces plot updates by using a timer to avoid excessive redraws
+ when data changes rapidly.
+ """
+ self._plot_update_timer.start()
+
+ def _floats_match(self, a, b, epsilon=1e-9):
+ """Check if two floats match within epsilon tolerance.
+
+ Parameters
+ ----------
+ a : float
+ First value to compare.
+ b : float
+ Second value to compare.
+ epsilon : float, optional
+ Tolerance for comparison (default: 1e-9).
+
+ Returns
+ -------
+ bool
+ True if |a - b| < epsilon, False otherwise.
+ """
+ return abs(a - b) < epsilon
+
+ def _on_plot_point_clicked(self, x, y, label, data_type):
+ """Handle plot point clicks and select corresponding table row.
+
+ Uses epsilon tolerance for floating-point comparison to avoid
+ precision issues. Synchronizes the table selection with the clicked
+ plot point.
+
+ Parameters
+ ----------
+ x : float
+ X-coordinate of the clicked point (time).
+ y : float
+ Y-coordinate of the clicked point (measurement or simulation value).
+ label : str
+ Label of the clicked point (observable ID).
+ data_type : str
+ Type of data: "measurement" or "simulation".
+ """
+ # Check for None label
+ if label is None:
+ self.logger.log_message(
+ "Cannot select table row: plot point has no label.",
+ color="orange",
+ )
+ return
+
+ # Extract observable ID from label
+ proxy = self.main.measurement_controller.proxy_model
+ view = self.main.measurement_controller.view.table_view
+ if data_type == "simulation":
+ proxy = self.main.simulation_table_controller.proxy_model
+ view = self.main.simulation_table_controller.view.table_view
+ obs = label
+
+ x_axis_col = "time"
+ y_axis_col = data_type
+ observable_col = "observableId"
+
+ # Get column indices with error handling
+ def column_index(name):
+ for col in range(proxy.columnCount()):
+ if proxy.headerData(col, Qt.Horizontal) == name:
+ return col
+ raise ValueError(f"Column '{name}' not found.")
+
+ try:
+ x_col = column_index(x_axis_col)
+ y_col = column_index(y_axis_col)
+ obs_col = column_index(observable_col)
+ except ValueError as e:
+ self.logger.log_message(
+ f"Table selection failed: {e}",
+ color="red",
+ )
+ return
+
+ # Search for matching row using epsilon tolerance for floats
+ matched = False
+ for row in range(proxy.rowCount()):
+ row_obs = proxy.index(row, obs_col).data()
+ row_x = proxy.index(row, x_col).data()
+ row_y = proxy.index(row, y_col).data()
+ try:
+ row_x, row_y = float(row_x), float(row_y)
+ except ValueError:
+ continue
+
+ # Use epsilon tolerance for float comparison
+ if (
+ row_obs == obs
+ and self._floats_match(row_x, x)
+ and self._floats_match(row_y, y)
+ ):
+ # Manually update highlight BEFORE selecting row
+ # This ensures the circle appears even though we skip the signal handler
+ if data_type == "measurement":
+ self.plotter.highlight_from_selection([row])
+ else:
+ self.plotter.highlight_from_selection(
+ [row],
+ proxy=self.main.simulation_table_controller.proxy_model,
+ y_axis_col="simulation",
+ )
+
+ # Set flag to prevent redundant highlight update from signal
+ self._updating_from_plot = True
+ try:
+ view.selectRow(row)
+ matched = True
+ finally:
+ self._updating_from_plot = False
+ break
+
+ # Provide feedback if no match found
+ if not matched:
+ self.logger.log_message(
+ f"No matching row found for plot point (obs={obs}, x={x:.4g}, y={y:.4g})",
+ color="orange",
+ )
+
+ def _handle_table_selection_changed(
+ self, table_view, proxy=None, y_axis_col="measurement"
+ ):
+ """Common handler for table selection changes.
+
+ Skips update if selection was triggered by plot click to prevent
+ redundant highlight updates. Updates the plot highlights based on
+ the current table selection.
+
+ Parameters
+ ----------
+ table_view : QTableView
+ The table view with selection to highlight.
+ proxy : QSortFilterProxyModel, optional
+ Optional proxy model for simulation data.
+ y_axis_col : str, optional
+ Column name for y-axis data (default: "measurement").
+ """
+ # Skip if selection was triggered by plot point click
+ if self._updating_from_plot:
+ return
+
+ # Set flag to prevent infinite loop if highlight triggers selection
+ self._updating_from_table = True
+ try:
+ selected_rows = get_selected(table_view)
+ if proxy:
+ self.plotter.highlight_from_selection(
+ selected_rows, proxy=proxy, y_axis_col=y_axis_col
+ )
+ else:
+ self.plotter.highlight_from_selection(selected_rows)
+ finally:
+ self._updating_from_table = False
+
+ def _on_table_selection_changed(self, selected, deselected):
+ """Highlight the cells selected in measurement table.
+
+ Parameters
+ ----------
+ selected : QItemSelection
+ The newly selected items.
+ deselected : QItemSelection
+ The newly deselected items.
+ """
+ self._handle_table_selection_changed(
+ self.main.measurement_controller.view.table_view
+ )
+
+ def _on_simulation_selection_changed(self, selected, deselected):
+ """Highlight the cells selected in simulation table.
+
+ Parameters
+ ----------
+ selected : QItemSelection
+ The newly selected items.
+ deselected : QItemSelection
+ The newly deselected items.
+ """
+ self._handle_table_selection_changed(
+ self.main.simulation_table_controller.view.table_view,
+ proxy=self.main.simulation_table_controller.proxy_model,
+ y_axis_col="simulation",
+ )
diff --git a/src/petab_gui/controllers/sbml_controller.py b/src/petab_gui/controllers/sbml_controller.py
index d2292e6..f55860d 100644
--- a/src/petab_gui/controllers/sbml_controller.py
+++ b/src/petab_gui/controllers/sbml_controller.py
@@ -9,7 +9,7 @@
from ..C import DEFAULT_ANTIMONY_TEXT
from ..models.sbml_model import SbmlViewerModel
-from ..utils import sbmlToAntimony
+from ..models.sbml_utils import sbml_to_antimony
from ..views.sbml_view import SbmlViewer
@@ -67,7 +67,7 @@ def reset_to_original_model(self):
self.model.sbml_text = libsbml.writeSBMLToString(
self.model._sbml_model_original.sbml_model.getSBMLDocument()
)
- self.model.antimony_text = sbmlToAntimony(self.model.sbml_text)
+ self.model.antimony_text = sbml_to_antimony(self.model.sbml_text)
self.view.sbml_text_edit.setPlainText(self.model.sbml_text)
self.view.antimony_text_edit.setPlainText(self.model.antimony_text)
diff --git a/src/petab_gui/controllers/simulation_controller.py b/src/petab_gui/controllers/simulation_controller.py
new file mode 100644
index 0000000..24b6652
--- /dev/null
+++ b/src/petab_gui/controllers/simulation_controller.py
@@ -0,0 +1,129 @@
+"""Simulation Controller for PEtab GUI.
+
+This module contains the SimulationController class, which handles PEtab model
+simulation operations, including:
+- Running PEtab simulations using COPASI/basico
+- Managing simulation settings
+- Handling simulation results and updating the simulation table
+"""
+
+import tempfile
+
+import petab.v1 as petab
+
+
+class SimulationController:
+ """Controller for PEtab simulations.
+
+ Handles execution of PEtab simulations using the basico/COPASI backend.
+ Manages simulation settings, runs simulations, and updates the simulation
+ results table with the output.
+
+ Attributes
+ ----------
+ main : MainController
+ Reference to the main controller for access to models, views, and other controllers.
+ model : PEtabModel
+ The PEtab model being simulated.
+ logger : LoggerController
+ The logger for user feedback.
+ simulation_table_controller : MeasurementController
+ The controller for the simulation results table.
+ """
+
+ def __init__(self, main_controller):
+ """Initialize the SimulationController.
+
+ Parameters
+ ----------
+ main_controller : MainController
+ The main controller instance.
+ """
+ self.main = main_controller
+ self.model = main_controller.model
+ self.logger = main_controller.logger
+
+ def simulate(self):
+ """Simulate the PEtab model using COPASI/basico.
+
+ Runs a simulation of the current PEtab problem using the basico
+ library with COPASI as the backend simulator. The simulation results
+ are written to the simulation table and invalid cells are cleared.
+
+ Notes
+ -----
+ Uses a temporary directory for simulation working files.
+ Currently configured to use COPASI's CURRENT_SOLUTION method.
+ Requires basico and COPASI to be installed.
+ """
+ # obtain petab problem
+ petab_problem = self.model.current_petab_problem
+
+ # Check if nominalValue column exists, if not add it from SBML model
+ parameter_df = petab_problem.parameter_df.copy()
+ if (
+ parameter_df is not None
+ and not parameter_df.empty
+ and petab.C.NOMINAL_VALUE not in parameter_df.columns
+ ):
+ self.logger.log_message(
+ "nominalValue column missing in parameter table. "
+ "Extracting nominal values from SBML model...",
+ color="orange",
+ )
+ # Extract parameter values from SBML model
+ sbml_model = self.model.sbml.get_current_sbml_model()
+ if sbml_model is not None:
+ nominal_values = []
+ for param_id in parameter_df.index:
+ try:
+ value = sbml_model.get_parameter_value(param_id)
+ nominal_values.append(value)
+ except Exception:
+ # If parameter not found in SBML, use default value of 1
+ nominal_values.append(1.0)
+
+ # Add nominalValue column to parameter_df
+ parameter_df[petab.C.NOMINAL_VALUE] = nominal_values
+ self.logger.log_message(
+ f"Successfully extracted {len(nominal_values)} "
+ f"nominal values from SBML model. Add nominalValue "
+ f"column to parameter table to set values manually.",
+ color="green",
+ )
+
+ # Update the petab problem with the modified parameter_df
+ petab_problem = petab.Problem(
+ condition_df=petab_problem.condition_df,
+ measurement_df=petab_problem.measurement_df,
+ observable_df=petab_problem.observable_df,
+ parameter_df=parameter_df,
+ visualization_df=petab_problem.visualization_df,
+ model=petab_problem.model,
+ )
+
+ # import petabsimualtor
+ import basico
+ from basico.petab import PetabSimulator
+
+ # report current basico / COPASI version
+ self.logger.log_message(
+ f"Simulate with basico: {basico.__version__}, COPASI: {basico.COPASI.__version__}",
+ color="green",
+ )
+
+ # create temp directory in temp folder:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ # settings is only current solution statistic for now:
+ settings = {"method": {"name": basico.PE.CURRENT_SOLUTION}}
+ # create simulator
+ simulator = PetabSimulator(
+ petab_problem, settings=settings, working_dir=temp_dir
+ )
+
+ # simulate
+ sim_df = simulator.simulate()
+
+ # assign to simulation table
+ self.main.simulation_table_controller.overwrite_df(sim_df)
+ self.main.simulation_table_controller.model.reset_invalid_cells()
diff --git a/src/petab_gui/controllers/table_controllers.py b/src/petab_gui/controllers/table_controllers.py
index 1edad5a..b51dc32 100644
--- a/src/petab_gui/controllers/table_controllers.py
+++ b/src/petab_gui/controllers/table_controllers.py
@@ -23,14 +23,14 @@
PandasTableFilterProxy,
PandasTableModel,
)
+from ..resources.whats_this import WHATS_THIS
from ..settings_manager import settings_manager
from ..utils import (
CaptureLogHandler,
- ConditionInputDialog,
get_selected,
process_file,
)
-from ..views.other_views import DoseTimeDialog
+from ..views.dialogs import ConditionInputDialog, DoseTimeDialog
from ..views.table_view import (
ColumnSuggestionDelegate,
ComboBoxDelegate,
@@ -38,7 +38,6 @@
SingleSuggestionDelegate,
TableViewer,
)
-from ..views.whats_this import WHATS_THIS
from .utils import linter_wrapper, prompt_overwrite_or_append, save_petab_table
@@ -181,7 +180,7 @@ def open_table(self, file_path=None, separator=None, mode="overwrite"):
else:
new_df = pd.read_csv(file_path, sep=separator, index_col=0)
except Exception as e:
- self.view.log_message(
+ self.logger.log_message(
f"Failed to read file: {str(e)}", color="red"
)
return
diff --git a/src/petab_gui/controllers/validation_controller.py b/src/petab_gui/controllers/validation_controller.py
new file mode 100644
index 0000000..550cd13
--- /dev/null
+++ b/src/petab_gui/controllers/validation_controller.py
@@ -0,0 +1,95 @@
+"""Validation Controller for PEtab GUI.
+
+This module contains the ValidationController class, which handles all model
+validation operations, including:
+- PEtab model consistency checking
+- Validation error logging and reporting
+- Invalid cell management
+"""
+
+import logging
+
+from ..utils import CaptureLogHandler
+from .utils import filtered_error
+
+
+class ValidationController:
+ """Controller for model validation.
+
+ Handles validation of PEtab models and reports errors to the user through
+ the logging system. Manages the PEtab lint process and coordinates
+ validation results with the model's invalid cell tracking.
+
+ Attributes
+ ----------
+ main : MainController
+ Reference to the main controller for access to models, views, and other controllers.
+ model : PEtabModel
+ The PEtab model being validated.
+ logger : LoggerController
+ The logger for user feedback.
+ """
+
+ def __init__(self, main_controller):
+ """Initialize the ValidationController.
+
+ Parameters
+ ----------
+ main_controller : MainController
+ The main controller instance.
+ """
+ self.main = main_controller
+ self.model = main_controller.model
+ self.logger = main_controller.logger
+
+ def check_model(self):
+ """Check the consistency of the model and log the results.
+
+ Runs the PEtab linter to validate the model structure, data, and
+ consistency. Captures and reports all validation messages, and
+ resets invalid cell markers if validation passes.
+
+ Notes
+ -----
+ Uses PEtab's built-in validation through `model.test_consistency()`.
+ Captures log messages from the PEtab linter for display to the user.
+ """
+ capture_handler = CaptureLogHandler()
+ logger_lint = logging.getLogger("petab.v1.lint")
+ logger_vis = logging.getLogger("petab.v1.visualize.lint")
+ logger_lint.addHandler(capture_handler)
+ logger_vis.addHandler(capture_handler)
+
+ try:
+ # Run the consistency check
+ failed = self.model.test_consistency()
+
+ # Process captured logs
+ if capture_handler.records:
+ captured_output = "
".join(
+ capture_handler.get_formatted_messages()
+ )
+ self.logger.log_message(
+ f"Captured petab lint logs:
"
+ f" {captured_output}",
+ color="purple",
+ )
+
+ # Log the consistency check result
+ if not failed:
+ self.logger.log_message(
+ "PEtab problem has no errors.", color="green"
+ )
+ for model in self.model.pandas_models.values():
+ model.reset_invalid_cells()
+ else:
+ self.logger.log_message(
+ "PEtab problem has errors.", color="red"
+ )
+ except Exception as e:
+ msg = f"PEtab linter failed at some point: {filtered_error(e)}"
+ self.logger.log_message(msg, color="red")
+ finally:
+ # Always remove the capture handler
+ logger_lint.removeHandler(capture_handler)
+ logger_vis.removeHandler(capture_handler)
diff --git a/src/petab_gui/controllers/default_handler.py b/src/petab_gui/models/default_handler.py
similarity index 100%
rename from src/petab_gui/controllers/default_handler.py
rename to src/petab_gui/models/default_handler.py
diff --git a/src/petab_gui/models/pandas_table_model.py b/src/petab_gui/models/pandas_table_model.py
index b7764cc..db0c48a 100644
--- a/src/petab_gui/models/pandas_table_model.py
+++ b/src/petab_gui/models/pandas_table_model.py
@@ -10,7 +10,6 @@
Signal,
)
from PySide6.QtGui import QBrush, QColor, QPalette
-from PySide6.QtWidgets import QApplication
from ..C import COLUMNS
from ..commands import (
@@ -19,16 +18,42 @@
ModifyRowCommand,
RenameIndexCommand,
)
-from ..controllers.default_handler import DefaultHandlerModel
+from ..resources.whats_this import column_whats_this
from ..settings_manager import settings_manager
from ..utils import (
create_empty_dataframe,
get_selected,
- is_invalid,
- validate_value,
)
-from ..views.whats_this import column_whats_this
+from .default_handler import DefaultHandlerModel
from .tooltips import cell_tip, header_tip
+from .validators import is_invalid, validate_value
+
+
+def _get_system_palette_color(role):
+ """Get system palette color, with fallback if Qt is not available.
+
+ Args:
+ role: QPalette color role (e.g., QPalette.Highlight)
+
+ Returns:
+ QColor: The system color or a fallback color
+ """
+ try:
+ # Try to get system palette from QApplication
+ from PySide6.QtWidgets import QApplication
+
+ app = QApplication.instance()
+ if app:
+ return app.palette().color(role)
+ except (ImportError, RuntimeError):
+ pass
+
+ # Fallback colors when Qt is not available or no QApplication
+ fallback_colors = {
+ QPalette.Highlight: QColor(51, 153, 255, 100), # Light blue
+ QPalette.HighlightedText: QColor(255, 255, 255), # White
+ }
+ return fallback_colors.get(role, QColor(0, 0, 0))
class PandasTableModel(QAbstractTableModel):
@@ -88,6 +113,13 @@ def __init__(
self.config = settings_manager.get_table_defaults(table_type)
self.default_handler = DefaultHandlerModel(self, self.config)
self.undo_stack = undo_stack
+ # Cache colors to avoid runtime dependency on QApplication
+ self._highlight_bg_color = _get_system_palette_color(
+ QPalette.Highlight
+ )
+ self._highlight_fg_color = _get_system_palette_color(
+ QPalette.HighlightedText
+ )
def rowCount(self, parent=None):
"""Return the number of rows in the model.
@@ -159,9 +191,9 @@ def data(self, index, role=Qt.DisplayRole):
if role == Qt.BackgroundRole:
return self.determine_background_color(row, column)
if role == Qt.ForegroundRole:
- # Return yellow text if this cell is a match
+ # Return highlighted text color if this cell is a match
if (row, column) in self.highlighted_cells:
- return QApplication.palette().color(QPalette.HighlightedText)
+ return self._highlight_fg_color
return QBrush(QColor(0, 0, 0)) # Default black text
if role == Qt.ToolTipRole:
if row == self._data_frame.shape[0]:
@@ -880,7 +912,7 @@ def determine_background_color(self, row, column):
if (row, column) == (self._data_frame.shape[0], 0):
return QColor(144, 238, 144, 150)
if (row, column) in self.highlighted_cells:
- return QApplication.palette().color(QPalette.Highlight)
+ return self._highlight_bg_color
if (row, column) in self._invalid_cells:
return QColor(255, 100, 100, 150)
if row % 2 == 0:
diff --git a/src/petab_gui/models/sbml_model.py b/src/petab_gui/models/sbml_model.py
index 5cd8ac4..ef8a57c 100644
--- a/src/petab_gui/models/sbml_model.py
+++ b/src/petab_gui/models/sbml_model.py
@@ -7,7 +7,7 @@
from PySide6.QtCore import QObject, QSignalBlocker, Signal
from ..C import DEFAULT_ANTIMONY_TEXT
-from ..utils import antimonyToSBML, sbmlToAntimony
+from .sbml_utils import antimony_to_sbml, sbml_to_antimony
class SbmlViewerModel(QObject):
@@ -30,7 +30,7 @@ def __init__(self, sbml_model: petab.models.Model, parent=None):
self.sbml_text = libsbml.writeSBMLToString(
self._sbml_model_original.sbml_model.getSBMLDocument()
)
- self.antimony_text = sbmlToAntimony(self.sbml_text)
+ self.antimony_text = sbml_to_antimony(self.sbml_text)
else:
self.antimony_text = DEFAULT_ANTIMONY_TEXT
with QSignalBlocker(self):
@@ -38,12 +38,12 @@ def __init__(self, sbml_model: petab.models.Model, parent=None):
self.model_id = self._get_model_id()
def convert_sbml_to_antimony(self):
- self.antimony_text = sbmlToAntimony(self.sbml_text)
+ self.antimony_text = sbml_to_antimony(self.sbml_text)
self.model_id = self._get_model_id()
self.something_changed.emit(True)
def convert_antimony_to_sbml(self):
- self.sbml_text = antimonyToSBML(self.antimony_text)
+ self.sbml_text = antimony_to_sbml(self.antimony_text)
self.model_id = self._get_model_id()
self.something_changed.emit(True)
diff --git a/src/petab_gui/models/sbml_utils.py b/src/petab_gui/models/sbml_utils.py
new file mode 100644
index 0000000..158b6e8
--- /dev/null
+++ b/src/petab_gui/models/sbml_utils.py
@@ -0,0 +1,70 @@
+"""SBML and Antimony conversion utilities."""
+
+import logging
+import os
+
+import antimony
+
+
+def _check_antimony_return_code(code):
+ """Helper for checking the antimony response code.
+
+ Raises Exception if error in antimony.
+
+ Args:
+ code: antimony response code
+
+ Raises:
+ Exception: If antimony encountered an error
+ """
+ if code < 0:
+ raise Exception(f"Antimony: {antimony.getLastError()}")
+
+
+def sbml_to_antimony(sbml):
+ """Convert SBML to antimony string.
+
+ Args:
+ sbml: SBML string or file path
+
+ Returns:
+ str: Antimony representation
+ """
+ antimony.clearPreviousLoads()
+ antimony.freeAll()
+ isfile = False
+ try:
+ isfile = os.path.isfile(sbml)
+ except Exception as e:
+ logging.warning(f"Error checking if {sbml} is a file: {str(e)}")
+ isfile = False
+ if isfile:
+ code = antimony.loadSBMLFile(sbml)
+ else:
+ code = antimony.loadSBMLString(str(sbml))
+ _check_antimony_return_code(code)
+ return antimony.getAntimonyString(None)
+
+
+def antimony_to_sbml(ant):
+ """Convert Antimony to SBML string.
+
+ Args:
+ ant: Antimony string or file path
+
+ Returns:
+ str: SBML representation
+ """
+ antimony.clearPreviousLoads()
+ antimony.freeAll()
+ try:
+ isfile = os.path.isfile(ant)
+ except ValueError:
+ isfile = False
+ if isfile:
+ code = antimony.loadAntimonyFile(ant)
+ else:
+ code = antimony.loadAntimonyString(ant)
+ _check_antimony_return_code(code)
+ mid = antimony.getMainModuleName()
+ return antimony.getSBMLString(mid)
diff --git a/src/petab_gui/models/validators.py b/src/petab_gui/models/validators.py
new file mode 100644
index 0000000..dc80618
--- /dev/null
+++ b/src/petab_gui/models/validators.py
@@ -0,0 +1,46 @@
+"""Validation utilities for PEtab data."""
+
+import math
+
+import numpy as np
+
+
+def validate_value(value, expected_type):
+ """Validate and convert a value to the expected type.
+
+ Args:
+ value: The value to validate and convert
+ expected_type: The numpy type to convert the value to
+
+ Returns:
+ tuple: A tuple containing:
+ - The converted value, or None if conversion failed
+ - An error message if conversion failed, or None if successful
+ """
+ try:
+ if expected_type == np.object_:
+ value = str(value)
+ elif expected_type == np.float64:
+ value = float(value)
+ except ValueError as e:
+ return None, str(e)
+ return value, None
+
+
+def is_invalid(value):
+ """Check if a value is invalid.
+
+ Args:
+ value: The value to check
+
+ Returns:
+ bool: True if the value is invalid (None, NaN, or infinity)
+ """
+ if value is None: # None values are invalid
+ return True
+ if isinstance(value, str): # Strings can always be displayed
+ return False
+ try:
+ return not math.isfinite(value)
+ except TypeError:
+ return True
diff --git a/src/petab_gui/views/whats_this.py b/src/petab_gui/resources/whats_this.py
similarity index 100%
rename from src/petab_gui/views/whats_this.py
rename to src/petab_gui/resources/whats_this.py
diff --git a/src/petab_gui/settings_manager.py b/src/petab_gui/settings_manager.py
index 9bb899a..157ce2d 100644
--- a/src/petab_gui/settings_manager.py
+++ b/src/petab_gui/settings_manager.py
@@ -1,428 +1,30 @@
-"""SettingsManager class to handle application setting's persistent storage.
+"""SettingsManager - Backward compatibility layer for application settings.
-Creates a single instance that will be imported and used.
-"""
-
-from PySide6.QtCore import QObject, QSettings, Qt, Signal
-from PySide6.QtWidgets import (
- QComboBox,
- QDialog,
- QFormLayout,
- QGridLayout,
- QGroupBox,
- QHBoxLayout,
- QLabel,
- QLineEdit,
- QListWidget,
- QPushButton,
- QScrollArea,
- QSizePolicy,
- QSpacerItem,
- QStackedWidget,
- QVBoxLayout,
- QWidget,
-)
-
-from .C import (
- ALLOWED_STRATEGIES,
- COPY_FROM,
- DEFAULT_CONFIGS,
- DEFAULT_VALUE,
- MODE,
- NO_DEFAULT,
- SOURCE_COLUMN,
- STRATEGIES_DEFAULT_ALL,
- STRATEGY_TOOLTIP,
- USE_DEFAULT,
-)
-
-
-class SettingsManager(QObject):
- """Handles application settings with persistent storage."""
-
- settings_changed = Signal(str) # Signal emitted when a setting is updated
- new_log_message = Signal(str, str) # message, color
-
- def __init__(self):
- """Initialize settings storage."""
- super().__init__()
- self.settings = QSettings("petab", "petab_gui")
-
- def get_value(self, key, default=None, value_type=None):
- """Retrieve a setting with an optional type conversion."""
- if value_type:
- return self.settings.value(key, default, type=value_type)
- return self.settings.value(key, default)
-
- def set_value(self, key, value):
- """Store a setting and emit a signal when changed."""
- self.settings.setValue(key, value)
- self.settings_changed.emit(key) # Notify listeners
-
- def load_ui_settings(self, main_window):
- """Load UI-related settings such as main window and dock states."""
- # Restore main window geometry and state
- main_window.restoreGeometry(
- self.get_value("main_window/geometry", main_window.saveGeometry())
- )
- main_window.restoreState(
- self.get_value("main_window/state", main_window.saveState())
- )
-
- # Restore dock widget visibility
- for dock, _ in main_window.dock_visibility.items():
- dock.setVisible(
- self.get_value(
- f"docks/{dock.objectName()}", True, value_type=bool
- )
- )
-
- main_window.data_tab.restoreGeometry(
- self.get_value(
- "data_tab/geometry", main_window.data_tab.saveGeometry()
- )
- )
- main_window.data_tab.restoreState(
- self.get_value("data_tab/state", main_window.data_tab.saveState())
- )
-
- def save_ui_settings(self, main_window):
- """Save UI-related settings such as main window and dock states."""
- # Save main window geometry and state
- self.set_value("main_window/geometry", main_window.saveGeometry())
- self.set_value("main_window/state", main_window.saveState())
-
- # Save dock widget visibility
- for dock, _ in main_window.dock_visibility.items():
- self.set_value(f"docks/{dock.objectName()}", dock.isVisible())
-
- # Save data tab settings
- self.set_value(
- "data_tab/geometry", main_window.data_tab.saveGeometry()
- )
- self.set_value("data_tab/state", main_window.data_tab.saveState())
-
- def get_table_defaults(self, table_name):
- """Retrieve default configuration for a specific table."""
- return self.settings.value(
- f"table_defaults/{table_name}", DEFAULT_CONFIGS.get(table_name, {})
- )
-
- def set_table_defaults(self, table_name, config):
- """Update default configuration for a specific table."""
- self.settings.setValue(f"table_defaults/{table_name}", config)
- self.settings_changed.emit(f"table_defaults/{table_name}")
-
-
-# Create a single instance of the SettingsManager to be imported and used
-settings_manager = SettingsManager()
-
-
-class ColumnConfigWidget(QWidget):
- """Widget for editing a single column's configuration."""
-
- def __init__(
- self, column_name, config, table_columns, strategies=None, parent=None
- ):
- """
- Initialize the column configuration widget.
-
- :param column_name:
- Name of the column
- :param config:
- Dictionary containing settings for the column
- :param table_columns:
- List of columns in the same table (used for dropdown)
- """
- super().__init__(parent)
- self.setWindowTitle(column_name)
- self.config = config
- self.table_columns = table_columns
-
- # Main vertical layout
- main_layout = QVBoxLayout(self)
- self.setLayout(main_layout)
-
- # Column Name (Bold Title)
- self.column_label = QLabel(f"{column_name}")
- self.column_label.setAlignment(Qt.AlignCenter)
- main_layout.addWidget(self.column_label)
-
- # Form layout for settings
- self.layout = QFormLayout()
- self.layout.setLabelAlignment(Qt.AlignRight)
- main_layout.addLayout(self.layout)
-
- # Strategy Dropdown
- self.strategy_choice = QComboBox()
- self.strategies = strategies if strategies else STRATEGIES_DEFAULT_ALL
- self.strategy_choice.addItems(self.strategies)
- self.strategy_choice.setCurrentText(config.get("strategy", NO_DEFAULT))
- self.strategy_choice.setToolTip(
- STRATEGY_TOOLTIP.get(self.strategy_choice.currentText(), "")
- )
- self.strategy_choice.currentTextChanged.connect(
- lambda text: self.strategy_choice.setToolTip(
- STRATEGY_TOOLTIP.get(text, "")
- )
- )
- self.strategy_row = self.add_aligned_row(
- "Strategy:", self.strategy_choice
- )
- # Default Value Input
- self.default_value = QLineEdit(str(config.get(DEFAULT_VALUE, "")))
- self.default_value_row = self.add_aligned_row(
- "Default Value:", self.default_value
- )
- # Source Column Dropdown (Only for "copy_column")
- self.source_column_dropdown = QComboBox()
- self.source_column_dropdown.addItems([""] + table_columns)
- self.source_column_dropdown.setCurrentText(
- config.get(SOURCE_COLUMN, "")
- )
- self.source_column_row = self.add_aligned_row(
- "Source Column:", self.source_column_dropdown
- )
-
- for widget in [
- self.strategy_choice,
- self.default_value,
- self.source_column_dropdown,
- ]:
- widget.setFixedWidth(150)
- widget.setMinimumHeight(24)
-
- # Connect strategy selection to update UI
- self.strategy_choice.currentTextChanged.connect(self.update_ui)
-
- # Apply initial visibility state
- self.update_ui(self.strategy_choice.currentText())
-
- def add_aligned_row(self, label_text, widget):
- """Add a row of constant size to the FormLayout."""
- container = QWidget()
- layout = QHBoxLayout(container)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.addStretch()
- layout.addWidget(widget)
- self.layout.addRow(QLabel(label_text), container)
- return container
-
- def update_ui(self, strategy):
- """Show/hide relevant fields based on selected strategy."""
- self.layout.setRowVisible(
- self.source_column_row, strategy == COPY_FROM
- )
- self.layout.setRowVisible(
- self.default_value_row, strategy == USE_DEFAULT
- )
-
- def get_current_config(self):
- """Return the current configuration from the UI."""
- config = {
- "strategy": self.strategy_choice.currentText(),
- DEFAULT_VALUE: self.default_value.text(),
- }
- if config["strategy"] == COPY_FROM:
- config[SOURCE_COLUMN] = self.source_column_dropdown.currentText()
- if config["strategy"] == MODE:
- config[SOURCE_COLUMN] = SOURCE_COLUMN # Placeholder
-
- return config
+This module maintains backward compatibility by providing a singleton instance
+of SettingsModel and re-exporting UI classes from the settings package.
+For new code, prefer importing directly from the settings package:
+ from petab_gui.settings import SettingsModel, SettingsDialog
-class TableDefaultsWidget(QWidget):
- """Widget for editing an entire table's default settings."""
-
- def __init__(self, table_name, table_columns, settings, parent=None):
- """
- Initialize the table defaults widget.
-
- :param table_name: The name of the table
- :param table_columns: List of column names in this table
- :param settings: Dictionary of settings for this table
- """
- super().__init__(parent)
- self.table_name = table_name
- self.setWindowTitle(f"{table_name.capitalize()} Table")
-
- # Use QGroupBox for better title visibility
- group_box = QGroupBox(f"{table_name.capitalize()} Table")
- group_layout = QVBoxLayout(group_box)
-
- self.column_widgets = {}
- allowed_strats = ALLOWED_STRATEGIES.get(table_name, {})
- # Iterate over columns and create widgets
- for column_name in table_columns:
- column_settings = settings.get(
- column_name, self.default_col_config()
- )
- strategies = allowed_strats.get(column_name, None)
- column_widget = ColumnConfigWidget(
- column_name, column_settings, table_columns, strategies
- )
- column_widget.setSizePolicy(
- QSizePolicy.Expanding, QSizePolicy.Minimum
- )
- group_layout.addWidget(column_widget)
- self.column_widgets[column_name] = column_widget
-
- group_layout.addSpacerItem(
- QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding)
- )
-
- # Apply layout
- main_layout = QVBoxLayout(self)
- main_layout.addWidget(group_box)
- self.setLayout(main_layout)
-
- def save_current_settings(self):
- """Retrieve settings from all column widgets."""
- table_settings = {}
-
- for column_name, column_widget in self.column_widgets.items():
- table_settings[column_name] = column_widget.get_current_config()
- settings_manager.set_table_defaults(self.table_name, table_settings)
-
- def default_col_config(self):
- """Return default config for new columns."""
- return settings_manager.get_table_defaults(self.table_name)
-
-
-class SettingsDialog(QDialog):
- """Dialog for editing application settings."""
-
- def __init__(self, table_columns, parent=None):
- """Initialize the settings dialog."""
- super().__init__(parent)
- self.setWindowTitle("Settings")
- self.table_columns = table_columns
- self.settings = {
- table_type: settings_manager.get_value(
- f"table_defaults/{table_type}", {}
- )
- for table_type in table_columns
- }
-
- self.main_layout = QHBoxLayout(self)
-
- self.nav_list = QListWidget()
- self.nav_list.addItems(["General", "Table Defaults"])
- self.nav_list.currentRowChanged.connect(self.switch_page)
- self.main_layout.addWidget(self.nav_list, 1)
-
- self.content_stack = QStackedWidget()
- self.main_layout.addWidget(self.content_stack, 3)
-
- # add pages to the stack
- self.init_general_page()
- self.init_table_defaults_page()
-
- self.nav_list.setCurrentRow(0)
-
- def switch_page(self, index):
- """Switch to the selected settings page."""
- self.content_stack.setCurrentIndex(index)
-
- def init_general_page(self):
- """Create the general settings page."""
- page = QWidget()
- layout = QVBoxLayout(page)
-
- # Header
- header = QLabel("Profile")
- desc = QLabel(
- "These information can be automatically used when saving "
- "a COMBINE archive."
- )
- desc.setWordWrap(True)
-
- layout.addWidget(header)
- layout.addWidget(desc)
-
- # Form
- form = QFormLayout()
- self.forms = {
- "general": {
- "family_name": None,
- "given_name": None,
- "email": None,
- "orga": None,
- }
- }
- for key in self.forms["general"]:
- self.forms["general"][key] = QLineEdit(
- settings_manager.get_value(f"general/{key}", "")
- )
- self.forms["general"][key].setMinimumWidth(250)
-
- form.addRow("Family Name:", self.forms["general"]["family_name"])
- form.addRow("Given Name:", self.forms["general"]["given_name"])
- form.addRow("Email:", self.forms["general"]["email"])
- form.addRow("Organization:", self.forms["general"]["orga"])
-
- layout.addLayout(form)
- page.setLayout(layout)
- self._add_buttons(page)
- self.content_stack.addWidget(page)
-
- def init_table_defaults_page(self):
- """Create the table defaults settings page."""
- page = QWidget()
- layout = QVBoxLayout(page) # Vertical layout for stacking tables
-
- # Scroll Area
- scroll_area = QScrollArea()
- scroll_area.setWidgetResizable(True) # Allow resizing
- scroll_content = QWidget()
- grid_layout = QGridLayout(scroll_content)
-
- self.table_widgets = {}
- # Add tables in a 2x2 grid
- for i_table, (table_name, column_list) in enumerate(
- self.table_columns.items()
- ):
- table_widget = TableDefaultsWidget(
- table_name, column_list, self.settings.get(table_name, {})
- )
- grid_layout.addWidget(table_widget, i_table // 2, i_table % 2)
- self.table_widgets[table_name] = table_widget
-
- scroll_content.setLayout(grid_layout)
- scroll_area.setWidget(scroll_content)
- layout.addWidget(scroll_area)
-
- self._add_buttons(page)
- self.content_stack.addWidget(page)
-
- def _add_buttons(self, page: QWidget):
- """Add Apply and Cancel buttons to a settings page."""
- button_layout = QHBoxLayout()
- self.apply_button = QPushButton("Apply")
- self.cancel_button = QPushButton("Cancel")
-
- button_layout.addStretch()
- button_layout.addWidget(self.cancel_button)
- button_layout.addWidget(self.apply_button)
- page.layout().addLayout(button_layout)
-
- self.cancel_button.clicked.connect(self.reject)
- self.apply_button.clicked.connect(self.apply_settings)
- self.apply_button.setDefault(True)
- self.apply_button.setAutoDefault(True)
- self.cancel_button.setAutoDefault(False)
-
- def apply_settings(self):
- """Retrieve UI settings and save them in SettingsManager."""
- # Save general settings
- for key in self.forms["general"]:
- settings_manager.set_value(
- f"general/{key}", self.forms["general"][key].text()
- )
+This module is kept for backward compatibility with existing imports:
+ from petab_gui.settings_manager import settings_manager, SettingsDialog
+"""
- # Save table defaults
- for _table_name, table_widget in self.table_widgets.items():
- table_widget.save_current_settings()
+from .settings import (
+ ColumnConfigWidget,
+ SettingsDialog,
+ SettingsModel,
+ TableDefaultsWidget,
+)
- settings_manager.new_log_message.emit("New settings applied.", "green")
- self.accept()
+# Create a single instance of the SettingsModel to be imported and used
+settings_manager = SettingsModel()
+
+# Re-export classes for backward compatibility
+__all__ = [
+ "settings_manager",
+ "SettingsModel",
+ "SettingsDialog",
+ "ColumnConfigWidget",
+ "TableDefaultsWidget",
+]
diff --git a/src/petab_gui/utils.py b/src/petab_gui/utils.py
index 74dcf81..aae0b08 100644
--- a/src/petab_gui/utils.py
+++ b/src/petab_gui/utils.py
@@ -1,10 +1,16 @@
+"""Shared utilities for the PEtab GUI.
+
+NOTE: This module is being deprecated in favor of layer-specific modules:
+- models/sbml_utils.py - SBML conversion functions
+- models/validators.py - Validation functions
+- views/dialogs.py - UI dialog widgets
+"""
+
import logging
-import math
import os
import re
from typing import Any
-import antimony
import numpy as np
import pandas as pd
import petab.v1 as petab
@@ -18,7 +24,6 @@
from PySide6.QtWidgets import (
QCheckBox,
QCompleter,
- QDialog,
QGridLayout,
QHBoxLayout,
QLabel,
@@ -34,180 +39,6 @@
from .C import COLUMN, INDEX, ROW
-def _checkAntimonyReturnCode(code):
- """Helper for checking the antimony response code.
-
- Raises Exception if error in antimony.
-
- :param code: antimony response
- :type code: int
- """
- if code < 0:
- raise Exception(f"Antimony: {antimony.getLastError()}")
-
-
-def sbmlToAntimony(sbml):
- """Convert SBML to antimony string.
-
- :param sbml: SBML string or file
- :type sbml: str | file
- :return: Antimony
- :rtype: str
- """
- antimony.clearPreviousLoads()
- antimony.freeAll()
- isfile = False
- try:
- isfile = os.path.isfile(sbml)
- except Exception as e:
- logging.warning(f"Error checking if {sbml} is a file: {str(e)}")
- isfile = False
- if isfile:
- code = antimony.loadSBMLFile(sbml)
- else:
- code = antimony.loadSBMLString(str(sbml))
- _checkAntimonyReturnCode(code)
- return antimony.getAntimonyString(None)
-
-
-def antimonyToSBML(ant):
- """Convert Antimony to SBML string.
-
- :param ant: Antimony string or file
- :type ant: str | file
- :return: SBML
- :rtype: str
- """
- antimony.clearPreviousLoads()
- antimony.freeAll()
- try:
- isfile = os.path.isfile(ant)
- except ValueError:
- isfile = False
- if isfile:
- code = antimony.loadAntimonyFile(ant)
- else:
- code = antimony.loadAntimonyString(ant)
- _checkAntimonyReturnCode(code)
- mid = antimony.getMainModuleName()
- return antimony.getSBMLString(mid)
-
-
-class ConditionInputDialog(QDialog):
- """Dialog for adding or editing experimental conditions.
-
- Provides input fields for simulation condition ID and optional
- preequilibration condition ID.
- """
-
- def __init__(self, condition_id=None, parent=None):
- """Initialize the condition input dialog.
-
- Args:
- condition_id:
- Optional initial value for the simulation condition ID
- parent:
- The parent widget
- """
- super().__init__(parent)
- self.setWindowTitle("Add Condition")
-
- self.layout = QVBoxLayout(self)
- self.notification_label = QLabel("", self)
- self.notification_label.setStyleSheet("color: red;")
- self.notification_label.setVisible(False)
- self.layout.addWidget(self.notification_label)
-
- # Simulation Condition
- sim_layout = QHBoxLayout()
- sim_label = QLabel("Simulation Condition:", self)
- self.sim_input = QLineEdit(self)
- if condition_id:
- self.sim_input.setText(condition_id)
- sim_layout.addWidget(sim_label)
- sim_layout.addWidget(self.sim_input)
- self.layout.addLayout(sim_layout)
-
- # Preequilibration Condition
- preeq_layout = QHBoxLayout()
- preeq_label = QLabel("Preequilibration Condition:", self)
- self.preeq_input = QLineEdit(self)
- self.preeq_input.setToolTip(
- "This field is only needed when your experiment started in steady "
- "state. In this case add here the experimental condition id for "
- "the steady state."
- )
- preeq_layout.addWidget(preeq_label)
- preeq_layout.addWidget(self.preeq_input)
- self.layout.addLayout(preeq_layout)
-
- # Buttons
- self.buttons_layout = QHBoxLayout()
- self.ok_button = QPushButton("OK", self)
- self.cancel_button = QPushButton("Cancel", self)
- self.buttons_layout.addWidget(self.ok_button)
- self.buttons_layout.addWidget(self.cancel_button)
- self.layout.addLayout(self.buttons_layout)
-
- self.ok_button.clicked.connect(self.accept)
- self.cancel_button.clicked.connect(self.reject)
-
- def accept(self):
- """Override the accept method to validate inputs before accepting.
-
- Checks if the simulation condition ID is provided.
- If not, shows an error message and prevents the dialog from closing.
- """
- if not self.sim_input.text().strip():
- self.sim_input.setStyleSheet("background-color: red;")
- self.notification_label.setText(
- "Simulation Condition is required."
- )
- self.notification_label.setVisible(True)
- return
- self.notification_label.setVisible(False)
- self.sim_input.setStyleSheet("")
- super().accept()
-
- def get_inputs(self):
- """Get the user inputs as a dictionary.
-
- Returns:
- A dictionary containing:
- - 'simulationConditionId': The simulation condition ID
- - 'preequilibrationConditionId': The preequilibration condition ID
- (only included if provided)
- """
- inputs = {}
- inputs["simulationConditionId"] = self.sim_input.text()
- preeq = self.preeq_input.text()
- if preeq:
- inputs["preequilibrationConditionId"] = preeq
- return inputs
-
-
-def validate_value(value, expected_type):
- """Validate and convert a value to the expected type.
-
- Args:
- value: The value to validate and convert
- expected_type: The numpy type to convert the value to
-
- Returns:
- tuple: A tuple containing:
- - The converted value, or None if conversion failed
- - An error message if conversion failed, or None if successful
- """
- try:
- if expected_type == np.object_:
- value = str(value)
- elif expected_type == np.float64:
- value = float(value)
- except ValueError as e:
- return None, str(e)
- return value, None
-
-
class PlotWidget(FigureCanvas):
"""A widget for displaying matplotlib plots in Qt applications.
@@ -408,9 +239,6 @@ def process_file(filepath, logger):
)
return None, None
- # if separator is None:
- # separator = "\t"
-
# Case 3.2: Identify the table type based on header content
if {petab.C.OBSERVABLE_ID, petab.C.MEASUREMENT, petab.C.TIME}.issubset(
header
@@ -430,6 +258,7 @@ def process_file(filepath, logger):
petab.C.CONDITION_ID in header
or f"\ufeff{petab.C.CONDITION_ID}" in header
):
+ # For condition files with single column, use tab as default separator
return "condition", separator if separator is not None else "\t"
if petab.C.PLOT_ID in header:
return "visualization", separator
@@ -448,15 +277,3 @@ def process_file(filepath, logger):
f"Unrecognized file type for file: {filepath}.", color="red"
)
return None, None
-
-
-def is_invalid(value):
- """Check if a value is invalid."""
- if value is None: # None values are invalid
- return True
- if isinstance(value, str): # Strings can always be displayed
- return False
- try:
- return not math.isfinite(value)
- except TypeError:
- return True
diff --git a/src/petab_gui/views/dialogs.py b/src/petab_gui/views/dialogs.py
new file mode 100644
index 0000000..1db3e6e
--- /dev/null
+++ b/src/petab_gui/views/dialogs.py
@@ -0,0 +1,428 @@
+"""Dialog widgets for the PEtab GUI."""
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QComboBox,
+ QDialog,
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QTextBrowser,
+ QVBoxLayout,
+)
+
+
+class ConditionInputDialog(QDialog):
+ """Dialog for adding or editing experimental conditions.
+
+ Provides input fields for simulation condition ID and optional
+ preequilibration condition ID.
+ """
+
+ def __init__(self, condition_id=None, parent=None):
+ """Initialize the condition input dialog.
+
+ Args:
+ condition_id:
+ Optional initial value for the simulation condition ID
+ parent:
+ The parent widget
+ """
+ super().__init__(parent)
+ self.setWindowTitle("Add Condition")
+
+ self.layout = QVBoxLayout(self)
+ self.notification_label = QLabel("", self)
+ self.notification_label.setStyleSheet("color: red;")
+ self.notification_label.setVisible(False)
+ self.layout.addWidget(self.notification_label)
+
+ # Simulation Condition
+ sim_layout = QHBoxLayout()
+ sim_label = QLabel("Simulation Condition:", self)
+ self.sim_input = QLineEdit(self)
+ if condition_id:
+ self.sim_input.setText(condition_id)
+ sim_layout.addWidget(sim_label)
+ sim_layout.addWidget(self.sim_input)
+ self.layout.addLayout(sim_layout)
+
+ # Preequilibration Condition
+ preeq_layout = QHBoxLayout()
+ preeq_label = QLabel("Preequilibration Condition:", self)
+ self.preeq_input = QLineEdit(self)
+ self.preeq_input.setToolTip(
+ "This field is only needed when your experiment started in steady "
+ "state. In this case add here the experimental condition id for "
+ "the steady state."
+ )
+ preeq_layout.addWidget(preeq_label)
+ preeq_layout.addWidget(self.preeq_input)
+ self.layout.addLayout(preeq_layout)
+
+ # Buttons
+ self.buttons_layout = QHBoxLayout()
+ self.ok_button = QPushButton("OK", self)
+ self.cancel_button = QPushButton("Cancel", self)
+ self.buttons_layout.addWidget(self.ok_button)
+ self.buttons_layout.addWidget(self.cancel_button)
+ self.layout.addLayout(self.buttons_layout)
+
+ self.ok_button.clicked.connect(self.accept)
+ self.cancel_button.clicked.connect(self.reject)
+
+ def accept(self):
+ """Override the accept method to validate inputs before accepting.
+
+ Checks if the simulation condition ID is provided.
+ If not, shows an error message and prevents the dialog from closing.
+ """
+ if not self.sim_input.text().strip():
+ self.sim_input.setStyleSheet("background-color: red;")
+ self.notification_label.setText(
+ "Simulation Condition is required."
+ )
+ self.notification_label.setVisible(True)
+ return
+ self.notification_label.setVisible(False)
+ self.sim_input.setStyleSheet("")
+ super().accept()
+
+ def get_inputs(self):
+ """Get the user inputs as a dictionary.
+
+ Returns:
+ dict: A dictionary containing:
+ - 'simulationConditionId': The simulation condition ID
+ - 'preequilibrationConditionId': The preequilibration condition ID
+ (only included if provided)
+ """
+ inputs = {}
+ inputs["simulationConditionId"] = self.sim_input.text()
+ preeq = self.preeq_input.text()
+ if preeq:
+ inputs["preequilibrationConditionId"] = preeq
+ return inputs
+
+
+class DoseTimeDialog(QDialog):
+ """Pick dose and time (or steady state)."""
+
+ def __init__(
+ self, columns: list[str], dose_suggested: list[str], parent=None
+ ):
+ super().__init__(parent)
+ self.setWindowTitle("Select Dose and Time")
+ order = [c for c in dose_suggested if c in columns] + [
+ c for c in columns if c not in dose_suggested
+ ]
+ self._dose = QComboBox(self)
+ self._dose.addItems(order)
+ self._time = QLineEdit(self)
+ self._time.setPlaceholderText(
+ "Enter constant time (e.g. 0, 5, 12.5). Use 'inf' for steady state"
+ )
+ self._preeq_edit = QLineEdit(self)
+ self._preeq_edit.setPlaceholderText(
+ "Optional preequilibrationConditionId"
+ )
+ self._dose_lbl = QLabel("Dose column:", self)
+ self._time_lbl = QLabel("Time:", self)
+ self._preeq_lbl = QLabel(
+ "Preequilibration condition (optional):", self
+ )
+ ok = QPushButton("OK", self)
+ ok.clicked.connect(self.accept)
+ cancel = QPushButton("Cancel", self)
+ cancel.clicked.connect(self.reject)
+ lay = QVBoxLayout(self)
+ row1 = QHBoxLayout()
+ row1.addWidget(self._dose_lbl)
+ row1.addWidget(self._dose)
+ lay.addLayout(row1)
+ row2 = QHBoxLayout()
+ row2.addWidget(self._time_lbl)
+ row2.addWidget(self._time)
+ lay.addLayout(row2)
+ row3 = QHBoxLayout()
+ row3.addWidget(self._preeq_lbl)
+ row3.addWidget(self._preeq_edit)
+ lay.addLayout(row3)
+ btns = QHBoxLayout()
+ btns.addWidget(cancel)
+ btns.addWidget(ok)
+ lay.addLayout(btns)
+
+ def get_result(self) -> tuple[str | None, str | None, str]:
+ dose = self._dose.currentText() or None
+ time_text = (self._time.text() or "").strip() or None
+ preeq = (self._preeq_edit.text() or "").strip()
+ return dose, time_text, preeq
+
+
+class NextStepsPanel(QDialog):
+ """Non-modal panel showing possible next steps after saving."""
+
+ dont_show_again_changed = Signal(bool)
+
+ # Styling constants
+ MIN_WIDTH = 450
+ MAX_WIDTH = 600
+ MIN_HEIGHT = 360
+ FRAME_PADDING = 8
+ FRAME_BORDER_RADIUS = 4
+ LAYOUT_MARGIN = 12
+ LAYOUT_SPACING = 10
+
+ # Card background colors
+ COLOR_BENCHMARK = "rgba(255, 193, 7, 0.08)"
+ COLOR_PYPESTO = "rgba(100, 149, 237, 0.08)"
+ COLOR_COPASI = "rgba(169, 169, 169, 0.08)"
+ COLOR_OTHER_TOOLS = "rgba(144, 238, 144, 0.08)"
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Possible next steps")
+ self.setModal(False)
+ self.setMinimumWidth(self.MIN_WIDTH)
+ self.setMaximumWidth(self.MAX_WIDTH)
+ self.setMinimumHeight(self.MIN_HEIGHT)
+
+ # Main layout
+ main_layout = QVBoxLayout(self)
+ main_layout.setContentsMargins(
+ self.LAYOUT_MARGIN,
+ self.LAYOUT_MARGIN,
+ self.LAYOUT_MARGIN,
+ self.LAYOUT_MARGIN,
+ )
+ main_layout.setSpacing(self.LAYOUT_SPACING)
+
+ # Description
+ desc = QLabel(
+ "This parameter estimation problem can now be used in the following tools:"
+ )
+ desc.setWordWrap(True)
+ main_layout.addWidget(desc)
+
+ # Main suggestions
+ suggestions_layout = QVBoxLayout()
+ suggestions_layout.setSpacing(8)
+
+ # Benchmark Collection action
+ benchmark_frame = self._create_tool_card(
+ bg_color=self.COLOR_BENCHMARK,
+ html_content=(
+ '
'
+ "📚 Contribute to Benchmark Collection
"
+ "Share your publsihed PEtab problem with the community to "
+ "validate it, enable reproducibility, and support "
+ "benchmarking.
"
+ 'Benchmark Collection
'
+ "â–¶ Parameter Estimation with pyPESTO
"
+ "Use pyPESTO for parameter estimation, uncertainty analysis, "
+ "and model selection.
"
+ 'pyPESTO documentation
'
+ "âš™ Advanced Model Adaptation and Simulation
"
+ "Use COPASI for further model adjustment and advanced "
+ "simulation with a graphical interface.
"
+ 'COPASI website
'
- "📚 Contribute to Benchmark Collection
"
- "Share your publsihed PEtab problem with the community to "
- "validate it, enable reproducibility, and support "
- "benchmarking.
"
- 'Benchmark Collection
'
- "â–¶ Parameter Estimation with pyPESTO
"
- "Use pyPESTO for parameter estimation, uncertainty analysis, "
- "and model selection.
"
- 'pyPESTO documentation
'
- "âš™ Advanced Model Adaptation and Simulation
"
- "Use COPASI for further model adjustment and advanced "
- "simulation with a graphical interface.
"
- 'COPASI website