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,174 @@
1
+ """Read prior lab saved-analysis outputs for parity checks.
2
+
3
+ Inputs: optional ``savedAnalysis/*.mat`` files produced by prior analysis.
4
+ Outputs: typed NumPy arrays that tests or audit scripts can compare to twopy
5
+ outputs.
6
+
7
+ This module is read-only. It does not make source MATLAB files part of the
8
+ twopy analysis path; it exists only to compare twopy-owned converted analysis
9
+ against previous saved outputs when those files are available.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import cast
15
+
16
+ import h5py
17
+ import numpy as np
18
+ import numpy.typing as npt
19
+
20
+ __all__ = ["SavedAnalysisLastRoi", "load_saved_analysis_last_roi"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SavedAnalysisLastRoi:
25
+ """Selected fields from one saved ``lastRoi`` object.
26
+
27
+ Inputs: one HDF5-backed MATLAB ``savedAnalysis`` file.
28
+ Outputs: ROI masks, epoch labels, epoch windows, and saved ROI traces.
29
+
30
+ These fields are enough to build numeric parity checks without coupling
31
+ normal twopy analysis to MATLAB-owned files.
32
+ """
33
+
34
+ path: Path
35
+ roi_mask_initial: npt.NDArray[np.float64]
36
+ epoch_list: npt.NDArray[np.int64]
37
+ epoch_start_times: tuple[npt.NDArray[np.int64], ...]
38
+ epoch_durations: tuple[npt.NDArray[np.int64], ...]
39
+ time_by_rois_initial: tuple[npt.NDArray[np.float64], ...]
40
+
41
+
42
+ def load_saved_analysis_last_roi(path: Path) -> SavedAnalysisLastRoi:
43
+ """Load selected ``lastRoi`` outputs from a saved-analysis MAT file.
44
+
45
+ Args:
46
+ path: Path to an HDF5-backed MATLAB saved-analysis file.
47
+
48
+ Returns:
49
+ ``SavedAnalysisLastRoi`` with arrays ready for parity checks.
50
+
51
+ Raises:
52
+ ValueError: If the file does not contain the expected ``lastRoi``
53
+ fields.
54
+
55
+ MATLAB v7.3 files store cell contents as HDF5 object references. This reader
56
+ resolves those references but keeps the output small and explicit.
57
+ """
58
+ input_path = path.expanduser()
59
+ with h5py.File(input_path, "r") as h5_file:
60
+ if "lastRoi" not in h5_file:
61
+ msg = f"Saved analysis file has no 'lastRoi' group: {input_path}"
62
+ raise ValueError(msg)
63
+ roi_group = h5_file["lastRoi"]
64
+ return SavedAnalysisLastRoi(
65
+ path=input_path,
66
+ roi_mask_initial=cast(
67
+ npt.NDArray[np.float64],
68
+ roi_group["roiMaskInitial"][()],
69
+ ),
70
+ epoch_list=np.ravel(roi_group["epochList"][()]).astype(np.int64),
71
+ epoch_start_times=_read_int_cell_arrays(
72
+ h5_file,
73
+ roi_group,
74
+ "epochStartTimes",
75
+ ),
76
+ epoch_durations=_read_int_cell_arrays(
77
+ h5_file,
78
+ roi_group,
79
+ "epochDurations",
80
+ ),
81
+ time_by_rois_initial=_read_trace_cell_arrays(
82
+ h5_file,
83
+ roi_group,
84
+ "timeByRoisInitial",
85
+ ),
86
+ )
87
+
88
+
89
+ def _read_int_cell_arrays(
90
+ h5_file: h5py.File,
91
+ group: h5py.Group,
92
+ dataset_name: str,
93
+ ) -> tuple[npt.NDArray[np.int64], ...]:
94
+ """Read a MATLAB cell array of integer vectors.
95
+
96
+ Args:
97
+ h5_file: Open HDF5 file.
98
+ group: Group containing the cell dataset.
99
+ dataset_name: Name of the cell dataset.
100
+
101
+ Returns:
102
+ Tuple of one-dimensional integer arrays.
103
+ """
104
+ arrays = _read_cell_arrays(h5_file, group, dataset_name)
105
+ return tuple(np.ravel(array).astype(np.int64) for array in arrays)
106
+
107
+
108
+ def _read_trace_cell_arrays(
109
+ h5_file: h5py.File,
110
+ group: h5py.Group,
111
+ dataset_name: str,
112
+ ) -> tuple[npt.NDArray[np.float64], ...]:
113
+ """Read a MATLAB cell array of ROI trace matrices.
114
+
115
+ Args:
116
+ h5_file: Open HDF5 file.
117
+ group: Group containing the cell dataset.
118
+ dataset_name: Name of the cell dataset.
119
+
120
+ Returns:
121
+ Tuple of trace arrays shaped ``(frames, rois)`` when the saved matrix is
122
+ two-dimensional.
123
+ """
124
+ arrays = _read_cell_arrays(h5_file, group, dataset_name)
125
+ return tuple(_matlab_trace_matrix_to_frames_by_rois(array) for array in arrays)
126
+
127
+
128
+ def _read_cell_arrays(
129
+ h5_file: h5py.File,
130
+ group: h5py.Group,
131
+ dataset_name: str,
132
+ ) -> tuple[npt.NDArray[np.float64], ...]:
133
+ """Resolve a MATLAB cell array stored as HDF5 references.
134
+
135
+ Args:
136
+ h5_file: Open HDF5 file.
137
+ group: Group containing the cell dataset.
138
+ dataset_name: Name of the cell dataset.
139
+
140
+ Returns:
141
+ Tuple of referenced numeric arrays.
142
+ """
143
+ if dataset_name not in group:
144
+ msg = f"Saved analysis lastRoi is missing {dataset_name!r}"
145
+ raise ValueError(msg)
146
+ references = group[dataset_name][()]
147
+ arrays: list[npt.NDArray[np.float64]] = []
148
+ for reference in np.ravel(references):
149
+ if not isinstance(reference, h5py.Reference) or not reference:
150
+ continue
151
+ arrays.append(cast(npt.NDArray[np.float64], h5_file[reference][()]))
152
+ return tuple(arrays)
153
+
154
+
155
+ def _matlab_trace_matrix_to_frames_by_rois(
156
+ array: npt.NDArray[np.float64],
157
+ ) -> npt.NDArray[np.float64]:
158
+ """Return saved trace matrices as ``(frames, rois)``.
159
+
160
+ Args:
161
+ array: Referenced MATLAB trace matrix.
162
+
163
+ Returns:
164
+ Trace matrix with frame axis first.
165
+
166
+ MATLAB writes column-major arrays into HDF5 with dimensions that appear
167
+ reversed to h5py. Saved ROI traces are usually ``rois x frames`` when read
168
+ directly, so this helper transposes two-dimensional trace matrices into the
169
+ twopy convention.
170
+ """
171
+ values = np.asarray(array, dtype=np.float64)
172
+ if values.ndim == 2:
173
+ return values.T
174
+ return values
twopy/photodiode.py ADDED
@@ -0,0 +1,375 @@
1
+ """Shared photodiode timing helpers.
2
+
3
+ Inputs: converted or source photodiode vectors plus imaging-frame metadata.
4
+ Outputs: thresholded flash events and stimulus-bounded imaging-frame ranges.
5
+
6
+ The photodiode is the common timing evidence between the stimulus computer and
7
+ the imaging computer. This module keeps that low-level contract in one place so
8
+ conversion and response analysis do not drift into different threshold or
9
+ end-flash rules.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Literal
14
+
15
+ import numpy as np
16
+ import numpy.typing as npt
17
+
18
+ from twopy._segments import true_segments
19
+
20
+ __all__ = [
21
+ "DEFAULT_END_FLASH_PROJECTOR_FRAME_THRESHOLD",
22
+ "DEFAULT_PHOTODIODE_THRESHOLD_FRACTION",
23
+ "DEFAULT_PROJECTOR_HZ",
24
+ "PhotodiodeEvent",
25
+ "PhotodiodePolarity",
26
+ "detect_photodiode_event_segments",
27
+ "photodiode_threshold",
28
+ "stimulus_frame_bounds_from_photodiode",
29
+ ]
30
+
31
+ PhotodiodePolarity = Literal["above", "below"]
32
+
33
+ DEFAULT_PHOTODIODE_THRESHOLD_FRACTION = 0.3
34
+ DEFAULT_PROJECTOR_HZ = 60.0
35
+ DEFAULT_END_FLASH_PROJECTOR_FRAME_THRESHOLD = 19.0
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class PhotodiodeEvent:
40
+ """One contiguous photodiode flash segment.
41
+
42
+ Inputs: one thresholded photodiode signal.
43
+ Outputs: start, stop, center, duration, and peak for one detected event.
44
+
45
+ ``start_sample`` is inclusive and ``stop_sample`` is exclusive. When the
46
+ source signal is ``imaging_res_pd``, sample indices are imaging-frame
47
+ indices. When the source signal is ``high_res_pd``, sample indices are
48
+ high-rate photodiode samples.
49
+ """
50
+
51
+ start_sample: int
52
+ stop_sample: int
53
+ center_sample: float
54
+ duration_samples: int
55
+ peak_value: float
56
+
57
+
58
+ def photodiode_threshold(
59
+ signal: npt.ArrayLike,
60
+ *,
61
+ fraction: float = DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
62
+ ) -> float:
63
+ """Compute the default active-flash threshold for a photodiode signal.
64
+
65
+ Args:
66
+ signal: One-dimensional photodiode samples.
67
+ fraction: Fraction of the signal range above the minimum used as the
68
+ threshold.
69
+
70
+ Returns:
71
+ ``min(signal) + fraction * (max(signal) - min(signal))``.
72
+
73
+ Raises:
74
+ ValueError: If the signal is not one-dimensional or ``fraction`` is
75
+ outside ``[0, 1]``.
76
+
77
+ The default fraction is 0.3 to match the lab analysis convention. This
78
+ lower-than-midpoint threshold detects flash boundaries closer to the first
79
+ rising edge while still using only local signal range information.
80
+ """
81
+ values = _photodiode_values(signal)
82
+ if not 0.0 <= fraction <= 1.0:
83
+ msg = f"Photodiode threshold fraction must be in [0, 1]; got {fraction}"
84
+ raise ValueError(msg)
85
+ if values.size == 0:
86
+ return 0.0
87
+
88
+ minimum = float(np.nanmin(values))
89
+ maximum = float(np.nanmax(values))
90
+ return minimum + (fraction * (maximum - minimum))
91
+
92
+
93
+ def detect_photodiode_event_segments(
94
+ signal: npt.ArrayLike,
95
+ *,
96
+ threshold: float | None = None,
97
+ threshold_fraction: float = DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
98
+ polarity: PhotodiodePolarity = "above",
99
+ minimum_width_samples: int = 1,
100
+ merge_gap_samples: int = 0,
101
+ ) -> tuple[tuple[PhotodiodeEvent, ...], float]:
102
+ """Detect event segments and return the threshold used.
103
+
104
+ Args:
105
+ signal: One-dimensional photodiode samples.
106
+ threshold: Optional explicit event threshold. When omitted, the
107
+ range-fraction threshold is used.
108
+ threshold_fraction: Fraction used when ``threshold`` is omitted.
109
+ polarity: Which side of threshold marks an event.
110
+ minimum_width_samples: Drop events shorter than this width.
111
+ merge_gap_samples: Merge neighboring events separated by this width.
112
+
113
+ Returns:
114
+ ``(events, threshold)``.
115
+
116
+ Raises:
117
+ ValueError: If signal shape or width arguments are invalid.
118
+ """
119
+ values = _photodiode_values(signal)
120
+ if minimum_width_samples < 1:
121
+ msg = "minimum_width_samples must be at least 1"
122
+ raise ValueError(msg)
123
+ if merge_gap_samples < 0:
124
+ msg = "merge_gap_samples cannot be negative"
125
+ raise ValueError(msg)
126
+
127
+ resolved_threshold = (
128
+ photodiode_threshold(values, fraction=threshold_fraction)
129
+ if threshold is None
130
+ else float(threshold)
131
+ )
132
+ if values.size == 0:
133
+ return (), resolved_threshold
134
+
135
+ active = _threshold_signal(
136
+ values=values,
137
+ threshold=resolved_threshold,
138
+ polarity=polarity,
139
+ )
140
+ segments = true_segments(active)
141
+ merged_segments = _merge_close_segments(segments, merge_gap_samples)
142
+ events = tuple(
143
+ _event_from_segment(values, start, stop)
144
+ for start, stop in merged_segments
145
+ if stop - start >= minimum_width_samples
146
+ )
147
+ return events, resolved_threshold
148
+
149
+
150
+ def stimulus_frame_bounds_from_photodiode(
151
+ high_res_pd: npt.ArrayLike,
152
+ *,
153
+ movie_frame_count: int,
154
+ frame_rate_hz: float,
155
+ threshold: float | None = None,
156
+ threshold_fraction: float = DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
157
+ projector_hz: float = DEFAULT_PROJECTOR_HZ,
158
+ end_flash_projector_frame_threshold: float = (
159
+ DEFAULT_END_FLASH_PROJECTOR_FRAME_THRESHOLD
160
+ ),
161
+ ) -> tuple[int, int]:
162
+ """Return imaging-frame bounds for the stimulus presentation window.
163
+
164
+ Args:
165
+ high_res_pd: High-rate photodiode signal.
166
+ movie_frame_count: Number of aligned movie frames.
167
+ frame_rate_hz: Imaging frame rate.
168
+ threshold: Optional explicit event threshold.
169
+ threshold_fraction: Range fraction used when ``threshold`` is omitted.
170
+ projector_hz: Stimulus projector refresh rate.
171
+ end_flash_projector_frame_threshold: Long-flash duration threshold in
172
+ projector frames.
173
+
174
+ Returns:
175
+ Half-open imaging-frame range ``(start, stop)``.
176
+
177
+ Raises:
178
+ ValueError: If no start/end flash can be found or if long end flashes
179
+ are ambiguous.
180
+
181
+ The first detected flash marks stimulus start. The selected long flash marks
182
+ stimulus end, and the end frame is the frame immediately before that flash.
183
+ """
184
+ events, _ = detect_photodiode_event_segments(
185
+ high_res_pd,
186
+ threshold=threshold,
187
+ threshold_fraction=threshold_fraction,
188
+ )
189
+ if len(events) == 0:
190
+ msg = "Cannot find stimulus frame bounds without photodiode flashes"
191
+ raise ValueError(msg)
192
+
193
+ samples_per_frame = _high_res_samples_per_frame(
194
+ high_res_sample_count=_photodiode_values(high_res_pd).size,
195
+ movie_frame_count=movie_frame_count,
196
+ )
197
+ end_index = _end_flash_event_index(
198
+ events=events,
199
+ high_res_samples_per_frame=samples_per_frame,
200
+ frame_rate_hz=frame_rate_hz,
201
+ projector_hz=projector_hz,
202
+ end_flash_projector_frame_threshold=end_flash_projector_frame_threshold,
203
+ )
204
+ epoch_begin = events[0].start_sample / samples_per_frame
205
+ epoch_end = (events[end_index].start_sample - 1) / samples_per_frame
206
+ start = int(np.floor(epoch_begin))
207
+ stop = int(np.floor(epoch_end)) + 1
208
+ if start < 0 or stop > movie_frame_count or start >= stop:
209
+ msg = (
210
+ f"Invalid stimulus frame bounds [{start}, {stop}) for "
211
+ f"{movie_frame_count} frames"
212
+ )
213
+ raise ValueError(msg)
214
+ return start, stop
215
+
216
+
217
+ def _photodiode_values(signal: npt.ArrayLike) -> npt.NDArray[np.float64]:
218
+ """Return a validated one-dimensional photodiode vector.
219
+
220
+ Args:
221
+ signal: Candidate photodiode samples.
222
+
223
+ Returns:
224
+ Float64 one-dimensional values.
225
+ """
226
+ values = np.asarray(signal, dtype=np.float64)
227
+ if values.ndim != 1:
228
+ msg = f"Photodiode signal must be one-dimensional; got {values.shape}"
229
+ raise ValueError(msg)
230
+ return values
231
+
232
+
233
+ def _high_res_samples_per_frame(
234
+ *,
235
+ high_res_sample_count: int,
236
+ movie_frame_count: int,
237
+ ) -> int:
238
+ """Return high-rate photodiode samples per imaging frame.
239
+
240
+ Args:
241
+ high_res_sample_count: Number of high-rate photodiode samples.
242
+ movie_frame_count: Number of aligned movie frames.
243
+
244
+ Returns:
245
+ Integer high-rate samples per imaging frame.
246
+ """
247
+ if movie_frame_count <= 0:
248
+ msg = f"movie_frame_count must be positive; got {movie_frame_count}"
249
+ raise ValueError(msg)
250
+ if high_res_sample_count % movie_frame_count != 0:
251
+ msg = (
252
+ "high-rate photodiode sample count must be an integer multiple of "
253
+ f"movie frames: {high_res_sample_count} samples for "
254
+ f"{movie_frame_count} frames"
255
+ )
256
+ raise ValueError(msg)
257
+ return high_res_sample_count // movie_frame_count
258
+
259
+
260
+ def _end_flash_event_index(
261
+ *,
262
+ events: tuple[PhotodiodeEvent, ...],
263
+ high_res_samples_per_frame: int,
264
+ frame_rate_hz: float,
265
+ projector_hz: float,
266
+ end_flash_projector_frame_threshold: float,
267
+ ) -> int:
268
+ """Identify the long photodiode event that marks stimulus end.
269
+
270
+ Args:
271
+ events: High-rate photodiode events.
272
+ high_res_samples_per_frame: High-rate samples per imaging frame.
273
+ frame_rate_hz: Imaging frame rate.
274
+ projector_hz: Stimulus projector refresh rate.
275
+ end_flash_projector_frame_threshold: Minimum projector-frame duration
276
+ for an end flash.
277
+
278
+ Returns:
279
+ Index of the flash whose start bounds the stimulus end.
280
+ """
281
+ durations = np.asarray(
282
+ [event.duration_samples for event in events],
283
+ dtype=np.float64,
284
+ )
285
+ projector_frames = (
286
+ durations / float(high_res_samples_per_frame) / frame_rate_hz * projector_hz
287
+ )
288
+ end_candidates = np.flatnonzero(
289
+ projector_frames > end_flash_projector_frame_threshold,
290
+ )
291
+ if end_candidates.size == 0:
292
+ msg = "Cannot find stimulus end because no long end flash was detected"
293
+ raise ValueError(msg)
294
+ if end_candidates.size == 2 and end_candidates[1] - end_candidates[0] > 3:
295
+ return int(end_candidates[1])
296
+ if end_candidates.size > 2:
297
+ msg = (
298
+ "Ambiguous stimulus end: more than two long photodiode flashes were "
299
+ f"detected at event indices {end_candidates.tolist()}"
300
+ )
301
+ raise ValueError(msg)
302
+ return int(end_candidates[0])
303
+
304
+
305
+ def _threshold_signal(
306
+ *,
307
+ values: npt.NDArray[np.float64],
308
+ threshold: float,
309
+ polarity: PhotodiodePolarity,
310
+ ) -> npt.NDArray[np.bool_]:
311
+ """Convert signal values into an active-event mask.
312
+
313
+ Args:
314
+ values: One-dimensional signal values.
315
+ threshold: Event threshold.
316
+ polarity: Which side of threshold is active.
317
+
318
+ Returns:
319
+ Boolean mask with one value per input sample.
320
+ """
321
+ if polarity == "above":
322
+ return values > threshold
323
+ return values < threshold
324
+
325
+
326
+ def _merge_close_segments(
327
+ segments: tuple[tuple[int, int], ...],
328
+ merge_gap_samples: int,
329
+ ) -> tuple[tuple[int, int], ...]:
330
+ """Merge event segments separated by short inactive gaps.
331
+
332
+ Args:
333
+ segments: Event start/stop pairs.
334
+ merge_gap_samples: Maximum inactive gap to merge.
335
+
336
+ Returns:
337
+ Merged start/stop pairs.
338
+ """
339
+ if len(segments) <= 1 or merge_gap_samples == 0:
340
+ return segments
341
+
342
+ merged: list[tuple[int, int]] = [segments[0]]
343
+ for start, stop in segments[1:]:
344
+ last_start, last_stop = merged[-1]
345
+ if start - last_stop <= merge_gap_samples:
346
+ merged[-1] = (last_start, stop)
347
+ else:
348
+ merged.append((start, stop))
349
+
350
+ return tuple(merged)
351
+
352
+
353
+ def _event_from_segment(
354
+ values: npt.NDArray[np.float64],
355
+ start: int,
356
+ stop: int,
357
+ ) -> PhotodiodeEvent:
358
+ """Summarize one active event segment.
359
+
360
+ Args:
361
+ values: Full one-dimensional photodiode signal.
362
+ start: Inclusive event start sample.
363
+ stop: Exclusive event stop sample.
364
+
365
+ Returns:
366
+ ``PhotodiodeEvent`` for the segment.
367
+ """
368
+ event_values = values[start:stop]
369
+ return PhotodiodeEvent(
370
+ start_sample=start,
371
+ stop_sample=stop,
372
+ center_sample=float(start + (stop - start - 1) / 2.0),
373
+ duration_samples=stop - start,
374
+ peak_value=float(np.nanmax(event_values)),
375
+ )