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,345 @@
1
+ """Group ROI responses by stimulus epoch and trial.
2
+
3
+ Inputs: computed ROI dF/F traces and stimulus-labeled frame windows.
4
+ Outputs: per-trial response arrays and compact per-ROI summary rows.
5
+
6
+ This module keeps response grouping independent from plotting, napari, and file
7
+ storage. It only works with converted twopy analysis objects that already have
8
+ explicit frame coordinates.
9
+ """
10
+
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+ import numpy.typing as npt
16
+
17
+ from twopy.analysis.dff import RoiDeltaFOverF
18
+ from twopy.analysis.trials import EpochFrameWindow
19
+
20
+ __all__ = [
21
+ "GroupedRoiResponseSummary",
22
+ "GroupedRoiResponses",
23
+ "RoiResponseSummary",
24
+ "RoiResponseTrial",
25
+ "group_delta_f_over_f_by_epoch",
26
+ "is_gray_epoch_name",
27
+ "summarize_epoch_roi_responses",
28
+ "summarize_grouped_responses",
29
+ ]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class RoiResponseTrial:
34
+ """One ROI response matrix for one stimulus trial.
35
+
36
+ Inputs: one stimulus-labeled frame window and a slice of ROI dF/F.
37
+ Outputs: frame-by-ROI values plus absolute frame numbers and relative time.
38
+
39
+ ``trial_index`` is one-based within each epoch because that is the number a
40
+ scientist naturally uses when checking repeated presentations.
41
+ """
42
+
43
+ epoch_number: int
44
+ epoch_name: str
45
+ trial_index: int
46
+ window_index: int
47
+ start_frame: int
48
+ stop_frame: int
49
+ frame_numbers: npt.NDArray[np.int64]
50
+ time_seconds: npt.NDArray[np.float64]
51
+ values: npt.NDArray[np.float64]
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class GroupedRoiResponses:
56
+ """ROI responses grouped by epoch and trial.
57
+
58
+ Inputs: one dF/F result plus epoch windows.
59
+ Outputs: trial response objects, ROI labels, and the frame rate used for
60
+ relative trial time.
61
+
62
+ The values stay as one matrix per trial instead of being padded into a
63
+ larger cube. This avoids fake samples when trial lengths differ.
64
+ """
65
+
66
+ roi_labels: tuple[str, ...]
67
+ data_rate_hz: float
68
+ trials: tuple[RoiResponseTrial, ...]
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class RoiResponseSummary:
73
+ """Compact response metrics for one ROI in one trial.
74
+
75
+ Inputs: one trial response matrix.
76
+ Outputs: mean, peak, minimum, and frame metadata for one ROI.
77
+
78
+ These rows are intentionally simple so they can be written to CSV and
79
+ inspected without custom Python code.
80
+ """
81
+
82
+ epoch_number: int
83
+ epoch_name: str
84
+ trial_index: int
85
+ window_index: int
86
+ roi_label: str
87
+ start_frame: int
88
+ stop_frame: int
89
+ frame_count: int
90
+ mean_response: float
91
+ peak_response: float
92
+ min_response: float
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class GroupedRoiResponseSummary:
97
+ """Response metrics collapsed across trials for one epoch and ROI.
98
+
99
+ Inputs: trial-level response summaries for one ``(epoch, ROI)`` pair.
100
+ Outputs: mean response, SEM, and the trial count behind those values.
101
+
102
+ A single usable trial gets SEM ``0`` because there is no across-trial spread
103
+ to estimate. Empty or all-NaN groups are not emitted.
104
+ """
105
+
106
+ epoch_number: int
107
+ epoch_name: str
108
+ roi_label: str
109
+ mean_response: float
110
+ sem_response: float
111
+ n_trials: int
112
+
113
+
114
+ def group_delta_f_over_f_by_epoch(
115
+ dff: RoiDeltaFOverF,
116
+ epoch_windows: Sequence[EpochFrameWindow],
117
+ *,
118
+ data_rate_hz: float,
119
+ pre_window_seconds: float = 0.0,
120
+ post_window_seconds: float = 0.0,
121
+ ) -> GroupedRoiResponses:
122
+ """Group dF/F values by stimulus epoch windows.
123
+
124
+ Args:
125
+ dff: Computed ROI dF/F with absolute frame coordinates.
126
+ epoch_windows: Stimulus-labeled windows to slice.
127
+ data_rate_hz: Imaging frame rate in hertz for relative trial time.
128
+ pre_window_seconds: Seconds before each epoch window to include. This
129
+ is useful for plots that show the gray interleave baseline before
130
+ stimulus onset.
131
+ post_window_seconds: Seconds after each epoch window to include. This
132
+ is useful for plots that show the response returning into the next
133
+ gray interleave.
134
+
135
+ Returns:
136
+ ``GroupedRoiResponses`` with one response object per input window.
137
+
138
+ Raises:
139
+ ValueError: If the dF/F shape, frame rate, or windows are invalid.
140
+
141
+ The function only groups existing dF/F values. It does not decide which
142
+ windows are trials and does not recompute fluorescence.
143
+ """
144
+ _validate_grouping_inputs(
145
+ dff,
146
+ epoch_windows,
147
+ data_rate_hz,
148
+ pre_window_seconds,
149
+ post_window_seconds,
150
+ )
151
+
152
+ trials: list[RoiResponseTrial] = []
153
+ epoch_counts: dict[tuple[int, str], int] = {}
154
+ pre_window_frames = int(round(pre_window_seconds * data_rate_hz))
155
+ post_window_frames = int(round(post_window_seconds * data_rate_hz))
156
+ for epoch_window in epoch_windows:
157
+ key = (epoch_window.epoch_number, epoch_window.epoch_name)
158
+ epoch_counts[key] = epoch_counts.get(key, 0) + 1
159
+ window = epoch_window.window
160
+ response_start_frame = max(
161
+ window.start_frame - pre_window_frames, dff.start_frame
162
+ )
163
+ response_stop_frame = min(
164
+ window.stop_frame + post_window_frames,
165
+ dff.stop_frame,
166
+ )
167
+ local_start = response_start_frame - dff.start_frame
168
+ local_stop = response_stop_frame - dff.start_frame
169
+ frame_numbers = np.arange(
170
+ response_start_frame, response_stop_frame, dtype=np.int64
171
+ )
172
+ time_seconds = (frame_numbers - window.start_frame) / data_rate_hz
173
+ trials.append(
174
+ RoiResponseTrial(
175
+ epoch_number=epoch_window.epoch_number,
176
+ epoch_name=epoch_window.epoch_name,
177
+ trial_index=epoch_counts[key],
178
+ window_index=window.index,
179
+ start_frame=response_start_frame,
180
+ stop_frame=response_stop_frame,
181
+ frame_numbers=frame_numbers,
182
+ time_seconds=time_seconds.astype(np.float64, copy=False),
183
+ values=dff.values[local_start:local_stop, :],
184
+ ),
185
+ )
186
+
187
+ return GroupedRoiResponses(
188
+ roi_labels=dff.labels,
189
+ data_rate_hz=float(data_rate_hz),
190
+ trials=tuple(trials),
191
+ )
192
+
193
+
194
+ def summarize_grouped_responses(
195
+ grouped: GroupedRoiResponses,
196
+ ) -> tuple[RoiResponseSummary, ...]:
197
+ """Summarize grouped responses with one row per trial and ROI.
198
+
199
+ Args:
200
+ grouped: Grouped response object.
201
+
202
+ Returns:
203
+ Tuple of compact response summary rows.
204
+
205
+ The summary uses NaN-aware metrics so motion-masked dF/F frames do not make
206
+ an entire trial unusable.
207
+ """
208
+ summaries: list[RoiResponseSummary] = []
209
+ for trial in grouped.trials:
210
+ if trial.values.shape[1] != len(grouped.roi_labels):
211
+ msg = (
212
+ "Trial response width does not match ROI labels: "
213
+ f"{trial.values.shape[1]} values, {len(grouped.roi_labels)} labels"
214
+ )
215
+ raise ValueError(msg)
216
+ for roi_index, roi_label in enumerate(grouped.roi_labels):
217
+ values = trial.values[:, roi_index]
218
+ summaries.append(
219
+ RoiResponseSummary(
220
+ epoch_number=trial.epoch_number,
221
+ epoch_name=trial.epoch_name,
222
+ trial_index=trial.trial_index,
223
+ window_index=trial.window_index,
224
+ roi_label=roi_label,
225
+ start_frame=trial.start_frame,
226
+ stop_frame=trial.stop_frame,
227
+ frame_count=trial.stop_frame - trial.start_frame,
228
+ mean_response=float(np.nanmean(values)),
229
+ peak_response=float(np.nanmax(values)),
230
+ min_response=float(np.nanmin(values)),
231
+ ),
232
+ )
233
+ return tuple(summaries)
234
+
235
+
236
+ def summarize_epoch_roi_responses(
237
+ grouped: GroupedRoiResponses,
238
+ ) -> tuple[GroupedRoiResponseSummary, ...]:
239
+ """Summarize responses across trials with one row per epoch and ROI.
240
+
241
+ Args:
242
+ grouped: Grouped response object.
243
+
244
+ Returns:
245
+ Tuple of grouped summary rows.
246
+
247
+ The grouped mean and SEM are computed from trial-level mean responses, not
248
+ from every frame pooled together. That keeps each trial at equal weight even
249
+ if window lengths differ slightly.
250
+ """
251
+ trial_summaries = summarize_grouped_responses(grouped)
252
+ grouped_values: dict[tuple[int, str, str], list[float]] = {}
253
+ for summary in trial_summaries:
254
+ if np.isnan(summary.mean_response):
255
+ continue
256
+ key = (summary.epoch_number, summary.epoch_name, summary.roi_label)
257
+ grouped_values.setdefault(key, []).append(summary.mean_response)
258
+
259
+ rows: list[GroupedRoiResponseSummary] = []
260
+ for key, values in grouped_values.items():
261
+ epoch_number, epoch_name, roi_label = key
262
+ response_values = np.asarray(values, dtype=np.float64)
263
+ trial_count = int(response_values.size)
264
+ sem = (
265
+ float(np.nanstd(response_values, ddof=1) / np.sqrt(trial_count))
266
+ if trial_count > 1
267
+ else 0.0
268
+ )
269
+ rows.append(
270
+ GroupedRoiResponseSummary(
271
+ epoch_number=epoch_number,
272
+ epoch_name=epoch_name,
273
+ roi_label=roi_label,
274
+ mean_response=float(np.nanmean(response_values)),
275
+ sem_response=sem,
276
+ n_trials=trial_count,
277
+ ),
278
+ )
279
+ return tuple(rows)
280
+
281
+
282
+ def is_gray_epoch_name(epoch_name: str) -> bool:
283
+ """Return whether an epoch name looks like a gray interleave.
284
+
285
+ Args:
286
+ epoch_name: Stimulus epoch name.
287
+
288
+ Returns:
289
+ ``True`` for ``gray`` or ``grey`` spelling.
290
+ """
291
+ lowered = epoch_name.lower()
292
+ return "gray" in lowered or "grey" in lowered
293
+
294
+
295
+ def _validate_grouping_inputs(
296
+ dff: RoiDeltaFOverF,
297
+ epoch_windows: Sequence[EpochFrameWindow],
298
+ data_rate_hz: float,
299
+ pre_window_seconds: float,
300
+ post_window_seconds: float,
301
+ ) -> None:
302
+ """Validate response grouping inputs.
303
+
304
+ Args:
305
+ dff: Candidate dF/F result.
306
+ epoch_windows: Candidate windows.
307
+ data_rate_hz: Candidate frame rate.
308
+ pre_window_seconds: Candidate prestimulus duration.
309
+ post_window_seconds: Candidate poststimulus duration.
310
+
311
+ Returns:
312
+ None.
313
+ """
314
+ if dff.values.ndim != 2:
315
+ msg = f"dF/F values must have shape (frames, rois); got {dff.values.shape}"
316
+ raise ValueError(msg)
317
+ if dff.values.shape[1] != len(dff.labels):
318
+ msg = (
319
+ f"dF/F labels length {len(dff.labels)} does not match "
320
+ f"{dff.values.shape[1]} ROIs"
321
+ )
322
+ raise ValueError(msg)
323
+ if data_rate_hz <= 0:
324
+ msg = f"data_rate_hz must be positive; got {data_rate_hz}"
325
+ raise ValueError(msg)
326
+ if pre_window_seconds < 0:
327
+ msg = f"pre_window_seconds must be nonnegative; got {pre_window_seconds}"
328
+ raise ValueError(msg)
329
+ if post_window_seconds < 0:
330
+ msg = f"post_window_seconds must be nonnegative; got {post_window_seconds}"
331
+ raise ValueError(msg)
332
+ for epoch_window in epoch_windows:
333
+ window = epoch_window.window
334
+ if window.start_frame < dff.start_frame or window.stop_frame > dff.stop_frame:
335
+ msg = (
336
+ f"Window [{window.start_frame}, {window.stop_frame}) is outside "
337
+ f"dF/F range [{dff.start_frame}, {dff.stop_frame})"
338
+ )
339
+ raise ValueError(msg)
340
+ if window.start_frame >= window.stop_frame:
341
+ msg = (
342
+ f"Window [{window.start_frame}, {window.stop_frame}) must have "
343
+ "positive duration"
344
+ )
345
+ raise ValueError(msg)
@@ -0,0 +1,341 @@
1
+ """Split ROI traces into frame windows for response analysis.
2
+
3
+ Inputs: converted recordings, ROI traces, frame boundaries, and photodiode
4
+ alignment.
5
+ Outputs: frame windows, stimulus-epoch labels, and windowed ROI response
6
+ arrays.
7
+
8
+ This module keeps trial slicing independent from the GUI and from source files.
9
+ Stimulus-specific labels can be added when the stimulus metadata has been
10
+ decoded.
11
+ """
12
+
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass
15
+
16
+ import numpy as np
17
+ import numpy.typing as npt
18
+
19
+ from twopy.converted import RecordingData
20
+ from twopy.photodiode_classification import classify_recording_photodiode_events
21
+ from twopy.roi import RoiTraces
22
+ from twopy.synchronization import PhotodiodeAlignment
23
+
24
+ __all__ = [
25
+ "EpochFrameWindow",
26
+ "FrameWindow",
27
+ "WindowedRoiResponse",
28
+ "frame_windows_from_photodiode_alignment",
29
+ "make_frame_windows",
30
+ "map_stimulus_epochs_to_frame_windows",
31
+ "select_epoch_frame_windows",
32
+ "split_traces_by_frame_windows",
33
+ ]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class FrameWindow:
38
+ """One half-open frame interval.
39
+
40
+ Inputs: start and stop frame indices in aligned movie coordinates.
41
+ Outputs: a labeled interval that can slice ROI traces.
42
+
43
+ ``start_frame`` is inclusive and ``stop_frame`` is exclusive, matching
44
+ Python slicing and HDF5 frame reads.
45
+ """
46
+
47
+ index: int
48
+ start_frame: int
49
+ stop_frame: int
50
+ label: str
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class EpochFrameWindow:
55
+ """One photodiode-aligned frame window with stimulus epoch metadata.
56
+
57
+ Inputs: one frame window and the matching stimulus epoch run.
58
+ Outputs: frame bounds plus the epoch number and epoch name.
59
+
60
+ This object is the bridge between stimulus-computer rows and imaging-frame
61
+ movie slices. It is intentionally small so later response code can inspect
62
+ exactly which epoch each frame window came from.
63
+ """
64
+
65
+ window: FrameWindow
66
+ epoch_number: int
67
+ epoch_name: str
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class WindowedRoiResponse:
72
+ """ROI traces for one frame window.
73
+
74
+ Inputs: one ``FrameWindow`` and the matching slice of ``RoiTraces``.
75
+ Outputs: frame-by-ROI values plus ROI labels.
76
+
77
+ The values remain in frame order so callers can compute baselines,
78
+ peak responses, averages, or plots without losing timing.
79
+ """
80
+
81
+ window: FrameWindow
82
+ values: npt.NDArray[np.float64]
83
+ roi_labels: tuple[str, ...]
84
+
85
+
86
+ def make_frame_windows(
87
+ boundary_frames: Sequence[int],
88
+ *,
89
+ frame_count: int,
90
+ labels: Sequence[str] | None = None,
91
+ ) -> tuple[FrameWindow, ...]:
92
+ """Create frame windows from explicit frame boundaries.
93
+
94
+ Args:
95
+ boundary_frames: Ordered frame boundaries. Each neighboring pair forms
96
+ one window.
97
+ frame_count: Total number of aligned movie frames.
98
+ labels: Optional label for each output window.
99
+
100
+ Returns:
101
+ Tuple of ``FrameWindow`` objects.
102
+
103
+ Raises:
104
+ ValueError: If boundaries are out of range, not increasing, or too few.
105
+
106
+ The caller owns what the boundaries mean: trials, epochs, baseline periods,
107
+ or any other stimulus-derived interval.
108
+ """
109
+ boundaries = tuple(int(frame) for frame in boundary_frames)
110
+ if len(boundaries) < 2:
111
+ msg = "At least two frame boundaries are required to make windows"
112
+ raise ValueError(msg)
113
+ if any(frame < 0 or frame > frame_count for frame in boundaries):
114
+ msg = f"Frame boundaries must be within [0, {frame_count}]: {boundaries}"
115
+ raise ValueError(msg)
116
+ if any(
117
+ stop <= start for start, stop in zip(boundaries, boundaries[1:], strict=False)
118
+ ):
119
+ msg = f"Frame boundaries must be strictly increasing: {boundaries}"
120
+ raise ValueError(msg)
121
+
122
+ window_count = len(boundaries) - 1
123
+ resolved_labels = _resolve_window_labels(window_count, labels)
124
+ return tuple(
125
+ FrameWindow(
126
+ index=index,
127
+ start_frame=boundaries[index],
128
+ stop_frame=boundaries[index + 1],
129
+ label=resolved_labels[index],
130
+ )
131
+ for index in range(window_count)
132
+ )
133
+
134
+
135
+ def frame_windows_from_photodiode_alignment(
136
+ alignment: PhotodiodeAlignment,
137
+ *,
138
+ frame_count: int,
139
+ labels: Sequence[str] | None = None,
140
+ ) -> tuple[FrameWindow, ...]:
141
+ """Create frame windows from paired photodiode event frames.
142
+
143
+ Args:
144
+ alignment: Photodiode events paired to imaging frames.
145
+ frame_count: Total number of aligned movie frames.
146
+ labels: Optional label for each neighboring event interval.
147
+
148
+ Returns:
149
+ Frame windows between consecutive paired photodiode events.
150
+
151
+ This turns decoded photodiode boundaries into frame intervals without
152
+ assuming what each event means in a stimulus-specific protocol.
153
+ """
154
+ return make_frame_windows(
155
+ tuple(event.imaging_frame for event in alignment.paired_events),
156
+ frame_count=frame_count,
157
+ labels=labels,
158
+ )
159
+
160
+
161
+ def map_stimulus_epochs_to_frame_windows(
162
+ recording: RecordingData,
163
+ alignment: PhotodiodeAlignment,
164
+ ) -> tuple[EpochFrameWindow, ...]:
165
+ """Map stimulus epoch runs onto photodiode-aligned imaging-frame windows.
166
+
167
+ Args:
168
+ recording: Loaded converted recording with ``stimulus/data`` and epoch
169
+ parameter metadata.
170
+ alignment: Photodiode events paired to imaging frames.
171
+
172
+ Returns:
173
+ One ``EpochFrameWindow`` per nonzero stimulus epoch run.
174
+
175
+ Raises:
176
+ ValueError: If the stimulus epoch runs and photodiode windows do not
177
+ have the same count.
178
+
179
+ This wraps the stricter photodiode classifier and returns the older
180
+ ``EpochFrameWindow`` shape used by response helpers.
181
+ """
182
+ timing = classify_recording_photodiode_events(recording, alignment)
183
+ return tuple(
184
+ EpochFrameWindow(
185
+ window=FrameWindow(
186
+ index=window.index,
187
+ start_frame=window.start_frame,
188
+ stop_frame=window.stop_frame,
189
+ label=_epoch_window_label(window.epoch_number, window.epoch_name),
190
+ ),
191
+ epoch_number=window.epoch_number,
192
+ epoch_name=window.epoch_name,
193
+ )
194
+ for window in timing.windows
195
+ )
196
+
197
+
198
+ def select_epoch_frame_windows(
199
+ epoch_windows: tuple[EpochFrameWindow, ...],
200
+ *,
201
+ epoch_name: str | None = None,
202
+ epoch_number: int | None = None,
203
+ ) -> tuple[FrameWindow, ...]:
204
+ """Select frame windows for one stimulus epoch.
205
+
206
+ Args:
207
+ epoch_windows: Stimulus-labeled frame windows.
208
+ epoch_name: Optional epoch name to match exactly.
209
+ epoch_number: Optional epoch number to match exactly.
210
+
211
+ Returns:
212
+ Matching ``FrameWindow`` objects.
213
+
214
+ Raises:
215
+ ValueError: If neither selector is supplied.
216
+
217
+ This is the small script-facing helper used for baseline windows such as
218
+ ``Gray Interleave`` without making dF/F code know stimulus-table details.
219
+ """
220
+ if epoch_name is None and epoch_number is None:
221
+ msg = "Select by epoch_name, epoch_number, or both"
222
+ raise ValueError(msg)
223
+
224
+ return tuple(
225
+ epoch_window.window
226
+ for epoch_window in epoch_windows
227
+ if _matches_epoch_window(
228
+ epoch_window,
229
+ epoch_name=epoch_name,
230
+ epoch_number=epoch_number,
231
+ )
232
+ )
233
+
234
+
235
+ def split_traces_by_frame_windows(
236
+ traces: RoiTraces,
237
+ windows: Sequence[FrameWindow],
238
+ ) -> tuple[WindowedRoiResponse, ...]:
239
+ """Split ROI traces into explicit frame windows.
240
+
241
+ Args:
242
+ traces: ROI traces with known start and stop frame indices.
243
+ windows: Frame windows to slice.
244
+
245
+ Returns:
246
+ One ``WindowedRoiResponse`` per window.
247
+
248
+ Raises:
249
+ ValueError: If a window falls outside the trace frame range.
250
+
251
+ This function only slices; it does not compute response metrics. Keeping
252
+ those steps separate makes later baseline and trial logic auditable.
253
+ """
254
+ responses: list[WindowedRoiResponse] = []
255
+ for window in windows:
256
+ _validate_window_inside_traces(window, traces)
257
+ start = window.start_frame - traces.start_frame
258
+ stop = window.stop_frame - traces.start_frame
259
+ responses.append(
260
+ WindowedRoiResponse(
261
+ window=window,
262
+ values=traces.values[start:stop, :],
263
+ roi_labels=traces.labels,
264
+ ),
265
+ )
266
+
267
+ return tuple(responses)
268
+
269
+
270
+ def _epoch_window_label(epoch_number: int, epoch_name: str) -> str:
271
+ """Create a readable label for a stimulus epoch frame window.
272
+
273
+ Args:
274
+ epoch_number: One-indexed stimulus epoch number.
275
+ epoch_name: Human-readable epoch name from converted stimulus metadata.
276
+
277
+ Returns:
278
+ Label suitable for plots and logs.
279
+ """
280
+ safe_name = epoch_name.strip().replace(" ", "_") or "unnamed"
281
+ return f"epoch_{epoch_number:04d}_{safe_name}"
282
+
283
+
284
+ def _matches_epoch_window(
285
+ epoch_window: EpochFrameWindow,
286
+ *,
287
+ epoch_name: str | None,
288
+ epoch_number: int | None,
289
+ ) -> bool:
290
+ """Check whether one epoch window matches optional selectors.
291
+
292
+ Args:
293
+ epoch_window: Candidate stimulus-labeled window.
294
+ epoch_name: Optional epoch name selector.
295
+ epoch_number: Optional epoch number selector.
296
+
297
+ Returns:
298
+ ``True`` when all supplied selectors match.
299
+ """
300
+ if epoch_name is not None and epoch_window.epoch_name != epoch_name:
301
+ return False
302
+ return epoch_number is None or epoch_window.epoch_number == epoch_number
303
+
304
+
305
+ def _resolve_window_labels(
306
+ window_count: int,
307
+ labels: Sequence[str] | None,
308
+ ) -> tuple[str, ...]:
309
+ """Resolve optional frame-window labels.
310
+
311
+ Args:
312
+ window_count: Number of windows being created.
313
+ labels: Optional caller labels.
314
+
315
+ Returns:
316
+ One label per window.
317
+ """
318
+ if labels is None:
319
+ return tuple(f"window_{index + 1:04d}" for index in range(window_count))
320
+ if len(labels) != window_count:
321
+ msg = f"Expected {window_count} labels; got {len(labels)}"
322
+ raise ValueError(msg)
323
+ return tuple(labels)
324
+
325
+
326
+ def _validate_window_inside_traces(window: FrameWindow, traces: RoiTraces) -> None:
327
+ """Confirm one frame window can slice a trace matrix.
328
+
329
+ Args:
330
+ window: Frame window to check.
331
+ traces: ROI traces with available frame bounds.
332
+
333
+ Returns:
334
+ None.
335
+ """
336
+ if window.start_frame < traces.start_frame or window.stop_frame > traces.stop_frame:
337
+ msg = (
338
+ f"Window [{window.start_frame}, {window.stop_frame}) is outside "
339
+ f"trace range [{traces.start_frame}, {traces.stop_frame})"
340
+ )
341
+ raise ValueError(msg)