TwoPy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. twopy/__init__.py +168 -0
  2. twopy/_segments.py +53 -0
  3. twopy/analysis/__init__.py +101 -0
  4. twopy/analysis/background_subtraction.py +672 -0
  5. twopy/analysis/dff.py +524 -0
  6. twopy/analysis/epoch_mapping.py +223 -0
  7. twopy/analysis/motion.py +66 -0
  8. twopy/analysis/persistence.py +975 -0
  9. twopy/analysis/response_processing/__init__.py +55 -0
  10. twopy/analysis/response_processing/apply.py +298 -0
  11. twopy/analysis/response_processing/options.py +192 -0
  12. twopy/analysis/response_processing/qc.py +270 -0
  13. twopy/analysis/response_processing/signals.py +200 -0
  14. twopy/analysis/responses.py +345 -0
  15. twopy/analysis/trials.py +341 -0
  16. twopy/analysis/workflow.py +457 -0
  17. twopy/api.py +78 -0
  18. twopy/config.py +200 -0
  19. twopy/conversion/__init__.py +40 -0
  20. twopy/conversion/alignment_crop.py +329 -0
  21. twopy/conversion/api.py +113 -0
  22. twopy/conversion/frame_ranges.py +35 -0
  23. twopy/conversion/hdf5_writing.py +362 -0
  24. twopy/conversion/matlab_values.py +123 -0
  25. twopy/conversion/source_loading.py +403 -0
  26. twopy/conversion/stimulus_code.py +248 -0
  27. twopy/conversion/types.py +295 -0
  28. twopy/converted.py +681 -0
  29. twopy/database/__init__.py +33 -0
  30. twopy/database/cache.py +166 -0
  31. twopy/database/catalog.py +90 -0
  32. twopy/database/connection.py +27 -0
  33. twopy/database/generic.py +254 -0
  34. twopy/database/modeled.py +419 -0
  35. twopy/database/sql.py +46 -0
  36. twopy/database/types.py +116 -0
  37. twopy/inspection.py +492 -0
  38. twopy/matlab.py +398 -0
  39. twopy/napari/__init__.py +27 -0
  40. twopy/napari/__main__.py +10 -0
  41. twopy/napari/constants.py +13 -0
  42. twopy/napari/controls.py +739 -0
  43. twopy/napari/display.py +181 -0
  44. twopy/napari/display_paths.py +197 -0
  45. twopy/napari/interactive.py +310 -0
  46. twopy/napari/launcher.py +175 -0
  47. twopy/napari/layout.py +147 -0
  48. twopy/napari/loading.py +207 -0
  49. twopy/napari/movie.py +71 -0
  50. twopy/napari/paths.py +181 -0
  51. twopy/napari/plotting/__init__.py +21 -0
  52. twopy/napari/plotting/data.py +266 -0
  53. twopy/napari/plotting/docks.py +1052 -0
  54. twopy/napari/plotting/export.py +803 -0
  55. twopy/napari/plotting/export_controls.py +209 -0
  56. twopy/napari/plotting/label_visibility.py +260 -0
  57. twopy/napari/plotting/options.py +269 -0
  58. twopy/napari/plotting/panels.py +119 -0
  59. twopy/napari/plotting/widgets.py +767 -0
  60. twopy/napari/protocols.py +90 -0
  61. twopy/napari/responses.py +91 -0
  62. twopy/napari/roi.py +238 -0
  63. twopy/napari/session.py +381 -0
  64. twopy/napari/state.py +106 -0
  65. twopy/napari/text.py +26 -0
  66. twopy/napari/types.py +39 -0
  67. twopy/napari/viewer.py +405 -0
  68. twopy/parity/__init__.py +30 -0
  69. twopy/parity/dff.py +399 -0
  70. twopy/parity/roi.py +145 -0
  71. twopy/parity/saved_analysis.py +174 -0
  72. twopy/photodiode.py +375 -0
  73. twopy/photodiode_classification.py +484 -0
  74. twopy/roi.py +462 -0
  75. twopy/session.py +167 -0
  76. twopy/spatial.py +154 -0
  77. twopy/stimulus.py +233 -0
  78. twopy/synchronization.py +235 -0
  79. twopy/typing_guards.py +255 -0
  80. twopy-0.1.0.dist-info/METADATA +311 -0
  81. twopy-0.1.0.dist-info/RECORD +84 -0
  82. twopy-0.1.0.dist-info/WHEEL +5 -0
  83. twopy-0.1.0.dist-info/entry_points.txt +2 -0
  84. twopy-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1052 @@
