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
twopy/spatial.py ADDED
@@ -0,0 +1,154 @@
1
+ """Spatial crop contracts for converted two-photon recordings.
2
+
3
+ Inputs: movie spatial shapes, alignment-derived crop bounds, and ROI masks.
4
+ Outputs: typed crop objects and helpers that keep full-frame and cropped
5
+ analysis coordinates explicit.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any, Literal
10
+
11
+ import numpy as np
12
+ import numpy.typing as npt
13
+
14
+ __all__ = [
15
+ "SpatialCrop",
16
+ "SpatialDomain",
17
+ "full_frame_crop",
18
+ ]
19
+
20
+ SpatialDomain = Literal["alignment_valid_crop", "full_frame"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SpatialCrop:
25
+ """A rectangular spatial domain inside a full movie frame.
26
+
27
+ Inputs: half-open bounds along the two spatial movie axes plus the original
28
+ full-frame shape.
29
+ Outputs: validated crop metadata and array slicing helpers.
30
+
31
+ ``axis0`` and ``axis1`` name NumPy movie axes directly. twopy avoids
32
+ top/left/x/y here because source files and display tools can disagree about
33
+ image orientation, while the array axes are unambiguous.
34
+ """
35
+
36
+ axis0_start: int
37
+ axis0_stop: int
38
+ axis1_start: int
39
+ axis1_stop: int
40
+ original_shape: tuple[int, int]
41
+ source: str
42
+
43
+ def __post_init__(self) -> None:
44
+ """Validate crop bounds immediately after construction.
45
+
46
+ Inputs: dataclass field values.
47
+ Outputs: ``None`` or a clear ``ValueError``.
48
+ """
49
+ axis0_length, axis1_length = self.original_shape
50
+ if (
51
+ self.axis0_start < 0
52
+ or self.axis1_start < 0
53
+ or self.axis0_stop > axis0_length
54
+ or self.axis1_stop > axis1_length
55
+ or self.axis0_start >= self.axis0_stop
56
+ or self.axis1_start >= self.axis1_stop
57
+ ):
58
+ msg = (
59
+ "Invalid spatial crop "
60
+ f"axis0=[{self.axis0_start}, {self.axis0_stop}), "
61
+ f"axis1=[{self.axis1_start}, {self.axis1_stop}) for "
62
+ f"shape {self.original_shape}"
63
+ )
64
+ raise ValueError(msg)
65
+
66
+ @property
67
+ def shape(self) -> tuple[int, int]:
68
+ """Return the spatial shape of this crop.
69
+
70
+ Inputs: crop bounds.
71
+ Outputs: ``(axis0_length, axis1_length)``.
72
+ """
73
+ return (
74
+ self.axis0_stop - self.axis0_start,
75
+ self.axis1_stop - self.axis1_start,
76
+ )
77
+
78
+ @property
79
+ def is_full_frame(self) -> bool:
80
+ """Return whether this crop covers the whole source frame.
81
+
82
+ Inputs: crop bounds and original shape.
83
+ Outputs: ``True`` for full-frame domains, otherwise ``False``.
84
+ """
85
+ return (
86
+ self.axis0_start == 0
87
+ and self.axis1_start == 0
88
+ and self.axis0_stop == self.original_shape[0]
89
+ and self.axis1_stop == self.original_shape[1]
90
+ )
91
+
92
+ def crop_image(
93
+ self,
94
+ image: npt.NDArray[Any],
95
+ ) -> npt.NDArray[Any]:
96
+ """Return the crop view from a full-frame image.
97
+
98
+ Args:
99
+ image: Two-dimensional full-frame image with ``original_shape``.
100
+
101
+ Returns:
102
+ View of ``image`` inside this crop.
103
+ """
104
+ if image.shape != self.original_shape:
105
+ msg = f"Image shape {image.shape} does not match {self.original_shape}"
106
+ raise ValueError(msg)
107
+ return image[
108
+ self.axis0_start : self.axis0_stop,
109
+ self.axis1_start : self.axis1_stop,
110
+ ]
111
+
112
+ def crop_masks(
113
+ self,
114
+ masks: npt.NDArray[np.bool_],
115
+ ) -> npt.NDArray[np.bool_]:
116
+ """Return ROI masks restricted to this crop.
117
+
118
+ Args:
119
+ masks: Boolean masks shaped ``(rois, axis0, axis1)`` in full-frame
120
+ movie coordinates.
121
+
122
+ Returns:
123
+ Boolean mask view shaped ``(rois, cropped_axis0, cropped_axis1)``.
124
+ """
125
+ if masks.ndim != 3 or masks.shape[1:] != self.original_shape:
126
+ msg = (
127
+ f"ROI mask shape {masks.shape} does not match crop source "
128
+ f"shape {self.original_shape}"
129
+ )
130
+ raise ValueError(msg)
131
+ return masks[
132
+ :,
133
+ self.axis0_start : self.axis0_stop,
134
+ self.axis1_start : self.axis1_stop,
135
+ ]
136
+
137
+
138
+ def full_frame_crop(spatial_shape: tuple[int, int]) -> SpatialCrop:
139
+ """Create a crop object that covers an entire movie frame.
140
+
141
+ Args:
142
+ spatial_shape: Full movie spatial shape.
143
+
144
+ Returns:
145
+ ``SpatialCrop`` spanning the full frame.
146
+ """
147
+ return SpatialCrop(
148
+ axis0_start=0,
149
+ axis0_stop=spatial_shape[0],
150
+ axis1_start=0,
151
+ axis1_stop=spatial_shape[1],
152
+ original_shape=spatial_shape,
153
+ source="full_frame",
154
+ )
twopy/stimulus.py ADDED
@@ -0,0 +1,233 @@
1
+ """Helpers for interpreting converted stimulus data columns.
2
+
3
+ Inputs: a loaded twopy ``RecordingData`` object.
4
+ Outputs: small typed mappings from stable ``stimulus/data`` column names to the
5
+ experiment-specific MATLAB code that wrote those columns.
6
+
7
+ The stable HDF5 column name stays generic because different ``stimtype`` values
8
+ can reuse the same slot for different meanings. These helpers make that
9
+ per-``stimtype`` mapping explicit for scripts and analysis code.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from typing import cast
14
+
15
+ from twopy.converted import RecordingData
16
+
17
+ __all__ = [
18
+ "StimulusSpecificColumnMapping",
19
+ "map_stimulus_specific_column",
20
+ "stimulus_column_index",
21
+ "stimulus_epoch_names_by_number",
22
+ ]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class StimulusSpecificColumnMapping:
27
+ """Meaning of one stable stimulus-specific column for one ``stimtype``.
28
+
29
+ Inputs: decoded metadata from ``stimulus/stimulus_specific_columns_json``.
30
+ Outputs: a script-friendly record containing the stable column name, the
31
+ ``stimtype``, the backed-up MATLAB function, and the source assignment.
32
+
33
+ ``specific_name`` is the most readable available label: an inline MATLAB
34
+ comment when present, otherwise the right-hand source expression.
35
+ """
36
+
37
+ stable_column_name: str
38
+ stimtype: str
39
+ specific_name: str
40
+ source_expression: str
41
+ source_comment: str | None
42
+ source_line: int
43
+ source_path: str
44
+ stimulus_function: str
45
+
46
+
47
+ def map_stimulus_specific_column(
48
+ recording: RecordingData,
49
+ stable_column_name: str,
50
+ *,
51
+ stimtype: int | str | None = None,
52
+ ) -> tuple[StimulusSpecificColumnMapping, ...]:
53
+ """Map a stable stimulus column to experiment-specific meanings.
54
+
55
+ Args:
56
+ recording: Loaded converted recording.
57
+ stable_column_name: Stable name such as ``stimulus_specific_04``.
58
+ stimtype: Optional stimulus type. When omitted, mappings for all
59
+ ``stimtype`` values in the recording are returned.
60
+
61
+ Returns:
62
+ One mapping per matching ``stimtype``. An unused stimulus-specific slot
63
+ returns an empty tuple.
64
+
65
+ Raises:
66
+ ValueError: If ``stable_column_name`` is not a stimulus-specific column.
67
+ KeyError: If the requested ``stimtype`` is not present in the recording.
68
+
69
+ This function exists so analysis code can use the simple stable column
70
+ names for data access while still showing the exact experiment-specific
71
+ meaning from the backed-up MATLAB code.
72
+ """
73
+ if not stable_column_name.startswith("stimulus_specific_"):
74
+ msg = f"Expected stimulus_specific_* column; got {stable_column_name!r}"
75
+ raise ValueError(msg)
76
+
77
+ selected_stimtypes = _selected_stimtypes(recording, stimtype)
78
+ mappings: list[StimulusSpecificColumnMapping] = []
79
+ for stimtype_key in selected_stimtypes:
80
+ metadata = recording.stimulus_specific_columns[stimtype_key]
81
+ mappings.extend(
82
+ _mappings_from_metadata(
83
+ stable_column_name=stable_column_name,
84
+ stimtype=stimtype_key,
85
+ metadata=metadata,
86
+ ),
87
+ )
88
+ return tuple(mappings)
89
+
90
+
91
+ def stimulus_column_index(recording: RecordingData, column_name: str) -> int:
92
+ """Return the index for one converted stimulus data column.
93
+
94
+ Args:
95
+ recording: Loaded converted recording.
96
+ column_name: Required column name from ``stimulus/data_column_names``.
97
+
98
+ Returns:
99
+ Integer column index.
100
+
101
+ Raises:
102
+ ValueError: If the column is not present.
103
+
104
+ Keeping this lookup in one helper avoids slightly different error handling
105
+ in timing, classification, and script-facing analysis code.
106
+ """
107
+ try:
108
+ return recording.stimulus_data_column_names.index(column_name)
109
+ except ValueError as error:
110
+ msg = f"stimulus/data is missing required column {column_name!r}"
111
+ raise ValueError(msg) from error
112
+
113
+
114
+ def stimulus_epoch_names_by_number(recording: RecordingData) -> dict[int, str]:
115
+ """Map one-based stimulus epoch numbers to readable epoch names.
116
+
117
+ Args:
118
+ recording: Loaded converted recording with stimulus parameter metadata.
119
+
120
+ Returns:
121
+ Dictionary mapping epoch number to name.
122
+
123
+ Epoch numbers in ``stimulus/data`` are one-based because they come from the
124
+ stimulus computer. Missing names fall back to ``epoch_N`` at the call site.
125
+ """
126
+ names: dict[int, str] = {}
127
+ for index, parameters in enumerate(recording.stimulus_parameters, start=1):
128
+ epoch_name = parameters.get("epochName", f"epoch_{index}")
129
+ names[index] = str(epoch_name)
130
+ return names
131
+
132
+
133
+ def _selected_stimtypes(
134
+ recording: RecordingData,
135
+ stimtype: int | str | None,
136
+ ) -> tuple[str, ...]:
137
+ """Choose which ``stimtype`` entries to inspect.
138
+
139
+ Args:
140
+ recording: Loaded converted recording.
141
+ stimtype: Optional requested stimulus type.
142
+
143
+ Returns:
144
+ Tuple of stimulus type keys present in ``recording``.
145
+ """
146
+ if stimtype is None:
147
+ return tuple(recording.stimulus_specific_columns)
148
+
149
+ stimtype_key = str(int(stimtype)) if isinstance(stimtype, int) else str(stimtype)
150
+ if stimtype_key not in recording.stimulus_specific_columns:
151
+ msg = f"stimtype {stimtype_key!r} is not present in this recording"
152
+ raise KeyError(msg)
153
+ return (stimtype_key,)
154
+
155
+
156
+ def _mappings_from_metadata(
157
+ *,
158
+ stable_column_name: str,
159
+ stimtype: str,
160
+ metadata: dict[str, object],
161
+ ) -> tuple[StimulusSpecificColumnMapping, ...]:
162
+ """Build mappings from one decoded stimulus-function metadata block.
163
+
164
+ Args:
165
+ stable_column_name: Stable HDF5 column name to find.
166
+ stimtype: Stimulus type key for this metadata block.
167
+ metadata: Decoded JSON dictionary for one stimulus function.
168
+
169
+ Returns:
170
+ Matching column mappings.
171
+ """
172
+ columns = metadata.get("columns")
173
+ if not isinstance(columns, list):
174
+ msg = f"stimulus metadata for stimtype {stimtype!r} has no column list"
175
+ raise ValueError(msg)
176
+
177
+ mappings: list[StimulusSpecificColumnMapping] = []
178
+ for column in columns:
179
+ if not isinstance(column, dict):
180
+ msg = f"stimulus metadata for stimtype {stimtype!r} is malformed"
181
+ raise ValueError(msg)
182
+ column_metadata = cast(dict[str, object], column)
183
+ if column_metadata.get("column_name") != stable_column_name:
184
+ continue
185
+
186
+ source_comment = _optional_text(column_metadata.get("source_comment"))
187
+ source_expression = str(column_metadata["source_expression"])
188
+ source_line = _required_int(column_metadata["source_line"])
189
+ mappings.append(
190
+ StimulusSpecificColumnMapping(
191
+ stable_column_name=stable_column_name,
192
+ stimtype=stimtype,
193
+ specific_name=source_comment or source_expression,
194
+ source_expression=source_expression,
195
+ source_comment=source_comment,
196
+ source_line=source_line,
197
+ source_path=str(metadata["source_path"]),
198
+ stimulus_function=str(metadata["function"]),
199
+ ),
200
+ )
201
+ return tuple(mappings)
202
+
203
+
204
+ def _optional_text(value: object) -> str | None:
205
+ """Return optional metadata text as ``str | None``.
206
+
207
+ Args:
208
+ value: Raw decoded JSON value.
209
+
210
+ Returns:
211
+ Text when present, otherwise ``None``.
212
+ """
213
+ if value is None:
214
+ return None
215
+ return str(value)
216
+
217
+
218
+ def _required_int(value: object) -> int:
219
+ """Return a required JSON metadata integer.
220
+
221
+ Args:
222
+ value: Raw decoded JSON value.
223
+
224
+ Returns:
225
+ Integer value.
226
+
227
+ Raises:
228
+ ValueError: If the value cannot honestly be read as an integer.
229
+ """
230
+ if isinstance(value, bool) or not isinstance(value, int | str):
231
+ msg = f"Expected integer metadata value; got {value!r}"
232
+ raise ValueError(msg)
233
+ return int(value)
@@ -0,0 +1,235 @@
1
+ """Detect photodiode events and map them to imaging frames.
2
+
3
+ Inputs: converted photodiode signals from ``RecordingData``.
4
+ Outputs: photodiode event segments and high-resolution events paired with
5
+ frame-resolution imaging events.
6
+
7
+ The stimulus and imaging computers have separate clocks. This module uses the
8
+ photodiode signals as the shared evidence for alignment instead of relying on
9
+ nominal frame rates.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Literal
14
+
15
+ import numpy.typing as npt
16
+
17
+ from twopy.converted import RecordingData
18
+ from twopy.photodiode import (
19
+ DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
20
+ PhotodiodeEvent,
21
+ PhotodiodePolarity,
22
+ detect_photodiode_event_segments,
23
+ )
24
+
25
+ __all__ = [
26
+ "AlignedPhotodiodeEvent",
27
+ "PhotodiodeAlignment",
28
+ "PhotodiodeEvent",
29
+ "PhotodiodeEventSet",
30
+ "PhotodiodePolarity",
31
+ "detect_photodiode_events",
32
+ "detect_recording_photodiode_events",
33
+ "pair_photodiode_events_to_imaging_frames",
34
+ ]
35
+
36
+ PhotodiodeSignalName = Literal["high_res_pd", "imaging_res_pd"]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class PhotodiodeEventSet:
41
+ """Detected events for one photodiode signal.
42
+
43
+ Inputs: signal name, thresholding options, and detected event segments.
44
+ Outputs: auditable event list plus the threshold that produced it.
45
+
46
+ Keeping the threshold with the events makes later trial alignment easier to
47
+ inspect and reproduce.
48
+ """
49
+
50
+ signal_name: PhotodiodeSignalName
51
+ events: tuple[PhotodiodeEvent, ...]
52
+ threshold: float
53
+ polarity: PhotodiodePolarity
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class AlignedPhotodiodeEvent:
58
+ """One high-resolution event paired with one imaging-frame event.
59
+
60
+ Inputs: detected high-resolution and imaging-resolution photodiode events.
61
+ Outputs: the imaging frame assigned to the high-resolution event.
62
+
63
+ The imaging frame is the first frame-resolution sample where the paired
64
+ photodiode event is detected. This is the conservative frame boundary used
65
+ before stimulus-specific event classification exists.
66
+ """
67
+
68
+ high_res_event: PhotodiodeEvent
69
+ imaging_res_event: PhotodiodeEvent
70
+ imaging_frame: int
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class PhotodiodeAlignment:
75
+ """Detected and paired photodiode events for one recording.
76
+
77
+ Inputs: converted high-resolution and imaging-resolution photodiode signals.
78
+ Outputs: event sets plus ordered high-res-to-frame pairings.
79
+
80
+ This is the first timing object response analysis should consume. It still
81
+ does not classify stimulus-specific flash patterns.
82
+ """
83
+
84
+ high_res: PhotodiodeEventSet
85
+ imaging_res: PhotodiodeEventSet
86
+ paired_events: tuple[AlignedPhotodiodeEvent, ...]
87
+
88
+
89
+ def detect_photodiode_events(
90
+ signal: npt.ArrayLike,
91
+ *,
92
+ signal_name: PhotodiodeSignalName = "high_res_pd",
93
+ threshold: float | None = None,
94
+ threshold_fraction: float = DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
95
+ polarity: PhotodiodePolarity = "above",
96
+ minimum_width_samples: int = 1,
97
+ merge_gap_samples: int = 0,
98
+ ) -> PhotodiodeEventSet:
99
+ """Detect contiguous photodiode events in one signal.
100
+
101
+ Args:
102
+ signal: One-dimensional photodiode samples.
103
+ signal_name: Name of the converted photodiode signal being decoded.
104
+ threshold: Optional threshold. When omitted, twopy uses
105
+ ``min + threshold_fraction * range``.
106
+ threshold_fraction: Signal-range fraction used when ``threshold`` is
107
+ omitted. Defaults to the lab analysis convention, 0.3.
108
+ polarity: ``above`` detects samples greater than threshold; ``below``
109
+ detects samples less than threshold.
110
+ minimum_width_samples: Drop events shorter than this width.
111
+ merge_gap_samples: Merge neighboring events separated by this many
112
+ samples or fewer.
113
+
114
+ Returns:
115
+ Event set with the requested signal name.
116
+
117
+ Raises:
118
+ ValueError: If the signal is not one-dimensional or widths are invalid.
119
+
120
+ This function segments flashes. It does not classify stimulus-specific flash
121
+ patterns because that requires stimulus protocol rules.
122
+ """
123
+ events, resolved_threshold = detect_photodiode_event_segments(
124
+ signal=signal,
125
+ threshold=threshold,
126
+ threshold_fraction=threshold_fraction,
127
+ polarity=polarity,
128
+ minimum_width_samples=minimum_width_samples,
129
+ merge_gap_samples=merge_gap_samples,
130
+ )
131
+ return PhotodiodeEventSet(
132
+ signal_name=signal_name,
133
+ events=events,
134
+ threshold=resolved_threshold,
135
+ polarity=polarity,
136
+ )
137
+
138
+
139
+ def detect_recording_photodiode_events(
140
+ recording: RecordingData,
141
+ *,
142
+ high_res_threshold: float | None = None,
143
+ imaging_res_threshold: float | None = None,
144
+ threshold_fraction: float = DEFAULT_PHOTODIODE_THRESHOLD_FRACTION,
145
+ polarity: PhotodiodePolarity = "above",
146
+ minimum_width_samples: int = 1,
147
+ merge_gap_samples: int = 0,
148
+ ) -> PhotodiodeAlignment:
149
+ """Detect and pair photodiode events for one converted recording.
150
+
151
+ Args:
152
+ recording: Loaded converted recording.
153
+ high_res_threshold: Optional threshold for ``high_res_pd``.
154
+ imaging_res_threshold: Optional threshold for ``imaging_res_pd``.
155
+ threshold_fraction: Signal-range fraction used for either signal when
156
+ its explicit threshold is omitted.
157
+ polarity: Which side of threshold marks a photodiode flash.
158
+ minimum_width_samples: Drop events shorter than this width in each
159
+ signal.
160
+ merge_gap_samples: Merge neighboring events separated by this many
161
+ samples or fewer in each signal.
162
+
163
+ Returns:
164
+ ``PhotodiodeAlignment`` with high-resolution events paired to imaging
165
+ frames by event order.
166
+
167
+ Pairing by order uses the photodiode evidence in both signals. It avoids
168
+ assuming the two computers share a frame rate or clock.
169
+ """
170
+ high_res = detect_photodiode_events(
171
+ recording.high_res_pd,
172
+ signal_name="high_res_pd",
173
+ threshold=high_res_threshold,
174
+ threshold_fraction=threshold_fraction,
175
+ polarity=polarity,
176
+ minimum_width_samples=minimum_width_samples,
177
+ merge_gap_samples=merge_gap_samples,
178
+ )
179
+ imaging_res = detect_photodiode_events(
180
+ recording.imaging_res_pd,
181
+ signal_name="imaging_res_pd",
182
+ threshold=imaging_res_threshold,
183
+ threshold_fraction=threshold_fraction,
184
+ polarity=polarity,
185
+ minimum_width_samples=minimum_width_samples,
186
+ merge_gap_samples=merge_gap_samples,
187
+ )
188
+ return PhotodiodeAlignment(
189
+ high_res=high_res,
190
+ imaging_res=imaging_res,
191
+ paired_events=pair_photodiode_events_to_imaging_frames(
192
+ high_res.events,
193
+ imaging_res.events,
194
+ ),
195
+ )
196
+
197
+
198
+ def pair_photodiode_events_to_imaging_frames(
199
+ high_res_events: tuple[PhotodiodeEvent, ...],
200
+ imaging_res_events: tuple[PhotodiodeEvent, ...],
201
+ ) -> tuple[AlignedPhotodiodeEvent, ...]:
202
+ """Pair high-resolution photodiode events to imaging-frame events.
203
+
204
+ Args:
205
+ high_res_events: Events detected from ``high_res_pd``.
206
+ imaging_res_events: Events detected from ``imaging_res_pd``.
207
+
208
+ Returns:
209
+ Ordered event pairs with assigned imaging-frame indices.
210
+
211
+ Raises:
212
+ ValueError: If event counts differ.
213
+
214
+ Matching counts are required for this first implementation because silently
215
+ dropping or inventing event pairings would corrupt trial alignment.
216
+ """
217
+ if len(high_res_events) != len(imaging_res_events):
218
+ msg = (
219
+ "Cannot pair photodiode events with different counts: "
220
+ f"high_res={len(high_res_events)}, imaging_res={len(imaging_res_events)}"
221
+ )
222
+ raise ValueError(msg)
223
+
224
+ return tuple(
225
+ AlignedPhotodiodeEvent(
226
+ high_res_event=high_res_event,
227
+ imaging_res_event=imaging_res_event,
228
+ imaging_frame=imaging_res_event.start_sample,
229
+ )
230
+ for high_res_event, imaging_res_event in zip(
231
+ high_res_events,
232
+ imaging_res_events,
233
+ strict=True,
234
+ )
235
+ )