Skip to content
Open
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
6 changes: 3 additions & 3 deletions src/petab_gui/views/main_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,16 @@ def __init__(self):

self.tab_widget.currentChanged.connect(self.set_docks_visible)

# Track if we're in a minimize/restore cycle (must be set before load_ui_settings)
self._was_minimized = False

settings_manager.load_ui_settings(self)

# drag drop
self.setAcceptDrops(True)

self.find_replace_bar = None

# Track if we're in a minimize/restore cycle
self._was_minimized = False

def default_view(self):
"""Reset the view to a fixed 3x2 grid using manual geometry."""
if hasattr(self, "dock_visibility"):
Expand Down
94 changes: 72 additions & 22 deletions src/petab_gui/views/simple_plot_view.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from collections import defaultdict

import petab.v1.C as PETAB_C
import qtawesome as qta
from matplotlib import pyplot as plt
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
Expand Down Expand Up @@ -77,6 +78,42 @@ def __init__(self, parent=None):
self.observable_to_subplot = {}
self.no_plotting_rn = False

# DataFrame caching system for performance optimization
self._df_cache = {
"measurements": None,
"simulations": None,
"conditions": None,
"visualization": None,
}
self._cache_valid = {
"measurements": False,
"simulations": False,
"conditions": False,
"visualization": False,
}

def _invalidate_cache(self, table_name):
"""Invalidate cache for specific table."""
self._cache_valid[table_name] = False

def _get_cached_df(self, table_name, proxy_model):
"""Get cached DataFrame or convert if invalid."""
if not self._cache_valid[table_name]:
self._df_cache[table_name] = proxy_to_dataframe(proxy_model)
self._cache_valid[table_name] = True
return self._df_cache[table_name]

def _connect_proxy_signals(self, proxy, cache_key):
"""Connect proxy signals for cache invalidation and plotting."""

def on_data_change(*args, **kwargs):
self._invalidate_cache(cache_key)
self._debounced_plot()

proxy.dataChanged.connect(on_data_change)
proxy.rowsInserted.connect(on_data_change)
proxy.rowsRemoved.connect(on_data_change)

