Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 30 additions & 28 deletions src/petab_gui/C.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,42 @@
"""Constants for the PEtab edit GUI."""
import numpy as np

COLUMNS = {
"measurement": {
"observableId": {"type": "STRING", "optional": False},
"preequilibrationConditionId": {"type": "STRING", "optional": True},
"simulationConditionId": {"type": "STRING", "optional": False},
"time": {"type": "NUMERIC", "optional": False},
"measurement": {"type": "NUMERIC", "optional": False},
"observableParameters": {"type": "STRING", "optional": True},
"noiseParameters": {"type": "STRING", "optional": True},
"datasetId": {"type": "STRING", "optional": True},
"replicateId": {"type": "STRING", "optional": True},
"observableId": {"type": np.object_, "optional": False},
"preequilibrationConditionId": {"type": np.object_, "optional": True},
"simulationConditionId": {"type": np.object_, "optional": False},
"time": {"type": np.float64, "optional": False},
"measurement": {"type": np.float64, "optional": False},
"observableParameters": {"type": np.object_, "optional": True},
"noiseParameters": {"type": np.object_, "optional": True},
"datasetId": {"type": np.object_, "optional": True},
"replicateId": {"type": np.object_, "optional": True},
},
"observable": {
"observableId": {"type": "STRING", "optional": False},
"observableName": {"type": "STRING", "optional": True},
"observableFormula": {"type": "STRING", "optional": False},
"observableTransformation": {"type": "STRING", "optional": True},
"noiseFormula": {"type": "STRING", "optional": False},
"noiseDistribution": {"type": "STRING", "optional": True},
"observableId": {"type": np.object_, "optional": False},
"observableName": {"type": np.object_, "optional": True},
"observableFormula": {"type": np.object_, "optional": False},
"observableTransformation": {"type": np.object_, "optional": True},
"noiseFormula": {"type": np.object_, "optional": False},
"noiseDistribution": {"type": np.object_, "optional": True},
},
"parameter": {
"parameterId": {"type": "STRING", "optional": False},
"parameterName": {"type": "STRING", "optional": True},
"parameterScale": {"type": "STRING", "optional": False},
"lowerBound": {"type": "NUMERIC", "optional": False},
"upperBound": {"type": "NUMERIC", "optional": False},
"nominalValue": {"type": "NUMERIC", "optional": False},
"estimate": {"type": "STRING", "optional": False},
"initializationPriorType": {"type": "STRING", "optional": True},
"initializationPriorParameters": {"type": "STRING", "optional": True},
"objectivePriorType": {"type": "STRING", "optional": True},
"objectivePriorParameters": {"type": "STRING", "optional": True},
"parameterId": {"type": np.object_, "optional": False},
"parameterName": {"type": np.object_, "optional": True},
"parameterScale": {"type": np.object_, "optional": False},
"lowerBound": {"type": np.float64, "optional": False},
"upperBound": {"type": np.float64, "optional": False},
"nominalValue": {"type": np.float64, "optional": False},
"estimate": {"type": np.object_, "optional": False},
"initializationPriorType": {"type": np.object_, "optional": True},
"initializationPriorParameters": {"type": np.object_, "optional": True},
"objectivePriorType": {"type": np.object_, "optional": True},
"objectivePriorParameters": {"type": np.object_, "optional": True},
},
"condition": {
"conditionId": {"type": "STRING", "optional": False},
"conditionName": {"type": "STRING", "optional": False},
"conditionId": {"type": np.object_, "optional": False},
"conditionName": {"type": np.object_, "optional": False},
}
}

