Skip to content

Commit 35f1908

Browse files
Visualization cache (#224)
* More efficient dataframe creation from proxy model * Dataframe caching, should reduce calls to proxy by ~50% * Function for proxy connections + PEtab constants usage * Update src/petab_gui/views/utils.py * fixed problem with updating
1 parent 6f00191 commit 35f1908

File tree

3 files changed

+125
-56
lines changed

3 files changed

+125
-56
lines changed

src/petab_gui/views/main_view.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,16 +126,16 @@ def __init__(self):
126126

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

129+
# Track if we're in a minimize/restore cycle (must be set before load_ui_settings)
130+
self._was_minimized = False
131+
129132
settings_manager.load_ui_settings(self)
130133

131134
# drag drop
132135
self.setAcceptDrops(True)
133136

134137
self.find_replace_bar = None
135138

136-
# Track if we're in a minimize/restore cycle
137-
self._was_minimized = False
138-
139139
def default_view(self):
140140
"""Reset the view to a fixed 3x2 grid using manual geometry."""
141141
if hasattr(self, "dock_visibility"):

src/petab_gui/views/simple_plot_view.py

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections import defaultdict
22

3+
import petab.v1.C as PETAB_C
34
import qtawesome as qta
45
from matplotlib import pyplot as plt
56
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
@@ -77,6 +78,42 @@ def __init__(self, parent=None):
7778
self.observable_to_subplot = {}
7879
self.no_plotting_rn = False
7980

81+
# DataFrame caching system for performance optimization
82+
self._df_cache = {
83+
"measurements": None,
84+
"simulations": None,
85+
"conditions": None,
86+
"visualization": None,
87+
}
88+
self._cache_valid = {
89+
"measurements": False,
90+
"simulations": False,
91+
"conditions": False,
92+
"visualization": False,
93+
}
94+
95+
def _invalidate_cache(self, table_name):
96+
"""Invalidate cache for specific table."""
97+
self._cache_valid[table_name] = False
98+
99+
def _get_cached_df(self, table_name, proxy_model):
100+
"""Get cached DataFrame or convert if invalid."""
101+
if not self._cache_valid[table_name]:
102+
self._df_cache[table_name] = proxy_to_dataframe(proxy_model)
103+
self._cache_valid[table_name] = True
104+
return self._df_cache[table_name]
105+
106+
def _connect_proxy_signals(self, proxy, cache_key):
107+
"""Connect proxy signals for cache invalidation and plotting."""
108+
109+
def on_data_change(*args, **kwargs):
110+
self._invalidate_cache(cache_key)
111+
self._debounced_plot()
112+
113+
proxy.dataChanged.connect(on_data_change)
114+
proxy.rowsInserted.connect(on_data_change)
115+
proxy.rowsRemoved.connect(on_data_change)
116+
80117
def initialize(
81118
self, meas_proxy, sim_proxy, cond_proxy, vis_proxy, petab_model
82119
):
@@ -86,20 +123,19 @@ def initialize(
86123
self.vis_proxy = vis_proxy
87124
self.petab_model = petab_model
88125

89-
# Connect data changes
126+
# Clear all cache when reinitializing
127+
for key in self._cache_valid:
128+
self._cache_valid[key] = False
129+
130+
# Connect cache invalidation and data changes
90131
self.options_manager.option_changed.connect(self._debounced_plot)
91-
self.meas_proxy.dataChanged.connect(self._debounced_plot)
92-
self.meas_proxy.rowsInserted.connect(self._debounced_plot)
93-
self.meas_proxy.rowsRemoved.connect(self._debounced_plot)
94-
self.cond_proxy.dataChanged.connect(self._debounced_plot)
95-
self.cond_proxy.rowsInserted.connect(self._debounced_plot)
96-
self.cond_proxy.rowsRemoved.connect(self._debounced_plot)
97-
self.sim_proxy.dataChanged.connect(self._debounced_plot)
98-
self.sim_proxy.rowsInserted.connect(self._debounced_plot)
99-
self.sim_proxy.rowsRemoved.connect(self._debounced_plot)
100-
self.vis_proxy.dataChanged.connect(self._debounced_plot)
101-
self.vis_proxy.rowsInserted.connect(self._debounced_plot)
102-
self.vis_proxy.rowsRemoved.connect(self._debounced_plot)
132+
133+
# Connect proxy signals for all tables
134+
self._connect_proxy_signals(self.meas_proxy, "measurements")
135+
self._connect_proxy_signals(self.cond_proxy, "conditions")
136+
self._connect_proxy_signals(self.sim_proxy, "simulations")
137+
self._connect_proxy_signals(self.vis_proxy, "visualization")
138+
103139
self.visibilityChanged.connect(self._debounced_plot)
104140

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

116-
measurements_df = proxy_to_dataframe(self.meas_proxy)
117-
simulations_df = proxy_to_dataframe(self.sim_proxy)
118-
conditions_df = proxy_to_dataframe(self.cond_proxy)
119-
visualisation_df = proxy_to_dataframe(self.vis_proxy)
152+
# Use cached DataFrames for performance
153+
measurements_df = self._get_cached_df("measurements", self.meas_proxy)
154+
simulations_df = self._get_cached_df("simulations", self.sim_proxy)
155+
conditions_df = self._get_cached_df("conditions", self.cond_proxy)
156+
visualisation_df = self._get_cached_df("visualization", self.vis_proxy)
120157
group_by = self.options_manager.get_option()
121158
# group_by different value in petab.visualize
122159
if group_by == "condition":
@@ -184,6 +221,9 @@ def _render_on_main_thread(self, payload):
184221
self._update_tabs(fig)
185222

186223
def _update_tabs(self, fig: plt.Figure):
224+
# Save current tab index before clearing
225+
current_tab_index = self.tab_widget.currentIndex()
226+
187227
# Clean previous tabs
188228
self.tab_widget.clear()
189229
# Clear Highlighter
@@ -295,15 +335,19 @@ def _update_tabs(self, fig: plt.Figure):
295335
# Plot residuals if necessary
296336
self.plot_residuals()
297337

338+
# Restore the previously selected tab (if valid)
339+
if 0 <= current_tab_index < self.tab_widget.count():
340+
self.tab_widget.setCurrentIndex(current_tab_index)
341+
298342
def highlight_from_selection(
299343
self, selected_rows: list[int], proxy=None, y_axis_col="measurement"
300344
):
301345
proxy = proxy or self.meas_proxy
302346
if not proxy:
303347
return
304348

305-
x_axis_col = "time"
306-
observable_col = "observableId"
349+
x_axis_col = PETAB_C.TIME
350+
observable_col = PETAB_C.OBSERVABLE_ID
307351

308352
def column_index(name):
309353
for col in range(proxy.columnCount()):
@@ -345,7 +389,8 @@ def plot_residuals(self):
345389
return
346390

347391
problem = self.petab_model.current_petab_problem
348-
simulations_df = proxy_to_dataframe(self.sim_proxy)
392+
# Reuse cached DataFrame instead of converting again
393+
simulations_df = self._get_cached_df("simulations", self.sim_proxy)
349394

350395
if simulations_df.empty:
351396
return
@@ -521,8 +566,13 @@ def __init__(self, canvas, parent):
521566
self.addWidget(self.settings_btn)
522567

523568
def update_checked_state(self, selected_option):
524-
for action in self.groupy_by_options.values():
525-
action.setChecked(action.text() == f"Groupy by {selected_option}")
569+
for grp, action in self.groupy_by_options.items():
570+
if grp == "vis_df":
571+
action.setChecked(selected_option == "vis_df")
572+
else:
573+
action.setChecked(
574+
action.text() == f"Group by {selected_option}"
575+
)
526576

527577

528578
def create_plot_tab(

src/petab_gui/views/utils.py

Lines changed: 50 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,64 @@
11
import pandas as pd
2+
from petab.v1.C import (
3+
CONDITION_ID,
4+
MEASUREMENT,
5+
OBSERVABLE_ID,
6+
PARAMETER_ID,
7+
SIMULATION,
8+
TIME,
9+
X_OFFSET,
10+
Y_OFFSET,
11+
)
212
from PySide6.QtCore import Qt
313

414

515
def proxy_to_dataframe(proxy_model):
16+
"""Convert Proxy Model to pandas DataFrame."""
617
rows = proxy_model.rowCount()
718
cols = proxy_model.columnCount()
819

20+
if rows <= 1: # <=1 due to "New row..." in every table
21+
return pd.DataFrame()
22+
923
headers = [proxy_model.headerData(c, Qt.Horizontal) for c in range(cols)]
10-
data = []
1124

25+
data = []
1226
for r in range(rows - 1):
13-
row = {headers[c]: proxy_model.index(r, c).data() for c in range(cols)}
14-
for key, value in row.items():
15-
if isinstance(value, str) and value == "":
16-
row[key] = None
27+
row = []
28+
for c in range(cols):
29+
value = proxy_model.index(r, c).data()
30+
# Convert empty strings to None
31+
row.append(
32+
None if (isinstance(value, str) and value == "") else value
33+
)
1734
data.append(row)
35+
1836
if not data:
1937
return pd.DataFrame()
20-
if proxy_model.source_model.table_type == "condition":
21-
data = pd.DataFrame(data).set_index("conditionId")
22-
elif proxy_model.source_model.table_type == "observable":
23-
data = pd.DataFrame(data).set_index("observableId")
24-
elif proxy_model.source_model.table_type == "parameter":
25-
data = pd.DataFrame(data).set_index("parameterId")
26-
elif proxy_model.source_model.table_type == "measurement":
27-
# turn measurement and time to float
28-
data = pd.DataFrame(data)
29-
data["measurement"] = data["measurement"].astype(float)
30-
data["time"] = data["time"].astype(float)
31-
elif proxy_model.source_model.table_type == "simulation":
32-
# turn simulation and time to float
33-
data = pd.DataFrame(data)
34-
data["simulation"] = data["simulation"].astype(float)
35-
data["time"] = data["time"].astype(float)
36-
elif proxy_model.source_model.table_type == "visualization":
37-
data = pd.DataFrame(data)
38-
if "xOffset" in data.columns:
39-
data["xOffset"] = data["xOffset"].astype(float)
40-
if "yOffset" in data.columns:
41-
data["yOffset"] = data["yOffset"].astype(float)
42-
else:
43-
data = pd.DataFrame(data)
44-
45-
return data
38+
39+
# Create DataFrame in one shot
40+
df = pd.DataFrame(data, columns=headers)
41+
42+
# Apply type-specific transformations
43+
table_type = proxy_model.source_model.table_type
44+
45+
if table_type == "condition":
46+
df = df.set_index(CONDITION_ID)
47+
elif table_type == "observable":
48+
df = df.set_index(OBSERVABLE_ID)
49+
elif table_type == "parameter":
50+
df = df.set_index(PARAMETER_ID)
51+
elif table_type == "measurement":
52+
# Use pd.to_numeric with errors='coerce' for robust conversion
53+
df[MEASUREMENT] = pd.to_numeric(df[MEASUREMENT], errors="coerce")
54+
df[TIME] = pd.to_numeric(df[TIME], errors="coerce")
55+
elif table_type == "simulation":
56+
df[SIMULATION] = pd.to_numeric(df[SIMULATION], errors="coerce")
57+
df[TIME] = pd.to_numeric(df[TIME], errors="coerce")
58+
elif table_type == "visualization":
59+
if X_OFFSET in df.columns:
60+
df[X_OFFSET] = pd.to_numeric(df[X_OFFSET], errors="coerce")
61+
if Y_OFFSET in df.columns:
62+
df[Y_OFFSET] = pd.to_numeric(df[Y_OFFSET], errors="coerce")
63+
64+
return df

0 commit comments

Comments
 (0)