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