def initialize(
self, meas_proxy, sim_proxy, cond_proxy, vis_proxy, petab_model
):
Expand All @@ -86,20 +123,19 @@ def initialize(
self.vis_proxy = vis_proxy
self.petab_model = petab_model

# Connect data changes
# Clear all cache when reinitializing
for key in self._cache_valid:
self._cache_valid[key] = False

# Connect cache invalidation and data changes
self.options_manager.option_changed.connect(self._debounced_plot)
self.meas_proxy.dataChanged.connect(self._debounced_plot)
self.meas_proxy.rowsInserted.connect(self._debounced_plot)
self.meas_proxy.rowsRemoved.connect(self._debounced_plot)
self.cond_proxy.dataChanged.connect(self._debounced_plot)
self.cond_proxy.rowsInserted.connect(self._debounced_plot)
self.cond_proxy.rowsRemoved.connect(self._debounced_plot)
self.sim_proxy.dataChanged.connect(self._debounced_plot)
self.sim_proxy.rowsInserted.connect(self._debounced_plot)
self.sim_proxy.rowsRemoved.connect(self._debounced_plot)
self.vis_proxy.dataChanged.connect(self._debounced_plot)
self.vis_proxy.rowsInserted.connect(self._debounced_plot)
self.vis_proxy.rowsRemoved.connect(self._debounced_plot)

# Connect proxy signals for all tables
self._connect_proxy_signals(self.meas_proxy, "measurements")
self._connect_proxy_signals(self.cond_proxy, "conditions")
self._connect_proxy_signals(self.sim_proxy, "simulations")
self._connect_proxy_signals(self.vis_proxy, "visualization")

self.visibilityChanged.connect(self._debounced_plot)

self.plot_it()
Expand All @@ -113,10 +149,11 @@ def plot_it(self):
# If the dock is not visible, do not plot
return

measurements_df = proxy_to_dataframe(self.meas_proxy)
simulations_df = proxy_to_dataframe(self.sim_proxy)
conditions_df = proxy_to_dataframe(self.cond_proxy)
visualisation_df = proxy_to_dataframe(self.vis_proxy)
# Use cached DataFrames for performance
measurements_df = self._get_cached_df("measurements", self.meas_proxy)
simulations_df = self._get_cached_df("simulations", self.sim_proxy)
conditions_df = self._get_cached_df("conditions", self.cond_proxy)
visualisation_df = self._get_cached_df("visualization", self.vis_proxy)
group_by = self.options_manager.get_option()
# group_by different value in petab.visualize
if group_by == "condition":
Expand Down Expand Up @@ -184,6 +221,9 @@ def _render_on_main_thread(self, payload):
self._update_tabs(fig)

def _update_tabs(self, fig: plt.Figure):
# Save current tab index before clearing
current_tab_index = self.tab_widget.currentIndex()

# Clean previous tabs
self.tab_widget.clear()
# Clear Highlighter
Expand Down Expand Up @@ -295,15 +335,19 @@ def _update_tabs(self, fig: plt.Figure):
# Plot residuals if necessary
self.plot_residuals()

# Restore the previously selected tab (if valid)
if 0 <= current_tab_index < self.tab_widget.count():
self.tab_widget.setCurrentIndex(current_tab_index)

def highlight_from_selection(
self, selected_rows: list[int], proxy=None, y_axis_col="measurement"
):
proxy = proxy or self.meas_proxy
if not proxy:
return

x_axis_col = "time"
observable_col = "observableId"
x_axis_col = PETAB_C.TIME
observable_col = PETAB_C.OBSERVABLE_ID

def column_index(name):
for col in range(proxy.columnCount()):
Expand Down Expand Up @@ -345,7 +389,8 @@ def plot_residuals(self):
return

problem = self.petab_model.current_petab_problem
simulations_df = proxy_to_dataframe(self.sim_proxy)
# Reuse cached DataFrame instead of converting again
simulations_df = self._get_cached_df("simulations", self.sim_proxy)

if simulations_df.empty:
return
Expand Down Expand Up @@ -521,8 +566,13 @@ def __init__(self, canvas, parent):
self.addWidget(self.settings_btn)

def update_checked_state(self, selected_option):
for action in self.groupy_by_options.values():
action.setChecked(action.text() == f"Groupy by {selected_option}")
for grp, action in self.groupy_by_options.items():
if grp == "vis_df":
action.setChecked(selected_option == "vis_df")
else:
action.setChecked(
action.text() == f"Group by {selected_option}"
)


def create_plot_tab(
Expand Down
81 changes: 50 additions & 31 deletions src/petab_gui/views/utils.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,64 @@
import pandas as pd
from petab.v1.C import (
CONDITION_ID,
MEASUREMENT,
OBSERVABLE_ID,
PARAMETER_ID,
SIMULATION,
TIME,
X_OFFSET,
Y_OFFSET,
)
from PySide6.QtCore import Qt


def proxy_to_dataframe(proxy_model):
"""Convert Proxy Model to pandas DataFrame."""
rows = proxy_model.rowCount()
cols = proxy_model.columnCount()

if rows <= 1: # <=1 due to "New row..." in every table
return pd.DataFrame()

headers = [proxy_model.headerData(c, Qt.Horizontal) for c in range(cols)]
data = []

data = []
for r in range(rows - 1):
row = {headers[c]: proxy_model.index(r, c).data() for c in range(cols)}
for key, value in row.items():
if isinstance(value, str) and value == "":
row[key] = None
row = []
for c in range(cols):
value = proxy_model.index(r, c).data()
# Convert empty strings to None
row.append(
None if (isinstance(value, str) and value == "") else value
)
data.append(row)

if not data:
return pd.DataFrame()
if proxy_model.source_model.table_type == "condition":
data = pd.DataFrame(data).set_index("conditionId")
elif proxy_model.source_model.table_type == "observable":
data = pd.DataFrame(data).set_index("observableId")
elif proxy_model.source_model.table_type == "parameter":
data = pd.DataFrame(data).set_index("parameterId")
elif proxy_model.source_model.table_type == "measurement":
# turn measurement and time to float
data = pd.DataFrame(data)
data["measurement"] = data["measurement"].astype(float)
data["time"] = data["time"].astype(float)
elif proxy_model.source_model.table_type == "simulation":
# turn simulation and time to float
data = pd.DataFrame(data)
data["simulation"] = data["simulation"].astype(float)
data["time"] = data["time"].astype(float)
elif proxy_model.source_model.table_type == "visualization":
data = pd.DataFrame(data)
if "xOffset" in data.columns:
data["xOffset"] = data["xOffset"].astype(float)
if "yOffset" in data.columns:
data["yOffset"] = data["yOffset"].astype(float)
else:
data = pd.DataFrame(data)

return data

# Create DataFrame in one shot
df = pd.DataFrame(data, columns=headers)

# Apply type-specific transformations
table_type = proxy_model.source_model.table_type

if table_type == "condition":
df = df.set_index(CONDITION_ID)
elif table_type == "observable":
df = df.set_index(OBSERVABLE_ID)
elif table_type == "parameter":
df = df.set_index(PARAMETER_ID)
elif table_type == "measurement":
# Use pd.to_numeric with errors='coerce' for robust conversion
df[MEASUREMENT] = pd.to_numeric(df[MEASUREMENT], errors="coerce")
df[TIME] = pd.to_numeric(df[TIME], errors="coerce")
elif table_type == "simulation":
df[SIMULATION] = pd.to_numeric(df[SIMULATION], errors="coerce")
df[TIME] = pd.to_numeric(df[TIME], errors="coerce")
elif table_type == "visualization":
if X_OFFSET in df.columns:
df[X_OFFSET] = pd.to_numeric(df[X_OFFSET], errors="coerce")
if Y_OFFSET in df.columns:
df[Y_OFFSET] = pd.to_numeric(df[Y_OFFSET], errors="coerce")

return df