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/converted.py ADDED
@@ -0,0 +1,681 @@
1
+ """Load twopy-converted HDF5 recordings for analysis.
2
+
3
+ Inputs: ``recording_data.h5`` plus its referenced ``aligned_movie.h5`` file.
4
+ Outputs: typed Python objects with metadata, stimulus data, photodiode signals,
5
+ and a lazy movie reader.
6
+
7
+ Analysis code should start here after source conversion. This module does not
8
+ read MATLAB or raw TIFF files.
9
+ """
10
+
11
+ import json
12
+ from collections.abc import Iterator
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import cast
16
+
17
+ import h5py
18
+ import numpy as np
19
+ import numpy.typing as npt
20
+
21
+ from twopy.conversion.types import FrameCountAudit
22
+ from twopy.spatial import SpatialCrop, full_frame_crop
23
+ from twopy.typing_guards import (
24
+ require_bool_array,
25
+ require_float64_array,
26
+ require_string_key_mapping,
27
+ require_string_key_mapping_values,
28
+ )
29
+
30
+ __all__ = [
31
+ "ConvertedMovie",
32
+ "RecordingData",
33
+ "load_converted_recording",
34
+ ]
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ConvertedMovie:
39
+ """Lazy reader for the converted aligned movie.
40
+
41
+ Inputs: ``aligned_movie.h5`` path, dataset name, shape, and dtype.
42
+ Outputs: frame-range reads or frame batches from the converted movie.
43
+
44
+ The aligned movie is usually the largest file in a recording. This object
45
+ keeps analysis code from loading the full movie unless the caller asks for
46
+ it explicitly.
47
+ """
48
+
49
+ path: Path
50
+ dataset_name: str
51
+ shape: tuple[int, int, int]
52
+ dtype: str
53
+
54
+ def read_frames(
55
+ self,
56
+ start: int | None = None,
57
+ stop: int | None = None,
58
+ *,
59
+ spatial_crop: SpatialCrop | None = None,
60
+ ) -> npt.NDArray[np.float64]:
61
+ """Read a frame range from the converted aligned movie.
62
+
63
+ Args:
64
+ start: First frame index. ``None`` starts at frame zero.
65
+ stop: Exclusive stop frame. ``None`` reads through the final frame.
66
+ spatial_crop: Optional crop to read from each frame.
67
+
68
+ Returns:
69
+ Movie block with shape ``(frames, axis0, axis1)``.
70
+
71
+ This is the direct escape hatch for previews, tests, and small frame
72
+ ranges. Trace extraction should prefer ``iter_frame_batches``.
73
+ """
74
+ frame_slice = slice(start, stop)
75
+ crop = _normalize_spatial_crop(self.shape[1:], spatial_crop)
76
+ with h5py.File(self.path, "r") as h5_file:
77
+ dataset = h5_file[self.dataset_name]
78
+ return require_float64_array(
79
+ dataset[
80
+ frame_slice,
81
+ crop.axis0_start : crop.axis0_stop,
82
+ crop.axis1_start : crop.axis1_stop,
83
+ ],
84
+ name=f"{self.dataset_name} frame slice",
85
+ ndim=3,
86
+ )
87
+
88
+ def iter_frame_batches(
89
+ self,
90
+ *,
91
+ chunk_frames: int = 128,
92
+ start: int | None = None,
93
+ stop: int | None = None,
94
+ spatial_crop: SpatialCrop | None = None,
95
+ ) -> Iterator[tuple[int, int, npt.NDArray[np.float64]]]:
96
+ """Read movie frames in bounded chunks.
97
+
98
+ Args:
99
+ chunk_frames: Maximum number of frames per returned block.
100
+ start: First frame index. ``None`` starts at frame zero.
101
+ stop: Exclusive stop frame. ``None`` reads through the final frame.
102
+ spatial_crop: Optional crop to read from each frame.
103
+
104
+ Yields:
105
+ ``(start, stop, frames)`` blocks.
106
+
107
+ The iterator streams from disk and keeps memory bounded by the chunk
108
+ size. This is the intended access pattern for trace extraction.
109
+ """
110
+ start_frame, stop_frame = _normalize_movie_frame_range(
111
+ frame_count=self.shape[0],
112
+ start=start,
113
+ stop=stop,
114
+ )
115
+ if chunk_frames < 1:
116
+ msg = f"chunk_frames must be at least 1; got {chunk_frames}"
117
+ raise ValueError(msg)
118
+ crop = _normalize_spatial_crop(self.shape[1:], spatial_crop)
119
+
120
+ with h5py.File(self.path, "r") as h5_file:
121
+ dataset = h5_file[self.dataset_name]
122
+ for chunk_start in range(start_frame, stop_frame, chunk_frames):
123
+ chunk_stop = min(chunk_start + chunk_frames, stop_frame)
124
+ frames = require_float64_array(
125
+ dataset[
126
+ chunk_start:chunk_stop,
127
+ crop.axis0_start : crop.axis0_stop,
128
+ crop.axis1_start : crop.axis1_stop,
129
+ ],
130
+ name=f"{self.dataset_name} frame batch",
131
+ ndim=3,
132
+ )
133
+ yield chunk_start, chunk_stop, frames
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class RecordingData:
138
+ """Loaded non-source recording data ready for analysis.
139
+
140
+ Inputs: twopy-converted HDF5 files.
141
+ Outputs: metadata dictionaries, stimulus arrays with column names,
142
+ photodiode vectors, mean image, frame-count audit values, and a lazy movie
143
+ reader.
144
+
145
+ This object is the analysis-facing representation of one converted
146
+ recording. It deliberately excludes source MATLAB/TIFF ownership.
147
+ """
148
+
149
+ path: Path
150
+ movie: ConvertedMovie
151
+ source_session_dir: Path
152
+ acquisition_metadata: dict[str, object]
153
+ run_metadata: dict[str, object]
154
+ synchronization_metadata: dict[str, str]
155
+ stimulus_data: npt.NDArray[np.float64]
156
+ stimulus_data_column_names: tuple[str, ...]
157
+ stimulus_parameters: tuple[dict[str, object], ...]
158
+ stimulus_function_lookup: dict[str, str]
159
+ stimulus_specific_columns: dict[str, dict[str, object]]
160
+ imaging_res_pd: npt.NDArray[np.float64]
161
+ high_res_pd: npt.NDArray[np.float64]
162
+ mean_image: npt.NDArray[np.float64]
163
+ alignment_valid_crop: SpatialCrop
164
+ alignment_shift_pixels: npt.NDArray[np.float64]
165
+ motion_artifact_mask: npt.NDArray[np.bool_]
166
+ frame_counts: FrameCountAudit
167
+
168
+
169
+ def load_converted_recording(
170
+ recording_data_path: Path,
171
+ *,
172
+ movie_path: Path | None = None,
173
+ ) -> RecordingData:
174
+ """Load one twopy-converted recording from HDF5.
175
+
176
+ Args:
177
+ recording_data_path: Path to ``recording_data.h5``.
178
+ movie_path: Optional explicit path to ``aligned_movie.h5``. When omitted,
179
+ the path is read from the HDF5 movie reference.
180
+
181
+ Returns:
182
+ A ``RecordingData`` object with small arrays loaded and movie frames kept
183
+ lazy.
184
+
185
+ Raises:
186
+ ValueError: If required HDF5 groups, attributes, or frame contracts are
187
+ inconsistent.
188
+
189
+ The main HDF5 file is treated as the manifest. It names the separate movie
190
+ file so scripts can usually pass only ``recording_data.h5``.
191
+ """
192
+ data_path = recording_data_path.expanduser()
193
+ with h5py.File(data_path, "r") as h5_file:
194
+ _require_format(h5_file, expected="converted-recording", path=data_path)
195
+ resolved_movie_path = _resolve_movie_path(
196
+ h5_file=h5_file,
197
+ recording_data_path=data_path,
198
+ explicit_movie_path=movie_path,
199
+ )
200
+ movie_dataset = _read_str_attr(h5_file["movie"], "aligned_movie_dataset")
201
+ movie_shape = _read_int_tuple_attr(h5_file["movie"], "aligned_movie_shape")
202
+ movie_dtype = _read_str_attr(h5_file["movie"], "aligned_movie_dtype")
203
+ if len(movie_shape) != 3:
204
+ msg = f"aligned movie shape must have 3 dimensions: {movie_shape}"
205
+ raise ValueError(msg)
206
+ movie_shape_3d = cast(tuple[int, int, int], movie_shape)
207
+
208
+ recording = RecordingData(
209
+ path=data_path,
210
+ movie=ConvertedMovie(
211
+ path=resolved_movie_path,
212
+ dataset_name=movie_dataset,
213
+ shape=movie_shape_3d,
214
+ dtype=movie_dtype,
215
+ ),
216
+ source_session_dir=Path(
217
+ _read_str_attr(h5_file, "source_session_dir"),
218
+ ),
219
+ acquisition_metadata=_read_attrs(h5_file["metadata"]),
220
+ run_metadata=_read_attrs(h5_file["run"]),
221
+ synchronization_metadata=_read_str_attrs(h5_file["photodiode"]),
222
+ stimulus_data=require_float64_array(
223
+ h5_file["stimulus/data"][()],
224
+ name="stimulus/data",
225
+ ndim=2,
226
+ ),
227
+ stimulus_data_column_names=_read_string_dataset(
228
+ h5_file,
229
+ "stimulus/data_column_names",
230
+ ),
231
+ stimulus_parameters=_read_stimulus_parameters(h5_file),
232
+ stimulus_function_lookup=_read_stimulus_function_lookup(h5_file),
233
+ stimulus_specific_columns=_read_stimulus_specific_columns(h5_file),
234
+ imaging_res_pd=require_float64_array(
235
+ h5_file["photodiode/imaging_res_pd"][()],
236
+ name="photodiode/imaging_res_pd",
237
+ ndim=1,
238
+ ),
239
+ high_res_pd=require_float64_array(
240
+ h5_file["photodiode/high_res_pd"][()],
241
+ name="photodiode/high_res_pd",
242
+ ndim=1,
243
+ ),
244
+ mean_image=require_float64_array(
245
+ h5_file["movie/mean_image"][()],
246
+ name="movie/mean_image",
247
+ ndim=2,
248
+ ),
249
+ alignment_valid_crop=_read_alignment_valid_crop(h5_file),
250
+ alignment_shift_pixels=require_float64_array(
251
+ h5_file["movie/alignment_valid_crop/alignment_shift_pixels"][()],
252
+ name="movie/alignment_valid_crop/alignment_shift_pixels",
253
+ ndim=1,
254
+ ),
255
+ motion_artifact_mask=require_bool_array(
256
+ h5_file["movie/alignment_valid_crop/motion_artifact_mask"][()],
257
+ name="movie/alignment_valid_crop/motion_artifact_mask",
258
+ ndim=1,
259
+ ),
260
+ frame_counts=_read_frame_counts(h5_file),
261
+ )
262
+
263
+ _validate_loaded_recording(recording)
264
+ return recording
265
+
266
+
267
+ def _resolve_movie_path(
268
+ *,
269
+ h5_file: h5py.File,
270
+ recording_data_path: Path,
271
+ explicit_movie_path: Path | None,
272
+ ) -> Path:
273
+ """Resolve the aligned movie file path for one recording.
274
+
275
+ Args:
276
+ h5_file: Open ``recording_data.h5`` file.
277
+ recording_data_path: Path to the manifest file.
278
+ explicit_movie_path: Caller-provided movie path, if any.
279
+
280
+ Returns:
281
+ Expanded movie file path.
282
+ """
283
+ if explicit_movie_path is not None:
284
+ return explicit_movie_path.expanduser()
285
+
286
+ movie_name = _read_str_attr(h5_file["movie"], "aligned_movie_file")
287
+ return recording_data_path.parent / movie_name
288
+
289
+
290
+ def _validate_loaded_recording(recording: RecordingData) -> None:
291
+ """Check the converted files agree on the core frame contract.
292
+
293
+ Args:
294
+ recording: Loaded recording to validate.
295
+
296
+ Returns:
297
+ None.
298
+
299
+ Raises:
300
+ ValueError: If movie file metadata or photodiode samples disagree.
301
+
302
+ This keeps frame alignment failures close to loading instead of letting them
303
+ surface later as incorrect ROI responses.
304
+ """
305
+ if not recording.movie.path.is_file():
306
+ msg = f"Missing converted aligned movie file: {recording.movie.path}"
307
+ raise ValueError(msg)
308
+
309
+ with h5py.File(recording.movie.path, "r") as h5_file:
310
+ _require_format(h5_file, expected="aligned-movie", path=recording.movie.path)
311
+ if recording.movie.dataset_name not in h5_file:
312
+ msg = (
313
+ f"Missing aligned movie dataset {recording.movie.dataset_name!r} "
314
+ f"in {recording.movie.path}"
315
+ )
316
+ raise ValueError(msg)
317
+ dataset = h5_file[recording.movie.dataset_name]
318
+ if tuple(dataset.shape) != recording.movie.shape:
319
+ msg = (
320
+ f"Movie shape mismatch: manifest has {recording.movie.shape}, "
321
+ f"movie file has {tuple(dataset.shape)}"
322
+ )
323
+ raise ValueError(msg)
324
+
325
+ frame_count = recording.movie.shape[0]
326
+ if recording.imaging_res_pd.shape != (frame_count,):
327
+ msg = (
328
+ "imaging_res_pd must have one sample per aligned movie frame; "
329
+ f"got {recording.imaging_res_pd.shape} for {frame_count} frames"
330
+ )
331
+ raise ValueError(msg)
332
+ if len(recording.stimulus_data_column_names) != recording.stimulus_data.shape[1]:
333
+ msg = (
334
+ "stimulus data column names must match data width; "
335
+ f"got {len(recording.stimulus_data_column_names)} names for "
336
+ f"{recording.stimulus_data.shape[1]} columns"
337
+ )
338
+ raise ValueError(msg)
339
+ if recording.alignment_valid_crop.original_shape != recording.movie.shape[1:]:
340
+ msg = (
341
+ "alignment_valid_crop original shape must match movie spatial shape; "
342
+ f"got {recording.alignment_valid_crop.original_shape} and "
343
+ f"{recording.movie.shape[1:]}"
344
+ )
345
+ raise ValueError(msg)
346
+ if recording.alignment_shift_pixels.shape != (frame_count,):
347
+ msg = (
348
+ "alignment_shift_pixels must have one value per movie frame; "
349
+ f"got {recording.alignment_shift_pixels.shape} for {frame_count} frames"
350
+ )
351
+ raise ValueError(msg)
352
+ if recording.motion_artifact_mask.shape != (frame_count,):
353
+ msg = (
354
+ "motion_artifact_mask must have one value per movie frame; "
355
+ f"got {recording.motion_artifact_mask.shape} for {frame_count} frames"
356
+ )
357
+ raise ValueError(msg)
358
+
359
+
360
+ def _require_format(h5_file: h5py.File, *, expected: str, path: Path) -> None:
361
+ """Validate a twopy HDF5 file format marker.
362
+
363
+ Args:
364
+ h5_file: Open HDF5 file.
365
+ expected: Expected ``twopy_format`` attribute.
366
+ path: File path used in clear error messages.
367
+
368
+ Returns:
369
+ None.
370
+ """
371
+ actual = _read_str_attr(h5_file, "twopy_format")
372
+ if actual != expected:
373
+ msg = f"Expected {expected!r} HDF5 file at {path}; got {actual!r}"
374
+ raise ValueError(msg)
375
+
376
+
377
+ def _read_attrs(group: h5py.Group | h5py.File) -> dict[str, object]:
378
+ """Read HDF5 attributes into plain Python values.
379
+
380
+ Args:
381
+ group: HDF5 object with attributes.
382
+
383
+ Returns:
384
+ Dictionary keyed by attribute name.
385
+ """
386
+ return {key: _plain_attr_value(value) for key, value in group.attrs.items()}
387
+
388
+
389
+ def _read_str_attrs(group: h5py.Group | h5py.File) -> dict[str, str]:
390
+ """Read HDF5 attributes that should all be text.
391
+
392
+ Args:
393
+ group: HDF5 object with attributes.
394
+
395
+ Returns:
396
+ Dictionary of string attributes.
397
+ """
398
+ return {key: str(_plain_attr_value(value)) for key, value in group.attrs.items()}
399
+
400
+
401
+ def _read_str_attr(group: h5py.Group | h5py.File, key: str) -> str:
402
+ """Read one required string HDF5 attribute.
403
+
404
+ Args:
405
+ group: HDF5 object with attributes.
406
+ key: Attribute name.
407
+
408
+ Returns:
409
+ Attribute value as text.
410
+ """
411
+ if key not in group.attrs:
412
+ msg = f"Missing required HDF5 attribute {key!r}"
413
+ raise ValueError(msg)
414
+ value = _plain_attr_value(group.attrs[key])
415
+ return str(value)
416
+
417
+
418
+ def _read_int_attr(group: h5py.Group | h5py.File, key: str) -> int:
419
+ """Read one required integer HDF5 attribute.
420
+
421
+ Args:
422
+ group: HDF5 object with attributes.
423
+ key: Attribute name.
424
+
425
+ Returns:
426
+ Attribute value as an integer.
427
+ """
428
+ if key not in group.attrs:
429
+ msg = f"Missing required HDF5 attribute {key!r}"
430
+ raise ValueError(msg)
431
+ return int(group.attrs[key])
432
+
433
+
434
+ def _read_int_tuple_attr(group: h5py.Group | h5py.File, key: str) -> tuple[int, ...]:
435
+ """Read one integer tuple HDF5 attribute.
436
+
437
+ Args:
438
+ group: HDF5 object with attributes.
439
+ key: Attribute name.
440
+
441
+ Returns:
442
+ Tuple of integer values.
443
+ """
444
+ if key not in group.attrs:
445
+ msg = f"Missing required HDF5 attribute {key!r}"
446
+ raise ValueError(msg)
447
+ value = group.attrs[key]
448
+ if isinstance(value, np.ndarray):
449
+ return tuple(int(item) for item in value.tolist())
450
+ return (int(value),)
451
+
452
+
453
+ def _plain_attr_value(value: object) -> object:
454
+ """Convert a small HDF5 attribute value into plain Python data.
455
+
456
+ Args:
457
+ value: Raw h5py attribute value.
458
+
459
+ Returns:
460
+ Python scalar, string, or list.
461
+ """
462
+ if isinstance(value, bytes):
463
+ return value.decode("utf-8")
464
+ if isinstance(value, np.generic):
465
+ return value.item()
466
+ if isinstance(value, np.ndarray):
467
+ return np.asarray(value, dtype=object).tolist()
468
+ return value
469
+
470
+
471
+ def _read_stimulus_parameters(
472
+ h5_file: h5py.File,
473
+ ) -> tuple[dict[str, object], ...]:
474
+ """Read converted stimulus epoch parameter dictionaries.
475
+
476
+ Args:
477
+ h5_file: Open ``recording_data.h5`` file.
478
+
479
+ Returns:
480
+ Tuple of per-epoch dictionaries.
481
+ """
482
+ decoded = _read_json_dataset(h5_file, "stimulus/parameters_json")
483
+ if not isinstance(decoded, list):
484
+ msg = "stimulus/parameters_json must decode to a list"
485
+ raise ValueError(msg)
486
+
487
+ parameters: list[dict[str, object]] = []
488
+ for item in decoded:
489
+ parameters.append(
490
+ require_string_key_mapping(
491
+ item,
492
+ name="each stimulus parameter entry",
493
+ )
494
+ )
495
+
496
+ return tuple(parameters)
497
+
498
+
499
+ def _read_stimulus_function_lookup(h5_file: h5py.File) -> dict[str, str]:
500
+ """Read converted ``stimtype`` to MATLAB function-name mapping.
501
+
502
+ Args:
503
+ h5_file: Open ``recording_data.h5`` file.
504
+
505
+ Returns:
506
+ Dictionary mapping ``stimtype`` text to function name.
507
+ """
508
+ decoded = _read_json_dataset(h5_file, "stimulus/function_lookup_json")
509
+ if not isinstance(decoded, dict):
510
+ msg = "stimulus/function_lookup_json must decode to a dictionary"
511
+ raise ValueError(msg)
512
+ return {str(key): str(value) for key, value in decoded.items()}
513
+
514
+
515
+ def _read_stimulus_specific_columns(
516
+ h5_file: h5py.File,
517
+ ) -> dict[str, dict[str, object]]:
518
+ """Read per-``stimtype`` stimulus-specific column metadata.
519
+
520
+ Args:
521
+ h5_file: Open ``recording_data.h5`` file.
522
+
523
+ Returns:
524
+ Dictionary keyed by ``stimtype`` text.
525
+ """
526
+ decoded = _read_json_dataset(h5_file, "stimulus/stimulus_specific_columns_json")
527
+ if not isinstance(decoded, dict):
528
+ msg = "stimulus/stimulus_specific_columns_json must decode to a dictionary"
529
+ raise ValueError(msg)
530
+ return require_string_key_mapping_values(
531
+ decoded,
532
+ name="stimulus/stimulus_specific_columns_json",
533
+ )
534
+
535
+
536
+ def _read_json_dataset(h5_file: h5py.File, dataset_name: str) -> object:
537
+ """Read one scalar JSON HDF5 dataset.
538
+
539
+ Args:
540
+ h5_file: Open ``recording_data.h5`` file.
541
+ dataset_name: Dataset path.
542
+
543
+ Returns:
544
+ Decoded JSON object.
545
+ """
546
+ raw_json = h5_file[dataset_name][()]
547
+ if isinstance(raw_json, bytes):
548
+ json_text = raw_json.decode("utf-8")
549
+ else:
550
+ json_text = str(raw_json)
551
+ return json.loads(json_text)
552
+
553
+
554
+ def _read_string_dataset(h5_file: h5py.File, dataset_name: str) -> tuple[str, ...]:
555
+ """Read a one-dimensional HDF5 string dataset.
556
+
557
+ Args:
558
+ h5_file: Open ``recording_data.h5`` file.
559
+ dataset_name: Dataset path.
560
+
561
+ Returns:
562
+ Tuple of decoded strings.
563
+ """
564
+ values = h5_file[dataset_name][()]
565
+ return tuple(_decode_string_value(value) for value in values)
566
+
567
+
568
+ def _decode_string_value(value: object) -> str:
569
+ """Decode one HDF5 string scalar.
570
+
571
+ Args:
572
+ value: Raw scalar value from h5py.
573
+
574
+ Returns:
575
+ Text value.
576
+ """
577
+ if isinstance(value, bytes):
578
+ return value.decode("utf-8")
579
+ return str(value)
580
+
581
+
582
+ def _read_frame_counts(h5_file: h5py.File) -> FrameCountAudit:
583
+ """Read frame-count audit metadata from converted HDF5.
584
+
585
+ Args:
586
+ h5_file: Open ``recording_data.h5`` file.
587
+
588
+ Returns:
589
+ ``FrameCountAudit`` object with all saved counts and deltas.
590
+ """
591
+ group = h5_file["frame_counts"]
592
+ return FrameCountAudit(
593
+ aligned_movie_frames=_read_int_attr(group, "aligned_movie_frames"),
594
+ imaging_res_pd_samples=_read_int_attr(group, "imaging_res_pd_samples"),
595
+ acquisition_number_of_frames=_read_int_attr(
596
+ group,
597
+ "acquisition_number_of_frames",
598
+ ),
599
+ imaging_res_pd_minus_movie=_read_int_attr(
600
+ group,
601
+ "imaging_res_pd_minus_movie",
602
+ ),
603
+ acquisition_minus_movie=_read_int_attr(group, "acquisition_minus_movie"),
604
+ )
605
+
606
+
607
+ def _read_alignment_valid_crop(h5_file: h5py.File) -> SpatialCrop:
608
+ """Read the alignment-valid crop from converted HDF5.
609
+
610
+ Args:
611
+ h5_file: Open ``recording_data.h5`` file.
612
+
613
+ Returns:
614
+ ``SpatialCrop`` describing the saved alignment-valid frame area.
615
+ """
616
+ if "movie/alignment_valid_crop" not in h5_file:
617
+ msg = "Missing required HDF5 group 'movie/alignment_valid_crop'"
618
+ raise ValueError(msg)
619
+ group = h5_file["movie/alignment_valid_crop"]
620
+ return SpatialCrop(
621
+ axis0_start=_read_int_attr(group, "axis0_start"),
622
+ axis0_stop=_read_int_attr(group, "axis0_stop"),
623
+ axis1_start=_read_int_attr(group, "axis1_start"),
624
+ axis1_stop=_read_int_attr(group, "axis1_stop"),
625
+ original_shape=cast(
626
+ tuple[int, int],
627
+ _read_int_tuple_attr(group, "original_shape"),
628
+ ),
629
+ source=_read_str_attr(group, "source"),
630
+ )
631
+
632
+
633
+ def _normalize_spatial_crop(
634
+ spatial_shape: tuple[int, int],
635
+ spatial_crop: SpatialCrop | None,
636
+ ) -> SpatialCrop:
637
+ """Return a validated crop for movie reads.
638
+
639
+ Args:
640
+ spatial_shape: Full movie spatial shape.
641
+ spatial_crop: Optional crop requested by caller.
642
+
643
+ Returns:
644
+ ``SpatialCrop`` safe to use for HDF5 slicing.
645
+ """
646
+ if spatial_crop is None:
647
+ return full_frame_crop(spatial_shape)
648
+ if spatial_crop.original_shape != spatial_shape:
649
+ msg = (
650
+ f"Spatial crop source shape {spatial_crop.original_shape} does not "
651
+ f"match movie spatial shape {spatial_shape}"
652
+ )
653
+ raise ValueError(msg)
654
+ return spatial_crop
655
+
656
+
657
+ def _normalize_movie_frame_range(
658
+ *,
659
+ frame_count: int,
660
+ start: int | None,
661
+ stop: int | None,
662
+ ) -> tuple[int, int]:
663
+ """Normalize and validate a converted movie frame range.
664
+
665
+ Args:
666
+ frame_count: Total number of frames in the movie.
667
+ start: Optional first frame index.
668
+ stop: Optional exclusive stop frame.
669
+
670
+ Returns:
671
+ ``(start, stop)`` frame bounds.
672
+ """
673
+ start_frame = 0 if start is None else start
674
+ stop_frame = frame_count if stop is None else stop
675
+ if start_frame < 0 or stop_frame > frame_count or start_frame >= stop_frame:
676
+ msg = (
677
+ f"Invalid frame range [{start_frame}, {stop_frame}) for "
678
+ f"{frame_count} frames"
679
+ )
680
+ raise ValueError(msg)
681
+ return start_frame, stop_frame
@@ -0,0 +1,33 @@
1
+ """Query Clark lab experiment SQLite databases.
2
+
3
+ Inputs: a DB folder. Outputs: typed table metadata and experiment records.
4
+
5
+ The package keeps public database helpers in one import location while splitting
6
+ catalog discovery, generic table search, and modeled recording search into
7
+ focused modules.
8
+ """
9
+
10
+ from twopy.database.catalog import read_database_catalog
11
+ from twopy.database.generic import find_experiments
12
+ from twopy.database.modeled import find_recordings, find_stimulus_presentations
13
+ from twopy.database.types import (
14
+ DatabaseAccess,
15
+ DatabaseCatalog,
16
+ DatabaseColumn,
17
+ DatabaseExperiment,
18
+ DatabaseRecord,
19
+ DatabaseTable,
20
+ )
21
+
22
+ __all__ = [
23
+ "DatabaseAccess",
24
+ "DatabaseCatalog",
25
+ "DatabaseColumn",
26
+ "DatabaseExperiment",
27
+ "DatabaseRecord",
28
+ "DatabaseTable",
29
+ "find_experiments",
30
+ "find_recordings",
31
+ "find_stimulus_presentations",
32
+ "read_database_catalog",
33
+ ]