diff --git a/src/petab_gui/C.py b/src/petab_gui/C.py index e7acf26..eb412af 100644 --- a/src/petab_gui/C.py +++ b/src/petab_gui/C.py @@ -67,6 +67,7 @@ MIN_COLUMN = "use column min" MAX_COLUMN = "use column max" MODE = "use most frequent" +SBML_LOOK = "sbml value" STRATEGIES_DEFAULT = [COPY_FROM, USE_DEFAULT, NO_DEFAULT] STRATEGIES_DEFAULT_EXT = STRATEGIES_DEFAULT + [MODE] STRATEGIES_DEFAULT_ALL = STRATEGIES_DEFAULT_EXT + [MIN_COLUMN, MAX_COLUMN] @@ -77,6 +78,7 @@ MIN_COLUMN: "Use the minimum value of the column", MAX_COLUMN: "Use the maximum value of the column", MODE: "Use the most frequent value of the column", + SBML_LOOK: "Use the value from the SBML model", } SOURCE_COLUMN = "source_column" DEFAULT_VALUE = "default_value" @@ -96,7 +98,7 @@ "parameterScale": [USE_DEFAULT, NO_DEFAULT, MODE], "lowerBound": [MIN_COLUMN, MAX_COLUMN, USE_DEFAULT, NO_DEFAULT, MODE], "upperBound": [MAX_COLUMN, MAX_COLUMN, USE_DEFAULT, NO_DEFAULT, MODE], - "nominalValue": [USE_DEFAULT, NO_DEFAULT], + "nominalValue": [USE_DEFAULT, NO_DEFAULT, SBML_LOOK], "estimate": [USE_DEFAULT, NO_DEFAULT, MODE], } ALLOWED_STRATEGIES_COND = { @@ -157,6 +159,9 @@ "estimate": { "strategy": USE_DEFAULT, DEFAULT_VALUE: 1 }, + "nominalValue": { + "strategy": SBML_LOOK + }, } DEFAULT_COND_CONFIG = { "conditionId": { diff --git a/src/petab_gui/commands.py b/src/petab_gui/commands.py index 34336d2..2563c1a 100644 --- a/src/petab_gui/commands.py +++ b/src/petab_gui/commands.py @@ -99,7 +99,7 @@ def redo(self): position = df.shape[0] - 1 # insert *before* the auto-row self.model.beginInsertRows(QModelIndex(), position, position + len(self.row_indices) - 1) for i, idx in enumerate(self.row_indices): - df.loc[idx] = [""] * df.shape[1] + df.loc[idx] = [np.nan] * df.shape[1] self.model.endInsertRows() else: self.model.beginRemoveRows(QModelIndex(), min(self.row_indices), max(self.row_indices)) diff --git a/src/petab_gui/controllers/default_handler.py b/src/petab_gui/controllers/default_handler.py index 3c98772..1454f93 100644 --- a/src/petab_gui/controllers/default_handler.py +++ b/src/petab_gui/controllers/default_handler.py @@ -5,11 +5,11 @@ from collections import Counter from ..C import (COPY_FROM, USE_DEFAULT, NO_DEFAULT, MIN_COLUMN, MAX_COLUMN, - MODE, DEFAULT_VALUE, SOURCE_COLUMN, STRATEGIES_DEFAULT) + MODE, DEFAULT_VALUE, SOURCE_COLUMN, SBML_LOOK) class DefaultHandlerModel: - def __init__(self, model, config): + def __init__(self, model, config, sbml_model = None): """ Initialize the handler for the model. :param model: The PandasTable Model containing the Data. @@ -20,12 +20,21 @@ def __init__(self, model, config): self.model = model._data_frame self.config = config self.model_index = self.model.index.name + self._sbml_model = sbml_model - def get_default(self, column_name, row_index=None): + def get_default( + self, + column_name, + row_index=None, + par_scale=None, + changed: dict | None = None, + ): """ Get the default value for a column based on its strategy. :param column_name: The name of the column to compute the default for. :param row_index: Optional index of the row (needed for some strategies). + :param par_scale: Optional parameter scale (needed for some strategies). + :param changed: Optional tuple containing the column name and index of the changed cell. :return: The computed default value. """ source_column = column_name @@ -40,40 +49,64 @@ def get_default(self, column_name, row_index=None): default_value = column_config.get(DEFAULT_VALUE, "") if strategy == USE_DEFAULT: + if self.model.dtypes[column_name] == float: + return float(default_value) return default_value elif strategy == NO_DEFAULT: return "" elif strategy == MIN_COLUMN: - return self._min_column(column_name) + return self._min_column(column_name, par_scale) elif strategy == MAX_COLUMN: - return self._max_column(column_name) + return self._max_column(column_name, par_scale) elif strategy == COPY_FROM: - return self._copy_column(column_name, column_config, row_index) + return self._copy_column( + column_name, column_config, row_index, changed + ) elif strategy == MODE: column_config[SOURCE_COLUMN] = source_column return self._majority_vote(column_name, column_config) + elif strategy == SBML_LOOK: + return self._sbml_lookup(row_index) else: raise ValueError(f"Unknown strategy '{strategy}' for column '{column_name}'.") - def _min_column(self, column_name): - if column_name in self.model: - column_data = self.model[column_name].replace("", np.nan).dropna() - if not column_data.empty: - return column_data.min() - return "" + def _min_column(self, column_name, par_scale=None): + if column_name not in self.model: + return "" + column_data = self.model[column_name].replace("", np.nan).dropna() + if column_name in ["upperBound", "lowerBound"]: + column_data = column_data.loc[ + self.model["parameterScale"] == par_scale + ] + if not column_data.empty: + return column_data.min() - def _max_column(self, column_name): - if column_name in self.model: - column_data = self.model[column_name].replace("", np.nan).dropna() - if not column_data.empty: - return column_data.max() - return "" + def _max_column(self, column_name, par_scale=None): + if column_name not in self.model: + return "" + column_data = self.model[column_name].replace("", np.nan).dropna() + if column_name in ["upperBound", "lowerBound"]: + column_data = column_data.loc[ + self.model["parameterScale"] == par_scale + ] + if not column_data.empty: + return column_data.max() - def _copy_column(self, column_name, config, row_index): + def _copy_column( + self, + column_name, + config, + row_index, + changed: dict | None = None + ): + """Copy the value from another column in the same row.""" source_column = config.get(SOURCE_COLUMN, column_name) source_column_valid = ( source_column in self.model or source_column == self.model_index ) + if changed: + if source_column in changed.keys(): + return changed[source_column] if source_column and source_column_valid and row_index is not None: prefix = config.get("prefix", "") if row_index in self.model.index: @@ -100,3 +133,20 @@ def _majority_vote(self, column_name, config): value_counts = Counter(valid_values) return value_counts.most_common(1)[0][0] return "" + + def _sbml_lookup(self, row_key): + """Use the most frequent value in the column as the default. + + Defaults to last used value in case of a tie. + """ + if self._sbml_model is None: + return 1 + if row_key is None: + return 1 + curr_model = self._sbml_model.get_current_sbml_model() + if curr_model is None: + return 1 + parameters = curr_model.get_valid_parameters_for_parameter_table() + if row_key not in list(parameters): + return 1 + return curr_model.get_parameter_value(row_key) diff --git a/src/petab_gui/controllers/logger_controller.py b/src/petab_gui/controllers/logger_controller.py index 9fd7f96..e791f51 100644 --- a/src/petab_gui/controllers/logger_controller.py +++ b/src/petab_gui/controllers/logger_controller.py @@ -32,7 +32,8 @@ def log_message(self, message, color="black", loglevel=1): return timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") full_message = \ - f"[{timestamp}]\t {message}" + (f"[{timestamp}]\t " + f"{message}") for view in self.views: view.logger.append(full_message) diff --git a/src/petab_gui/controllers/mother_controller.py b/src/petab_gui/controllers/mother_controller.py index 2f1471f..605803b 100644 --- a/src/petab_gui/controllers/mother_controller.py +++ b/src/petab_gui/controllers/mother_controller.py @@ -186,6 +186,10 @@ def setup_connections(self): settings_manager.new_log_message.connect( self.logger.log_message ) + # Update Parameter SBML Model + self.sbml_controller.overwritten_model.connect( + self.parameter_controller.update_handler_sbml + ) def setup_actions(self): """Setup actions for the main controller.""" diff --git a/src/petab_gui/controllers/sbml_controller.py b/src/petab_gui/controllers/sbml_controller.py index 217e03c..1cfdfeb 100644 --- a/src/petab_gui/controllers/sbml_controller.py +++ b/src/petab_gui/controllers/sbml_controller.py @@ -34,6 +34,7 @@ def __init__( The main controller of the application. Needed for signal forwarding. """ + super().__init__() self.view = view self.model = model self.logger = logger @@ -119,7 +120,8 @@ def overwrite_sbml(self, file_path=None): self.view.antimony_text_edit.setPlainText( self.model.antimony_text ) - # self.overwritten_model.emit() # Deactivated for now. Discuss! + + self.overwritten_model.emit() self.logger.log_message( "SBML model successfully opened and overwritten.", color="green" diff --git a/src/petab_gui/controllers/table_controllers.py b/src/petab_gui/controllers/table_controllers.py index c3f8a27..ed771d6 100644 --- a/src/petab_gui/controllers/table_controllers.py +++ b/src/petab_gui/controllers/table_controllers.py @@ -978,6 +978,9 @@ def update_handler_model(self): """Update the handler model.""" self.model.default_handler.model = self.model._data_frame + def update_handler_sbml(self): + self.model.default_handler._sbml_model = self.mother_controller.model.sbml + def setup_completers(self): """Set completers for the parameter table.""" table_view = self.view.table_view diff --git a/src/petab_gui/controllers/utils.py b/src/petab_gui/controllers/utils.py index 7669022..a608604 100644 --- a/src/petab_gui/controllers/utils.py +++ b/src/petab_gui/controllers/utils.py @@ -6,6 +6,7 @@ import functools import pandas as pd import re +import html from ..settings_manager import settings_manager from ..C import COMMON_ERRORS @@ -23,6 +24,7 @@ def wrapper(self, row_data: pd.DataFrame = None, row_name: return True except Exception as e: err_msg = filtered_error(e) + err_msg = html.escape(err_msg) if additional_error_check: if "Missing parameter(s)" in err_msg: match = re.search(r"\{(.+?)\}", err_msg) diff --git a/src/petab_gui/models/pandas_table_model.py b/src/petab_gui/models/pandas_table_model.py index 83e1de4..dbcd7fe 100644 --- a/src/petab_gui/models/pandas_table_model.py +++ b/src/petab_gui/models/pandas_table_model.py @@ -222,22 +222,22 @@ def _set_data_single(self, index, value): # Special ID emitters if column_name == "observableId": - self._push_change_and_notify(row, column, column_name, old_value, value) - self.relevant_id_changed.emit(value, old_value, "observable") if fill_with_defaults: - self.get_default_values(index) + self.get_default_values(index, {column_name: value}) + self.relevant_id_changed.emit(value, old_value, "observable") + self._push_change_and_notify(row, column, column_name, old_value, value) return True if column_name in ["conditionId", "simulationConditionId", "preequilibrationConditionId"]: if fill_with_defaults: - self.get_default_values(index) - self._push_change_and_notify(row, column, column_name, old_value, value) + self.get_default_values(index, {column_name: value}) self.relevant_id_changed.emit(value, old_value, "condition") + self._push_change_and_notify(row, column, column_name, old_value, value) return True # Default value setting if fill_with_defaults: - self.get_default_values(index) + self.get_default_values(index, {column_name: value}) self._push_change_and_notify(row, column, column_name, old_value, value) return True @@ -249,9 +249,9 @@ def _push_change_and_notify( (self._data_frame.index[row], column_name): (old_value, new_value) } self.undo_stack.push(ModifyDataFrameCommand(self, change)) - self.cell_needs_validation.emit(row, column) self.dataChanged.emit(self.index(row, column), self.index(row, column), [Qt.DisplayRole]) + self.cell_needs_validation.emit(row, column) self.something_changed.emit(True) def clear_cells(self, selected): @@ -266,8 +266,15 @@ def handle_named_index(self, index, value): """Handle the named index column.""" pass - def get_default_values(self, index): - """Return the default values for a the row in a new index.""" + def get_default_values(self, index, changed: dict | None = None): + """Return the default values for the row in a new index. + + Parameters + ---------- + index: QModelIndex, index where the first change occurs + changed: + the changes made to the DataFrame, that have not yet been registered + """ pass def replace_text(self, old_text: str, new_text: str): @@ -517,7 +524,10 @@ def endResetModel(self): """Override endResetModel to reset the default handler.""" super().endResetModel() self.config = settings_manager.get_table_defaults(self.table_type) - self.default_handler = DefaultHandlerModel(self, self.config) + sbml_model = self.default_handler._sbml_model + self.default_handler = DefaultHandlerModel( + self, self.config, sbml_model=sbml_model + ) def fill_row(self, row_position: int, data: dict): """Fill a row with data. @@ -556,7 +566,10 @@ def fill_row(self, row_position: int, data: dict): self.undo_stack.push(ModifyDataFrameCommand( self, changes, "Fill values" )) - self.fill_defaults.emit(self.index(row_position, 0)) + self.get_default_values( + self.index(row_position, 0), + changed=changes, + ) class IndexedPandasTableModel(PandasTableModel): @@ -573,7 +586,7 @@ def __init__(self, data_frame, allowed_columns, table_type, parent=None): self._has_named_index = True self.column_offset = 1 - def get_default_values(self, index): + def get_default_values(self, index, changed: dict | None = None): """Return the default values for a the row in a new index.""" row_idx = index.row() df = self._data_frame @@ -588,21 +601,38 @@ def get_default_values(self, index): columns_with_index = [df.index.name] if df.index.name else [] columns_with_index += list(df.columns) + # ensure parameterScale is before lowerBound and upperBound (potential) + if "parameterScale" in columns_with_index: + columns_with_index.remove("parameterScale") + columns_with_index.insert(1, "parameterScale") for colname in columns_with_index: + if changed and colname in changed.keys(): + continue if colname == df.index.name: # Generate default index name if empty - default_value = self.default_handler.get_default(colname, row_key) + default_value = self.default_handler.get_default( + colname, + row_key, + changed = changed + ) if ( not row_key or f"new_{self.table_type}" in row_key - ): + ) and bool(default_value): rename_needed = True new_index = default_value + elif colname in ["upperBound", "lowerBound"]: + par_scale = changes[(row_key, "parameterScale")][1] if\ + (row_key, "parameterScale") in changes \ + else changed["parameterScale"] + default_value = self.default_handler.get_default( + colname, row_key, par_scale + ) + changes[(row_key, colname)] = ("", default_value) else: - if df.at[row_key, colname] == "": - default_value = self.default_handler.get_default(colname, row_key) - changes[(row_key, colname)] = ("", default_value) + default_value = self.default_handler.get_default(colname, row_key) + changes[(row_key, colname)] = ("", default_value) commands = [] if changes: @@ -679,7 +709,7 @@ def __init__(self, data_frame, parent=None): parent=parent ) - def get_default_values(self, index): + def get_default_values(self, index, changed: dict | None = None): """Fill missing values in a row without modifying the index.""" row = index.row() df = self._data_frame @@ -690,9 +720,10 @@ def get_default_values(self, index): changes = {} for colname in df.columns: - if df.at[row_key, colname] == "": - default = self.default_handler.get_default(colname, row_key) - changes[(row_key, colname)] = ("", default) + if colname in changed.keys(): + continue + default = self.default_handler.get_default(colname, row_key) + changes[(row_key, colname)] = ("", default) command = ModifyDataFrameCommand( self, changes, "Fill default values" ) @@ -757,13 +788,17 @@ def __init__(self, data_frame, parent=None): class ParameterModel(IndexedPandasTableModel): """Table model for the parameter data.""" - def __init__(self, data_frame, parent=None): + def __init__(self, data_frame, parent=None, sbml_model=None): super().__init__( data_frame=data_frame, allowed_columns=COLUMNS["parameter"], table_type="parameter", parent=parent ) + self.default_handler = DefaultHandlerModel( + self, self.config, + sbml_model=sbml_model + ) class ConditionModel(IndexedPandasTableModel): diff --git a/src/petab_gui/models/petab_model.py b/src/petab_gui/models/petab_model.py index be0509d..565bea2 100644 --- a/src/petab_gui/models/petab_model.py +++ b/src/petab_gui/models/petab_model.py @@ -46,6 +46,9 @@ def __init__( if petab_problem is None: petab_problem = petab.Problem() self.problem = petab_problem + self.sbml = SbmlViewerModel( + sbml_model=self.problem.model, + ) self.measurement = MeasurementModel( data_frame=self.problem.measurement_df, ) @@ -54,13 +57,11 @@ def __init__( ) self.parameter = ParameterModel( data_frame=self.problem.parameter_df, + sbml_model=self.sbml ) self.condition = ConditionModel( data_frame=self.problem.condition_df, ) - self.sbml = SbmlViewerModel( - sbml_model=self.problem.model, - ) @property def models(self):