Expand Down
25 changes: 24 additions & 1 deletion src/petab_gui/controllers/mother_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def setup_connections(self):
self.observable_controller.observable_2be_renamed.connect(
partial(
self.measurement_controller.rename_value,
column_names = "observableId"
column_names="observableId"
)
)
# Rename Condition
Expand Down Expand Up @@ -230,6 +230,19 @@ def setup_actions(self):
actions["find+replace"].setShortcut("Ctrl+R")
actions["find+replace"].triggered.connect(
self.open_find_replace_dialog)
# Copy / Paste
actions["copy"] = QAction(
qta.icon("mdi6.content-copy"),
"Copy", self.view
)
actions["copy"].setShortcut("Ctrl+C")
actions["copy"].triggered.connect(self.copy_to_clipboard)
actions["paste"] = QAction(
qta.icon("mdi6.content-paste"),
"Paste", self.view
)
actions["paste"].setShortcut("Ctrl+V")
actions["paste"].triggered.connect(self.paste_from_clipboard)
# add/delete row
actions["add_row"] = QAction(
qta.icon("mdi6.table-row-plus-after"),
Expand Down Expand Up @@ -678,3 +691,13 @@ def delete_column(self):
controller = self.active_controller()
if controller:
controller.delete_column()

def copy_to_clipboard(self):
controller = self.active_controller()
if controller:
controller.copy_to_clipboard()

def paste_from_clipboard(self):
controller = self.active_controller()
if controller:
controller.paste_from_clipboard()
60 changes: 50 additions & 10 deletions src/petab_gui/controllers/table_controllers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Classes for the controllers of the tables in the GUI."""
from PySide6.QtWidgets import QInputDialog, QMessageBox, QFileDialog, \
QCompleter
import numpy as np
import pandas as pd
import petab.v1 as petab
from PySide6.QtCore import Signal, QObject, QModelIndex, QPoint
Expand Down Expand Up @@ -45,6 +46,7 @@ def __init__(
self.model.view = self.view.table_view
self.proxy_model = PandasTableFilterProxy(model)
self.logger = logger
self.check_petab_lint_mode = True
self.mother_controller = mother_controller
self.view.table_view.setModel(self.model)
self.setup_connections()
Expand Down Expand Up @@ -85,6 +87,8 @@ def setup_connections(self):

def validate_changed_cell(self, row, column):
"""Validate the changed cell and whether its linting is correct."""
if not self.check_petab_lint_mode:
return
row_data = self.model.get_df().iloc[row]
index_name = self.model.get_df().index.name
row_data = row_data.to_frame().T
Expand Down Expand Up @@ -137,6 +141,12 @@ def open_table(self, file_path=None, separator=None, mode="overwrite"):
color="red"
)
return
dtypes = {
col: self.model._allowed_columns.get(
col, {"type": np.object_}
)["type"] for col in new_df.columns
}
new_df = new_df.astype(dtypes)
if mode is None:
mode = prompt_overwrite_or_append(self)
# Overwrite or append the table with the new DataFrame
Expand Down Expand Up @@ -259,12 +269,36 @@ def set_index_on_new_row(self, index: QModelIndex):
"""Set the index of the model when a new row is added."""
self.view.table_view.setCurrentIndex(index)

def copy_to_clipboard(self):
"""Copy the currently selected cells to the clipboard."""
self.view.copy_to_clipboard()

def paste_from_clipboard(self):
"""Paste the clipboard content to the currently selected cells."""
self.check_petab_lint_mode = False
self.view.paste_from_clipboard()
self.check_petab_lint_mode = True
try:
self.check_petab_lint()
except Exception as e:
self.logger.log_message(
f"PEtab linter failed after copying: {str(e)}",
color="red"
)
def check_petab_lint(self, row_data):
"""Check a single row of the model with petablint."""
raise NotImplementedError(
"This method must be implemented in child classes."
)


class MeasurementController(TableController):
"""Controller of the Measurement table."""

def check_petab_lint(self, row_data):
"""Check a single row of the model with petablint."""
def check_petab_lint(self, row_data: pd.DataFrame = None):
"""Check a number of rows of the model with petablint."""
if row_data is None:
row_data = self.model.get_df()
# Can this be done more elegantly?
observable_df = self.mother_controller.model.observable.get_df()
return petab.check_measurement_df(
Expand Down Expand Up @@ -496,8 +530,10 @@ def setup_connections_specific(self):
self.maybe_rename_condition
)

def check_petab_lint(self, row_data):
"""Check a single row of the model with petablint."""
def check_petab_lint(self, row_data: pd.DataFrame = None):
"""Check a number of rows of the model with petablint."""
if row_data is None:
row_data = self.model.get_df()
observable_df = self.mother_controller.model.observable.get_df()
sbml_model = self.mother_controller.model.sbml.get_current_sbml_model()
return petab.check_condition_df(
Expand Down Expand Up @@ -532,7 +568,7 @@ def maybe_rename_condition(self, new_id, old_id):

def maybe_add_condition(self, condition_id, old_id=None):
"""Add a condition to the condition table if it does not exist yet."""
if condition_id in self.model.get_df().index:
if condition_id in self.model.get_df().index or not condition_id:
return
# add a row
self.model.insertRows(position=None, rows=1)
Expand Down Expand Up @@ -635,8 +671,10 @@ def setup_connections_specific(self):
self.maybe_rename_observable
)

def check_petab_lint(self, row_data):
"""Check a single row of the model with petablint."""
def check_petab_lint(self, row_data: pd.DataFrame = None):
"""Check a number of rows of the model with petablint."""
if row_data is None:
row_data = self.model.get_df()
return petab.check_observable_df(row_data)

def maybe_rename_observable(self, new_id, old_id):
Expand Down Expand Up @@ -668,7 +706,7 @@ def maybe_add_observable(self, observable_id, old_id=None):

Currently, `old_id` is not used.
"""
if observable_id in self.model.get_df().index:
if observable_id in self.model.get_df().index or not observable_id:
return
# add a row
self.model.insertRows(position=None, rows=1)
Expand Down Expand Up @@ -754,8 +792,10 @@ def setup_completers(self):
self.completers["parameterId"]
)

