From 056f2e5df815ce00b79d3a3f514411629ebe77a2 Mon Sep 17 00:00:00 2001 From: PaulJonasJost Date: Thu, 12 Mar 2026 11:31:39 +0100 Subject: [PATCH 1/4] Strong refactoring, trying to disentangle imports from models views and controllers --- .../controllers/mother_controller.py | 2 +- src/petab_gui/controllers/sbml_controller.py | 4 +- .../controllers/table_controllers.py | 5 +- .../default_handler.py | 0 src/petab_gui/models/pandas_table_model.py | 48 +- src/petab_gui/models/sbml_model.py | 8 +- src/petab_gui/models/sbml_utils.py | 70 +++ src/petab_gui/models/validators.py | 46 ++ .../{views => resources}/whats_this.py | 0 src/petab_gui/utils.py | 197 +------- src/petab_gui/views/dialogs.py | 428 ++++++++++++++++++ src/petab_gui/views/main_view.py | 2 +- src/petab_gui/views/other_views.py | 337 +------------- src/petab_gui/views/sbml_view.py | 2 +- 14 files changed, 606 insertions(+), 543 deletions(-) rename src/petab_gui/{controllers => models}/default_handler.py (100%) create mode 100644 src/petab_gui/models/sbml_utils.py create mode 100644 src/petab_gui/models/validators.py rename src/petab_gui/{views => resources}/whats_this.py (100%) create mode 100644 src/petab_gui/views/dialogs.py diff --git a/src/petab_gui/controllers/mother_controller.py b/src/petab_gui/controllers/mother_controller.py index 04b98e5..f6868fa 100644 --- a/src/petab_gui/controllers/mother_controller.py +++ b/src/petab_gui/controllers/mother_controller.py @@ -42,7 +42,7 @@ process_file, ) from ..views import TaskBar -from ..views.other_views import NextStepsPanel +from ..views.dialogs import NextStepsPanel from .logger_controller import LoggerController from .sbml_controller import SbmlController from .table_controllers import ( 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/table_controllers.py b/src/petab_gui/controllers/table_controllers.py index 1edad5a..89a190b 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 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/utils.py b/src/petab_gui/utils.py index 24bb48e..e04b4dd 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. @@ -445,15 +276,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

' + ), + ) + suggestions_layout.addWidget(benchmark_frame) + + # pyPESTO action + pypesto_frame = self._create_tool_card( + bg_color=self.COLOR_PYPESTO, + html_content=( + '

' + "â–¶ Parameter Estimation with pyPESTO
" + "Use pyPESTO for parameter estimation, uncertainty analysis, " + "and model selection.
" + 'pyPESTO documentation

' + ), + ) + suggestions_layout.addWidget(pypesto_frame) + + # COPASI action + copasi_frame = self._create_tool_card( + bg_color=self.COLOR_COPASI, + html_content=( + '

' + "âš™ Advanced Model Adaptation and Simulation
" + "Use COPASI for further model adjustment and advanced " + "simulation with a graphical interface.
" + 'COPASI website

' + ), + ) + suggestions_layout.addWidget(copasi_frame) + + main_layout.addLayout(suggestions_layout) + + # Collapsible section for other tools + self._other_tools_btn = QPushButton( + "📊 ▶ Other tools supporting PEtab" + ) + self._other_tools_btn.setCheckable(True) + self._other_tools_btn.setFlat(True) + self._other_tools_btn.setStyleSheet( + "QPushButton { text-align: left; padding: 6px; " + "font-weight: normal; }" + "QPushButton:checked { font-weight: bold; }" + ) + self._other_tools_btn.clicked.connect(self._toggle_other_tools) + main_layout.addWidget(self._other_tools_btn) + + # Other tools frame (initially hidden) + self._other_tools_frame = QFrame() + self._other_tools_frame.setStyleSheet( + f"QFrame {{ background-color: {self.COLOR_OTHER_TOOLS}; " + f"border-radius: {self.FRAME_BORDER_RADIUS}px; " + f"padding: {self.FRAME_PADDING}px; }}" + ) + self._other_tools_frame.setVisible(False) + other_tools_layout = QVBoxLayout(self._other_tools_frame) + other_tools_layout.setContentsMargins( + self.FRAME_PADDING, + self.FRAME_PADDING, + self.FRAME_PADDING, + self.FRAME_PADDING, + ) + other_tools_layout.setSpacing(4) + + # Framing text + framing_text = QLabel("Additional tools in the PEtab ecosystem:") + framing_text.setWordWrap(True) + other_tools_layout.addWidget(framing_text) + + other_tools_text = QTextBrowser() + other_tools_text.setOpenExternalLinks(True) + other_tools_text.setMaximumHeight(120) + other_tools_text.setFrameStyle(QFrame.NoFrame) + other_tools_text.setStyleSheet( + "QTextBrowser { background: transparent; }" + ) + other_tools_text.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + other_tools_text.setHtml( + '" + ) + other_tools_layout.addWidget(other_tools_text) + + main_layout.addWidget(self._other_tools_frame) + + # Spacer + main_layout.addStretch() + + # Reassurance text + reassurance = QLabel( + "You can always access this dialog from the " + "Help menu." + ) + reassurance.setWordWrap(True) + reassurance.setStyleSheet("QLabel { color: gray; padding: 0; }") + main_layout.addWidget(reassurance) + + # Bottom section with checkbox and close button + bottom_layout = QHBoxLayout() + bottom_layout.setSpacing(8) + + self._dont_show_checkbox = QCheckBox("Don't show after saving") + self._dont_show_checkbox.toggled.connect( + self.dont_show_again_changed.emit + ) + bottom_layout.addWidget(self._dont_show_checkbox) + + bottom_layout.addStretch() + + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.close) + close_btn.setDefault(True) + bottom_layout.addWidget(close_btn) + + main_layout.addLayout(bottom_layout) + + def _create_tool_card( + self, bg_color: str, html_content: str, scrollbar_policy=None + ) -> QFrame: + """Create a styled card for displaying tool information. + + Args: + bg_color: Background color for the frame (rgba string) + html_content: HTML content to display in the text browser + scrollbar_policy: Optional scrollbar policy (defaults to AlwaysOff) + + Returns: + Configured QFrame containing the tool information + """ + frame = QFrame() + frame.setStyleSheet( + f"QFrame {{ background-color: {bg_color}; " + f"border-radius: {self.FRAME_BORDER_RADIUS}px; " + f"padding: {self.FRAME_PADDING}px; }}" + ) + layout = QVBoxLayout(frame) + layout.setContentsMargins( + self.FRAME_PADDING, + self.FRAME_PADDING, + self.FRAME_PADDING, + self.FRAME_PADDING, + ) + layout.setSpacing(4) + + text_browser = QTextBrowser() + text_browser.setOpenExternalLinks(True) + text_browser.setFrameStyle(QFrame.NoFrame) + text_browser.setStyleSheet("QTextBrowser { background: transparent; }") + if scrollbar_policy is None: + scrollbar_policy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + text_browser.setVerticalScrollBarPolicy(scrollbar_policy) + text_browser.setHtml(html_content) + layout.addWidget(text_browser) + + return frame + + def _toggle_other_tools(self, checked): + """Toggle visibility of other tools section.""" + self._other_tools_frame.setVisible(checked) + # Update button text to show expand/collapse state + arrow = "▼" if checked else "▶" + icon = "📊" + self._other_tools_btn.setText( + f"{icon} {arrow} Other tools supporting PEtab" + ) + # Adjust window size + self.adjustSize() + + def set_dont_show_again(self, dont_show: bool): + """Set the 'don't show again' checkbox state.""" + self._dont_show_checkbox.setChecked(dont_show) + + def show_panel(self): + """Show the panel and center it on the parent.""" + if self.parent(): + # Center on parent window + parent_geo = self.parent().geometry() + self.move( + parent_geo.center().x() - self.width() // 2, + parent_geo.center().y() - self.height() // 2, + ) + self.show() + self.raise_() + self.activateWindow() diff --git a/src/petab_gui/views/main_view.py b/src/petab_gui/views/main_view.py index c16f084..d672ccf 100644 --- a/src/petab_gui/views/main_view.py +++ b/src/petab_gui/views/main_view.py @@ -25,13 +25,13 @@ SIM_TABLE_TOOLTIP, VIS_TABLE_TOOLTIP, ) +from ..resources.whats_this import WHATS_THIS from ..settings_manager import settings_manager from .find_replace_bar import FindReplaceBar from .logger import Logger from .sbml_view import SbmlViewer from .simple_plot_view import MeasurementPlotter from .table_view import TableViewer -from .whats_this import WHATS_THIS class MainWindow(QMainWindow): diff --git a/src/petab_gui/views/other_views.py b/src/petab_gui/views/other_views.py index f75561f..cf3e2f6 100644 --- a/src/petab_gui/views/other_views.py +++ b/src/petab_gui/views/other_views.py @@ -1,335 +1,4 @@ -"""Collection of other views aside from the main ones.""" +"""Collection of other views aside from the main ones. -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QCheckBox, - QComboBox, - QDialog, - QFrame, - QHBoxLayout, - QLabel, - QLineEdit, - QPushButton, - QTextBrowser, - QVBoxLayout, -) - - -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

