Skip to content

Commit b7cefb2

Browse files
Refactoring v1 (#235)
* Strong refactoring, trying to disentangle imports from models views and controllers * defractured mother controller slightly * temporary fronzen. Issues with pandas > 3.0.0 * Added #238 back into code (lost due to merge conflict) * Forgot to commit settings folder
1 parent 7ec957a commit b7cefb2

23 files changed

+2626
-1919
lines changed

src/petab_gui/controllers/file_io_controller.py

Lines changed: 724 additions & 0 deletions
Large diffs are not rendered by default.

src/petab_gui/controllers/mother_controller.py

Lines changed: 42 additions & 949 deletions
Large diffs are not rendered by default.
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
"""Plot Coordinator for PEtab GUI.
2+
3+
This module contains the PlotCoordinator class, which handles all plotting
4+
and selection synchronization operations, including:
5+
- Initializing and updating plot visualizations
6+
- Synchronizing selections between tables and plots
7+
- Handling plot point click interactions
8+
- Managing plot update debouncing
9+
"""
10+
11+
from PySide6.QtCore import Qt, QTimer
12+
13+
from ..utils import get_selected
14+
15+
16+
class PlotCoordinator:
17+
"""Coordinator for plotting and selection synchronization.
18+
19+
Manages the bidirectional synchronization between table selections and plot
20+
highlights, handles plot interactions, and coordinates plot updates across
21+
multiple data views.
22+
23+
Attributes
24+
----------
25+
main : MainController
26+
Reference to the main controller for access to models, views, and other controllers.
27+
model : PEtabModel
28+
The PEtab model being visualized.
29+
view : MainWindow
30+
The main application window.
31+
logger : LoggerController
32+
The logger for user feedback.
33+
plotter : PlotDock
34+
The plot widget for data visualization.
35+
"""
36+
37+
def __init__(self, main_controller):
38+
"""Initialize the PlotCoordinator.
39+
40+
Parameters
41+
----------
42+
main_controller : MainController
43+
The main controller instance.
44+
"""
45+
self.main = main_controller
46+
self.model = main_controller.model
47+
self.view = main_controller.view
48+
self.logger = main_controller.logger
49+
50+
# Plot widget reference (set by init_plotter)
51+
self.plotter = None
52+
53+
# Selection synchronization flags to prevent redundant updates
54+
self._updating_from_plot = False
55+
self._updating_from_table = False
56+
57+
# Plot update timer for debouncing
58+
self._plot_update_timer = QTimer()
59+
self._plot_update_timer.setSingleShot(True)
60+
self._plot_update_timer.setInterval(0)
61+
self._plot_update_timer.timeout.connect(self.init_plotter)
62+
63+
def init_plotter(self):
64+
"""(Re-)initialize the plotter.
65+
66+
Sets up the plot widget with the current data models and configures
67+
the click callback for interactive plot point selection.
68+
"""
69+
self.view.plot_dock.initialize(
70+
self.main.measurement_controller.proxy_model,
71+
self.main.simulation_table_controller.proxy_model,
72+
self.main.condition_controller.proxy_model,
73+
self.main.visualization_controller.proxy_model,
74+
self.model,
75+
)
76+
self.plotter = self.view.plot_dock
77+
self.plotter.highlighter.click_callback = self._on_plot_point_clicked
78+
79+
def handle_selection_changed(self):
80+
"""Update the plot when selection in the measurement table changes.
81+
82+
This is a convenience method that delegates to update_plot().
83+
"""
84+
self.update_plot()
85+
86+
def handle_data_changed(self, top_left, bottom_right, roles):
87+
"""Update the plot when the data in the measurement table changes.
88+
89+
Parameters
90+
----------
91+
top_left : QModelIndex
92+
Top-left index of the changed region.
93+
bottom_right : QModelIndex
94+
Bottom-right index of the changed region.
95+
roles : list[int]
96+
List of Qt item data roles that changed.
97+
"""
98+
if not roles or Qt.DisplayRole in roles:
99+
self.update_plot()
100+
101+
def update_plot(self):
102+
"""Update the plot with the selected measurement data.
103+
104+
Extracts the selected data points from the measurement table and
105+
updates the plot visualization with this data. The plot shows all
106+
data for the selected observables with the selected points highlighted.
107+
"""
108+
selection_model = (
109+
self.view.measurement_dock.table_view.selectionModel()
110+
)
111+
indexes = selection_model.selectedIndexes()
112+
if not indexes:
113+
return
114+
115+
selected_points = {}
116+
for index in indexes:
117+
if index.row() == self.model.measurement.get_df().shape[0]:
118+
continue
119+
row = index.row()
120+
observable_id = self.model.measurement._data_frame.iloc[row][
121+
"observableId"
122+
]
123+
if observable_id not in selected_points:
124+
selected_points[observable_id] = []
125+
selected_points[observable_id].append(
126+
{
127+
"x": self.model.measurement._data_frame.iloc[row]["time"],
128+
"y": self.model.measurement._data_frame.iloc[row][
129+
"measurement"
130+
],
131+
}
132+
)
133+
if selected_points == {}:
134+
return
135+
136+
measurement_data = self.model.measurement._data_frame
137+
plot_data = {"all_data": [], "selected_points": selected_points}
138+
for observable_id in selected_points:
139+
observable_data = measurement_data[
140+
measurement_data["observableId"] == observable_id
141+
]
142+
plot_data["all_data"].append(
143+
{
144+
"observable_id": observable_id,
145+
"x": observable_data["time"].tolist(),
146+
"y": observable_data["measurement"].tolist(),
147+
}
148+
)
149+
150+
self.view.plot_dock.update_visualization(plot_data)
151+
152+
def _schedule_plot_update(self):
153+
"""Start the plot schedule timer.
154+
155+
Debounces plot updates by using a timer to avoid excessive redraws
156+
when data changes rapidly.
157+
"""
158+
self._plot_update_timer.start()
159+
160+
def _floats_match(self, a, b, epsilon=1e-9):
161+
"""Check if two floats match within epsilon tolerance.
162+
163+
Parameters
164+
----------
165+
a : float
166+
First value to compare.
167+
b : float
168+
Second value to compare.
169+
epsilon : float, optional
170+
Tolerance for comparison (default: 1e-9).
171+
172+
Returns
173+
-------
174+
bool
175+
True if |a - b| < epsilon, False otherwise.
176+
"""
177+
return abs(a - b) < epsilon
178+
179+
def _on_plot_point_clicked(self, x, y, label, data_type):
180+
"""Handle plot point clicks and select corresponding table row.
181+
182+
Uses epsilon tolerance for floating-point comparison to avoid
183+
precision issues. Synchronizes the table selection with the clicked
184+
plot point.
185+
186+
Parameters
187+
----------
188+
x : float
189+
X-coordinate of the clicked point (time).
190+
y : float
191+
Y-coordinate of the clicked point (measurement or simulation value).
192+
label : str
193+
Label of the clicked point (observable ID).
194+
data_type : str
195+
Type of data: "measurement" or "simulation".
196+
"""
197+
# Check for None label
198+
if label is None:
199+
self.logger.log_message(
200+
"Cannot select table row: plot point has no label.",
201+
color="orange",
202+
)
203+
return
204+
205+
# Extract observable ID from label
206+
proxy = self.main.measurement_controller.proxy_model
207+
view = self.main.measurement_controller.view.table_view
208+
if data_type == "simulation":
209+
proxy = self.main.simulation_table_controller.proxy_model
210+
view = self.main.simulation_table_controller.view.table_view
211+
obs = label
212+
213+
x_axis_col = "time"
214+
y_axis_col = data_type
215+
observable_col = "observableId"
216+
217+
# Get column indices with error handling
218+
def column_index(name):
219+
for col in range(proxy.columnCount()):
220+
if proxy.headerData(col, Qt.Horizontal) == name:
221+
return col
222+
raise ValueError(f"Column '{name}' not found.")
223+
224+
try:
225+
x_col = column_index(x_axis_col)
226+
y_col = column_index(y_axis_col)
227+
obs_col = column_index(observable_col)
228+
except ValueError as e:
229+
self.logger.log_message(
230+
f"Table selection failed: {e}",
231+
color="red",
232+
)
233+
return
234+
235+
# Search for matching row using epsilon tolerance for floats
236+
matched = False
237+
for row in range(proxy.rowCount()):
238+
row_obs = proxy.index(row, obs_col).data()
239+
row_x = proxy.index(row, x_col).data()
240+
row_y = proxy.index(row, y_col).data()
241+
try:
242+
row_x, row_y = float(row_x), float(row_y)
243+
except ValueError:
244+
continue
245+
246+
# Use epsilon tolerance for float comparison
247+
if (
248+
row_obs == obs
249+
and self._floats_match(row_x, x)
250+
and self._floats_match(row_y, y)
251+
):
252+
# Manually update highlight BEFORE selecting row
253+
# This ensures the circle appears even though we skip the signal handler
254+
if data_type == "measurement":
255+
self.plotter.highlight_from_selection([row])
256+
else:
257+
self.plotter.highlight_from_selection(
258+
[row],
259+
proxy=self.main.simulation_table_controller.proxy_model,
260+
y_axis_col="simulation",
261+
)
262+
263+
# Set flag to prevent redundant highlight update from signal
264+
self._updating_from_plot = True
265+
try:
266+
view.selectRow(row)
267+
matched = True
268+
finally:
269+
self._updating_from_plot = False
270+
break
271+
272+
# Provide feedback if no match found
273+
if not matched:
274+
self.logger.log_message(
275+
f"No matching row found for plot point (obs={obs}, x={x:.4g}, y={y:.4g})",
276+
color="orange",
277+
)
278+
279+
def _handle_table_selection_changed(
280+
self, table_view, proxy=None, y_axis_col="measurement"
281+
):
282+
"""Common handler for table selection changes.
283+
284+
Skips update if selection was triggered by plot click to prevent
285+
redundant highlight updates. Updates the plot highlights based on
286+
the current table selection.
287+
288+
Parameters
289+
----------
290+
table_view : QTableView
291+
The table view with selection to highlight.
292+
proxy : QSortFilterProxyModel, optional
293+
Optional proxy model for simulation data.
294+
y_axis_col : str, optional
295+
Column name for y-axis data (default: "measurement").
296+
"""
297+
# Skip if selection was triggered by plot point click
298+
if self._updating_from_plot:
299+
return
300+
301+
# Set flag to prevent infinite loop if highlight triggers selection
302+
self._updating_from_table = True
303+
try:
304+
selected_rows = get_selected(table_view)
305+
if proxy:
306+
self.plotter.highlight_from_selection(
307+
selected_rows, proxy=proxy, y_axis_col=y_axis_col
308+
)
309+
else:
310+
self.plotter.highlight_from_selection(selected_rows)
311+
finally:
312+
self._updating_from_table = False
313+
314+
def _on_table_selection_changed(self, selected, deselected):
315+
"""Highlight the cells selected in measurement table.
316+
317+
Parameters
318+
----------
319+
selected : QItemSelection
320+
The newly selected items.
321+
deselected : QItemSelection
322+
The newly deselected items.
323+
"""
324+
self._handle_table_selection_changed(
325+
self.main.measurement_controller.view.table_view
326+
)
327+
328+
def _on_simulation_selection_changed(self, selected, deselected):
329+
"""Highlight the cells selected in simulation table.
330+
331+
Parameters
332+
----------
333+
selected : QItemSelection
334+
The newly selected items.
335+
deselected : QItemSelection
336+
The newly deselected items.
337+
"""
338+
self._handle_table_selection_changed(
339+
self.main.simulation_table_controller.view.table_view,
340+
proxy=self.main.simulation_table_controller.proxy_model,
341+
y_axis_col="simulation",
342+
)

src/petab_gui/controllers/sbml_controller.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from ..C import DEFAULT_ANTIMONY_TEXT
1111
from ..models.sbml_model import SbmlViewerModel
12-
from ..utils import sbmlToAntimony
12+
from ..models.sbml_utils import sbml_to_antimony
1313
from ..views.sbml_view import SbmlViewer
1414

1515

@@ -67,7 +67,7 @@ def reset_to_original_model(self):
6767
self.model.sbml_text = libsbml.writeSBMLToString(
6868
self.model._sbml_model_original.sbml_model.getSBMLDocument()
6969
)
70-
self.model.antimony_text = sbmlToAntimony(self.model.sbml_text)
70+
self.model.antimony_text = sbml_to_antimony(self.model.sbml_text)
7171
self.view.sbml_text_edit.setPlainText(self.model.sbml_text)
7272
self.view.antimony_text_edit.setPlainText(self.model.antimony_text)
7373

0 commit comments

Comments
 (0)