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,767 @@
1
+ """Qt widgets and drawing helpers for twopy response plots.
2
+
3
+ Inputs: plot-ready response data, ROI visibility state, and napari Labels
4
+ colors.
5
+ Outputs: Qt widgets that draw response traces.
6
+
7
+ This module owns drawing helpers only. It does not load recordings, compute
8
+ analysis outputs, or build option panels.
9
+ """
10
+
11
+ from typing import Protocol, cast
12
+
13
+ import numpy as np
14
+ import numpy.typing as npt
15
+ from qtpy.QtCore import QPointF, QRectF, QSize
16
+ from qtpy.QtGui import QColor, QPainter, QPainterPath, QPaintEvent, QPen
17
+ from qtpy.QtWidgets import QLayout, QSizePolicy, QWidget
18
+
19
+ from twopy.napari.plotting.data import EpochResponsePlotData, ResponsePlotData
20
+
21
+ __all__ = [
22
+ "EpochPlotWidget",
23
+ "clear_layout",
24
+ "global_time_bounds",
25
+ "global_value_bounds",
26
+ "ordered_bounds",
27
+ "opaque_colors",
28
+ "resolved_time_bounds",
29
+ "resolved_value_bounds",
30
+ "roi_colors_from_label_values",
31
+ "roi_colors_from_layer",
32
+ ]
33
+
34
+
35
+ class _LabelsColorLayer(Protocol):
36
+ """Small protocol for napari Labels layers that can report label colors."""
37
+
38
+ def get_color(self, label: int) -> object:
39
+ """Return an RGBA color for one integer label.
40
+
41
+ Args:
42
+ label: Positive Labels-layer value.
43
+
44
+ Returns:
45
+ RGBA-like color object.
46
+ """
47
+ ...
48
+
49
+
50
+ class EpochPlotWidget(QWidget):
51
+ """Small custom Qt plot for one stimulus epoch type."""
52
+
53
+ def __init__(
54
+ self,
55
+ data: EpochResponsePlotData,
56
+ *,
57
+ show_sem: bool,
58
+ roi_indices: tuple[int, ...],
59
+ roi_colors: tuple[QColor, ...],
60
+ time_min: float,
61
+ time_max: float,
62
+ value_min: float,
63
+ value_max: float,
64
+ plot_size: int = 360,
65
+ ) -> None:
66
+ """Create a plot widget.
67
+
68
+ Args:
69
+ data: Plot data for one epoch.
70
+ show_sem: Whether SEM bands should be drawn.
71
+ roi_indices: Zero-based ROI indices to draw.
72
+ roi_colors: Colors matching the viewer Labels layer by ROI index.
73
+ time_min: Shared x-axis minimum.
74
+ time_max: Shared x-axis maximum.
75
+ value_min: Shared y-axis minimum.
76
+ value_max: Shared y-axis maximum.
77
+ plot_size: Square widget size in screen pixels.
78
+ """
79
+ super().__init__()
80
+ self._data = data
81
+ self._show_sem = show_sem
82
+ self._roi_indices = roi_indices
83
+ self._roi_colors = roi_colors
84
+ self._time_min = time_min
85
+ self._time_max = time_max
86
+ self._value_min = value_min
87
+ self._value_max = value_max
88
+ self._plot_size = int(plot_size)
89
+ self.setMinimumSize(self._plot_size, self._plot_size)
90
+ self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
91
+
92
+ def sizeHint(self) -> QSize:
93
+ """Return the preferred square size for one epoch plot.
94
+
95
+ Args:
96
+ None.
97
+
98
+ Returns:
99
+ Qt size with equal width and height.
100
+ """
101
+ return QSize(self._plot_size, self._plot_size)
102
+
103
+ def update_display(
104
+ self,
105
+ *,
106
+ show_sem: bool,
107
+ roi_indices: tuple[int, ...],
108
+ roi_colors: tuple[QColor, ...],
109
+ time_min: float,
110
+ time_max: float,
111
+ value_min: float,
112
+ value_max: float,
113
+ plot_size: int,
114
+ ) -> None:
115
+ """Update visible plot options without rebuilding the widget.
116
+
117
+ Args:
118
+ show_sem: Whether SEM bands should be drawn.
119
+ roi_indices: Zero-based ROI indices to draw.
120
+ roi_colors: Colors matching the viewer Labels layer by ROI index.
121
+ time_min: Shared x-axis minimum.
122
+ time_max: Shared x-axis maximum.
123
+ value_min: Shared y-axis minimum.
124
+ value_max: Shared y-axis maximum.
125
+ plot_size: Square widget size in screen pixels.
126
+
127
+ Returns:
128
+ None.
129
+
130
+ Visibility toggles do not change the computed response arrays. Updating
131
+ the existing widget keeps checkbox interactions responsive because Qt
132
+ only repaints the cached plot instead of destroying and recreating the
133
+ full plot strip.
134
+ """
135
+ self._show_sem = show_sem
136
+ self._roi_indices = roi_indices
137
+ self._roi_colors = roi_colors
138
+ self._time_min = time_min
139
+ self._time_max = time_max
140
+ self._value_min = value_min
141
+ self._value_max = value_max
142
+ self._plot_size = int(plot_size)
143
+ self.setMinimumSize(self._plot_size, self._plot_size)
144
+ self.updateGeometry()
145
+ self.update()
146
+
147
+ def paintEvent(self, a0: QPaintEvent | None) -> None:
148
+ """Draw response traces.
149
+
150
+ Args:
151
+ a0: Qt paint event.
152
+
153
+ Returns:
154
+ None.
155
+ """
156
+ del a0
157
+ painter = QPainter(self)
158
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
159
+ plot_rect = _square_plot_rect(QRectF(self.rect()))
160
+ painter.fillRect(self.rect(), QColor("#20252d"))
161
+ _draw_axes(
162
+ painter=painter,
163
+ rect=plot_rect,
164
+ time_min=self._time_min,
165
+ time_max=self._time_max,
166
+ value_min=self._value_min,
167
+ value_max=self._value_max,
168
+ )
169
+ for roi_index in self._roi_indices:
170
+ color = self._roi_colors[roi_index]
171
+ if self._show_sem:
172
+ _draw_sem_band(
173
+ painter=painter,
174
+ rect=plot_rect,
175
+ time_values=self._data.time_seconds,
176
+ mean_values=self._data.mean_values[roi_index],
177
+ sem_values=self._data.sem_values[roi_index],
178
+ time_min=self._time_min,
179
+ time_max=self._time_max,
180
+ value_min=self._value_min,
181
+ value_max=self._value_max,
182
+ color=color,
183
+ )
184
+ _draw_trace(
185
+ painter=painter,
186
+ rect=plot_rect,
187
+ time_values=self._data.time_seconds,
188
+ values=self._data.mean_values[roi_index],
189
+ time_min=self._time_min,
190
+ time_max=self._time_max,
191
+ value_min=self._value_min,
192
+ value_max=self._value_max,
193
+ color=color,
194
+ )
195
+ painter.end()
196
+
197
+
198
+ def _square_plot_rect(bounds: QRectF) -> QRectF:
199
+ """Return a square plot rectangle with room for axis labels.
200
+
201
+ Args:
202
+ bounds: Full widget rectangle.
203
+
204
+ Returns:
205
+ Square drawing rectangle inside the widget.
206
+ """
207
+ left_margin = 88.0
208
+ top_margin = 12.0
209
+ right_margin = 18.0
210
+ bottom_margin = 46.0
211
+ side = max(
212
+ 1.0,
213
+ min(
214
+ bounds.width() - left_margin - right_margin,
215
+ bounds.height() - top_margin - bottom_margin,
216
+ ),
217
+ )
218
+ return QRectF(left_margin, top_margin, side, side)
219
+
220
+
221
+ def clear_layout(layout: QLayout) -> None:
222
+ """Remove all widgets from a Qt layout.
223
+
224
+ Args:
225
+ layout: Layout to clear.
226
+
227
+ Returns:
228
+ None.
229
+ """
230
+ while layout.count():
231
+ item = layout.takeAt(0)
232
+ if item is None:
233
+ continue
234
+ widget = item.widget()
235
+ if widget is not None:
236
+ widget.deleteLater()
237
+
238
+
239
+ def global_time_bounds(
240
+ data: ResponsePlotData,
241
+ epoch_indices: tuple[int, ...] | None = None,
242
+ ) -> tuple[float, float]:
243
+ """Return shared x-axis bounds across selected epoch plots.
244
+
245
+ Args:
246
+ data: Full response plot data.
247
+ epoch_indices: Optional selected epoch row indices.
248
+
249
+ Returns:
250
+ ``(min, max)`` response time in seconds.
251
+ """
252
+ epochs = _selected_epochs(data, epoch_indices)
253
+ time_min = min(float(epoch.time_seconds[0]) for epoch in epochs)
254
+ time_max = max(float(epoch.time_seconds[-1]) for epoch in epochs)
255
+ if time_min == time_max:
256
+ return time_min - 1.0, time_max + 1.0
257
+ return time_min, time_max
258
+
259
+
260
+ def global_value_bounds(
261
+ data: ResponsePlotData,
262
+ roi_indices: tuple[int, ...] | None = None,
263
+ epoch_indices: tuple[int, ...] | None = None,
264
+ ) -> tuple[float, float]:
265
+ """Return shared y-axis bounds across all epoch plots.
266
+
267
+ Args:
268
+ data: Full response plot data.
269
+ roi_indices: Optional selected ROI indices.
270
+ epoch_indices: Optional selected epoch row indices.
271
+
272
+ Returns:
273
+ ``(min, max)`` bounds expanded by 20 percent and rounded to one decimal.
274
+ """
275
+ epochs = _selected_epochs(data, epoch_indices)
276
+ selected_roi_indices = roi_indices or tuple(range(len(epochs[0].roi_labels)))
277
+ lower = np.concatenate(
278
+ tuple(
279
+ (epoch.mean_values - epoch.sem_values)[list(selected_roi_indices), :]
280
+ for epoch in epochs
281
+ ),
282
+ axis=None,
283
+ )
284
+ upper = np.concatenate(
285
+ tuple(
286
+ (epoch.mean_values + epoch.sem_values)[list(selected_roi_indices), :]
287
+ for epoch in epochs
288
+ ),
289
+ axis=None,
290
+ )
291
+ value_min = float(np.nanmin(lower))
292
+ value_max = float(np.nanmax(upper))
293
+ if value_min == value_max:
294
+ return value_min - 1.0, value_max + 1.0
295
+ lower_bound = value_min * 1.2 if value_min < 0 else value_min * 0.8
296
+ upper_bound = value_max * 1.2 if value_max > 0 else value_max * 0.8
297
+ rounded_min = round(lower_bound, 1)
298
+ rounded_max = round(upper_bound, 1)
299
+ if rounded_min >= rounded_max:
300
+ return rounded_min - 0.1, rounded_max + 0.1
301
+ return rounded_min, rounded_max
302
+
303
+
304
+ def roi_colors_from_layer(layer: object | None, count: int) -> tuple[QColor, ...]:
305
+ """Return ROI colors that match the napari Labels layer when possible.
306
+
307
+ Args:
308
+ layer: Current napari Labels layer.
309
+ count: Number of ROI traces.
310
+
311
+ Returns:
312
+ Tuple of Qt colors.
313
+ """
314
+ return roi_colors_from_label_values(
315
+ layer,
316
+ tuple(range(1, count + 1)),
317
+ fallback_count=count,
318
+ )
319
+
320
+
321
+ def roi_colors_from_label_values(
322
+ layer: object | None,
323
+ label_values: tuple[int, ...],
324
+ *,
325
+ fallback_count: int,
326
+ ) -> tuple[QColor, ...]:
327
+ """Return ROI colors for explicit napari Labels-layer values.
328
+
329
+ Args:
330
+ layer: Current napari Labels layer.
331
+ label_values: Integer label values to sample.
332
+ fallback_count: Number of fallback colors when no layer is available.
333
+
334
+ Returns:
335
+ Tuple of Qt colors.
336
+ """
337
+ if layer is not None and hasattr(layer, "get_color"):
338
+ labels_layer = cast(_LabelsColorLayer, layer)
339
+ return tuple(
340
+ _qcolor_from_rgba(labels_layer.get_color(label_value))
341
+ for label_value in label_values
342
+ )
343
+ base = (
344
+ QColor("#4cc9f0"),
345
+ QColor("#f72585"),
346
+ QColor("#f8961e"),
347
+ QColor("#90be6d"),
348
+ QColor("#b5179e"),
349
+ QColor("#43aa8b"),
350
+ )
351
+ return tuple(base[index % len(base)] for index in range(fallback_count))
352
+
353
+
354
+ def ordered_bounds(min_value: float, max_value: float) -> tuple[float, float]:
355
+ """Return usable bounds even when manual inputs cross.
356
+
357
+ Args:
358
+ min_value: Requested minimum.
359
+ max_value: Requested maximum.
360
+
361
+ Returns:
362
+ Ordered bounds with nonzero span.
363
+ """
364
+ if min_value < max_value:
365
+ return min_value, max_value
366
+ if min_value > max_value:
367
+ return max_value, min_value
368
+ return min_value - 0.5, max_value + 0.5
369
+
370
+
371
+ def resolved_time_bounds(
372
+ data: ResponsePlotData,
373
+ epoch_indices: tuple[int, ...],
374
+ *,
375
+ manual_min: float | None,
376
+ manual_max: float | None,
377
+ ) -> tuple[float, float]:
378
+ """Return plot x-axis bounds after applying optional manual overrides.
379
+
380
+ Args:
381
+ data: Full response plot data.
382
+ epoch_indices: Visible epoch row indices.
383
+ manual_min: Optional user-entered minimum.
384
+ manual_max: Optional user-entered maximum.
385
+
386
+ Returns:
387
+ Ordered nonzero x-axis bounds.
388
+ """
389
+ auto_min, auto_max = global_time_bounds(data, epoch_indices)
390
+ time_min = manual_min if manual_min is not None else auto_min
391
+ time_max = manual_max if manual_max is not None else auto_max
392
+ return ordered_bounds(time_min, time_max)
393
+
394
+
395
+ def resolved_value_bounds(
396
+ data: ResponsePlotData,
397
+ roi_indices: tuple[int, ...],
398
+ epoch_indices: tuple[int, ...],
399
+ *,
400
+ manual_min: float | None,
401
+ manual_max: float | None,
402
+ ) -> tuple[float, float]:
403
+ """Return plot y-axis bounds after applying optional manual overrides.
404
+
405
+ Args:
406
+ data: Full response plot data.
407
+ roi_indices: Visible ROI indices.
408
+ epoch_indices: Visible epoch row indices.
409
+ manual_min: Optional user-entered minimum.
410
+ manual_max: Optional user-entered maximum.
411
+
412
+ Returns:
413
+ Ordered nonzero y-axis bounds.
414
+ """
415
+ auto_min, auto_max = global_value_bounds(data, roi_indices, epoch_indices)
416
+ value_min = manual_min if manual_min is not None else auto_min
417
+ value_max = manual_max if manual_max is not None else auto_max
418
+ return ordered_bounds(value_min, value_max)
419
+
420
+
421
+ def opaque_colors(colors: tuple[QColor, ...]) -> tuple[QColor, ...]:
422
+ """Return ROI colors with full alpha for plotting and visibility restore.
423
+
424
+ Args:
425
+ colors: Colors read from the Labels layer.
426
+
427
+ Returns:
428
+ Same RGB colors with alpha set to fully opaque.
429
+ """
430
+ opaque: list[QColor] = []
431
+ for color in colors:
432
+ updated = QColor(color)
433
+ updated.setAlpha(255)
434
+ opaque.append(updated)
435
+ return tuple(opaque)
436
+
437
+
438
+ def _selected_epochs(
439
+ data: ResponsePlotData,
440
+ epoch_indices: tuple[int, ...] | None,
441
+ ) -> tuple[EpochResponsePlotData, ...]:
442
+ """Return epochs selected for plotting.
443
+
444
+ Args:
445
+ data: Full response plot data.
446
+ epoch_indices: Optional selected epoch row indices.
447
+
448
+ Returns:
449
+ Tuple of selected epochs.
450
+ """
451
+ if epoch_indices is None:
452
+ return data.epochs
453
+ return tuple(
454
+ data.epochs[index] for index in epoch_indices if 0 <= index < len(data.epochs)
455
+ )
456
+
457
+
458
+ def _qcolor_from_rgba(value: object) -> QColor:
459
+ """Convert a napari RGBA color into ``QColor``.
460
+
461
+ Args:
462
+ value: RGBA-like sequence, usually floats in ``[0, 1]``.
463
+
464
+ Returns:
465
+ Qt color.
466
+ """
467
+ array = np.asarray(value, dtype=np.float64).reshape(-1)
468
+ if array.size < 3:
469
+ return QColor("#4cc9f0")
470
+ if np.nanmax(array[:3]) <= 1.0:
471
+ array = array * 255.0
472
+ alpha = int(array[3]) if array.size > 3 else 255
473
+ return QColor(int(array[0]), int(array[1]), int(array[2]), alpha)
474
+
475
+
476
+ def _draw_axes(
477
+ *,
478
+ painter: QPainter,
479
+ rect: QRectF,
480
+ time_min: float,
481
+ time_max: float,
482
+ value_min: float,
483
+ value_max: float,
484
+ ) -> None:
485
+ """Draw x/y axes, ticks, labels, and the zero-response line.
486
+
487
+ Args:
488
+ painter: Active Qt painter.
489
+ rect: Plot rectangle.
490
+ time_min: X-axis minimum.
491
+ time_max: X-axis maximum.
492
+ value_min: Y-axis minimum.
493
+ value_max: Y-axis maximum.
494
+
495
+ Returns:
496
+ None.
497
+ """
498
+ axis_color = QColor("#cfd6df")
499
+ grid_color = QColor("#6d7683")
500
+ painter.setPen(QPen(axis_color, 1))
501
+ painter.drawLine(rect.bottomLeft(), rect.bottomRight())
502
+ painter.drawLine(rect.bottomLeft(), rect.topLeft())
503
+
504
+ if value_min <= 0 <= value_max:
505
+ zero_y = _plot_point(
506
+ rect,
507
+ time_min,
508
+ 0.0,
509
+ time_min,
510
+ time_max,
511
+ value_min,
512
+ value_max,
513
+ ).y()
514
+ painter.setPen(_zero_line_pen(grid_color))
515
+ painter.drawLine(QPointF(rect.left(), zero_y), QPointF(rect.right(), zero_y))
516
+ if time_min <= 0 <= time_max:
517
+ zero_x = _plot_point(
518
+ rect,
519
+ 0.0,
520
+ value_min,
521
+ time_min,
522
+ time_max,
523
+ value_min,
524
+ value_max,
525
+ ).x()
526
+ painter.setPen(_zero_line_pen(grid_color))
527
+ painter.drawLine(QPointF(zero_x, rect.bottom()), QPointF(zero_x, rect.top()))
528
+
529
+ painter.setPen(QPen(axis_color, 1))
530
+ for tick in _axis_ticks(time_min, time_max):
531
+ point = _plot_point(
532
+ rect,
533
+ tick,
534
+ value_min,
535
+ time_min,
536
+ time_max,
537
+ value_min,
538
+ value_max,
539
+ )
540
+ painter.drawLine(point, QPointF(point.x(), point.y() + 4.0))
541
+ painter.drawText(QPointF(point.x() - 12.0, point.y() + 18.0), f"{tick:.1f}")
542
+ for tick in _axis_ticks(value_min, value_max):
543
+ point = _plot_point(
544
+ rect,
545
+ time_min,
546
+ tick,
547
+ time_min,
548
+ time_max,
549
+ value_min,
550
+ value_max,
551
+ )
552
+ painter.drawLine(QPointF(point.x() - 4.0, point.y()), point)
553
+ painter.drawText(QPointF(point.x() - 56.0, point.y() + 4.0), f"{tick:.1f}")
554
+
555
+ painter.drawText(
556
+ QPointF(rect.center().x() - 26.0, rect.bottom() + 36.0),
557
+ "Time (s)",
558
+ )
559
+ painter.save()
560
+ painter.translate(18.0, rect.center().y() + 18.0)
561
+ painter.rotate(-90.0)
562
+ painter.drawText(QPointF(0.0, 0.0), "dF/F")
563
+ painter.restore()
564
+
565
+
566
+ def _zero_line_pen(color: QColor) -> QPen:
567
+ """Create the shared dashed pen for zero-reference plot lines.
568
+
569
+ Args:
570
+ color: Zero-line color.
571
+
572
+ Returns:
573
+ Thick dashed pen used for both ``x=0`` and ``y=0``.
574
+ """
575
+ pen = QPen(color, 2)
576
+ pen.setDashPattern([10.0, 8.0])
577
+ return pen
578
+
579
+
580
+ def _axis_ticks(min_value: float, max_value: float) -> tuple[float, ...]:
581
+ """Return six tick values including axis endpoints.
582
+
583
+ Args:
584
+ min_value: Axis minimum.
585
+ max_value: Axis maximum.
586
+
587
+ Returns:
588
+ Tick values with endpoints included.
589
+ """
590
+ if min_value == max_value:
591
+ return (min_value,)
592
+ return tuple(float(value) for value in np.linspace(min_value, max_value, 6))
593
+
594
+
595
+ def _draw_trace(
596
+ *,
597
+ painter: QPainter,
598
+ rect: QRectF,
599
+ time_values: npt.NDArray[np.float64],
600
+ values: npt.NDArray[np.float64],
601
+ time_min: float,
602
+ time_max: float,
603
+ value_min: float,
604
+ value_max: float,
605
+ color: QColor,
606
+ ) -> None:
607
+ """Draw one ROI mean trace.
608
+
609
+ Args:
610
+ painter: Active Qt painter.
611
+ rect: Plot rectangle.
612
+ time_values: X-axis values in seconds.
613
+ values: Mean response values.
614
+ time_min: X-axis minimum.
615
+ time_max: X-axis maximum.
616
+ value_min: Y-axis minimum.
617
+ value_max: Y-axis maximum.
618
+ color: Trace color.
619
+
620
+ Returns:
621
+ None.
622
+ """
623
+ path = _trace_path(
624
+ rect,
625
+ time_values,
626
+ values,
627
+ time_min,
628
+ time_max,
629
+ value_min,
630
+ value_max,
631
+ )
632
+ painter.setPen(QPen(color, 2))
633
+ painter.drawPath(path)
634
+
635
+
636
+ def _draw_sem_band(
637
+ *,
638
+ painter: QPainter,
639
+ rect: QRectF,
640
+ time_values: npt.NDArray[np.float64],
641
+ mean_values: npt.NDArray[np.float64],
642
+ sem_values: npt.NDArray[np.float64],
643
+ time_min: float,
644
+ time_max: float,
645
+ value_min: float,
646
+ value_max: float,
647
+ color: QColor,
648
+ ) -> None:
649
+ """Draw one ROI SEM band.
650
+
651
+ Args:
652
+ painter: Active Qt painter.
653
+ rect: Plot rectangle.
654
+ time_values: X-axis values in seconds.
655
+ mean_values: Mean response values.
656
+ sem_values: SEM values.
657
+ time_min: X-axis minimum.
658
+ time_max: X-axis maximum.
659
+ value_min: Y-axis minimum.
660
+ value_max: Y-axis maximum.
661
+ color: Band color.
662
+
663
+ Returns:
664
+ None.
665
+ """
666
+ upper = mean_values + sem_values
667
+ lower = mean_values - sem_values
668
+ path = _trace_path(
669
+ rect,
670
+ time_values,
671
+ upper,
672
+ time_min,
673
+ time_max,
674
+ value_min,
675
+ value_max,
676
+ )
677
+ for index in range(len(time_values) - 1, -1, -1):
678
+ point = _plot_point(
679
+ rect,
680
+ time_values[index],
681
+ lower[index],
682
+ time_min,
683
+ time_max,
684
+ value_min,
685
+ value_max,
686
+ )
687
+ path.lineTo(point)
688
+ band_color = QColor(color)
689
+ band_color.setAlpha(55)
690
+ painter.fillPath(path, band_color)
691
+
692
+
693
+ def _trace_path(
694
+ rect: QRectF,
695
+ time_values: npt.NDArray[np.float64],
696
+ values: npt.NDArray[np.float64],
697
+ time_min: float,
698
+ time_max: float,
699
+ value_min: float,
700
+ value_max: float,
701
+ ) -> QPainterPath:
702
+ """Build a Qt path for one trace.
703
+
704
+ Args:
705
+ rect: Plot rectangle.
706
+ time_values: X-axis values in seconds.
707
+ values: Y-axis values.
708
+ time_min: X-axis minimum.
709
+ time_max: X-axis maximum.
710
+ value_min: Y-axis minimum.
711
+ value_max: Y-axis maximum.
712
+
713
+ Returns:
714
+ Painter path for valid values.
715
+ """
716
+ path = QPainterPath()
717
+ first = True
718
+ for time_value, value in zip(time_values, values, strict=True):
719
+ if np.isnan(value) or time_value < time_min or time_value > time_max:
720
+ first = True
721
+ continue
722
+ point = _plot_point(
723
+ rect,
724
+ time_value,
725
+ value,
726
+ time_min,
727
+ time_max,
728
+ value_min,
729
+ value_max,
730
+ )
731
+ if first:
732
+ path.moveTo(point)
733
+ first = False
734
+ else:
735
+ path.lineTo(point)
736
+ return path
737
+
738
+
739
+ def _plot_point(
740
+ rect: QRectF,
741
+ time_value: float,
742
+ value: float,
743
+ time_min: float,
744
+ time_max: float,
745
+ value_min: float,
746
+ value_max: float,
747
+ ) -> QPointF:
748
+ """Map data coordinates into plot coordinates.
749
+
750
+ Args:
751
+ rect: Plot rectangle.
752
+ time_value: X value in seconds.
753
+ value: Y value.
754
+ time_min: Minimum time value.
755
+ time_max: Maximum time value.
756
+ value_min: Minimum y value.
757
+ value_max: Maximum y value.
758
+
759
+ Returns:
760
+ Qt point in widget coordinates.
761
+ """
762
+ time_span = time_max - time_min
763
+ x_fraction = 0.0 if time_span == 0 else (time_value - time_min) / time_span
764
+ y_fraction = (value - value_min) / (value_max - value_min)
765
+ x = rect.left() + x_fraction * rect.width()
766
+ y = rect.bottom() - y_fraction * rect.height()
767
+ return QPointF(x, y)