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.
- twopy/__init__.py +168 -0
- twopy/_segments.py +53 -0
- twopy/analysis/__init__.py +101 -0
- twopy/analysis/background_subtraction.py +672 -0
- twopy/analysis/dff.py +524 -0
- twopy/analysis/epoch_mapping.py +223 -0
- twopy/analysis/motion.py +66 -0
- twopy/analysis/persistence.py +975 -0
- twopy/analysis/response_processing/__init__.py +55 -0
- twopy/analysis/response_processing/apply.py +298 -0
- twopy/analysis/response_processing/options.py +192 -0
- twopy/analysis/response_processing/qc.py +270 -0
- twopy/analysis/response_processing/signals.py +200 -0
- twopy/analysis/responses.py +345 -0
- twopy/analysis/trials.py +341 -0
- twopy/analysis/workflow.py +457 -0
- twopy/api.py +78 -0
- twopy/config.py +200 -0
- twopy/conversion/__init__.py +40 -0
- twopy/conversion/alignment_crop.py +329 -0
- twopy/conversion/api.py +113 -0
- twopy/conversion/frame_ranges.py +35 -0
- twopy/conversion/hdf5_writing.py +362 -0
- twopy/conversion/matlab_values.py +123 -0
- twopy/conversion/source_loading.py +403 -0
- twopy/conversion/stimulus_code.py +248 -0
- twopy/conversion/types.py +295 -0
- twopy/converted.py +681 -0
- twopy/database/__init__.py +33 -0
- twopy/database/cache.py +166 -0
- twopy/database/catalog.py +90 -0
- twopy/database/connection.py +27 -0
- twopy/database/generic.py +254 -0
- twopy/database/modeled.py +419 -0
- twopy/database/sql.py +46 -0
- twopy/database/types.py +116 -0
- twopy/inspection.py +492 -0
- twopy/matlab.py +398 -0
- twopy/napari/__init__.py +27 -0
- twopy/napari/__main__.py +10 -0
- twopy/napari/constants.py +13 -0
- twopy/napari/controls.py +739 -0
- twopy/napari/display.py +181 -0
- twopy/napari/display_paths.py +197 -0
- twopy/napari/interactive.py +310 -0
- twopy/napari/launcher.py +175 -0
- twopy/napari/layout.py +147 -0
- twopy/napari/loading.py +207 -0
- twopy/napari/movie.py +71 -0
- twopy/napari/paths.py +181 -0
- twopy/napari/plotting/__init__.py +21 -0
- twopy/napari/plotting/data.py +266 -0
- twopy/napari/plotting/docks.py +1052 -0
- twopy/napari/plotting/export.py +803 -0
- twopy/napari/plotting/export_controls.py +209 -0
- twopy/napari/plotting/label_visibility.py +260 -0
- twopy/napari/plotting/options.py +269 -0
- twopy/napari/plotting/panels.py +119 -0
- twopy/napari/plotting/widgets.py +767 -0
- twopy/napari/protocols.py +90 -0
- twopy/napari/responses.py +91 -0
- twopy/napari/roi.py +238 -0
- twopy/napari/session.py +381 -0
- twopy/napari/state.py +106 -0
- twopy/napari/text.py +26 -0
- twopy/napari/types.py +39 -0
- twopy/napari/viewer.py +405 -0
- twopy/parity/__init__.py +30 -0
- twopy/parity/dff.py +399 -0
- twopy/parity/roi.py +145 -0
- twopy/parity/saved_analysis.py +174 -0
- twopy/photodiode.py +375 -0
- twopy/photodiode_classification.py +484 -0
- twopy/roi.py +462 -0
- twopy/session.py +167 -0
- twopy/spatial.py +154 -0
- twopy/stimulus.py +233 -0
- twopy/synchronization.py +235 -0
- twopy/typing_guards.py +255 -0
- twopy-0.1.0.dist-info/METADATA +311 -0
- twopy-0.1.0.dist-info/RECORD +84 -0
- twopy-0.1.0.dist-info/WHEEL +5 -0
- twopy-0.1.0.dist-info/entry_points.txt +2 -0
- twopy-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Load microscope source files into typed conversion objects.
|
|
2
|
+
|
|
3
|
+
Inputs: one timestamped two-photon recording directory.
|
|
4
|
+
Outputs: Python objects used only to write converted twopy HDF5 files.
|
|
5
|
+
|
|
6
|
+
This module is the read-only boundary with MATLAB source data.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import h5py
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from twopy.conversion.alignment_crop import load_alignment_valid_crop
|
|
15
|
+
from twopy.conversion.matlab_values import (
|
|
16
|
+
load_scipy_variable,
|
|
17
|
+
matlab_struct_to_dict,
|
|
18
|
+
nested_matlab_field,
|
|
19
|
+
)
|
|
20
|
+
from twopy.conversion.stimulus_code import load_stimulus_code_metadata
|
|
21
|
+
from twopy.conversion.types import (
|
|
22
|
+
AcquisitionMetadata,
|
|
23
|
+
AlignedMovieSource,
|
|
24
|
+
FrameCountAudit,
|
|
25
|
+
PhotodiodeSignals,
|
|
26
|
+
RunMetadata,
|
|
27
|
+
SourceConversionInputs,
|
|
28
|
+
StimulusData,
|
|
29
|
+
StimulusParameters,
|
|
30
|
+
)
|
|
31
|
+
from twopy.session import discover_session_files
|
|
32
|
+
|
|
33
|
+
__all__ = ["load_source_conversion_inputs"]
|
|
34
|
+
|
|
35
|
+
STABLE_STIMULUS_DATA_COLUMNS = (
|
|
36
|
+
"time_seconds",
|
|
37
|
+
"stimulus_frame_number",
|
|
38
|
+
"epoch_number",
|
|
39
|
+
"closed_loop_01",
|
|
40
|
+
"closed_loop_02",
|
|
41
|
+
"closed_loop_03",
|
|
42
|
+
"closed_loop_04",
|
|
43
|
+
"closed_loop_05",
|
|
44
|
+
"closed_loop_06",
|
|
45
|
+
"closed_loop_07",
|
|
46
|
+
"closed_loop_08",
|
|
47
|
+
"closed_loop_09",
|
|
48
|
+
"closed_loop_10",
|
|
49
|
+
"stimulus_specific_01",
|
|
50
|
+
"stimulus_specific_02",
|
|
51
|
+
"stimulus_specific_03",
|
|
52
|
+
"stimulus_specific_04",
|
|
53
|
+
"stimulus_specific_05",
|
|
54
|
+
"stimulus_specific_06",
|
|
55
|
+
"stimulus_specific_07",
|
|
56
|
+
"stimulus_specific_08",
|
|
57
|
+
"stimulus_specific_09",
|
|
58
|
+
"stimulus_specific_10",
|
|
59
|
+
"stimulus_specific_11",
|
|
60
|
+
"stimulus_specific_12",
|
|
61
|
+
"stimulus_specific_13",
|
|
62
|
+
"stimulus_specific_14",
|
|
63
|
+
"stimulus_specific_15",
|
|
64
|
+
"stimulus_specific_16",
|
|
65
|
+
"stimulus_specific_17",
|
|
66
|
+
"stimulus_specific_18",
|
|
67
|
+
"stimulus_specific_19",
|
|
68
|
+
"stimulus_specific_20",
|
|
69
|
+
"photodiode_flash",
|
|
70
|
+
"trailing_empty",
|
|
71
|
+
)
|
|
72
|
+
ACQUISITION_METADATA_FIELDS = (
|
|
73
|
+
"configName",
|
|
74
|
+
"software.version",
|
|
75
|
+
"acq.linesPerFrame",
|
|
76
|
+
"acq.pixelsPerLine",
|
|
77
|
+
"acq.numberOfFrames",
|
|
78
|
+
"acq.numberOfChannelsSave",
|
|
79
|
+
"acq.frameRate",
|
|
80
|
+
"acq.zoomFactor",
|
|
81
|
+
"acq.pixelTime",
|
|
82
|
+
"acq.msPerLine",
|
|
83
|
+
"acq.zStepSize",
|
|
84
|
+
"acq.scanAngleMultiplierFast",
|
|
85
|
+
"acq.scanAngleMultiplierSlow",
|
|
86
|
+
"motor.absXPosition",
|
|
87
|
+
"motor.absYPosition",
|
|
88
|
+
"motor.absZPosition",
|
|
89
|
+
)
|
|
90
|
+
RUN_METADATA_FIELDS = (
|
|
91
|
+
("flyId", "fly_id"),
|
|
92
|
+
("genotype", "genotype"),
|
|
93
|
+
("rigName", "rig_name"),
|
|
94
|
+
("rigTemperature", "rig_temperature"),
|
|
95
|
+
("runNumber", "run_number"),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_source_conversion_inputs(session_dir: Path) -> SourceConversionInputs:
|
|
100
|
+
"""Load the source inputs needed for conversion into twopy format.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
session_dir: Timestamped two-photon recording directory.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
A typed source-input bundle for conversion only.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
FileNotFoundError: If the session or required files are missing.
|
|
110
|
+
KeyError: If an expected MATLAB variable or HDF5 dataset is absent.
|
|
111
|
+
ValueError: If loaded tables or frame counts have unexpected shapes.
|
|
112
|
+
"""
|
|
113
|
+
files = discover_session_files(session_dir)
|
|
114
|
+
aligned_movie = _load_aligned_movie_source(files.aligned_movie_mat)
|
|
115
|
+
acquisition = _load_acquisition_metadata(files.image_description_mat)
|
|
116
|
+
photodiode = _load_photodiode_signals(
|
|
117
|
+
imaging_res_path=files.imaging_res_pd_mat,
|
|
118
|
+
high_res_path=files.high_res_pd_mat,
|
|
119
|
+
)
|
|
120
|
+
frame_counts = _audit_frame_counts(
|
|
121
|
+
aligned_movie=aligned_movie,
|
|
122
|
+
acquisition=acquisition,
|
|
123
|
+
photodiode=photodiode,
|
|
124
|
+
)
|
|
125
|
+
alignment_crop = load_alignment_valid_crop(
|
|
126
|
+
alignment_path=files.alignment_text,
|
|
127
|
+
aligned_movie=aligned_movie,
|
|
128
|
+
acquisition=acquisition,
|
|
129
|
+
photodiode=photodiode,
|
|
130
|
+
)
|
|
131
|
+
stimulus_parameters = _load_stimulus_parameters(
|
|
132
|
+
files.stimulus_data_dir / "stimParams.mat",
|
|
133
|
+
)
|
|
134
|
+
return SourceConversionInputs(
|
|
135
|
+
session_files=files,
|
|
136
|
+
aligned_movie=aligned_movie,
|
|
137
|
+
acquisition=acquisition,
|
|
138
|
+
run=_load_run_metadata(files.stimulus_data_dir / "runDetails.mat"),
|
|
139
|
+
stimulus_parameters=stimulus_parameters,
|
|
140
|
+
stimulus_code=load_stimulus_code_metadata(
|
|
141
|
+
files.stimulus_filebackup_zip,
|
|
142
|
+
stimulus_parameters,
|
|
143
|
+
),
|
|
144
|
+
stimulus_data=_load_stimulus_data(
|
|
145
|
+
files.stimulus_data_dir / "stimdata.mat",
|
|
146
|
+
),
|
|
147
|
+
photodiode=photodiode,
|
|
148
|
+
frame_counts=frame_counts,
|
|
149
|
+
alignment_crop=alignment_crop,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _load_aligned_movie_source(path: Path) -> AlignedMovieSource:
|
|
154
|
+
"""Read aligned movie dataset metadata without loading frames.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
path: ``alignedMovie.mat`` path.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Lazy aligned movie source.
|
|
161
|
+
"""
|
|
162
|
+
dataset_name = "imgFrames_ch1"
|
|
163
|
+
with h5py.File(path, "r") as mat_file:
|
|
164
|
+
if dataset_name not in mat_file:
|
|
165
|
+
msg = f"Missing dataset {dataset_name!r} in {path}"
|
|
166
|
+
raise KeyError(msg)
|
|
167
|
+
dataset = mat_file[dataset_name]
|
|
168
|
+
return AlignedMovieSource(
|
|
169
|
+
path=path,
|
|
170
|
+
dataset_name=dataset_name,
|
|
171
|
+
shape=tuple(int(length) for length in dataset.shape),
|
|
172
|
+
dtype=str(dataset.dtype),
|
|
173
|
+
chunks=(
|
|
174
|
+
tuple(int(length) for length in dataset.chunks)
|
|
175
|
+
if dataset.chunks is not None
|
|
176
|
+
else None
|
|
177
|
+
),
|
|
178
|
+
compression=dataset.compression,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _load_acquisition_metadata(path: Path) -> AcquisitionMetadata:
|
|
183
|
+
"""Load selected ScanImage acquisition metadata from ``imageDescription.mat``.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
path: MATLAB file containing the ``state`` struct.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Acquisition metadata with selected fields flattened.
|
|
190
|
+
"""
|
|
191
|
+
state = load_scipy_variable(path, "state")
|
|
192
|
+
return AcquisitionMetadata(
|
|
193
|
+
path=path,
|
|
194
|
+
fields={
|
|
195
|
+
key: nested_matlab_field(state, key) for key in ACQUISITION_METADATA_FIELDS
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _load_run_metadata(path: Path) -> RunMetadata:
|
|
201
|
+
"""Load stimulus-run metadata from ``runDetails.mat``.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
path: MATLAB file containing run-level scalar metadata.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Run metadata with selected fields flattened.
|
|
208
|
+
|
|
209
|
+
The source file uses MATLAB-style names such as ``rigName``. twopy converts
|
|
210
|
+
them to snake_case before writing HDF5 so converted files have one naming
|
|
211
|
+
style.
|
|
212
|
+
"""
|
|
213
|
+
return RunMetadata(
|
|
214
|
+
path=path,
|
|
215
|
+
fields={
|
|
216
|
+
target_key: load_scipy_variable(path, source_key)
|
|
217
|
+
for source_key, target_key in RUN_METADATA_FIELDS
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _load_stimulus_parameters(path: Path) -> StimulusParameters:
|
|
223
|
+
"""Load stimulus epoch parameter structs.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
path: ``stimParams.mat`` path.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Stimulus parameters as one dictionary per epoch.
|
|
230
|
+
"""
|
|
231
|
+
raw_params = load_scipy_variable(path, "stimParams")
|
|
232
|
+
params_array = (
|
|
233
|
+
raw_params
|
|
234
|
+
if isinstance(raw_params, np.ndarray)
|
|
235
|
+
else np.array([raw_params], dtype=object)
|
|
236
|
+
)
|
|
237
|
+
return StimulusParameters(
|
|
238
|
+
path=path,
|
|
239
|
+
epochs=tuple(
|
|
240
|
+
matlab_struct_to_dict(param)
|
|
241
|
+
for param in np.ravel(params_array)
|
|
242
|
+
if hasattr(param, "_fieldnames")
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _load_stimulus_data(path: Path) -> StimulusData:
|
|
248
|
+
"""Load the numeric stimulus data table.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
path: ``stimdata.mat`` path.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Stimulus data with direct views/copies for common columns.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
ValueError: If the table does not include time, frame, and epoch columns.
|
|
258
|
+
"""
|
|
259
|
+
data = np.asarray(load_scipy_variable(path, "stimData"), dtype=np.float64)
|
|
260
|
+
if data.ndim != 2 or data.shape[1] < 3:
|
|
261
|
+
msg = f"stimData must be a 2D table with at least 3 columns: {path}"
|
|
262
|
+
raise ValueError(msg)
|
|
263
|
+
|
|
264
|
+
return StimulusData(
|
|
265
|
+
path=path,
|
|
266
|
+
data=data,
|
|
267
|
+
column_names=_load_stimulus_data_column_names(column_count=data.shape[1]),
|
|
268
|
+
time_seconds=data[:, 0],
|
|
269
|
+
frame_numbers=data[:, 1].astype(np.int64),
|
|
270
|
+
epoch_numbers=data[:, 2].astype(np.int64),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _load_stimulus_data_column_names(
|
|
275
|
+
*,
|
|
276
|
+
column_count: int,
|
|
277
|
+
) -> tuple[str, ...]:
|
|
278
|
+
"""Return code-derived labels for ``stimData`` columns.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
column_count: Number of columns in the MATLAB ``stimData`` table.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
One plain-language column label per MATLAB table column.
|
|
285
|
+
|
|
286
|
+
``filebackup/utils/ledUtils/WriteStimData.m`` is the source of truth for
|
|
287
|
+
the stable order. It writes time, stimulus frame, epoch, ten closed-loop
|
|
288
|
+
slots, twenty stimulus-specific slots, the photodiode flash value, then a
|
|
289
|
+
trailing comma. The stimulus-specific slots are decoded from the backed-up
|
|
290
|
+
MATLAB stimulus function selected by ``stimtype``.
|
|
291
|
+
"""
|
|
292
|
+
if column_count <= len(STABLE_STIMULUS_DATA_COLUMNS):
|
|
293
|
+
return STABLE_STIMULUS_DATA_COLUMNS[:column_count]
|
|
294
|
+
return STABLE_STIMULUS_DATA_COLUMNS + tuple(
|
|
295
|
+
f"extra_column_{index + 1:02d}"
|
|
296
|
+
for index in range(len(STABLE_STIMULUS_DATA_COLUMNS), column_count)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _load_photodiode_signals(
|
|
301
|
+
*,
|
|
302
|
+
imaging_res_path: Path,
|
|
303
|
+
high_res_path: Path,
|
|
304
|
+
) -> PhotodiodeSignals:
|
|
305
|
+
"""Load frame-resolution and high-resolution photodiode vectors.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
imaging_res_path: ``imagingResPd.mat`` path.
|
|
309
|
+
high_res_path: ``highResPd.mat`` path.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Photodiode arrays as float64 vectors.
|
|
313
|
+
"""
|
|
314
|
+
return PhotodiodeSignals(
|
|
315
|
+
imaging_res_path=imaging_res_path,
|
|
316
|
+
high_res_path=high_res_path,
|
|
317
|
+
imaging_res_pd=np.asarray(
|
|
318
|
+
load_scipy_variable(imaging_res_path, "imagingResPd"),
|
|
319
|
+
dtype=np.float64,
|
|
320
|
+
),
|
|
321
|
+
high_res_pd=np.asarray(
|
|
322
|
+
load_scipy_variable(high_res_path, "highResPd"),
|
|
323
|
+
dtype=np.float64,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _audit_frame_counts(
|
|
329
|
+
*,
|
|
330
|
+
aligned_movie: AlignedMovieSource,
|
|
331
|
+
acquisition: AcquisitionMetadata,
|
|
332
|
+
photodiode: PhotodiodeSignals,
|
|
333
|
+
) -> FrameCountAudit:
|
|
334
|
+
"""Check frame-count relationships that response analysis will rely on.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
aligned_movie: Aligned movie source metadata.
|
|
338
|
+
acquisition: ScanImage acquisition metadata from ``imageDescription.mat``.
|
|
339
|
+
photodiode: Loaded photodiode arrays.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Frame-count audit values for validation and HDF5 metadata.
|
|
343
|
+
|
|
344
|
+
Raises:
|
|
345
|
+
ValueError: If the imaging-resolution photodiode does not match the
|
|
346
|
+
aligned movie frame count, or if acquisition metadata differs by
|
|
347
|
+
more than the observed one-frame ScanImage offset.
|
|
348
|
+
|
|
349
|
+
Random sampling of real recordings showed ``imagingResPd`` and
|
|
350
|
+
``alignedMovie`` agree exactly, while ``acq.numberOfFrames`` is commonly one
|
|
351
|
+
less than the aligned movie. Treat that as a known ScanImage metadata
|
|
352
|
+
convention, not a dropped frame. Keep the offset visible for later response
|
|
353
|
+
analysis instead of normalizing it away.
|
|
354
|
+
"""
|
|
355
|
+
movie_frames = aligned_movie.shape[0]
|
|
356
|
+
imaging_res_pd_samples = int(photodiode.imaging_res_pd.size)
|
|
357
|
+
acquisition_frames = _required_int_field(acquisition, "acq.numberOfFrames")
|
|
358
|
+
|
|
359
|
+
imaging_delta = imaging_res_pd_samples - movie_frames
|
|
360
|
+
if imaging_delta != 0:
|
|
361
|
+
msg = (
|
|
362
|
+
"imagingResPd sample count must match aligned movie frames: "
|
|
363
|
+
f"imaging_res_pd={imaging_res_pd_samples}, movie={movie_frames}"
|
|
364
|
+
)
|
|
365
|
+
raise ValueError(msg)
|
|
366
|
+
|
|
367
|
+
acquisition_delta = acquisition_frames - movie_frames
|
|
368
|
+
if acquisition_delta not in {-1, 0}:
|
|
369
|
+
msg = (
|
|
370
|
+
"Unexpected ScanImage acquisition frame count offset: "
|
|
371
|
+
f"acq.numberOfFrames={acquisition_frames}, movie={movie_frames}"
|
|
372
|
+
)
|
|
373
|
+
raise ValueError(msg)
|
|
374
|
+
|
|
375
|
+
return FrameCountAudit(
|
|
376
|
+
aligned_movie_frames=movie_frames,
|
|
377
|
+
imaging_res_pd_samples=imaging_res_pd_samples,
|
|
378
|
+
acquisition_number_of_frames=acquisition_frames,
|
|
379
|
+
imaging_res_pd_minus_movie=imaging_delta,
|
|
380
|
+
acquisition_minus_movie=acquisition_delta,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _required_int_field(acquisition: AcquisitionMetadata, key: str) -> int:
|
|
385
|
+
"""Read one required integer acquisition metadata field.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
acquisition: Loaded acquisition metadata.
|
|
389
|
+
key: Field name to read.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Field value as an integer.
|
|
393
|
+
|
|
394
|
+
Raises:
|
|
395
|
+
ValueError: If the field cannot be interpreted as an integer.
|
|
396
|
+
"""
|
|
397
|
+
value = acquisition.fields[key]
|
|
398
|
+
if isinstance(value, str | bytes | int | float | np.generic):
|
|
399
|
+
return int(value)
|
|
400
|
+
|
|
401
|
+
value_type = type(value).__name__
|
|
402
|
+
msg = f"Acquisition field {key!r} must be integer-like, got {value_type}"
|
|
403
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Decode experiment-specific stimulus column metadata from backup code.
|
|
2
|
+
|
|
3
|
+
Inputs: ``stimulusData/filebackup.zip`` and loaded stimulus epoch parameters.
|
|
4
|
+
Outputs: small JSON-ready dictionaries that explain stimulus-specific
|
|
5
|
+
``stimData`` slots for the exact recording.
|
|
6
|
+
|
|
7
|
+
This module deliberately uses simple text parsing, not a MATLAB parser. The
|
|
8
|
+
goal is auditable provenance: record which backed-up function wrote which
|
|
9
|
+
``stimData.mat(N)`` slots.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from zipfile import ZipFile
|
|
15
|
+
|
|
16
|
+
from twopy.conversion.types import StimulusCodeMetadata, StimulusParameters
|
|
17
|
+
|
|
18
|
+
__all__ = ["load_stimulus_code_metadata"]
|
|
19
|
+
|
|
20
|
+
LOOKUP_PATH = "paramfiles/stimulus_lookup.txt"
|
|
21
|
+
STIMFUNCTION_DIR = "stimfunctions"
|
|
22
|
+
STIMULUS_SLOT_COUNT = 20
|
|
23
|
+
STIMULUS_ASSIGNMENT_PATTERN = re.compile(
|
|
24
|
+
r"stimData\.mat\((?P<slot>\d+)\)\s*=\s*(?P<expression>[^;]+);(?P<comment>.*)",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_stimulus_code_metadata(
|
|
29
|
+
filebackup_zip: Path,
|
|
30
|
+
parameters: StimulusParameters,
|
|
31
|
+
) -> StimulusCodeMetadata:
|
|
32
|
+
"""Load per-``stimtype`` stimulus-slot metadata from backup code.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
filebackup_zip: ``stimulusData/filebackup.zip`` for one recording.
|
|
36
|
+
parameters: Stimulus epoch parameters loaded from ``stimParams.mat``.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Function lookup and slot assignments for the ``stimtype`` values used
|
|
40
|
+
by this recording.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
FileNotFoundError: If the code backup zip is absent.
|
|
44
|
+
KeyError: If lookup or function files needed by this recording are
|
|
45
|
+
missing from the backup.
|
|
46
|
+
|
|
47
|
+
Reading this zip is cheap relative to movie conversion and gives later
|
|
48
|
+
analysis code the exact experiment-specific meaning of columns such as
|
|
49
|
+
``stimulus_specific_04``.
|
|
50
|
+
"""
|
|
51
|
+
if not filebackup_zip.is_file():
|
|
52
|
+
msg = f"Missing stimulus code backup zip: {filebackup_zip}"
|
|
53
|
+
raise FileNotFoundError(msg)
|
|
54
|
+
|
|
55
|
+
with ZipFile(filebackup_zip) as archive:
|
|
56
|
+
function_lookup = _load_function_lookup(archive)
|
|
57
|
+
used_stimtypes = _stimtypes_from_parameters(parameters)
|
|
58
|
+
_require_lookup_entries(function_lookup, used_stimtypes)
|
|
59
|
+
stimulus_specific_columns = {
|
|
60
|
+
stimtype: _load_function_columns(
|
|
61
|
+
archive=archive,
|
|
62
|
+
stimtype=stimtype,
|
|
63
|
+
function_name=function_lookup[stimtype],
|
|
64
|
+
)
|
|
65
|
+
for stimtype in used_stimtypes
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return StimulusCodeMetadata(
|
|
69
|
+
path=filebackup_zip,
|
|
70
|
+
function_lookup={
|
|
71
|
+
stimtype: function_lookup[stimtype] for stimtype in used_stimtypes
|
|
72
|
+
},
|
|
73
|
+
stimulus_specific_columns=stimulus_specific_columns,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _require_lookup_entries(
|
|
78
|
+
function_lookup: dict[str, str],
|
|
79
|
+
used_stimtypes: tuple[str, ...],
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Fail clearly when a recording uses a ``stimtype`` missing from lookup.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
function_lookup: Mapping loaded from ``stimulus_lookup.txt``.
|
|
85
|
+
used_stimtypes: Stimulus types present in epoch parameters.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
None.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
KeyError: If any used ``stimtype`` is absent from the lookup file.
|
|
92
|
+
"""
|
|
93
|
+
missing = tuple(
|
|
94
|
+
stimtype for stimtype in used_stimtypes if stimtype not in function_lookup
|
|
95
|
+
)
|
|
96
|
+
if missing:
|
|
97
|
+
msg = f"Missing stimtype entries in {LOOKUP_PATH}: {missing}"
|
|
98
|
+
raise KeyError(msg)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _load_function_lookup(archive: ZipFile) -> dict[str, str]:
|
|
102
|
+
"""Read ``stimtype`` to stimulus-function mappings from the backup zip.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
archive: Open ``filebackup.zip``.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Mapping from ``stimtype`` number as text to MATLAB function name.
|
|
109
|
+
"""
|
|
110
|
+
lookup_text = _read_zip_text(archive, LOOKUP_PATH)
|
|
111
|
+
lookup: dict[str, str] = {}
|
|
112
|
+
for line in lookup_text.splitlines():
|
|
113
|
+
stripped = line.strip()
|
|
114
|
+
if stripped == "" or stripped.startswith("#"):
|
|
115
|
+
continue
|
|
116
|
+
parts = [part.strip() for part in stripped.split(",", maxsplit=1)]
|
|
117
|
+
if len(parts) != 2:
|
|
118
|
+
continue
|
|
119
|
+
lookup[parts[0]] = parts[1]
|
|
120
|
+
return lookup
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _stimtypes_from_parameters(parameters: StimulusParameters) -> tuple[str, ...]:
|
|
124
|
+
"""Return unique ``stimtype`` values used by this recording.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
parameters: Stimulus epoch parameter dictionaries.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Ordered tuple of distinct ``stimtype`` values as text.
|
|
131
|
+
"""
|
|
132
|
+
stimtypes: list[str] = []
|
|
133
|
+
for epoch in parameters.epochs:
|
|
134
|
+
if "stimtype" not in epoch:
|
|
135
|
+
continue
|
|
136
|
+
stimtype = _stimtype_to_text(epoch["stimtype"])
|
|
137
|
+
if stimtype not in stimtypes:
|
|
138
|
+
stimtypes.append(stimtype)
|
|
139
|
+
return tuple(stimtypes)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _stimtype_to_text(value: object) -> str:
|
|
143
|
+
"""Convert one MATLAB-loaded ``stimtype`` value into stable text.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
value: ``stimtype`` value from one epoch parameter dictionary.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Integer-like stimulus type as text.
|
|
150
|
+
"""
|
|
151
|
+
if isinstance(value, int | float | str):
|
|
152
|
+
return str(int(value))
|
|
153
|
+
msg = f"stimtype must be an int-like scalar; got {value!r}"
|
|
154
|
+
raise TypeError(msg)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _load_function_columns(
|
|
158
|
+
*,
|
|
159
|
+
archive: ZipFile,
|
|
160
|
+
stimtype: str,
|
|
161
|
+
function_name: str,
|
|
162
|
+
) -> dict[str, object]:
|
|
163
|
+
"""Read one stimulus function and decode its ``stimData.mat`` writes.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
archive: Open ``filebackup.zip``.
|
|
167
|
+
stimtype: Stimulus type number from epoch parameters.
|
|
168
|
+
function_name: MATLAB stimulus function name from the lookup file.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
JSON-ready metadata for that function and its assigned slots.
|
|
172
|
+
"""
|
|
173
|
+
source_path = f"{STIMFUNCTION_DIR}/{function_name}.m"
|
|
174
|
+
source_text = _read_zip_text(archive, source_path)
|
|
175
|
+
return {
|
|
176
|
+
"stimtype": stimtype,
|
|
177
|
+
"function": function_name,
|
|
178
|
+
"source_path": source_path,
|
|
179
|
+
"columns": _parse_stimulus_assignments(source_text),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_stimulus_assignments(source_text: str) -> list[dict[str, object]]:
|
|
184
|
+
"""Find simple ``stimData.mat(N) = ...;`` assignments in MATLAB text.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
source_text: MATLAB function source text.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
One JSON-ready dictionary per assigned stimulus-specific slot.
|
|
191
|
+
|
|
192
|
+
The parser intentionally keeps the right-hand expression as text. That is
|
|
193
|
+
more honest than inventing semantic labels, and it is enough for a human to
|
|
194
|
+
audit the exact source line later.
|
|
195
|
+
"""
|
|
196
|
+
columns: list[dict[str, object]] = []
|
|
197
|
+
for line_number, line in enumerate(source_text.splitlines(), start=1):
|
|
198
|
+
match = STIMULUS_ASSIGNMENT_PATTERN.search(line)
|
|
199
|
+
if match is None:
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
slot = int(match.group("slot"))
|
|
203
|
+
if not 1 <= slot <= STIMULUS_SLOT_COUNT:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
comment = _clean_matlab_comment(match.group("comment"))
|
|
207
|
+
column = {
|
|
208
|
+
"mat_slot": slot,
|
|
209
|
+
"column_name": f"stimulus_specific_{slot:02d}",
|
|
210
|
+
"source_expression": match.group("expression").strip(),
|
|
211
|
+
"source_line": line_number,
|
|
212
|
+
}
|
|
213
|
+
if comment is not None:
|
|
214
|
+
column["source_comment"] = comment
|
|
215
|
+
columns.append(column)
|
|
216
|
+
return columns
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _clean_matlab_comment(raw_comment: str) -> str | None:
|
|
220
|
+
"""Normalize an optional MATLAB inline comment.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
raw_comment: Text after the semicolon on one MATLAB assignment line.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Comment text without the leading ``%``, or ``None`` when absent.
|
|
227
|
+
"""
|
|
228
|
+
comment = raw_comment.strip()
|
|
229
|
+
if not comment.startswith("%"):
|
|
230
|
+
return None
|
|
231
|
+
return comment.lstrip("%").strip() or None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _read_zip_text(archive: ZipFile, member_path: str) -> str:
|
|
235
|
+
"""Read one UTF-8-ish text file from a stimulus backup zip.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
archive: Open ``filebackup.zip``.
|
|
239
|
+
member_path: Path inside the zip.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Decoded text.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
KeyError: If the zip member is absent.
|
|
246
|
+
"""
|
|
247
|
+
with archive.open(member_path) as member:
|
|
248
|
+
return member.read().decode("utf-8", errors="replace")
|