def check_petab_lint(self, row_data):
"""Check a single row of the model with petablint."""
def check_petab_lint(self, row_data: pd.DataFrame = None):
"""Check a number of rows of the model with petablint."""
if row_data is None:
row_data = self.model.get_df()
observable_df = self.mother_controller.model.observable.get_df()
measurement_df = self.mother_controller.model.measurement.get_df()
condition_df = self.mother_controller.model.condition.get_df()
Expand Down
72 changes: 71 additions & 1 deletion src/petab_gui/models/pandas_table_model.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pandas as pd
from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex, Signal, QSortFilterProxyModel
from PySide6.QtCore import (Qt, QAbstractTableModel, QModelIndex, Signal,
QSortFilterProxyModel, QMimeData)
from PySide6.QtGui import QColor

from ..C import COLUMNS
Expand Down Expand Up @@ -130,6 +131,8 @@ def insertColumn(self, column_name: str):
def setData(self, index, value, role=Qt.EditRole):
if not (index.isValid() and role == Qt.EditRole):
return False
if is_invalid(value) or value == "":
value = None
# check whether multiple rows but only one column is selected
multi_row_change, selected = self.check_selection()
if not multi_row_change:
Expand Down Expand Up @@ -157,6 +160,16 @@ def _set_data_single(self, index, value):
# Handling non-index (regular data) columns
column_name = self._data_frame.columns[column - col_setoff]
old_value = self._data_frame.iloc[row, column - col_setoff]
# cast to numeric if necessary
if not self._data_frame[column_name].dtype == "object":
try:
value = float(value)
except ValueError:
self.new_log_message.emit(
f"Column '{column_name}' expects a numeric value",
"red"
)
return False
if value == old_value:
return False

Expand Down Expand Up @@ -288,11 +301,67 @@ def reset_invalid_cells(self):
self._invalid_cells = set()
self.layoutChanged.emit()