' - ), - ) - suggestions_layout.addWidget(benchmark_frame) - - # pyPESTO action - pypesto_frame = self._create_tool_card( - bg_color=self.COLOR_PYPESTO, - html_content=( - '

' - "â–¶ Parameter Estimation with pyPESTO
" - "Use pyPESTO for parameter estimation, uncertainty analysis, " - "and model selection.
" - 'pyPESTO documentation

' - ), - ) - suggestions_layout.addWidget(pypesto_frame) - - # COPASI action - copasi_frame = self._create_tool_card( - bg_color=self.COLOR_COPASI, - html_content=( - '

' - "âš™ Advanced Model Adaptation and Simulation
" - "Use COPASI for further model adjustment and advanced " - "simulation with a graphical interface.
" - 'COPASI website

' - ), - ) - suggestions_layout.addWidget(copasi_frame) - - main_layout.addLayout(suggestions_layout) - - # Collapsible section for other tools - self._other_tools_btn = QPushButton( - "📊 ▶ Other tools supporting PEtab" - ) - self._other_tools_btn.setCheckable(True) - self._other_tools_btn.setFlat(True) - self._other_tools_btn.setStyleSheet( - "QPushButton { text-align: left; padding: 6px; " - "font-weight: normal; }" - "QPushButton:checked { font-weight: bold; }" - ) - self._other_tools_btn.clicked.connect(self._toggle_other_tools) - main_layout.addWidget(self._other_tools_btn) - - # Other tools frame (initially hidden) - self._other_tools_frame = QFrame() - self._other_tools_frame.setStyleSheet( - f"QFrame {{ background-color: {self.COLOR_OTHER_TOOLS}; " - f"border-radius: {self.FRAME_BORDER_RADIUS}px; " - f"padding: {self.FRAME_PADDING}px; }}" - ) - self._other_tools_frame.setVisible(False) - other_tools_layout = QVBoxLayout(self._other_tools_frame) - other_tools_layout.setContentsMargins( - self.FRAME_PADDING, - self.FRAME_PADDING, - self.FRAME_PADDING, - self.FRAME_PADDING, - ) - other_tools_layout.setSpacing(4) - - # Framing text - framing_text = QLabel("Additional tools in the PEtab ecosystem:") - framing_text.setWordWrap(True) - other_tools_layout.addWidget(framing_text) - - other_tools_text = QTextBrowser() - other_tools_text.setOpenExternalLinks(True) - other_tools_text.setMaximumHeight(120) - other_tools_text.setFrameStyle(QFrame.NoFrame) - other_tools_text.setStyleSheet( - "QTextBrowser { background: transparent; }" - ) - other_tools_text.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded - ) - other_tools_text.setHtml( - '" - ) - other_tools_layout.addWidget(other_tools_text) - - main_layout.addWidget(self._other_tools_frame) - - # Spacer - main_layout.addStretch() - - # Reassurance text - reassurance = QLabel( - "You can always access this dialog from the " - "Help menu." - ) - reassurance.setWordWrap(True) - reassurance.setStyleSheet("QLabel { color: gray; padding: 0; }") - main_layout.addWidget(reassurance) - - # Bottom section with checkbox and close button - bottom_layout = QHBoxLayout() - bottom_layout.setSpacing(8) - - self._dont_show_checkbox = QCheckBox("Don't show after saving") - self._dont_show_checkbox.toggled.connect( - self.dont_show_again_changed.emit - ) - bottom_layout.addWidget(self._dont_show_checkbox) - - bottom_layout.addStretch() - - close_btn = QPushButton("Close") - close_btn.clicked.connect(self.close) - close_btn.setDefault(True) - bottom_layout.addWidget(close_btn) - - main_layout.addLayout(bottom_layout) - - def _create_tool_card( - self, bg_color: str, html_content: str, scrollbar_policy=None - ) -> QFrame: - """Create a styled card for displaying tool information. - - Args: - bg_color: Background color for the frame (rgba string) - html_content: HTML content to display in the text browser - scrollbar_policy: Optional scrollbar policy (defaults to AlwaysOff) - - Returns: - Configured QFrame containing the tool information - """ - frame = QFrame() - frame.setStyleSheet( - f"QFrame {{ background-color: {bg_color}; " - f"border-radius: {self.FRAME_BORDER_RADIUS}px; " - f"padding: {self.FRAME_PADDING}px; }}" - ) - layout = QVBoxLayout(frame) - layout.setContentsMargins( - self.FRAME_PADDING, - self.FRAME_PADDING, - self.FRAME_PADDING, - self.FRAME_PADDING, - ) - layout.setSpacing(4) - - text_browser = QTextBrowser() - text_browser.setOpenExternalLinks(True) - text_browser.setFrameStyle(QFrame.NoFrame) - text_browser.setStyleSheet("QTextBrowser { background: transparent; }") - if scrollbar_policy is None: - scrollbar_policy = Qt.ScrollBarPolicy.ScrollBarAlwaysOff - text_browser.setVerticalScrollBarPolicy(scrollbar_policy) - text_browser.setHtml(html_content) - layout.addWidget(text_browser) - - return frame - - def _toggle_other_tools(self, checked): - """Toggle visibility of other tools section.""" - self._other_tools_frame.setVisible(checked) - # Update button text to show expand/collapse state - arrow = "▼" if checked else "▶" - icon = "📊" - self._other_tools_btn.setText( - f"{icon} {arrow} Other tools supporting PEtab" - ) - # Adjust window size - self.adjustSize() - - def set_dont_show_again(self, dont_show: bool): - """Set the 'don't show again' checkbox state.""" - self._dont_show_checkbox.setChecked(dont_show) - - def show_panel(self): - """Show the panel and center it on the parent.""" - if self.parent(): - # Center on parent window - parent_geo = self.parent().geometry() - self.move( - parent_geo.center().x() - self.width() // 2, - parent_geo.center().y() - self.height() // 2, - ) - self.show() - self.raise_() - self.activateWindow() +Note: DoseTimeDialog and NextStepsPanel have been moved to dialogs.py +""" diff --git a/src/petab_gui/views/sbml_view.py b/src/petab_gui/views/sbml_view.py index 318b01d..325795f 100644 --- a/src/petab_gui/views/sbml_view.py +++ b/src/petab_gui/views/sbml_view.py @@ -12,7 +12,7 @@ ) from ..models.tooltips import ANTIMONY_VIEW_TOOLTIP, SBML_VIEW_TOOLTIP -from .whats_this import WHATS_THIS +from ..resources.whats_this import WHATS_THIS class SbmlViewer(QWidget): From 36e7e32924859e81ebb98cd580d63e754dd0f680 Mon Sep 17 00:00:00 2001 From: PaulJonasJost Date: Thu, 12 Mar 2026 16:56:37 +0100 Subject: [PATCH 2/4] defractured mother controller slightly --- .../controllers/file_io_controller.py | 724 ++++++++++++++ .../controllers/mother_controller.py | 946 +----------------- src/petab_gui/controllers/plot_coordinator.py | 342 +++++++ .../controllers/simulation_controller.py | 84 ++ .../controllers/validation_controller.py | 95 ++ src/petab_gui/views/find_replace_bar.py | 2 +- 6 files changed, 1287 insertions(+), 906 deletions(-) create mode 100644 src/petab_gui/controllers/file_io_controller.py create mode 100644 src/petab_gui/controllers/plot_coordinator.py create mode 100644 src/petab_gui/controllers/simulation_controller.py create mode 100644 src/petab_gui/controllers/validation_controller.py 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 f6868fa..31a6da9 100644 --- a/src/petab_gui/controllers/mother_controller.py +++ b/src/petab_gui/controllers/mother_controller.py @@ -43,8 +43,11 @@ ) from ..views import TaskBar 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,191 +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 - - # 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. @@ -1673,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/simulation_controller.py b/src/petab_gui/controllers/simulation_controller.py new file mode 100644 index 0000000..24acfaa --- /dev/null +++ b/src/petab_gui/controllers/simulation_controller.py @@ -0,0 +1,84 @@ +"""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 + + +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 + + # 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/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/views/find_replace_bar.py b/src/petab_gui/views/find_replace_bar.py index f9fad76..60a26a3 100644 --- a/src/petab_gui/views/find_replace_bar.py +++ b/src/petab_gui/views/find_replace_bar.py @@ -26,7 +26,7 @@ def __init__(self, controller, parent=None): "Parameter Table": self.controller.parameter_controller, "Measurement Table": self.controller.measurement_controller, "Visualization Table": self.controller.visualization_controller, - "Simulation Table": self.controller.simulation_controller, + "Simulation Table": self.controller.simulation_table_controller, } self.selected_controllers = self.controller_map.values() self.only_search = False From 74a2bc17b44e533fc874864019574f816f08f11a Mon Sep 17 00:00:00 2001 From: PaulJonasJost Date: Mon, 16 Mar 2026 10:21:55 +0100 Subject: [PATCH 3/4] temporary fronzen. Issues with pandas > 3.0.0 --- .../controllers/table_controllers.py | 2 +- src/petab_gui/settings_manager.py | 448 +----------------- src/petab_gui/utils.py | 3 +- 3 files changed, 28 insertions(+), 425 deletions(-) diff --git a/src/petab_gui/controllers/table_controllers.py b/src/petab_gui/controllers/table_controllers.py index 89a190b..b51dc32 100644 --- a/src/petab_gui/controllers/table_controllers.py +++ b/src/petab_gui/controllers/table_controllers.py @@ -180,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/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 e04b4dd..aae0b08 100644 --- a/src/petab_gui/utils.py +++ b/src/petab_gui/utils.py @@ -258,7 +258,8 @@ def process_file(filepath, logger): petab.C.CONDITION_ID in header or f"\ufeff{petab.C.CONDITION_ID}" in header ): - return "condition", separator + # 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 logger.log_message( From 3680e507d42e6c43cafd7a1a15604369e59a72ec Mon Sep 17 00:00:00 2001 From: PaulJonasJost Date: Wed, 18 Mar 2026 14:58:38 +0100 Subject: [PATCH 4/4] Added #238 back into code (lost due to merge conflict) --- .../controllers/simulation_controller.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/petab_gui/controllers/simulation_controller.py b/src/petab_gui/controllers/simulation_controller.py index 24acfaa..24b6652 100644 --- a/src/petab_gui/controllers/simulation_controller.py +++ b/src/petab_gui/controllers/simulation_controller.py @@ -9,6 +9,8 @@ import tempfile +import petab.v1 as petab + class SimulationController: """Controller for PEtab simulations. @@ -57,6 +59,49 @@ def simulate(self): # 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