1
+ """Response plotting widgets for the twopy napari adapter.
2
+
3
+ Inputs: loaded twopy analysis outputs or the current napari Labels ROI layer.
4
+ Outputs: Qt widgets showing one response plot per stimulus epoch and a
5
+ separate options panel.
6
+
7
+ The plotting code only owns GUI state and drawing. When the user asks to update
8
+ responses from the current Labels layer, this module converts labels to a
9
+ ``RoiSet`` and calls the normal analysis workflow.
10
+ """
11
+
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ from qtpy.QtGui import QColor
16
+ from qtpy.QtWidgets import (
17
+ QCheckBox,
18
+ QHBoxLayout,
19
+ QLabel,
20
+ QPushButton,
21
+ QScrollArea,
22
+ QSpinBox,
23
+ QTabWidget,
24
+ QVBoxLayout,
25
+ QWidget,
26
+ )
27
+
28
+ from twopy.analysis.responses import is_gray_epoch_name
29
+ from twopy.analysis.workflow import analyze_recording_responses
30
+ from twopy.converted import RecordingData
31
+ from twopy.napari.display_paths import (
32
+ format_output_folder,
33
+ format_twopy_h5_output,
34
+ recording_display_summary,
35
+ )
36
+ from twopy.napari.interactive import LiveResponseController
37
+ from twopy.napari.plotting.data import (
38
+ ResponsePlotData,
39
+ default_analysis_output_path,
40
+ load_response_plot_data,
41
+ )
42
+ from twopy.napari.plotting.export_controls import (
43
+ ResponseExportState,
44
+ create_response_export_tab,
45
+ )
46
+ from twopy.napari.plotting.label_visibility import (
47
+ apply_roi_visibility_to_labels_layer,
48
+ roi_label_values_from_labels,
49
+ )
50
+ from twopy.napari.plotting.options import (
51
+ axis_options_widget,
52
+ visibility_options_widget,
53
+ )
54
+ from twopy.napari.plotting.panels import (
55
+ epoch_plot_panel,
56
+ plot_size_widget,
57
+ response_update_tab,
58
+ scrolling_tab,
59
+ )
60
+ from twopy.napari.plotting.widgets import (
61
+ EpochPlotWidget,
62
+ clear_layout,
63
+ global_time_bounds,
64
+ global_value_bounds,
65
+ opaque_colors,
66
+ resolved_time_bounds,
67
+ resolved_value_bounds,
68
+ roi_colors_from_label_values,
69
+ )
70
+ from twopy.napari.protocols import NapariViewer
71
+ from twopy.napari.roi import (
72
+ roi_label_image_from_layer_for_recording,
73
+ save_napari_label_rois,
74
+ )
75
+ from twopy.napari.text import counted_noun
76
+
77
+ __all__ = [
78
+ "add_twopy_response_options_widget",
79
+ "add_twopy_response_plot_widget",
80
+ "refresh_response_plot_widget",
81
+ ]
82
+
83
+ _QT_APPLICATION: object | None = None
84
+
85
+
86
+ def add_twopy_response_plot_widget(
87
+ viewer: NapariViewer,
88
+ *,
89
+ recording: RecordingData | None,
90
+ roi_labels_layer: object | None = None,
91
+ roi_save_file: Path | None = None,
92
+ dock_name: str = "twopy responses",
93
+ dock_area: str = "top",
94
+ ) -> tuple[object, object]:
95
+ """Add the response plotting dock widget to a napari viewer.
96
+
97
+ Args:
98
+ viewer: Napari viewer that should receive the dock widget.
99
+ recording: Optional loaded converted recording.
100
+ roi_labels_layer: Optional napari Labels layer used for analysis
101
+ previews from current ROIs.
102
+ roi_save_file: Optional ROI HDF5 path used by the Save Analysis action.
103
+ dock_name: Dock widget title.
104
+ dock_area: Napari dock area.
105
+
106
+ Returns:
107
+ ``(widget, dock_widget)`` created by Qt and napari.
108
+ """
109
+ widget = create_response_plot_widget(
110
+ recording,
111
+ viewer=viewer,
112
+ roi_labels_layer=roi_labels_layer,
113
+ roi_save_file=roi_save_file,
114
+ )
115
+ dock_widget = viewer.window.add_dock_widget(
116
+ widget,
117
+ name=dock_name,
118
+ area=dock_area,
119
+ )
120
+ return widget, dock_widget
121
+
122
+
123
+ def add_twopy_response_options_widget(
124
+ viewer: NapariViewer,
125
+ response_plot_widget: object,
126
+ *,
127
+ dock_name: str = "twopy response options",
128
+ dock_area: str = "right",
129
+ ) -> tuple[object, object] | tuple[None, None]:
130
+ """Add the response plot options as a separate napari dock.
131
+
132
+ Args:
133
+ viewer: Napari viewer that should receive the dock widget.
134
+ response_plot_widget: Widget returned by ``create_response_plot_widget``.
135
+ dock_name: Dock widget title.
136
+ dock_area: Napari dock area.
137
+
138
+ Returns:
139
+ ``(widget, dock_widget)`` when the plot widget owns options, otherwise
140
+ ``(None, None)``.
141
+ """
142
+ if not isinstance(response_plot_widget, _ResponsePlotWidget):
143
+ return None, None
144
+ widget = response_plot_widget.options_widget()
145
+ dock_widget = viewer.window.add_dock_widget(
146
+ widget,
147
+ name=dock_name,
148
+ area=dock_area,
149
+ )
150
+ return widget, dock_widget
151
+
152
+
153
+ def create_response_plot_widget(
154
+ recording: RecordingData | None,
155
+ *,
156
+ viewer: object | None = None,
157
+ roi_labels_layer: object | None = None,
158
+ roi_save_file: Path | None = None,
159
+ ) -> object:
160
+ """Create a response plotting widget.
161
+
162
+ Args:
163
+ recording: Optional loaded converted recording.
164
+ viewer: Optional napari viewer used for figure export.
165
+ roi_labels_layer: Optional napari Labels layer used when the user asks
166
+ to compute responses from the current ROIs.
167
+ roi_save_file: Optional ROI HDF5 path used by the Save Analysis action.
168
+
169
+ Returns:
170
+ Qt widget as a plain object for napari docking.
171
+ """
172
+ _ensure_qapplication()
173
+ widget = _ResponsePlotWidget(viewer=viewer)
174
+ refresh_response_plot_widget(
175
+ widget,
176
+ recording=recording,
177
+ roi_labels_layer=roi_labels_layer,
178
+ roi_save_file=roi_save_file,
179
+ )
180
+ return widget
181
+
182
+
183
+ def refresh_response_plot_widget(
184
+ widget: object | None,
185
+ *,
186
+ recording: RecordingData | None,
187
+ roi_labels_layer: object | None = None,
188
+ roi_save_file: Path | None = None,
189
+ ) -> None:
190
+ """Refresh a response plotting widget after a recording is loaded.
191
+
192
+ Args:
193
+ widget: Optional widget returned by ``create_response_plot_widget``.
194
+ recording: Optional loaded converted recording.
195
+ roi_labels_layer: Optional current ROI Labels layer.
196
+ roi_save_file: Optional ROI HDF5 path used by the Save Analysis action.
197
+
198
+ Returns:
199
+ None.
200
+ """
201
+ if not isinstance(widget, _ResponsePlotWidget):
202
+ return
203
+ widget.set_roi_labels_layer(roi_labels_layer)
204
+ widget.set_roi_save_file(roi_save_file)
205
+ if recording is None:
206
+ widget.clear_recording()
207
+ return
208
+ widget.load_recording(recording)
209
+
210
+
211
+ def _ensure_qapplication() -> None:
212
+ """Create a Qt application when tests instantiate widgets without napari.
213
+
214
+ Args:
215
+ None.
216
+
217
+ Returns:
218
+ None.
219
+ """
220
+ from qtpy.QtWidgets import QApplication
221
+
222
+ global _QT_APPLICATION
223
+ if QApplication.instance() is None:
224
+ _QT_APPLICATION = QApplication([])
225
+
226
+
227
+ class _ResponsePlotWidget(QWidget):
228
+ """Qt widget for response plots plus a separately docked options panel."""
229
+
230
+ def __init__(self, *, viewer: object | None = None) -> None:
231
+ """Create empty response plots and an options panel.
232
+
233
+ Args:
234
+ viewer: Optional napari viewer used by export buttons to read the
235
+ currently displayed movie frame.
236
+ """
237
+ super().__init__()
238
+ self._viewer = viewer
239
+ self._recording: RecordingData | None = None
240
+ self._roi_labels_layer: object | None = None
241
+ self._analysis_path: Path | None = None
242
+ self._roi_save_file: Path | None = None
243
+ self._plot_data: ResponsePlotData | None = None
244
+ self._show_sem = True
245
+ self._plot_size = 360
246
+ self._epoch_plot_widgets: dict[int, EpochPlotWidget] = {}
247
+ self._epoch_plot_panels: dict[int, QWidget] = {}
248
+ self._roi_visibility: dict[int, bool] = {}
249
+ self._roi_colors: tuple[QColor, ...] = ()
250
+ self._epoch_visibility: dict[int, bool] = {}
251
+ self._manual_x_min: float | None = None
252
+ self._manual_x_max: float | None = None
253
+ self._manual_y_min: float | None = None
254
+ self._manual_y_max: float | None = None
255
+ self._live_controller = LiveResponseController(self)
256
+ self._status_label = QLabel("No recording loaded.")
257
+ self._plot_layout = QHBoxLayout()
258
+ self._plot_layout.addWidget(self._status_label)
259
+ plot_content = QWidget()
260
+ plot_content.setLayout(self._plot_layout)
261
+ plot_scroll = QScrollArea()
262
+ plot_scroll.setWidgetResizable(True)
263
+ plot_scroll.setWidget(plot_content)
264
+ layout = QVBoxLayout()
265
+ layout.setContentsMargins(0, 0, 0, 0)
266
+ layout.addWidget(plot_scroll)
267
+ self.setLayout(layout)
268
+
269
+ self._options_tabs = QTabWidget()
270
+ self._show_sem_checkbox = QCheckBox("Show SEM")
271
+ self._show_sem_checkbox.setChecked(True)
272
+ self._show_sem_checkbox.stateChanged.connect(self._set_show_sem)
273
+ reload_saved_button = QPushButton("Reload saved analysis")
274
+ reload_saved_button.clicked.connect(self.reload)
275
+ recompute_preview_button = QPushButton("Recompute preview now")
276
+ recompute_preview_button.clicked.connect(self.update_from_current_rois)
277
+ save_analysis_button = QPushButton("Save analysis + ROIs")
278
+ save_analysis_button.clicked.connect(self.save_analysis_and_rois)
279
+ self._recording_summary_label = QLabel("No recording loaded.")
280
+ self._recording_summary_label.setWordWrap(True)
281
+ self._analysis_path_label = QLabel("Analysis output: default")
282
+ self._analysis_path_label.setWordWrap(True)
283
+ self._roi_save_path_label = QLabel("ROI output: default")
284
+ self._roi_save_path_label.setWordWrap(True)
285
+ self._update_status_label = QLabel("")
286
+ self._update_status_label.setWordWrap(True)
287
+ self._plot_options_layout = QVBoxLayout()
288
+ self._plot_axis_layout = QVBoxLayout()
289
+ plot_axis_widget = QWidget()
290
+ plot_axis_widget.setLayout(self._plot_axis_layout)
291
+ self._plot_size_spin = QSpinBox()
292
+ self._plot_size_spin.setRange(240, 900)
293
+ self._plot_size_spin.setSingleStep(20)
294
+ self._plot_size_spin.setSuffix(" px")
295
+ self._plot_size_spin.setValue(self._plot_size)
296
+ self._plot_size_spin.valueChanged.connect(self._set_plot_size)
297
+ self._plot_options_layout.addWidget(self._show_sem_checkbox)
298
+ self._plot_options_layout.addWidget(plot_size_widget(self._plot_size_spin))
299
+ self._plot_options_layout.addWidget(plot_axis_widget)
300
+ self._plot_options_layout.addStretch(1)
301
+ self._roi_options_layout = QVBoxLayout()
302
+ self._epoch_options_layout = QVBoxLayout()
303
+ self._options_tabs.addTab(
304
+ response_update_tab(
305
+ reload_saved_button=reload_saved_button,
306
+ recompute_preview_button=recompute_preview_button,
307
+ save_analysis_button=save_analysis_button,
308
+ recording_summary_label=self._recording_summary_label,
309
+ analysis_output_label=self._analysis_path_label,
310
+ roi_output_label=self._roi_save_path_label,
311
+ status_label=self._update_status_label,
312
+ ),
313
+ "Update",
314
+ )
315
+ self._options_tabs.addTab(
316
+ scrolling_tab(self._plot_options_layout),
317
+ "Plot",
318
+ )
319
+ self._options_tabs.addTab(
320
+ scrolling_tab(self._roi_options_layout),
321
+ "ROIs",
322
+ )
323
+ self._options_tabs.addTab(
324
+ scrolling_tab(self._epoch_options_layout),
325
+ "Epochs",
326
+ )
327
+ self._options_tabs.addTab(
328
+ create_response_export_tab(self._export_state),
329
+ "Export",
330
+ )
331
+
332
+ def options_widget(self) -> object:
333
+ """Return the separately docked response-options widget.
334
+
335
+ Args:
336
+ None.
337
+
338
+ Returns:
339
+ Tabbed Qt widget with response plot controls.
340
+ """
341
+ return self._options_tabs
342
+
343
+ def set_roi_labels_layer(self, roi_labels_layer: object | None) -> None:
344
+ """Store the current editable ROI Labels layer.
345
+
346
+ Args:
347
+ roi_labels_layer: Optional napari Labels layer.
348
+
349
+ Returns:
350
+ None.
351
+ """
352
+ self._roi_labels_layer = roi_labels_layer
353
+ self._live_controller.set_context(self._recording, self._roi_labels_layer)
354
+ self._render_plots()
355
+
356
+ def set_roi_save_file(self, roi_save_file: Path | None) -> None:
357
+ """Store the ROI HDF5 path used by Save Analysis.
358
+
359
+ Args:
360
+ roi_save_file: Optional ROI output path for the selected recording.
361
+
362
+ Returns:
363
+ None.
364
+ """
365
+ self._roi_save_file = roi_save_file
366
+ self._refresh_update_path_labels()
367
+
368
+ def clear_recording(self) -> None:
369
+ """Reset the widget to the empty-launch state.
370
+
371
+ Args:
372
+ None.
373
+
374
+ Returns:
375
+ None.
376
+ """
377
+ self._recording = None
378
+ self._analysis_path = None
379
+ self._plot_data = None
380
+ self._live_controller.set_context(None, None)
381
+ self._reset_plot_state()
382
+ self._recording_summary_label.setText("No recording loaded.")
383
+ self._analysis_path_label.setText("Analysis output: default")
384
+ self._roi_save_path_label.setText("ROI output: default")
385
+ self._update_status_label.setText("")
386
+ self._clear_dynamic_option_tabs()
387
+ self._set_status("No recording loaded.")
388
+
389
+ def load_recording(self, recording: RecordingData) -> None:
390
+ """Load response plots for one recording.
391
+
392
+ Args:
393
+ recording: Loaded converted recording.
394
+
395
+ Returns:
396
+ None.
397
+ """
398
+ self._recording = recording
399
+ self._analysis_path = default_analysis_output_path(recording)
400
+ self._refresh_update_path_labels()
401
+ self.reload()
402
+ self._live_controller.set_context(self._recording, self._roi_labels_layer)
403
+
404
+ def update_from_current_rois(self) -> None:
405
+ """Compute and plot responses from the current ROI Labels layer.
406
+
407
+ Args:
408
+ None.
409
+
410
+ Returns:
411
+ None.
412
+
413
+ The live controller computes in memory and does not write analysis
414
+ files. Explicit analysis saves remain separate from plot previews.
415
+ """
416
+ self._live_controller.update_now()
417
+
418
+ def save_analysis_and_rois(self) -> None:
419
+ """Save current ROI labels plus persisted analysis outputs.
420
+
421
+ Args:
422
+ None.
423
+
424
+ Returns:
425
+ None.
426
+
427
+ Live response plots are previews. This method is the explicit
428
+ persistence action: it saves the current Labels layer as ROI HDF5, then
429
+ runs the normal script-facing analysis workflow to write analysis HDF5
430
+ and response CSV outputs.
431
+ """
432
+ if self._recording is None:
433
+ self._set_status("No recording loaded.")
434
+ return
435
+ if self._roi_labels_layer is None:
436
+ self._set_status("No ROI Labels layer is available.")
437
+ return
438
+ try:
439
+ label_image = roi_label_image_from_layer_for_recording(
440
+ self._roi_labels_layer,
441
+ self._recording,
442
+ )
443
+ except ValueError as error:
444
+ self._set_status(str(error))
445
+ return
446
+ if not np.any(label_image > 0):
447
+ self._set_status("No ROI labels to save or analyze.")
448
+ return
449
+
450
+ roi_output_path = self._resolved_roi_save_file()
451
+ analysis_output_path = self._resolved_analysis_path()
452
+ try:
453
+ roi_set = save_napari_label_rois(label_image, roi_output_path)
454
+ run = analyze_recording_responses(
455
+ self._recording.path,
456
+ roi_set,
457
+ output_path=analysis_output_path,
458
+ )
459
+ except ValueError as error:
460
+ self._set_status(str(error))
461
+ return
462
+
463
+ self._roi_save_file = roi_output_path
464
+ self._analysis_path = run.output_path
465
+ self._refresh_update_path_labels()
466
+ output_folder = format_output_folder(run.output_path, self._recording)
467
+ self._update_status_label.setText(
468
+ f"Saved {counted_noun(len(roi_set.labels), 'ROI', 'ROIs')} "
469
+ f"to {output_folder}"
470
+ )
471
+
472
+ def set_response_plot_data(
473
+ self,
474
+ plot_data: ResponsePlotData,
475
+ *,
476
+ reset_axes: bool,
477
+ ) -> None:
478
+ """Show plot data computed from live or saved analysis outputs.
479
+
480
+ Args:
481
+ plot_data: Plot-ready response data.
482
+ reset_axes: Whether manual axis bounds should return to automatic
483
+ data-derived values.
484
+
485
+ Returns:
486
+ None.
487
+ """
488
+ self._clear_epoch_plot_cache()
489
+ self._plot_data = plot_data
490
+ self._sync_plot_state(reset_axes=reset_axes)
491
+ self._render_plots()
492
+
493
+ def show_response_status(self, text: str) -> None:
494
+ """Show a user-facing response-analysis status message.
495
+
496
+ Args:
497
+ text: Status text.
498
+
499
+ Returns:
500
+ None.
501
+ """
502
+ self._plot_data = None
503
+ self._set_status(text)
504
+
505
+ def reload(self) -> None:
506
+ """Reload response plots from the current analysis output file."""
507
+ if self._analysis_path is None:
508
+ self._set_status("No recording loaded.")
509
+ return
510
+ result = load_response_plot_data(self._analysis_path)
511
+ if isinstance(result, str):
512
+ self._plot_data = None
513
+ self._reset_plot_state()
514
+ self._clear_dynamic_option_tabs()
515
+ self._set_status(result)
516
+ return
517
+ self.set_response_plot_data(result, reset_axes=True)
518
+
519
+ def _set_show_sem(self, state: int) -> None:
520
+ """Update whether SEM bands are drawn."""
521
+ del state
522
+ self._show_sem = self._show_sem_checkbox.isChecked()
523
+ self._update_visible_plot_widgets(apply_roi_layer_visibility=False)
524
+
525
+ def _set_plot_size(self, value: int) -> None:
526
+ """Update the square pixel size used for live response plots."""
527
+ self._plot_size = int(value)
528
+ self._update_visible_plot_widgets(apply_roi_layer_visibility=False)
529
+
530
+ def _sync_plot_state(self, *, reset_axes: bool) -> None:
531
+ """Synchronize option state with currently loaded plot data.
532
+
533
+ Args:
534
+ reset_axes: Whether axis spin boxes should return to data-derived
535
+ defaults.
536
+
537
+ Returns:
538
+ None.
539
+ """
540
+ if self._plot_data is None:
541
+ self._reset_plot_state()
542
+ return
543
+
544
+ roi_labels = self._plot_data.epochs[0].roi_labels
545
+ self._roi_visibility = {
546
+ index: self._roi_visibility.get(index, True)
547
+ for index in range(len(roi_labels))
548
+ }
549
+ self._roi_colors = opaque_colors(
550
+ roi_colors_from_label_values(
551
+ self._roi_labels_layer,
552
+ roi_label_values_from_labels(roi_labels),
553
+ fallback_count=len(roi_labels),
554
+ )
555
+ )
556
+ self._epoch_visibility = {
557
+ index: self._epoch_visibility.get(
558
+ index,
559
+ not is_gray_epoch_name(epoch.epoch_name),
560
+ )
561
+ for index, epoch in enumerate(self._plot_data.epochs)
562
+ }
563
+ if reset_axes:
564
+ self._manual_x_min = None
565
+ self._manual_x_max = None
566
+ self._manual_y_min = None
567
+ self._manual_y_max = None
568
+ self._render_options()
569
+
570
+ def _reset_plot_state(self) -> None:
571
+ """Clear plot-specific option state."""
572
+ self._roi_visibility = {}
573
+ self._roi_colors = ()
574
+ self._epoch_visibility = {}
575
+ self._manual_x_min = None
576
+ self._manual_x_max = None
577
+ self._manual_y_min = None
578
+ self._manual_y_max = None
579
+
580
+ def _render_options(self) -> None:
581
+ """Render axis, ROI, and epoch visibility controls."""
582
+ self._clear_dynamic_option_tabs()
583
+ if self._plot_data is None or len(self._plot_data.epochs) == 0:
584
+ return
585
+
586
+ auto_x_min, auto_x_max = global_time_bounds(self._plot_data)
587
+ auto_y_min, auto_y_max = global_value_bounds(
588
+ self._plot_data,
589
+ self._visible_roi_indices(),
590
+ self._visible_epoch_indices(),
591
+ )
592
+ axis_widget = axis_options_widget(
593
+ x_min=self._manual_x_min if self._manual_x_min is not None else auto_x_min,
594
+ x_max=self._manual_x_max if self._manual_x_max is not None else auto_x_max,
595
+ y_min=self._manual_y_min if self._manual_y_min is not None else auto_y_min,
596
+ y_max=self._manual_y_max if self._manual_y_max is not None else auto_y_max,
597
+ on_change=self._set_manual_axis_bounds,
598
+ )
599
+ self._plot_axis_layout.addWidget(axis_widget)
600
+ self._roi_options_layout.addWidget(
601
+ visibility_options_widget(
602
+ title="ROIs",
603
+ labels=self._roi_labels(),
604
+ visibility=self._roi_visibility,
605
+ on_change=self._set_roi_visibility,
606
+ on_change_batch=self._set_roi_visibility_batch,
607
+ keys=tuple(range(len(self._roi_labels()))),
608
+ colors=self._roi_colors,
609
+ ),
610
+ )
611
+ self._roi_options_layout.addStretch(1)
612
+ self._epoch_options_layout.addWidget(
613
+ visibility_options_widget(
614
+ title="Epochs",
615
+ labels=tuple(
616
+ f"{epoch.epoch_number}: {epoch.epoch_name}"
617
+ for epoch in self._plot_data.epochs
618
+ ),
619
+ visibility=self._epoch_visibility,
620
+ on_change=self._set_epoch_visibility,
621
+ on_change_batch=self._set_epoch_visibility_batch,
622
+ keys=tuple(range(len(self._plot_data.epochs))),
623
+ ),
624
+ )
625
+ self._epoch_options_layout.addStretch(1)
626
+
627
+ def _clear_dynamic_option_tabs(self) -> None:
628
+ """Clear dynamic option widgets from plot, ROI, and epoch tabs."""
629
+ clear_layout(self._plot_axis_layout)
630
+ clear_layout(self._roi_options_layout)
631
+ clear_layout(self._epoch_options_layout)
632
+
633
+ def _set_manual_axis_bounds(
634
+ self,
635
+ *,
636
+ x_min: float,
637
+ x_max: float,
638
+ y_min: float,
639
+ y_max: float,
640
+ ) -> None:
641
+ """Store user-selected plot bounds.
642
+
643
+ Args:
644
+ x_min: Minimum x-axis value in seconds.
645
+ x_max: Maximum x-axis value in seconds.
646
+ y_min: Minimum dF/F value.
647
+ y_max: Maximum dF/F value.
648
+
649
+ Returns:
650
+ None.
651
+ """
652
+ self._manual_x_min = x_min
653
+ self._manual_x_max = x_max
654
+ self._manual_y_min = y_min
655
+ self._manual_y_max = y_max
656
+ self._update_visible_plot_widgets(apply_roi_layer_visibility=False)
657
+
658
+ def _export_state(self) -> ResponseExportState:
659
+ """Return the current state needed by export buttons."""
660
+ return ResponseExportState(
661
+ viewer=self._viewer,
662
+ recording=self._recording,
663
+ roi_labels_layer=self._roi_labels_layer,
664
+ plot_data=self._plot_data,
665
+ output_dir=self._export_output_dir(),
666
+ roi_label_values=self._roi_label_values(),
667
+ roi_colors=self._roi_color_hex(),
668
+ epoch_indices=self._visible_epoch_indices(),
669
+ roi_indices=self._visible_roi_indices(),
670
+ show_sem=self._show_sem,
671
+ time_bounds=resolved_time_bounds(
672
+ self._plot_data,
673
+ self._visible_epoch_indices(),
674
+ manual_min=self._manual_x_min,
675
+ manual_max=self._manual_x_max,
676
+ )
677
+ if self._plot_data is not None
678
+ else (-1.0, 1.0),
679
+ value_bounds=resolved_value_bounds(
680
+ self._plot_data,
681
+ self._visible_roi_indices(),
682
+ self._visible_epoch_indices(),
683
+ manual_min=self._manual_y_min,
684
+ manual_max=self._manual_y_max,
685
+ )
686
+ if self._plot_data is not None
687
+ else (-1.0, 1.0),
688
+ )
689
+
690
+ def _export_output_dir(self) -> Path:
691
+ """Return the default export folder beside the current recording."""
692
+ if self._recording is None:
693
+ return Path("exports")
694
+ return self._recording.path.parent / "exports"
695
+
696
+ def _resolved_analysis_path(self) -> Path:
697
+ """Return the analysis HDF5 path for the selected recording."""
698
+ if self._analysis_path is not None:
699
+ return self._analysis_path
700
+ if self._recording is not None:
701
+ return default_analysis_output_path(self._recording)
702
+ return Path("analysis_outputs.h5")
703
+
704
+ def _resolved_roi_save_file(self) -> Path:
705
+ """Return the ROI HDF5 path for the selected recording."""
706
+ if self._roi_save_file is not None:
707
+ return self._roi_save_file
708
+ if self._recording is not None:
709
+ return self._recording.path.parent / "rois.h5"
710
+ return Path("rois.h5")
711
+
712
+ def _refresh_update_path_labels(self) -> None:
713
+ """Refresh compact recording and output labels in the Update tab.
714
+
715
+ Args:
716
+ None.
717
+
718
+ Returns:
719
+ None.
720
+ """
721
+ if self._recording is None:
722
+ self._recording_summary_label.setText("No recording loaded.")
723
+ self._analysis_path_label.setText("Analysis output: default")
724
+ self._roi_save_path_label.setText("ROI output: default")
725
+ return
726
+
727
+ summary = recording_display_summary(self._recording)
728
+ self._recording_summary_label.setText("\n\n".join(summary.lines()))
729
+ self._analysis_path_label.setText(
730
+ f"Analysis output: {format_twopy_h5_output(self._resolved_analysis_path())}"
731
+ )
732
+ self._roi_save_path_label.setText(
733
+ f"ROI output: {format_twopy_h5_output(self._resolved_roi_save_file())}"
734
+ )
735
+
736
+ def _roi_color_hex(self) -> tuple[str, ...]:
737
+ """Return ROI colors as hex strings in plot ROI order."""
738
+ return tuple(color.name() for color in self._roi_colors)
739
+
740
+ def _roi_label_values(self) -> tuple[int, ...]:
741
+ """Return integer Labels values in plot ROI order."""
742
+ return roi_label_values_from_labels(self._roi_labels())
743
+
744
+ def _set_roi_visibility(self, index: object, visible: bool) -> None:
745
+ """Update one ROI visibility flag from its displayed row index."""
746
+ row_index = _row_index_or_none(index, len(self._roi_labels()))
747
+ if row_index is not None:
748
+ self._roi_visibility[row_index] = visible
749
+ self._update_visible_plot_widgets()
750
+
751
+ def _set_roi_visibility_batch(self, visibility: dict[object, bool]) -> None:
752
+ """Update several ROI visibility flags with one plot refresh."""
753
+ for index, visible in visibility.items():
754
+ row_index = _row_index_or_none(index, len(self._roi_labels()))
755
+ if row_index is not None:
756
+ self._roi_visibility[row_index] = visible
757
+ self._update_visible_plot_widgets()
758
+
759
+ def _set_epoch_visibility(self, index: object, visible: bool) -> None:
760
+ """Update one epoch visibility flag from its displayed row index."""
761
+ row_index = _row_index_or_none(index, self._epoch_count())
762
+ if row_index is not None:
763
+ self._epoch_visibility[row_index] = visible
764
+ self._render_plots(apply_roi_layer_visibility=False)
765
+
766
+ def _set_epoch_visibility_batch(self, visibility: dict[object, bool]) -> None:
767
+ """Update several epoch visibility flags with one plot refresh."""
768
+ for index, visible in visibility.items():
769
+ row_index = _row_index_or_none(index, self._epoch_count())
770
+ if row_index is not None:
771
+ self._epoch_visibility[row_index] = visible
772
+ self._render_plots(apply_roi_layer_visibility=False)
773
+
774
+ def _visible_roi_indices(self) -> tuple[int, ...]:
775
+ """Return zero-based ROI indices currently visible in plots.
776
+
777
+ Args:
778
+ None.
779
+
780
+ Returns:
781
+ Tuple of ROI indices.
782
+ """
783
+ return tuple(
784
+ index
785
+ for index in range(len(self._roi_labels()))
786
+ if self._roi_visibility.get(index, True)
787
+ )
788
+
789
+ def _visible_epoch_indices(self) -> tuple[int, ...]:
790
+ """Return epoch row indices currently visible in plots.
791
+
792
+ Args:
793
+ None.
794
+
795
+ Returns:
796
+ Tuple of displayed epoch row indices.
797
+ """
798
+ return tuple(
799
+ index
800
+ for index in range(self._epoch_count())
801
+ if self._epoch_visibility.get(index, True)
802
+ )
803
+
804
+ def _roi_labels(self) -> tuple[str, ...]:
805
+ """Return ROI labels from the current plot data.
806
+
807
+ Args:
808
+ None.
809
+
810
+ Returns:
811
+ ROI labels, or an empty tuple before data is loaded.
812
+ """
813
+ if self._plot_data is None or len(self._plot_data.epochs) == 0:
814
+ return ()
815
+ return self._plot_data.epochs[0].roi_labels
816
+
817
+ def _epoch_count(self) -> int:
818
+ """Return the number of epoch rows in the current plot data."""
819
+ if self._plot_data is None:
820
+ return 0
821
+ return len(self._plot_data.epochs)
822
+
823
+ def _set_status(self, text: str) -> None:
824
+ """Replace plots with one status message.
825
+
826
+ Args:
827
+ text: User-facing status message.
828
+
829
+ Returns:
830
+ None.
831
+ """
832
+ self._clear_epoch_plot_cache()
833
+ clear_layout(self._plot_layout)
834
+ self._status_label = QLabel(text)
835
+ self._status_label.setWordWrap(True)
836
+ self._plot_layout.addWidget(self._status_label)
837
+ self._plot_layout.addStretch(1)
838
+
839
+ def _render_plots(self, *, apply_roi_layer_visibility: bool = True) -> None:
840
+ """Render one plot widget per epoch type."""
841
+ self._clear_plot_layout_preserving_epoch_cache()
842
+ if self._plot_data is None or len(self._plot_data.epochs) == 0:
843
+ self._set_status("No responses available.")
844
+ return
845
+ if apply_roi_layer_visibility:
846
+ self._apply_roi_layer_visibility()
847
+ roi_indices = self._visible_roi_indices()
848
+ epoch_indices = self._visible_epoch_indices()
849
+ if len(roi_indices) == 0 or len(epoch_indices) == 0:
850
+ self._set_status("No responses selected.")
851
+ return
852
+ time_min, time_max = resolved_time_bounds(
853
+ self._plot_data,
854
+ epoch_indices,
855
+ manual_min=self._manual_x_min,
856
+ manual_max=self._manual_x_max,
857
+ )
858
+ value_min, value_max = resolved_value_bounds(
859
+ self._plot_data,
860
+ roi_indices,
861
+ epoch_indices,
862
+ manual_min=self._manual_y_min,
863
+ manual_max=self._manual_y_max,
864
+ )
865
+ colors = self._roi_colors
866
+ self._ensure_epoch_plot_cache()
867
+ for epoch_index, _epoch in enumerate(self._plot_data.epochs):
868
+ if not self._epoch_visibility.get(epoch_index, True):
869
+ continue
870
+ self._plot_layout.addWidget(self._epoch_plot_panels[epoch_index])
871
+ self._epoch_plot_panels[epoch_index].show()
872
+ self._plot_layout.addStretch(1)
873
+ self._update_epoch_plot_widgets(
874
+ roi_indices=roi_indices,
875
+ epoch_indices=epoch_indices,
876
+ time_min=time_min,
877
+ time_max=time_max,
878
+ value_min=value_min,
879
+ value_max=value_max,
880
+ colors=colors,
881
+ )
882
+
883
+ def _update_visible_plot_widgets(
884
+ self,
885
+ *,
886
+ apply_roi_layer_visibility: bool = True,
887
+ ) -> None:
888
+ """Repaint currently shown plot widgets without rebuilding the layout.
889
+
890
+ Args:
891
+ apply_roi_layer_visibility: Whether ROI visibility should also dim
892
+ matching Labels-layer values in the napari viewer.
893
+
894
+ Returns:
895
+ None.
896
+ """
897
+ if self._plot_data is None or len(self._plot_data.epochs) == 0:
898
+ self._render_plots(apply_roi_layer_visibility=apply_roi_layer_visibility)
899
+ return
900
+ roi_indices = self._visible_roi_indices()
901
+ epoch_indices = self._visible_epoch_indices()
902
+ if len(roi_indices) == 0 or len(epoch_indices) == 0:
903
+ self._render_plots(apply_roi_layer_visibility=apply_roi_layer_visibility)
904
+ return
905
+ if any(index not in self._epoch_plot_widgets for index in epoch_indices):
906
+ self._render_plots(apply_roi_layer_visibility=apply_roi_layer_visibility)
907
+ return
908
+ if apply_roi_layer_visibility:
909
+ self._apply_roi_layer_visibility()
910
+ time_min, time_max = resolved_time_bounds(
911
+ self._plot_data,
912
+ epoch_indices,
913
+ manual_min=self._manual_x_min,
914
+ manual_max=self._manual_x_max,
915
+ )
916
+ value_min, value_max = resolved_value_bounds(
917
+ self._plot_data,
918
+ roi_indices,
919
+ epoch_indices,
920
+ manual_min=self._manual_y_min,
921
+ manual_max=self._manual_y_max,
922
+ )
923
+ self._update_epoch_plot_widgets(
924
+ roi_indices=roi_indices,
925
+ epoch_indices=epoch_indices,
926
+ time_min=time_min,
927
+ time_max=time_max,
928
+ value_min=value_min,
929
+ value_max=value_max,
930
+ colors=self._roi_colors,
931
+ )
932
+
933
+ def _update_epoch_plot_widgets(
934
+ self,
935
+ *,
936
+ roi_indices: tuple[int, ...],
937
+ epoch_indices: tuple[int, ...],
938
+ time_min: float,
939
+ time_max: float,
940
+ value_min: float,
941
+ value_max: float,
942
+ colors: tuple[QColor, ...],
943
+ ) -> None:
944
+ """Update cached epoch widgets from already-computed plot data.
945
+
946
+ Args:
947
+ roi_indices: ROI rows to draw.
948
+ epoch_indices: Epoch plot rows to update.
949
+ time_min: Shared x-axis minimum.
950
+ time_max: Shared x-axis maximum.
951
+ value_min: Shared y-axis minimum.
952
+ value_max: Shared y-axis maximum.
953
+ colors: ROI plot colors.
954
+
955
+ Returns:
956
+ None.
957
+ """
958
+ for epoch_index in epoch_indices:
959
+ plot = self._epoch_plot_widgets[epoch_index]
960
+ plot.update_display(
961
+ show_sem=self._show_sem,
962
+ roi_indices=roi_indices,
963
+ roi_colors=colors,
964
+ time_min=time_min,
965
+ time_max=time_max,
966
+ value_min=value_min,
967
+ value_max=value_max,
968
+ plot_size=self._plot_size,
969
+ )
970
+
971
+ def _ensure_epoch_plot_cache(self) -> None:
972
+ """Create one persistent plot widget per loaded epoch."""
973
+ if self._plot_data is None:
974
+ self._clear_epoch_plot_cache()
975
+ return
976
+ expected_indices = set(range(len(self._plot_data.epochs)))
977
+ for epoch_index in tuple(self._epoch_plot_widgets):
978
+ if epoch_index not in expected_indices:
979
+ self._epoch_plot_panels[epoch_index].deleteLater()
980
+ del self._epoch_plot_panels[epoch_index]
981
+ del self._epoch_plot_widgets[epoch_index]
982
+ for epoch_index, epoch in enumerate(self._plot_data.epochs):
983
+ if epoch_index in self._epoch_plot_widgets:
984
+ continue
985
+ plot = EpochPlotWidget(
986
+ epoch,
987
+ show_sem=self._show_sem,
988
+ roi_indices=self._visible_roi_indices(),
989
+ roi_colors=self._roi_colors,
990
+ time_min=0.0,
991
+ time_max=1.0,
992
+ value_min=0.0,
993
+ value_max=1.0,
994
+ plot_size=self._plot_size,
995
+ )
996
+ self._epoch_plot_widgets[epoch_index] = plot
997
+ panel = epoch_plot_panel(
998
+ title=f"Epoch {epoch.epoch_number}: {epoch.epoch_name}",
999
+ plot=plot,
1000
+ )
1001
+ panel.hide()
1002
+ self._epoch_plot_panels[epoch_index] = panel
1003
+
1004
+ def _clear_epoch_plot_cache(self) -> None:
1005
+ """Delete cached epoch plot panels and widgets."""
1006
+ for panel in self._epoch_plot_panels.values():
1007
+ panel.deleteLater()
1008
+ self._epoch_plot_widgets.clear()
1009
+ self._epoch_plot_panels.clear()
1010
+
1011
+ def _clear_plot_layout_preserving_epoch_cache(self) -> None:
1012
+ """Clear the plot strip while keeping cached epoch panels alive."""
1013
+ cached_panels = set(self._epoch_plot_panels.values())
1014
+ while self._plot_layout.count():
1015
+ item = self._plot_layout.takeAt(0)
1016
+ if item is None:
1017
+ continue
1018
+ widget = item.widget()
1019
+ if widget is None:
1020
+ continue
1021
+ if widget in cached_panels:
1022
+ # Qt keeps a widget visible after it is removed from a layout.
1023
+ # Hidden epoch plots must be hidden explicitly so stale panels
1024
+ # cannot remain on screen after checkbox changes.
1025
+ widget.hide()
1026
+ else:
1027
+ widget.deleteLater()
1028
+
1029
+ def _apply_roi_layer_visibility(self) -> None:
1030
+ """Mirror plot ROI visibility onto the napari Labels overlay."""
1031
+ apply_roi_visibility_to_labels_layer(
1032
+ self._roi_labels_layer,
1033
+ roi_labels=self._roi_labels(),
1034
+ visibility=self._roi_visibility,
1035
+ colors=self._roi_colors,
1036
+ keys=tuple(range(len(self._roi_labels()))),
1037
+ )
1038
+
1039
+
1040
+ def _row_index_or_none(value: object, row_count: int) -> int | None:
1041
+ """Return a valid widget row index from an external callback value.
1042
+
1043
+ Args:
1044
+ value: Callback key from a visibility checkbox.
1045
+ row_count: Number of rows currently displayed by that widget.
1046
+
1047
+ Returns:
1048
+ Zero-based row index, or ``None`` when ``value`` is invalid.
1049
+ """
1050
+ if type(value) is not int or value < 0 or value >= row_count:
1051
+ return None
1052
+ return value