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/roi.py ADDED
@@ -0,0 +1,462 @@
1
+ """Represent ROIs and extract fluorescence traces from converted movies.
2
+
3
+ Inputs: ROI masks plus a loaded twopy ``RecordingData`` object.
4
+ Outputs: saved ROI HDF5 files and ROI-by-frame fluorescence traces.
5
+
6
+ This module is GUI-independent. napari can create or edit masks, but analysis
7
+ code should use these typed objects and functions.
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ import h5py
15
+ import numpy as np
16
+ import numpy.typing as npt
17
+
18
+ from twopy.converted import RecordingData
19
+ from twopy.typing_guards import require_bool_array
20
+
21
+ __all__ = [
22
+ "ROI_FILE_FORMAT",
23
+ "RoiSet",
24
+ "RoiTraces",
25
+ "extract_roi_traces",
26
+ "load_roi_set",
27
+ "make_roi_set_from_label_image",
28
+ "make_roi_set",
29
+ "roi_set_to_label_image",
30
+ "save_roi_set",
31
+ ]
32
+
33
+ ROI_FILE_FORMAT = "twopy-roi-set"
34
+ TraceStatistic = Literal["mean"]
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class RoiSet:
39
+ """A named set of spatial ROI masks.
40
+
41
+ Inputs: boolean masks with shape ``(rois, x, y)`` and one label per ROI.
42
+ Outputs: validated ROI masks that can be saved or used for trace extraction.
43
+
44
+ Masks are stored in movie coordinates so the same object works for scripts,
45
+ tests, and napari-derived ROIs.
46
+ """
47
+
48
+ masks: npt.NDArray[np.bool_]
49
+ labels: tuple[str, ...]
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class RoiTraces:
54
+ """Fluorescence traces extracted from a converted movie.
55
+
56
+ Inputs: one converted recording and one ``RoiSet``.
57
+ Outputs: ``values`` with shape ``(frames, rois)`` plus labels and frame
58
+ bounds.
59
+
60
+ The trace values are computed from converted aligned movie frames, not from
61
+ source MATLAB or raw TIFF files.
62
+ """
63
+
64
+ values: npt.NDArray[np.float64]
65
+ labels: tuple[str, ...]
66
+ start_frame: int
67
+ stop_frame: int
68
+ statistic: TraceStatistic
69
+
70
+
71
+ def make_roi_set(
72
+ masks: npt.ArrayLike,
73
+ *,
74
+ labels: tuple[str, ...] | None = None,
75
+ ) -> RoiSet:
76
+ """Create a validated ROI set from mask data.
77
+
78
+ Args:
79
+ masks: Array shaped ``(rois, x, y)``. Nonzero values are treated as
80
+ inside the ROI.
81
+ labels: Optional ROI labels. When omitted, labels are ``roi_0001``,
82
+ ``roi_0002``, and so on.
83
+
84
+ Returns:
85
+ ``RoiSet`` with boolean masks and labels.
86
+
87
+ Raises:
88
+ ValueError: If masks are not three-dimensional, labels do not match, or
89
+ any ROI has no pixels.
90
+ """
91
+ bool_masks = np.asarray(masks, dtype=np.bool_)
92
+ if bool_masks.ndim != 3:
93
+ msg = f"ROI masks must have shape (rois, x, y); got {bool_masks.shape}"
94
+ raise ValueError(msg)
95
+ if bool_masks.shape[0] == 0:
96
+ msg = "ROI masks must contain at least one ROI"
97
+ raise ValueError(msg)
98
+
99
+ pixel_counts = bool_masks.reshape(bool_masks.shape[0], -1).sum(axis=1)
100
+ if np.any(pixel_counts == 0):
101
+ empty_indices = np.where(pixel_counts == 0)[0].tolist()
102
+ msg = f"ROI masks cannot be empty; empty ROI indices: {empty_indices}"
103
+ raise ValueError(msg)
104
+
105
+ resolved_labels = labels
106
+ if resolved_labels is None:
107
+ resolved_labels = tuple(
108
+ f"roi_{roi_index + 1:04d}" for roi_index in range(bool_masks.shape[0])
109
+ )
110
+ if len(resolved_labels) != bool_masks.shape[0]:
111
+ msg = (
112
+ f"ROI labels length {len(resolved_labels)} does not match "
113
+ f"{bool_masks.shape[0]} masks"
114
+ )
115
+ raise ValueError(msg)
116
+
117
+ return RoiSet(masks=bool_masks, labels=resolved_labels)
118
+
119
+
120
+ def make_roi_set_from_label_image(
121
+ label_image: npt.ArrayLike,
122
+ *,
123
+ labels: tuple[str, ...] | None = None,
124
+ label_prefix: str = "roi",
125
+ ) -> RoiSet:
126
+ """Create a ROI set from one two-dimensional integer label image.
127
+
128
+ Args:
129
+ label_image: Two-dimensional image where ``0`` is background and each
130
+ positive integer is one ROI.
131
+ labels: Optional text label for each positive ROI value, sorted by
132
+ numeric label.
133
+ label_prefix: Prefix used when ``labels`` is omitted.
134
+
135
+ Returns:
136
+ ``RoiSet`` with one boolean mask per positive label.
137
+
138
+ Raises:
139
+ ValueError: If the label image is not two-dimensional, contains
140
+ negative labels, non-integer values, or no positive ROIs.
141
+
142
+ This is the bridge from label-editing tools such as napari into the core ROI
143
+ object used by scripts and analysis. Sorting by numeric label keeps saved
144
+ output deterministic.
145
+ """
146
+ integer_labels = _integer_label_image(label_image)
147
+ label_values = _positive_label_values(integer_labels)
148
+ masks = np.stack(tuple(integer_labels == value for value in label_values))
149
+ resolved_labels = labels
150
+ if resolved_labels is None:
151
+ resolved_labels = tuple(f"{label_prefix}_{value:04d}" for value in label_values)
152
+ return make_roi_set(masks, labels=resolved_labels)
153
+
154
+
155
+ def roi_set_to_label_image(roi_set: RoiSet) -> npt.NDArray[np.int64]:
156
+ """Convert non-overlapping ROI masks into one integer label image.
157
+
158
+ Args:
159
+ roi_set: ROI masks in movie coordinates.
160
+
161
+ Returns:
162
+ Two-dimensional integer image where ``0`` is background and each ROI is
163
+ labeled by its one-based position in ``roi_set``.
164
+
165
+ Raises:
166
+ ValueError: If ROI masks overlap.
167
+
168
+ napari label layers represent one integer per pixel. Overlapping masks
169
+ cannot be represented without hiding one ROI, so this helper fails loudly
170
+ instead of silently losing pixels.
171
+ """
172
+ overlap_counts = roi_set.masks.sum(axis=0)
173
+ if np.any(overlap_counts > 1):
174
+ msg = "ROI masks overlap and cannot be represented as one label image"
175
+ raise ValueError(msg)
176
+
177
+ label_image = np.zeros(roi_set.masks.shape[1:], dtype=np.int64)
178
+ for roi_index, mask in enumerate(roi_set.masks, start=1):
179
+ label_image[mask] = roi_index
180
+ return label_image
181
+
182
+
183
+ def save_roi_set(roi_set: RoiSet, path: Path) -> None:
184
+ """Save ROI masks and labels to an inspectable HDF5 file.
185
+
186
+ Args:
187
+ roi_set: Validated ROI masks and labels.
188
+ path: Destination HDF5 file.
189
+
190
+ Returns:
191
+ None.
192
+
193
+ The file uses gzip compression because ROI masks are usually sparse and
194
+ compress well.
195
+ """
196
+ output_path = path.expanduser()
197
+ output_path.parent.mkdir(parents=True, exist_ok=True)
198
+ with h5py.File(output_path, "w") as h5_file:
199
+ h5_file.attrs["twopy_format"] = ROI_FILE_FORMAT
200
+ h5_file.create_dataset("masks", data=roi_set.masks, compression="gzip")
201
+ h5_file.create_dataset(
202
+ "labels",
203
+ data=np.asarray(roi_set.labels, dtype=h5py.string_dtype("utf-8")),
204
+ )
205
+
206
+
207
+ def load_roi_set(path: Path) -> RoiSet:
208
+ """Load a saved ROI set from HDF5.
209
+
210
+ Args:
211
+ path: ROI HDF5 file path.
212
+
213
+ Returns:
214
+ Validated ``RoiSet``.
215
+
216
+ Raises:
217
+ ValueError: If the file is not a twopy ROI file.
218
+ """
219
+ input_path = path.expanduser()
220
+ with h5py.File(input_path, "r") as h5_file:
221
+ actual_format = str(h5_file.attrs.get("twopy_format", ""))
222
+ if actual_format != ROI_FILE_FORMAT:
223
+ msg = f"Expected {ROI_FILE_FORMAT!r} file at {input_path}"
224
+ raise ValueError(msg)
225
+ masks = require_bool_array(h5_file["masks"][()], name="masks", ndim=3)
226
+ raw_labels = h5_file["labels"][()]
227
+
228
+ labels = tuple(_decode_label(label) for label in raw_labels)
229
+ return make_roi_set(masks, labels=labels)
230
+
231
+
232
+ def extract_roi_traces(
233
+ recording: RecordingData,
234
+ roi_set: RoiSet,
235
+ *,
236
+ start_frame: int | None = None,
237
+ stop_frame: int | None = None,
238
+ chunk_frames: int = 128,
239
+ statistic: TraceStatistic = "mean",
240
+ ) -> RoiTraces:
241
+ """Extract ROI fluorescence traces from the converted aligned movie.
242
+
243
+ Args:
244
+ recording: Loaded converted recording.
245
+ roi_set: ROI masks in aligned movie spatial coordinates.
246
+ start_frame: Optional first frame to extract.
247
+ stop_frame: Optional exclusive stop frame.
248
+ chunk_frames: Number of movie frames to read per chunk.
249
+ statistic: Pixel statistic inside each ROI. Currently only ``mean`` is
250
+ supported.
251
+
252
+ Returns:
253
+ ``RoiTraces`` with shape ``(frames, rois)``.
254
+
255
+ Raises:
256
+ ValueError: If ROI shape differs from movie spatial shape.
257
+
258
+ The function streams movie chunks from HDF5. Only one chunk and the output
259
+ traces are held in memory.
260
+ """
261
+ validate_trace_statistic(statistic)
262
+ if roi_set.masks.shape[1:] != recording.movie.shape[1:]:
263
+ _raise_roi_movie_shape_error(roi_set, recording)
264
+
265
+ frame_start, frame_stop = _normalize_trace_frame_range(
266
+ frame_count=recording.movie.shape[0],
267
+ start_frame=start_frame,
268
+ stop_frame=stop_frame,
269
+ )
270
+ roi_pixel_indices = _roi_pixel_indices(roi_set)
271
+ traces = _extract_mean_traces_from_indices(
272
+ recording=recording,
273
+ pixel_indices_by_trace=roi_pixel_indices,
274
+ frame_start=frame_start,
275
+ frame_stop=frame_stop,
276
+ chunk_frames=chunk_frames,
277
+ )
278
+
279
+ return RoiTraces(
280
+ values=traces,
281
+ labels=roi_set.labels,
282
+ start_frame=frame_start,
283
+ stop_frame=frame_stop,
284
+ statistic=statistic,
285
+ )
286
+
287
+
288
+ def validate_trace_statistic(statistic: TraceStatistic) -> None:
289
+ """Validate the requested ROI pixel statistic.
290
+
291
+ Args:
292
+ statistic: Pixel statistic requested by a public trace-extraction API.
293
+
294
+ Returns:
295
+ None.
296
+
297
+ Raises:
298
+ ValueError: If ``statistic`` names behavior twopy does not implement.
299
+
300
+ Only mean traces are implemented right now. Runtime validation prevents
301
+ untyped scripts from producing mean traces with misleading metadata.
302
+ """
303
+ if statistic != "mean":
304
+ msg = f"Unknown trace statistic {statistic!r}; supported statistics: mean"
305
+ raise ValueError(msg)
306
+
307
+
308
+ def _integer_label_image(label_image: npt.ArrayLike) -> npt.NDArray[np.int64]:
309
+ """Return a two-dimensional label image as validated integer labels.
310
+
311
+ Args:
312
+ label_image: Candidate label image.
313
+
314
+ Returns:
315
+ Integer label image with finite, nonnegative labels.
316
+ """
317
+ values = np.asarray(label_image, dtype=np.float64)
318
+ if values.ndim != 2:
319
+ msg = f"ROI label image must be two-dimensional; got {values.shape}"
320
+ raise ValueError(msg)
321
+
322
+ finite = np.isfinite(values)
323
+ rounded = np.rint(values)
324
+ if not np.allclose(values[finite], rounded[finite]):
325
+ msg = "ROI label image values must be integer-like"
326
+ raise ValueError(msg)
327
+
328
+ labels = np.zeros(values.shape, dtype=np.int64)
329
+ labels[finite] = rounded[finite].astype(np.int64)
330
+ if np.any(labels < 0):
331
+ msg = "ROI label image values cannot be negative"
332
+ raise ValueError(msg)
333
+ return labels
334
+
335
+
336
+ def _positive_label_values(label_image: npt.NDArray[np.int64]) -> tuple[int, ...]:
337
+ """Return sorted positive ROI label values from one label image.
338
+
339
+ Args:
340
+ label_image: Integer label image.
341
+
342
+ Returns:
343
+ Sorted positive integer label values.
344
+ """
345
+ label_values = tuple(int(value) for value in np.unique(label_image) if value > 0)
346
+ if len(label_values) == 0:
347
+ msg = "ROI label image contains no positive ROI labels"
348
+ raise ValueError(msg)
349
+ return label_values
350
+
351
+
352
+ def _roi_pixel_indices(roi_set: RoiSet) -> tuple[npt.NDArray[np.int64], ...]:
353
+ """Flatten ROI masks into pixel-index arrays.
354
+
355
+ Args:
356
+ roi_set: Validated ROI set.
357
+
358
+ Returns:
359
+ Tuple with one integer index array per ROI.
360
+
361
+ Precomputing indices keeps each movie chunk extraction simple and avoids
362
+ copying whole masks repeatedly.
363
+ """
364
+ flat_masks = roi_set.masks.reshape(roi_set.masks.shape[0], -1)
365
+ return tuple(np.flatnonzero(mask).astype(np.int64) for mask in flat_masks)
366
+
367
+
368
+ def _raise_roi_movie_shape_error(roi_set: RoiSet, recording: RecordingData) -> None:
369
+ """Raise a consistent ROI/movie shape error.
370
+
371
+ Args:
372
+ roi_set: ROI masks being used for trace extraction.
373
+ recording: Converted recording with movie shape metadata.
374
+
375
+ Returns:
376
+ None.
377
+ """
378
+ msg = (
379
+ f"ROI mask shape {roi_set.masks.shape[1:]} does not match movie "
380
+ f"shape {recording.movie.shape[1:]}"
381
+ )
382
+ raise ValueError(msg)
383
+
384
+
385
+ def _extract_mean_traces_from_indices(
386
+ *,
387
+ recording: RecordingData,
388
+ pixel_indices_by_trace: tuple[npt.NDArray[np.int64], ...],
389
+ frame_start: int,
390
+ frame_stop: int,
391
+ chunk_frames: int,
392
+ ) -> npt.NDArray[np.float64]:
393
+ """Extract mean traces for arbitrary flattened pixel-index groups.
394
+
395
+ Args:
396
+ recording: Converted recording with lazy movie batches.
397
+ pixel_indices_by_trace: One flattened pixel-index array per output
398
+ trace.
399
+ frame_start: First frame to read.
400
+ frame_stop: Exclusive stop frame.
401
+ chunk_frames: Maximum number of frames per HDF5 read.
402
+
403
+ Returns:
404
+ Array shaped ``(frames, traces)``.
405
+
406
+ This shared helper is used for ROI traces and background traces. It streams
407
+ one movie chunk at a time so memory scales with ``chunk_frames`` rather than
408
+ total recording length.
409
+ """
410
+ traces = np.empty((frame_stop - frame_start, len(pixel_indices_by_trace)))
411
+ for chunk_start, chunk_stop, frames in recording.movie.iter_frame_batches(
412
+ chunk_frames=chunk_frames,
413
+ start=frame_start,
414
+ stop=frame_stop,
415
+ ):
416
+ chunk_offset = chunk_start - frame_start
417
+ chunk_slice = slice(chunk_offset, chunk_offset + (chunk_stop - chunk_start))
418
+ chunk_flat = frames.reshape(frames.shape[0], -1)
419
+ for trace_index, pixel_indices in enumerate(pixel_indices_by_trace):
420
+ traces[chunk_slice, trace_index] = chunk_flat[:, pixel_indices].mean(
421
+ axis=1,
422
+ )
423
+
424
+ return traces
425
+
426
+
427
+ def _decode_label(value: object) -> str:
428
+ """Decode one HDF5 label value into text.
429
+
430
+ Args:
431
+ value: Raw scalar read from the labels dataset.
432
+
433
+ Returns:
434
+ ROI label as a string.
435
+ """
436
+ if isinstance(value, bytes):
437
+ return value.decode("utf-8")
438
+ return str(value)
439
+
440
+
441
+ def _normalize_trace_frame_range(
442
+ *,
443
+ frame_count: int,
444
+ start_frame: int | None,
445
+ stop_frame: int | None,
446
+ ) -> tuple[int, int]:
447
+ """Normalize and validate the frame range for trace extraction.
448
+
449
+ Args:
450
+ frame_count: Number of frames in the movie.
451
+ start_frame: Optional first frame index.
452
+ stop_frame: Optional exclusive stop frame.
453
+
454
+ Returns:
455
+ ``(start, stop)`` frame bounds.
456
+ """
457
+ start = 0 if start_frame is None else start_frame
458
+ stop = frame_count if stop_frame is None else stop_frame
459
+ if start < 0 or stop > frame_count or start >= stop:
460
+ msg = f"Invalid trace frame range [{start}, {stop}) for {frame_count} frames"
461
+ raise ValueError(msg)
462
+ return start, stop
twopy/session.py ADDED
@@ -0,0 +1,167 @@
1
+ """Find the required files inside one two-photon imaging session folder.
2
+
3
+ Inputs: a session directory such as a timestamped microscope output folder.
4
+ Outputs: typed paths to the required files that twopy needs before analysis.
5
+
6
+ This module does not read large imaging data. It only checks the folder shape so
7
+ later analysis code can fail early with clear, auditable messages.
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ __all__ = ["TwoPhotonSessionFiles", "discover_session_files"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class TwoPhotonSessionFiles:
18
+ """Paths that define one two-photon imaging session.
19
+
20
+ Inputs: filesystem paths discovered under one session directory.
21
+ Outputs: a frozen record that analysis and GUI code can pass around safely.
22
+
23
+ This object exists so the rest of the code does not repeat path-glob logic
24
+ or silently depend on filenames that change with the stimulus name.
25
+ """
26
+
27
+ session_dir: Path
28
+ stimulus_data_dir: Path
29
+ aligned_movie_mat: Path
30
+ alignment_text: Path
31
+ raw_tif: Path
32
+ default_align_channel_text: Path
33
+ high_res_pd_mat: Path
34
+ image_description_mat: Path
35
+ imaging_res_pd_mat: Path
36
+ stimulus_filebackup_zip: Path
37
+ saved_analysis_dir: Path | None
38
+
39
+
40
+ def discover_session_files(session_dir: Path) -> TwoPhotonSessionFiles:
41
+ """Discover the required files for one two-photon imaging session.
42
+
43
+ Args:
44
+ session_dir: Timestamped microscope output folder to inspect.
45
+
46
+ Returns:
47
+ A typed record containing the required paths and optional
48
+ ``savedAnalysis`` directory.
49
+
50
+ Raises:
51
+ FileNotFoundError: If the session directory or a required path is
52
+ missing.
53
+ ValueError: If a flexible filename pattern, such as the raw TIFF movie,
54
+ matches zero or multiple files.
55
+
56
+ The goal is to keep downstream analysis honest: before loading any large
57
+ MATLAB or TIFF data, twopy first proves the session has the expected shape.
58
+ """
59
+ root = session_dir.expanduser()
60
+ _require_directory(root, "session directory")
61
+
62
+ # The stimulus name changes between experiments, so glob only the stable
63
+ # parts of filenames: one raw TIFF movie and one alignment text file.
64
+ raw_tif = _find_one_file(root, "*.tif", "raw TIFF movie")
65
+ alignment_text = _find_one_file(root, "*_alignment.txt", "alignment text")
66
+
67
+ saved_analysis = root / "savedAnalysis"
68
+
69
+ return TwoPhotonSessionFiles(
70
+ session_dir=root,
71
+ stimulus_data_dir=_require_directory(root / "stimulusData", "stimulus data"),
72
+ aligned_movie_mat=_require_file(root / "alignedMovie.mat", "aligned movie"),
73
+ alignment_text=alignment_text,
74
+ raw_tif=raw_tif,
75
+ default_align_channel_text=_require_file(
76
+ root / "defaultAlignChannel.txt",
77
+ "default alignment channel",
78
+ ),
79
+ high_res_pd_mat=_require_file(root / "highResPd.mat", "high-resolution PD"),
80
+ image_description_mat=_require_file(
81
+ root / "imageDescription.mat",
82
+ "image description",
83
+ ),
84
+ imaging_res_pd_mat=_require_file(
85
+ root / "imagingResPd.mat",
86
+ "imaging-resolution PD",
87
+ ),
88
+ stimulus_filebackup_zip=_require_file(
89
+ root / "stimulusData" / "filebackup.zip",
90
+ "stimulus code backup",
91
+ ),
92
+ saved_analysis_dir=saved_analysis if saved_analysis.is_dir() else None,
93
+ )
94
+
95
+
96
+ def _require_directory(path: Path, label: str) -> Path:
97
+ """Return a directory path or raise a clear missing-data error.
98
+
99
+ Args:
100
+ path: Directory path that should exist.
101
+ label: Human-readable name used in the error message.
102
+
103
+ Returns:
104
+ The same path when it exists and is a directory.
105
+
106
+ Raises:
107
+ FileNotFoundError: If the directory is absent or is not a directory.
108
+
109
+ This helper keeps validation messages consistent and easy to interpret from
110
+ the GUI.
111
+ """
112
+ if not path.is_dir():
113
+ msg = f"Missing required {label} directory: {path}"
114
+ raise FileNotFoundError(msg)
115
+ return path
116
+
117
+
118
+ def _require_file(path: Path, label: str) -> Path:
119
+ """Return a file path or raise a clear missing-data error.
120
+
121
+ Args:
122
+ path: File path that should exist.
123
+ label: Human-readable name used in the error message.
124
+
125
+ Returns:
126
+ The same path when it exists and is a file.
127
+
128
+ Raises:
129
+ FileNotFoundError: If the file is absent or is not a regular file.
130
+
131
+ This helper separates fixed filenames from globbed filenames while keeping
132
+ the same error style for both.
133
+ """
134
+ if not path.is_file():
135
+ msg = f"Missing required {label} file: {path}"
136
+ raise FileNotFoundError(msg)
137
+ return path
138
+
139
+
140
+ def _find_one_file(session_dir: Path, pattern: str, label: str) -> Path:
141
+ """Find exactly one required file that has a stimulus-dependent name.
142
+
143
+ Args:
144
+ session_dir: Session directory to search.
145
+ pattern: Glob pattern that captures the stable part of the filename.
146
+ label: Human-readable name used in the error message.
147
+
148
+ Returns:
149
+ The single matching file path.
150
+
151
+ Raises:
152
+ ValueError: If the pattern finds no files or more than one file.
153
+
154
+ The raw movie and alignment text include stimulus/run details in their
155
+ names. A strict one-match check avoids guessing when the folder is messy.
156
+ """
157
+ matches = sorted(path for path in session_dir.glob(pattern) if path.is_file())
158
+
159
+ if len(matches) != 1:
160
+ joined = ", ".join(str(path) for path in matches) if matches else "none"
161
+ msg = (
162
+ f"Expected exactly one {label} matching {pattern!r} in "
163
+ f"{session_dir}; found {len(matches)}: {joined}"
164
+ )
165
+ raise ValueError(msg)
166
+
167
+ return matches[0]