def mimeData(self, rectangle, start_index):
"""Return the data to be copied to the clipboard.

Parameters
----------
rectangle: np.ndarray
The rectangle of selected cells. Creates a minimum rectangle
around all selected cells and is True if the cell is selected.
start_index: (int, int)
The start index of the selection. Used to determine the location
of the copied data.
"""
copied_data = ""
for row in range(rectangle.shape[0]):
for col in range(rectangle.shape[1]):
if rectangle[row, col]:
copied_data += self.data(
self.index(start_index[0] + row, start_index[1] + col),
Qt.DisplayRole
)
else:
copied_data += "SKIP"
if col < rectangle.shape[1] - 1:
copied_data += "\t"
copied_data += "\n"
mime_data = QMimeData()
mime_data.setText(copied_data.strip())
return mime_data

def setDataFromText(self, text, start_row, start_column):
"""Set the data from text."""
# TODO: Does this need to be more flexible in the separator?
lines = text.split("\n")
self.maybe_add_rows(start_row, len(lines))
for row_offset, line in enumerate(lines):
values = line.split("\t")
for col_offset, value in enumerate(values):
if value == "SKIP":
continue
self.setData(
self.index(
start_row + row_offset, start_column + col_offset
),
value,
Qt.EditRole
)

def maybe_add_rows(self, start_row, n_rows):
"""Add rows if needed."""
if start_row + n_rows > self._data_frame.shape[0]:
self.insertRows(
self._data_frame.shape[0],
start_row + n_rows - self._data_frame.shape[0]
)
self.layoutChanged.emit()


class IndexedPandasTableModel(PandasTableModel):
"""Table model for tables with named index."""
condition_2be_renamed = Signal(str, str) # Signal to mother controller

def __init__(self, data_frame, allowed_columns, table_type, parent=None):
super().__init__(
data_frame=data_frame,
Expand Down Expand Up @@ -341,6 +410,7 @@ class MeasurementModel(PandasTableModel):
"""Table model for the measurement data."""
possibly_new_condition = Signal(str) # Signal for new condition
possibly_new_observable = Signal(str) # Signal for new observable

def __init__(self, data_frame, parent=None):
super().__init__(
data_frame=data_frame,
Expand Down
28 changes: 25 additions & 3 deletions src/petab_gui/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,9 +524,8 @@ def connect_forwarded(self, slot):
def create_empty_dataframe(column_dict: dict, table_type: str):
columns = [col for col, props in column_dict.items() if not props["optional"]]
dtypes = {
col: 'float64' if props["type"] == "NUMERIC"
else 'object'
for col, props in column_dict.items() if not props["optional"]
col: props["type"] for col, props in column_dict.items() if not
props["optional"]
}
df = pd.DataFrame(columns=columns).astype(dtypes)
# set potential index columns
Expand Down Expand Up @@ -585,6 +584,29 @@ def get_selected(table_view: QTableView, mode: str = ROW) -> list[int]:
return None


def get_selected_rectangles(table_view: QTableView) -> np.array:
"""Returns the selected cells in a rectangular view.

The size of the rectangle is determined by Max_row - Min_row and
Max_column - Min_column. The returned array is a boolean array with
True values for selected cells.
"""
selected = get_selected(table_view, mode=INDEX)
if not selected:
return None
rows = [index.row() for index in selected]
cols = [index.column() for index in selected]
min_row, max_row = min(rows), max(rows)
min_col, max_col = min(cols), max(cols)
rect_start = (min_row, min_col)
selected_rect = np.zeros(
(max_row - min_row + 1, max_col - min_col + 1), dtype=bool
)
for index in selected:
selected_rect[index.row() - min_row, index.column() - min_col] = True
return selected_rect, rect_start


def process_file(filepath, logger):
"""
Utility function to process a file based on its type and content.
Expand Down
Loading