TwoPy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. twopy/__init__.py +168 -0
  2. twopy/_segments.py +53 -0
  3. twopy/analysis/__init__.py +101 -0
  4. twopy/analysis/background_subtraction.py +672 -0
  5. twopy/analysis/dff.py +524 -0
  6. twopy/analysis/epoch_mapping.py +223 -0
  7. twopy/analysis/motion.py +66 -0
  8. twopy/analysis/persistence.py +975 -0
  9. twopy/analysis/response_processing/__init__.py +55 -0
  10. twopy/analysis/response_processing/apply.py +298 -0
  11. twopy/analysis/response_processing/options.py +192 -0
  12. twopy/analysis/response_processing/qc.py +270 -0
  13. twopy/analysis/response_processing/signals.py +200 -0
  14. twopy/analysis/responses.py +345 -0
  15. twopy/analysis/trials.py +341 -0
  16. twopy/analysis/workflow.py +457 -0
  17. twopy/api.py +78 -0
  18. twopy/config.py +200 -0
  19. twopy/conversion/__init__.py +40 -0
  20. twopy/conversion/alignment_crop.py +329 -0
  21. twopy/conversion/api.py +113 -0
  22. twopy/conversion/frame_ranges.py +35 -0
  23. twopy/conversion/hdf5_writing.py +362 -0
  24. twopy/conversion/matlab_values.py +123 -0
  25. twopy/conversion/source_loading.py +403 -0
  26. twopy/conversion/stimulus_code.py +248 -0
  27. twopy/conversion/types.py +295 -0
  28. twopy/converted.py +681 -0
  29. twopy/database/__init__.py +33 -0
  30. twopy/database/cache.py +166 -0
  31. twopy/database/catalog.py +90 -0
  32. twopy/database/connection.py +27 -0
  33. twopy/database/generic.py +254 -0
  34. twopy/database/modeled.py +419 -0
  35. twopy/database/sql.py +46 -0
  36. twopy/database/types.py +116 -0
  37. twopy/inspection.py +492 -0
  38. twopy/matlab.py +398 -0
  39. twopy/napari/__init__.py +27 -0
  40. twopy/napari/__main__.py +10 -0
  41. twopy/napari/constants.py +13 -0
  42. twopy/napari/controls.py +739 -0
  43. twopy/napari/display.py +181 -0
  44. twopy/napari/display_paths.py +197 -0
  45. twopy/napari/interactive.py +310 -0
  46. twopy/napari/launcher.py +175 -0
  47. twopy/napari/layout.py +147 -0
  48. twopy/napari/loading.py +207 -0
  49. twopy/napari/movie.py +71 -0
  50. twopy/napari/paths.py +181 -0
  51. twopy/napari/plotting/__init__.py +21 -0
  52. twopy/napari/plotting/data.py +266 -0
  53. twopy/napari/plotting/docks.py +1052 -0
  54. twopy/napari/plotting/export.py +803 -0
  55. twopy/napari/plotting/export_controls.py +209 -0
  56. twopy/napari/plotting/label_visibility.py +260 -0
  57. twopy/napari/plotting/options.py +269 -0
  58. twopy/napari/plotting/panels.py +119 -0
  59. twopy/napari/plotting/widgets.py +767 -0
  60. twopy/napari/protocols.py +90 -0
  61. twopy/napari/responses.py +91 -0
  62. twopy/napari/roi.py +238 -0
  63. twopy/napari/session.py +381 -0
  64. twopy/napari/state.py +106 -0
  65. twopy/napari/text.py +26 -0
  66. twopy/napari/types.py +39 -0
  67. twopy/napari/viewer.py +405 -0
  68. twopy/parity/__init__.py +30 -0
  69. twopy/parity/dff.py +399 -0
  70. twopy/parity/roi.py +145 -0
  71. twopy/parity/saved_analysis.py +174 -0
  72. twopy/photodiode.py +375 -0
  73. twopy/photodiode_classification.py +484 -0
  74. twopy/roi.py +462 -0
  75. twopy/session.py +167 -0
  76. twopy/spatial.py +154 -0
  77. twopy/stimulus.py +233 -0
  78. twopy/synchronization.py +235 -0
  79. twopy/typing_guards.py +255 -0
  80. twopy-0.1.0.dist-info/METADATA +311 -0
  81. twopy-0.1.0.dist-info/RECORD +84 -0
  82. twopy-0.1.0.dist-info/WHEEL +5 -0
  83. twopy-0.1.0.dist-info/entry_points.txt +2 -0
  84. twopy-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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")