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,975 @@
|
|
|
1
|
+
"""Persist twopy analysis outputs to inspectable files.
|
|
2
|
+
|
|
3
|
+
Inputs: ROI sets, extracted traces, dF/F results, epoch windows, and grouped
|
|
4
|
+
responses.
|
|
5
|
+
Outputs: one HDF5 analysis file plus optional CSV response time series.
|
|
6
|
+
|
|
7
|
+
This module only saves twopy-owned Python objects. It does not read source
|
|
8
|
+
MATLAB/TIFF files or decide analysis parameters.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import h5py
|
|
18
|
+
import numpy as np
|
|
19
|
+
import numpy.typing as npt
|
|
20
|
+
|
|
21
|
+
from twopy.analysis.background_subtraction import (
|
|
22
|
+
BackgroundCorrectedRoiTraces,
|
|
23
|
+
BackgroundCorrectionMethod,
|
|
24
|
+
)
|
|
25
|
+
from twopy.analysis.dff import RoiDeltaFOverF
|
|
26
|
+
from twopy.analysis.responses import (
|
|
27
|
+
GroupedRoiResponses,
|
|
28
|
+
RoiResponseTrial,
|
|
29
|
+
)
|
|
30
|
+
from twopy.analysis.trials import EpochFrameWindow, FrameWindow
|
|
31
|
+
from twopy.roi import RoiSet, TraceStatistic, make_roi_set
|
|
32
|
+
from twopy.typing_guards import (
|
|
33
|
+
require_bool_array,
|
|
34
|
+
require_float64_array,
|
|
35
|
+
require_int64_array,
|
|
36
|
+
require_string_choice,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ANALYSIS_OUTPUT_FILE_FORMAT",
|
|
41
|
+
"LoadedAnalysisOutputs",
|
|
42
|
+
"load_analysis_outputs",
|
|
43
|
+
"save_analysis_outputs",
|
|
44
|
+
"write_response_summary_grouped_csv",
|
|
45
|
+
"write_response_summary_trials_csv",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
ANALYSIS_OUTPUT_FILE_FORMAT = "twopy-analysis-outputs"
|
|
49
|
+
_TRIAL_RESPONSE_CSV_BASE_COLUMNS = (
|
|
50
|
+
"epoch_number",
|
|
51
|
+
"epoch_name",
|
|
52
|
+
"trial_index",
|
|
53
|
+
"window_index",
|
|
54
|
+
"roi_label",
|
|
55
|
+
"start_frame",
|
|
56
|
+
"stop_frame",
|
|
57
|
+
"frame_count",
|
|
58
|
+
)
|
|
59
|
+
_GROUPED_RESPONSE_CSV_BASE_COLUMNS = (
|
|
60
|
+
"epoch_number",
|
|
61
|
+
"epoch_name",
|
|
62
|
+
"roi_label",
|
|
63
|
+
"statistic",
|
|
64
|
+
)
|
|
65
|
+
_TRACE_STATISTICS: tuple[TraceStatistic, ...] = ("mean",)
|
|
66
|
+
_BACKGROUND_CORRECTION_METHODS: tuple[BackgroundCorrectionMethod, ...] = (
|
|
67
|
+
"none",
|
|
68
|
+
"movie_global_percentile",
|
|
69
|
+
"roi_local_percentile_y",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class LoadedAnalysisOutputs:
|
|
75
|
+
"""Analysis objects loaded from one twopy analysis HDF5 file.
|
|
76
|
+
|
|
77
|
+
Inputs: one file written by ``save_analysis_outputs``.
|
|
78
|
+
Outputs: optional ROI, trace, dF/F, window, and response objects.
|
|
79
|
+
|
|
80
|
+
Groups are optional because scripts can save only the products they have
|
|
81
|
+
computed. Missing groups load as ``None`` or an empty window tuple.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
path: Path
|
|
85
|
+
roi_set: RoiSet | None
|
|
86
|
+
traces: BackgroundCorrectedRoiTraces | None
|
|
87
|
+
dff: RoiDeltaFOverF | None
|
|
88
|
+
epoch_windows: tuple[EpochFrameWindow, ...]
|
|
89
|
+
grouped_responses: GroupedRoiResponses | None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def save_analysis_outputs(
|
|
93
|
+
path: Path,
|
|
94
|
+
*,
|
|
95
|
+
roi_set: RoiSet | None = None,
|
|
96
|
+
traces: BackgroundCorrectedRoiTraces | None = None,
|
|
97
|
+
dff: RoiDeltaFOverF | None = None,
|
|
98
|
+
epoch_windows: Sequence[EpochFrameWindow] = (),
|
|
99
|
+
grouped_responses: GroupedRoiResponses | None = None,
|
|
100
|
+
response_summary_trials_csv: Path | None = None,
|
|
101
|
+
response_summary_grouped_csv: Path | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Save analysis outputs to one HDF5 file and optional CSV time series.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
path: Destination HDF5 path.
|
|
107
|
+
roi_set: Optional ROI masks and labels.
|
|
108
|
+
traces: Optional raw/background/corrected fluorescence traces.
|
|
109
|
+
dff: Optional ROI dF/F result.
|
|
110
|
+
epoch_windows: Optional stimulus windows used for response grouping.
|
|
111
|
+
grouped_responses: Optional grouped trial responses.
|
|
112
|
+
response_summary_trials_csv: Optional CSV path for one row per trial
|
|
113
|
+
and ROI, with one column per relative response timepoint.
|
|
114
|
+
response_summary_grouped_csv: Optional CSV path for one row per epoch,
|
|
115
|
+
ROI, and statistic, with one column per relative response
|
|
116
|
+
timepoint.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
None.
|
|
120
|
+
|
|
121
|
+
The HDF5 file stores arrays with gzip compression where arrays can be large.
|
|
122
|
+
The HDF5 file is the complete audit trail. CSV files duplicate response
|
|
123
|
+
time series in a spreadsheet-friendly layout for quick plotting and checks.
|
|
124
|
+
"""
|
|
125
|
+
summary_outputs = (
|
|
126
|
+
response_summary_trials_csv,
|
|
127
|
+
response_summary_grouped_csv,
|
|
128
|
+
)
|
|
129
|
+
if any(path is not None for path in summary_outputs) and grouped_responses is None:
|
|
130
|
+
msg = "response summary CSV outputs require grouped_responses"
|
|
131
|
+
raise ValueError(msg)
|
|
132
|
+
|
|
133
|
+
output_path = path.expanduser()
|
|
134
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
with h5py.File(output_path, "w") as h5_file:
|
|
136
|
+
h5_file.attrs["twopy_format"] = ANALYSIS_OUTPUT_FILE_FORMAT
|
|
137
|
+
if roi_set is not None:
|
|
138
|
+
_write_roi_set(h5_file.create_group("roi_set"), roi_set)
|
|
139
|
+
if traces is not None:
|
|
140
|
+
_write_background_traces(h5_file.create_group("traces"), traces)
|
|
141
|
+
if dff is not None:
|
|
142
|
+
_write_dff(h5_file.create_group("dff"), dff)
|
|
143
|
+
if len(epoch_windows) > 0:
|
|
144
|
+
_write_epoch_windows(h5_file.create_group("epoch_windows"), epoch_windows)
|
|
145
|
+
if grouped_responses is not None:
|
|
146
|
+
_write_grouped_responses(
|
|
147
|
+
h5_file.create_group("responses"),
|
|
148
|
+
grouped_responses,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if grouped_responses is not None:
|
|
152
|
+
if response_summary_trials_csv is not None:
|
|
153
|
+
write_response_summary_trials_csv(
|
|
154
|
+
grouped_responses,
|
|
155
|
+
response_summary_trials_csv,
|
|
156
|
+
)
|
|
157
|
+
if response_summary_grouped_csv is not None:
|
|
158
|
+
write_response_summary_grouped_csv(
|
|
159
|
+
grouped_responses,
|
|
160
|
+
response_summary_grouped_csv,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def load_analysis_outputs(path: Path) -> LoadedAnalysisOutputs:
|
|
165
|
+
"""Load twopy analysis outputs from HDF5.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
path: Analysis HDF5 file written by ``save_analysis_outputs``.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
``LoadedAnalysisOutputs`` with present groups reconstructed as typed
|
|
172
|
+
Python objects.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValueError: If the file is not a twopy analysis output file.
|
|
176
|
+
"""
|
|
177
|
+
input_path = path.expanduser()
|
|
178
|
+
with h5py.File(input_path, "r") as h5_file:
|
|
179
|
+
_require_analysis_format(h5_file, input_path)
|
|
180
|
+
return LoadedAnalysisOutputs(
|
|
181
|
+
path=input_path,
|
|
182
|
+
roi_set=(
|
|
183
|
+
_read_roi_set(h5_file["roi_set"]) if "roi_set" in h5_file else None
|
|
184
|
+
),
|
|
185
|
+
traces=(
|
|
186
|
+
_read_background_traces(h5_file["traces"])
|
|
187
|
+
if "traces" in h5_file
|
|
188
|
+
else None
|
|
189
|
+
),
|
|
190
|
+
dff=_read_dff(h5_file["dff"]) if "dff" in h5_file else None,
|
|
191
|
+
epoch_windows=(
|
|
192
|
+
_read_epoch_windows(h5_file["epoch_windows"])
|
|
193
|
+
if "epoch_windows" in h5_file
|
|
194
|
+
else ()
|
|
195
|
+
),
|
|
196
|
+
grouped_responses=(
|
|
197
|
+
_read_grouped_responses(h5_file["responses"])
|
|
198
|
+
if "responses" in h5_file
|
|
199
|
+
else None
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def write_response_summary_trials_csv(
|
|
205
|
+
grouped_responses: GroupedRoiResponses,
|
|
206
|
+
path: Path,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Write trial-level response time series to CSV.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
grouped_responses: Grouped response object to write.
|
|
212
|
+
path: Destination CSV path.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
None.
|
|
216
|
+
"""
|
|
217
|
+
output_path = path.expanduser()
|
|
218
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
time_columns = _time_columns(grouped_responses.trials)
|
|
220
|
+
fieldnames = (*_TRIAL_RESPONSE_CSV_BASE_COLUMNS, *time_columns)
|
|
221
|
+
with output_path.open("w", encoding="utf-8", newline="") as csv_file:
|
|
222
|
+
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
|
|
223
|
+
writer.writeheader()
|
|
224
|
+
for trial in grouped_responses.trials:
|
|
225
|
+
_validate_response_trial(trial, grouped_responses.roi_labels)
|
|
226
|
+
for roi_index, roi_label in enumerate(grouped_responses.roi_labels):
|
|
227
|
+
writer.writerow(
|
|
228
|
+
_trial_response_row(
|
|
229
|
+
trial,
|
|
230
|
+
roi_index=roi_index,
|
|
231
|
+
roi_label=roi_label,
|
|
232
|
+
time_columns=time_columns,
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def write_response_summary_grouped_csv(
|
|
238
|
+
grouped_responses: GroupedRoiResponses,
|
|
239
|
+
path: Path,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Write epoch-level response time series to CSV.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
grouped_responses: Grouped response object to write.
|
|
245
|
+
path: Destination CSV path.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
None.
|
|
249
|
+
"""
|
|
250
|
+
output_path = path.expanduser()
|
|
251
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
for trial in grouped_responses.trials:
|
|
253
|
+
_validate_response_trial(trial, grouped_responses.roi_labels)
|
|
254
|
+
time_columns = _time_columns(grouped_responses.trials)
|
|
255
|
+
fieldnames = (*_GROUPED_RESPONSE_CSV_BASE_COLUMNS, *time_columns)
|
|
256
|
+
with output_path.open("w", encoding="utf-8", newline="") as csv_file:
|
|
257
|
+
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
|
|
258
|
+
writer.writeheader()
|
|
259
|
+
for row in _grouped_response_rows(grouped_responses, time_columns):
|
|
260
|
+
writer.writerow(row)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _write_roi_set(group: h5py.Group, roi_set: RoiSet) -> None:
|
|
264
|
+
"""Write ROI masks and labels to an HDF5 group.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
group: Destination HDF5 group.
|
|
268
|
+
roi_set: ROI masks and labels.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
None.
|
|
272
|
+
"""
|
|
273
|
+
group.create_dataset("masks", data=roi_set.masks, compression="gzip")
|
|
274
|
+
_write_string_dataset(group, "labels", roi_set.labels)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _read_roi_set(group: h5py.Group) -> RoiSet:
|
|
278
|
+
"""Read a persisted ROI set.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
group: HDF5 ``roi_set`` group.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
``RoiSet`` with validated masks and labels.
|
|
285
|
+
"""
|
|
286
|
+
return make_roi_set(
|
|
287
|
+
require_bool_array(group["masks"][()], name="roi_set/masks", ndim=3),
|
|
288
|
+
labels=_read_string_dataset(group, "labels"),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _write_background_traces(
|
|
293
|
+
group: h5py.Group,
|
|
294
|
+
traces: BackgroundCorrectedRoiTraces,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Write background-corrected trace arrays.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
group: Destination HDF5 group.
|
|
300
|
+
traces: Trace object to persist.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
None.
|
|
304
|
+
"""
|
|
305
|
+
group.attrs["start_frame"] = traces.start_frame
|
|
306
|
+
group.attrs["stop_frame"] = traces.stop_frame
|
|
307
|
+
group.attrs["statistic"] = traces.statistic
|
|
308
|
+
group.attrs["method"] = traces.method
|
|
309
|
+
_write_metadata(group.create_group("metadata"), traces.metadata)
|
|
310
|
+
_write_string_dataset(group, "labels", traces.labels)
|
|
311
|
+
group.create_dataset("raw_values", data=traces.raw_values, compression="gzip")
|
|
312
|
+
group.create_dataset(
|
|
313
|
+
"background_values",
|
|
314
|
+
data=traces.background_values,
|
|
315
|
+
compression="gzip",
|
|
316
|
+
)
|
|
317
|
+
group.create_dataset(
|
|
318
|
+
"corrected_values",
|
|
319
|
+
data=traces.corrected_values,
|
|
320
|
+
compression="gzip",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _read_background_traces(group: h5py.Group) -> BackgroundCorrectedRoiTraces:
|
|
325
|
+
"""Read persisted background-corrected traces.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
group: HDF5 ``traces`` group.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
``BackgroundCorrectedRoiTraces`` reconstructed from arrays and attrs.
|
|
332
|
+
"""
|
|
333
|
+
return BackgroundCorrectedRoiTraces(
|
|
334
|
+
raw_values=require_float64_array(
|
|
335
|
+
group["raw_values"][()],
|
|
336
|
+
name="traces/raw_values",
|
|
337
|
+
ndim=2,
|
|
338
|
+
),
|
|
339
|
+
background_values=require_float64_array(
|
|
340
|
+
group["background_values"][()],
|
|
341
|
+
name="traces/background_values",
|
|
342
|
+
ndim=2,
|
|
343
|
+
),
|
|
344
|
+
corrected_values=require_float64_array(
|
|
345
|
+
group["corrected_values"][()],
|
|
346
|
+
name="traces/corrected_values",
|
|
347
|
+
ndim=2,
|
|
348
|
+
),
|
|
349
|
+
labels=_read_string_dataset(group, "labels"),
|
|
350
|
+
start_frame=int(group.attrs["start_frame"]),
|
|
351
|
+
stop_frame=int(group.attrs["stop_frame"]),
|
|
352
|
+
statistic=require_string_choice(
|
|
353
|
+
_plain_attr_value(group.attrs["statistic"]),
|
|
354
|
+
name="traces statistic",
|
|
355
|
+
allowed=_TRACE_STATISTICS,
|
|
356
|
+
),
|
|
357
|
+
method=require_string_choice(
|
|
358
|
+
_plain_attr_value(group.attrs["method"]),
|
|
359
|
+
name="traces background correction method",
|
|
360
|
+
allowed=_BACKGROUND_CORRECTION_METHODS,
|
|
361
|
+
),
|
|
362
|
+
metadata=_read_metadata(group["metadata"]),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _write_dff(group: h5py.Group, dff: RoiDeltaFOverF) -> None:
|
|
367
|
+
"""Write ROI dF/F arrays and fit metadata.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
group: Destination HDF5 group.
|
|
371
|
+
dff: dF/F object to persist.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
None.
|
|
375
|
+
"""
|
|
376
|
+
group.attrs["start_frame"] = dff.start_frame
|
|
377
|
+
group.attrs["stop_frame"] = dff.stop_frame
|
|
378
|
+
group.attrs["tau"] = dff.tau
|
|
379
|
+
_write_metadata(group.create_group("metadata"), dff.metadata)
|
|
380
|
+
_write_string_dataset(group, "labels", dff.labels)
|
|
381
|
+
group.create_dataset("fluorescence", data=dff.fluorescence, compression="gzip")
|
|
382
|
+
group.create_dataset("baseline", data=dff.baseline, compression="gzip")
|
|
383
|
+
group.create_dataset("values", data=dff.values, compression="gzip")
|
|
384
|
+
group.create_dataset("amplitudes", data=dff.amplitudes)
|
|
385
|
+
group.create_dataset("interleave_frame_numbers", data=dff.interleave_frame_numbers)
|
|
386
|
+
group.create_dataset(
|
|
387
|
+
"interleave_fluorescence",
|
|
388
|
+
data=dff.interleave_fluorescence,
|
|
389
|
+
compression="gzip",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _read_dff(group: h5py.Group) -> RoiDeltaFOverF:
|
|
394
|
+
"""Read persisted ROI dF/F arrays and metadata.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
group: HDF5 ``dff`` group.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
``RoiDeltaFOverF`` reconstructed from saved datasets.
|
|
401
|
+
"""
|
|
402
|
+
return RoiDeltaFOverF(
|
|
403
|
+
fluorescence=require_float64_array(
|
|
404
|
+
group["fluorescence"][()],
|
|
405
|
+
name="dff/fluorescence",
|
|
406
|
+
ndim=2,
|
|
407
|
+
),
|
|
408
|
+
baseline=require_float64_array(
|
|
409
|
+
group["baseline"][()],
|
|
410
|
+
name="dff/baseline",
|
|
411
|
+
ndim=2,
|
|
412
|
+
),
|
|
413
|
+
values=require_float64_array(group["values"][()], name="dff/values", ndim=2),
|
|
414
|
+
labels=_read_string_dataset(group, "labels"),
|
|
415
|
+
start_frame=int(group.attrs["start_frame"]),
|
|
416
|
+
stop_frame=int(group.attrs["stop_frame"]),
|
|
417
|
+
tau=float(group.attrs["tau"]),
|
|
418
|
+
amplitudes=require_float64_array(
|
|
419
|
+
group["amplitudes"][()],
|
|
420
|
+
name="dff/amplitudes",
|
|
421
|
+
ndim=1,
|
|
422
|
+
),
|
|
423
|
+
interleave_frame_numbers=require_float64_array(
|
|
424
|
+
group["interleave_frame_numbers"][()],
|
|
425
|
+
name="dff/interleave_frame_numbers",
|
|
426
|
+
ndim=1,
|
|
427
|
+
),
|
|
428
|
+
interleave_fluorescence=require_float64_array(
|
|
429
|
+
group["interleave_fluorescence"][()],
|
|
430
|
+
name="dff/interleave_fluorescence",
|
|
431
|
+
ndim=2,
|
|
432
|
+
),
|
|
433
|
+
metadata=_read_metadata(group["metadata"]),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _write_epoch_windows(
|
|
438
|
+
group: h5py.Group,
|
|
439
|
+
epoch_windows: Sequence[EpochFrameWindow],
|
|
440
|
+
) -> None:
|
|
441
|
+
"""Write stimulus epoch frame windows as column datasets.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
group: Destination HDF5 group.
|
|
445
|
+
epoch_windows: Epoch windows to persist.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
None.
|
|
449
|
+
"""
|
|
450
|
+
group.create_dataset(
|
|
451
|
+
"window_index",
|
|
452
|
+
data=np.asarray([item.window.index for item in epoch_windows], dtype=np.int64),
|
|
453
|
+
)
|
|
454
|
+
group.create_dataset(
|
|
455
|
+
"start_frame",
|
|
456
|
+
data=np.asarray(
|
|
457
|
+
[item.window.start_frame for item in epoch_windows],
|
|
458
|
+
dtype=np.int64,
|
|
459
|
+
),
|
|
460
|
+
)
|
|
461
|
+
group.create_dataset(
|
|
462
|
+
"stop_frame",
|
|
463
|
+
data=np.asarray(
|
|
464
|
+
[item.window.stop_frame for item in epoch_windows],
|
|
465
|
+
dtype=np.int64,
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
group.create_dataset(
|
|
469
|
+
"epoch_number",
|
|
470
|
+
data=np.asarray([item.epoch_number for item in epoch_windows], dtype=np.int64),
|
|
471
|
+
)
|
|
472
|
+
_write_string_dataset(
|
|
473
|
+
group,
|
|
474
|
+
"epoch_name",
|
|
475
|
+
tuple(item.epoch_name for item in epoch_windows),
|
|
476
|
+
)
|
|
477
|
+
_write_string_dataset(
|
|
478
|
+
group,
|
|
479
|
+
"label",
|
|
480
|
+
tuple(item.window.label for item in epoch_windows),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _read_epoch_windows(group: h5py.Group) -> tuple[EpochFrameWindow, ...]:
|
|
485
|
+
"""Read persisted epoch windows.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
group: HDF5 ``epoch_windows`` group.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
Tuple of stimulus-labeled frame windows.
|
|
492
|
+
"""
|
|
493
|
+
window_indices = require_int64_array(
|
|
494
|
+
group["window_index"][()],
|
|
495
|
+
name="epoch_windows/window_index",
|
|
496
|
+
ndim=1,
|
|
497
|
+
)
|
|
498
|
+
start_frames = require_int64_array(
|
|
499
|
+
group["start_frame"][()],
|
|
500
|
+
name="epoch_windows/start_frame",
|
|
501
|
+
ndim=1,
|
|
502
|
+
)
|
|
503
|
+
stop_frames = require_int64_array(
|
|
504
|
+
group["stop_frame"][()],
|
|
505
|
+
name="epoch_windows/stop_frame",
|
|
506
|
+
ndim=1,
|
|
507
|
+
)
|
|
508
|
+
epoch_numbers = require_int64_array(
|
|
509
|
+
group["epoch_number"][()],
|
|
510
|
+
name="epoch_windows/epoch_number",
|
|
511
|
+
ndim=1,
|
|
512
|
+
)
|
|
513
|
+
epoch_names = _read_string_dataset(group, "epoch_name")
|
|
514
|
+
labels = _read_string_dataset(group, "label")
|
|
515
|
+
return tuple(
|
|
516
|
+
EpochFrameWindow(
|
|
517
|
+
window=FrameWindow(
|
|
518
|
+
index=int(window_index),
|
|
519
|
+
start_frame=int(start_frame),
|
|
520
|
+
stop_frame=int(stop_frame),
|
|
521
|
+
label=label,
|
|
522
|
+
),
|
|
523
|
+
epoch_number=int(epoch_number),
|
|
524
|
+
epoch_name=epoch_name,
|
|
525
|
+
)
|
|
526
|
+
for (
|
|
527
|
+
window_index,
|
|
528
|
+
start_frame,
|
|
529
|
+
stop_frame,
|
|
530
|
+
epoch_number,
|
|
531
|
+
epoch_name,
|
|
532
|
+
label,
|
|
533
|
+
) in zip(
|
|
534
|
+
window_indices,
|
|
535
|
+
start_frames,
|
|
536
|
+
stop_frames,
|
|
537
|
+
epoch_numbers,
|
|
538
|
+
epoch_names,
|
|
539
|
+
labels,
|
|
540
|
+
strict=True,
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _write_grouped_responses(
|
|
546
|
+
group: h5py.Group,
|
|
547
|
+
grouped: GroupedRoiResponses,
|
|
548
|
+
) -> None:
|
|
549
|
+
"""Write grouped response arrays.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
group: Destination HDF5 group.
|
|
553
|
+
grouped: Grouped responses to persist.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
None.
|
|
557
|
+
|
|
558
|
+
Trial lengths can differ, so each trial gets its own subgroup instead of a
|
|
559
|
+
padded cube with artificial fill values.
|
|
560
|
+
"""
|
|
561
|
+
group.attrs["data_rate_hz"] = grouped.data_rate_hz
|
|
562
|
+
_write_string_dataset(group, "roi_labels", grouped.roi_labels)
|
|
563
|
+
trials_group = group.create_group("trials")
|
|
564
|
+
for trial_position, trial in enumerate(grouped.trials):
|
|
565
|
+
trial_group = trials_group.create_group(f"trial_{trial_position + 1:04d}")
|
|
566
|
+
trial_group.attrs["epoch_number"] = trial.epoch_number
|
|
567
|
+
trial_group.attrs["epoch_name"] = trial.epoch_name
|
|
568
|
+
trial_group.attrs["trial_index"] = trial.trial_index
|
|
569
|
+
trial_group.attrs["window_index"] = trial.window_index
|
|
570
|
+
trial_group.attrs["start_frame"] = trial.start_frame
|
|
571
|
+
trial_group.attrs["stop_frame"] = trial.stop_frame
|
|
572
|
+
trial_group.create_dataset("frame_numbers", data=trial.frame_numbers)
|
|
573
|
+
trial_group.create_dataset("time_seconds", data=trial.time_seconds)
|
|
574
|
+
trial_group.create_dataset("values", data=trial.values, compression="gzip")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _read_grouped_responses(group: h5py.Group) -> GroupedRoiResponses:
|
|
578
|
+
"""Read persisted grouped response arrays.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
group: HDF5 ``responses`` group.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
``GroupedRoiResponses`` with one object per trial subgroup.
|
|
585
|
+
"""
|
|
586
|
+
trials_group = group["trials"]
|
|
587
|
+
return GroupedRoiResponses(
|
|
588
|
+
roi_labels=_read_string_dataset(group, "roi_labels"),
|
|
589
|
+
data_rate_hz=float(group.attrs["data_rate_hz"]),
|
|
590
|
+
trials=tuple(
|
|
591
|
+
_read_response_trial(trials_group[name])
|
|
592
|
+
for name in sorted(trials_group.keys())
|
|
593
|
+
),
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _read_response_trial(group: h5py.Group) -> RoiResponseTrial:
|
|
598
|
+
"""Read one persisted response trial group.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
group: HDF5 trial subgroup.
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
``RoiResponseTrial`` with frame/time vectors and values.
|
|
605
|
+
"""
|
|
606
|
+
return RoiResponseTrial(
|
|
607
|
+
epoch_number=int(group.attrs["epoch_number"]),
|
|
608
|
+
epoch_name=str(_plain_attr_value(group.attrs["epoch_name"])),
|
|
609
|
+
trial_index=int(group.attrs["trial_index"]),
|
|
610
|
+
window_index=int(group.attrs["window_index"]),
|
|
611
|
+
start_frame=int(group.attrs["start_frame"]),
|
|
612
|
+
stop_frame=int(group.attrs["stop_frame"]),
|
|
613
|
+
frame_numbers=require_int64_array(
|
|
614
|
+
group["frame_numbers"][()],
|
|
615
|
+
name="responses/trial/frame_numbers",
|
|
616
|
+
ndim=1,
|
|
617
|
+
),
|
|
618
|
+
time_seconds=require_float64_array(
|
|
619
|
+
group["time_seconds"][()],
|
|
620
|
+
name="responses/trial/time_seconds",
|
|
621
|
+
ndim=1,
|
|
622
|
+
),
|
|
623
|
+
values=require_float64_array(
|
|
624
|
+
group["values"][()],
|
|
625
|
+
name="responses/trial/values",
|
|
626
|
+
ndim=2,
|
|
627
|
+
),
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _write_metadata(
|
|
632
|
+
group: h5py.Group,
|
|
633
|
+
metadata: Mapping[str, str | int | float | bool],
|
|
634
|
+
) -> None:
|
|
635
|
+
"""Write simple metadata values as HDF5 attributes.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
group: Destination HDF5 group.
|
|
639
|
+
metadata: Metadata values from analysis objects.
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
None.
|
|
643
|
+
"""
|
|
644
|
+
for key, value in metadata.items():
|
|
645
|
+
if isinstance(value, str | int | float | bool | np.generic):
|
|
646
|
+
group.attrs[key] = value
|
|
647
|
+
else:
|
|
648
|
+
group.attrs[key] = json.dumps(value)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _read_metadata(group: h5py.Group) -> dict[str, str | int | float | bool]:
|
|
652
|
+
"""Read simple metadata attributes.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
group: HDF5 metadata group.
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
Plain Python metadata dictionary.
|
|
659
|
+
"""
|
|
660
|
+
return {key: _plain_attr_value(value) for key, value in group.attrs.items()}
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _write_string_dataset(
|
|
664
|
+
group: h5py.Group,
|
|
665
|
+
name: str,
|
|
666
|
+
values: tuple[str, ...],
|
|
667
|
+
) -> None:
|
|
668
|
+
"""Write a tuple of strings as a UTF-8 HDF5 dataset.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
group: Destination HDF5 group.
|
|
672
|
+
name: Dataset name.
|
|
673
|
+
values: String values to write.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
None.
|
|
677
|
+
"""
|
|
678
|
+
group.create_dataset(
|
|
679
|
+
name,
|
|
680
|
+
data=np.asarray(values, dtype=h5py.string_dtype("utf-8")),
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _read_string_dataset(group: h5py.Group, name: str) -> tuple[str, ...]:
|
|
685
|
+
"""Read a UTF-8 string dataset.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
group: HDF5 group containing the dataset.
|
|
689
|
+
name: Dataset name.
|
|
690
|
+
|
|
691
|
+
Returns:
|
|
692
|
+
Tuple of decoded strings.
|
|
693
|
+
"""
|
|
694
|
+
return tuple(_decode_string(value) for value in group[name][()])
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _time_columns(trials: Sequence[RoiResponseTrial]) -> tuple[str, ...]:
|
|
698
|
+
"""Return stable CSV column names for every relative response timepoint.
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
trials: Response trials whose time axes should become CSV columns.
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
Sorted column names, one per unique relative time in seconds.
|
|
705
|
+
|
|
706
|
+
Trials can include pre-window or post-window context and different epoch
|
|
707
|
+
types can have different lengths. A shared union of timepoints lets one CSV
|
|
708
|
+
hold all rows without padding arrays in memory.
|
|
709
|
+
"""
|
|
710
|
+
time_values = sorted(
|
|
711
|
+
{float(time) for trial in trials for time in trial.time_seconds},
|
|
712
|
+
)
|
|
713
|
+
columns = tuple(_time_column_name(time) for time in time_values)
|
|
714
|
+
if len(columns) != len(set(columns)):
|
|
715
|
+
msg = "Response time columns are not unique after formatting"
|
|
716
|
+
raise ValueError(msg)
|
|
717
|
+
return columns
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _time_column_name(time_seconds: float) -> str:
|
|
721
|
+
"""Format one relative response time as a CSV column name.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
time_seconds: Relative time in seconds.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
Human-readable CSV column name.
|
|
728
|
+
"""
|
|
729
|
+
return f"time_s_{time_seconds:.6f}"
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _validate_response_trial(
|
|
733
|
+
trial: RoiResponseTrial,
|
|
734
|
+
roi_labels: tuple[str, ...],
|
|
735
|
+
) -> None:
|
|
736
|
+
"""Validate one response trial before flattening it into CSV rows.
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
trial: Trial response object to validate.
|
|
740
|
+
roi_labels: ROI labels that should match the response matrix width.
|
|
741
|
+
|
|
742
|
+
Returns:
|
|
743
|
+
None.
|
|
744
|
+
"""
|
|
745
|
+
if trial.values.shape != (trial.time_seconds.size, len(roi_labels)):
|
|
746
|
+
msg = (
|
|
747
|
+
"Trial response values must have shape (timepoints, rois); "
|
|
748
|
+
f"got {trial.values.shape}, expected "
|
|
749
|
+
f"({trial.time_seconds.size}, {len(roi_labels)})"
|
|
750
|
+
)
|
|
751
|
+
raise ValueError(msg)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _trial_response_row(
|
|
755
|
+
trial: RoiResponseTrial,
|
|
756
|
+
*,
|
|
757
|
+
roi_index: int,
|
|
758
|
+
roi_label: str,
|
|
759
|
+
time_columns: tuple[str, ...],
|
|
760
|
+
) -> dict[str, str | int | float]:
|
|
761
|
+
"""Convert one trial and ROI response trace to a CSV row.
|
|
762
|
+
|
|
763
|
+
Args:
|
|
764
|
+
trial: Trial response object.
|
|
765
|
+
roi_index: Zero-based ROI column index in ``trial.values``.
|
|
766
|
+
roi_label: Display label for that ROI.
|
|
767
|
+
time_columns: Complete CSV time-column union.
|
|
768
|
+
|
|
769
|
+
Returns:
|
|
770
|
+
Dictionary keyed by trial-response CSV column name.
|
|
771
|
+
"""
|
|
772
|
+
row: dict[str, str | int | float] = {
|
|
773
|
+
"epoch_number": trial.epoch_number,
|
|
774
|
+
"epoch_name": trial.epoch_name,
|
|
775
|
+
"trial_index": trial.trial_index,
|
|
776
|
+
"window_index": trial.window_index,
|
|
777
|
+
"roi_label": roi_label,
|
|
778
|
+
"start_frame": trial.start_frame,
|
|
779
|
+
"stop_frame": trial.stop_frame,
|
|
780
|
+
"frame_count": trial.stop_frame - trial.start_frame,
|
|
781
|
+
}
|
|
782
|
+
row.update({column: "" for column in time_columns})
|
|
783
|
+
for time_seconds, value in zip(
|
|
784
|
+
trial.time_seconds,
|
|
785
|
+
trial.values[:, roi_index],
|
|
786
|
+
strict=True,
|
|
787
|
+
):
|
|
788
|
+
row[_time_column_name(float(time_seconds))] = float(value)
|
|
789
|
+
return row
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _grouped_response_rows(
|
|
793
|
+
grouped: GroupedRoiResponses,
|
|
794
|
+
time_columns: tuple[str, ...],
|
|
795
|
+
) -> tuple[dict[str, str | int | float], ...]:
|
|
796
|
+
"""Convert grouped trial responses to mean, SEM, and count CSV rows.
|
|
797
|
+
|
|
798
|
+
Args:
|
|
799
|
+
grouped: Grouped response object.
|
|
800
|
+
time_columns: Complete CSV time-column union.
|
|
801
|
+
|
|
802
|
+
Returns:
|
|
803
|
+
Rows keyed by epoch, ROI, statistic, and relative timepoint.
|
|
804
|
+
|
|
805
|
+
Counts are per timepoint rather than one scalar because pre/post context or
|
|
806
|
+
clipped recording edges can make some trials shorter than others.
|
|
807
|
+
"""
|
|
808
|
+
rows: list[dict[str, str | int | float]] = []
|
|
809
|
+
for epoch_number, epoch_name, trials in _trials_by_epoch(grouped.trials):
|
|
810
|
+
for roi_index, roi_label in enumerate(grouped.roi_labels):
|
|
811
|
+
values_by_time = _values_by_time_column(
|
|
812
|
+
trials,
|
|
813
|
+
roi_index=roi_index,
|
|
814
|
+
time_columns=time_columns,
|
|
815
|
+
)
|
|
816
|
+
rows.extend(
|
|
817
|
+
_epoch_roi_statistic_rows(
|
|
818
|
+
epoch_number=epoch_number,
|
|
819
|
+
epoch_name=epoch_name,
|
|
820
|
+
roi_label=roi_label,
|
|
821
|
+
values_by_time=values_by_time,
|
|
822
|
+
time_columns=time_columns,
|
|
823
|
+
),
|
|
824
|
+
)
|
|
825
|
+
return tuple(rows)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _trials_by_epoch(
|
|
829
|
+
trials: Sequence[RoiResponseTrial],
|
|
830
|
+
) -> tuple[tuple[int, str, tuple[RoiResponseTrial, ...]], ...]:
|
|
831
|
+
"""Group trials by epoch while preserving first-seen epoch order.
|
|
832
|
+
|
|
833
|
+
Args:
|
|
834
|
+
trials: Trial responses to group.
|
|
835
|
+
|
|
836
|
+
Returns:
|
|
837
|
+
Tuple of ``(epoch_number, epoch_name, trials)`` groups.
|
|
838
|
+
"""
|
|
839
|
+
grouped: dict[tuple[int, str], list[RoiResponseTrial]] = {}
|
|
840
|
+
for trial in trials:
|
|
841
|
+
grouped.setdefault((trial.epoch_number, trial.epoch_name), []).append(trial)
|
|
842
|
+
return tuple(
|
|
843
|
+
(epoch_number, epoch_name, tuple(epoch_trials))
|
|
844
|
+
for (epoch_number, epoch_name), epoch_trials in grouped.items()
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _values_by_time_column(
|
|
849
|
+
trials: Sequence[RoiResponseTrial],
|
|
850
|
+
*,
|
|
851
|
+
roi_index: int,
|
|
852
|
+
time_columns: tuple[str, ...],
|
|
853
|
+
) -> dict[str, list[float]]:
|
|
854
|
+
"""Collect repeated-trial values for one ROI by relative time column.
|
|
855
|
+
|
|
856
|
+
Args:
|
|
857
|
+
trials: Trials from one epoch type.
|
|
858
|
+
roi_index: Zero-based ROI column index.
|
|
859
|
+
time_columns: Complete CSV time-column union.
|
|
860
|
+
|
|
861
|
+
Returns:
|
|
862
|
+
Mapping from time column to observed response values.
|
|
863
|
+
"""
|
|
864
|
+
values_by_time = {column: [] for column in time_columns}
|
|
865
|
+
for trial in trials:
|
|
866
|
+
for time_seconds, value in zip(
|
|
867
|
+
trial.time_seconds,
|
|
868
|
+
trial.values[:, roi_index],
|
|
869
|
+
strict=True,
|
|
870
|
+
):
|
|
871
|
+
if not np.isnan(value):
|
|
872
|
+
values_by_time[_time_column_name(float(time_seconds))].append(
|
|
873
|
+
float(value),
|
|
874
|
+
)
|
|
875
|
+
return values_by_time
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _epoch_roi_statistic_rows(
|
|
879
|
+
*,
|
|
880
|
+
epoch_number: int,
|
|
881
|
+
epoch_name: str,
|
|
882
|
+
roi_label: str,
|
|
883
|
+
values_by_time: Mapping[str, Sequence[float]],
|
|
884
|
+
time_columns: tuple[str, ...],
|
|
885
|
+
) -> tuple[dict[str, str | int | float], ...]:
|
|
886
|
+
"""Build grouped mean, SEM, and count rows for one epoch and ROI.
|
|
887
|
+
|
|
888
|
+
Args:
|
|
889
|
+
epoch_number: Stimulus epoch number.
|
|
890
|
+
epoch_name: Stimulus epoch name.
|
|
891
|
+
roi_label: ROI label.
|
|
892
|
+
values_by_time: Response values keyed by relative time column.
|
|
893
|
+
time_columns: Complete CSV time-column union.
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
Three CSV rows: ``mean``, ``sem``, and ``n_trials``.
|
|
897
|
+
"""
|
|
898
|
+
base_row: dict[str, str | int | float] = {
|
|
899
|
+
"epoch_number": epoch_number,
|
|
900
|
+
"epoch_name": epoch_name,
|
|
901
|
+
"roi_label": roi_label,
|
|
902
|
+
}
|
|
903
|
+
mean_row = {**base_row, "statistic": "mean"}
|
|
904
|
+
sem_row = {**base_row, "statistic": "sem"}
|
|
905
|
+
count_row = {**base_row, "statistic": "n_trials"}
|
|
906
|
+
for column in time_columns:
|
|
907
|
+
values = np.asarray(values_by_time[column], dtype=np.float64)
|
|
908
|
+
mean_row[column] = float(np.nanmean(values)) if values.size > 0 else ""
|
|
909
|
+
sem_row[column] = _sem(values) if values.size > 0 else ""
|
|
910
|
+
count_row[column] = int(values.size)
|
|
911
|
+
return mean_row, sem_row, count_row
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _sem(values: npt.NDArray[np.float64]) -> float:
|
|
915
|
+
"""Return the standard error for repeated-trial values.
|
|
916
|
+
|
|
917
|
+
Args:
|
|
918
|
+
values: One-dimensional response values at a single timepoint.
|
|
919
|
+
|
|
920
|
+
Returns:
|
|
921
|
+
SEM value, or ``0`` when only one trial contributes.
|
|
922
|
+
"""
|
|
923
|
+
if values.size <= 1:
|
|
924
|
+
return 0.0
|
|
925
|
+
return float(np.nanstd(values, ddof=1) / np.sqrt(values.size))
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _require_analysis_format(h5_file: h5py.File, path: Path) -> None:
|
|
929
|
+
"""Validate the twopy analysis HDF5 format marker.
|
|
930
|
+
|
|
931
|
+
Args:
|
|
932
|
+
h5_file: Open analysis HDF5 file.
|
|
933
|
+
path: Path used in error messages.
|
|
934
|
+
|
|
935
|
+
Returns:
|
|
936
|
+
None.
|
|
937
|
+
"""
|
|
938
|
+
actual = str(_plain_attr_value(h5_file.attrs.get("twopy_format", "")))
|
|
939
|
+
if actual != ANALYSIS_OUTPUT_FILE_FORMAT:
|
|
940
|
+
msg = f"Expected {ANALYSIS_OUTPUT_FILE_FORMAT!r} file at {path}"
|
|
941
|
+
raise ValueError(msg)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _plain_attr_value(value: object) -> str | int | float | bool:
|
|
945
|
+
"""Convert one HDF5 attribute value to a plain Python scalar.
|
|
946
|
+
|
|
947
|
+
Args:
|
|
948
|
+
value: Raw HDF5 attribute value.
|
|
949
|
+
|
|
950
|
+
Returns:
|
|
951
|
+
Plain string, integer, float, or boolean.
|
|
952
|
+
"""
|
|
953
|
+
if isinstance(value, bytes):
|
|
954
|
+
return value.decode("utf-8")
|
|
955
|
+
if isinstance(value, np.bool_ | bool):
|
|
956
|
+
return bool(value)
|
|
957
|
+
if isinstance(value, np.integer | int):
|
|
958
|
+
return int(value)
|
|
959
|
+
if isinstance(value, np.floating | float):
|
|
960
|
+
return float(value)
|
|
961
|
+
return str(value)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _decode_string(value: object) -> str:
|
|
965
|
+
"""Decode one HDF5 string scalar.
|
|
966
|
+
|
|
967
|
+
Args:
|
|
968
|
+
value: Raw scalar read from a string dataset.
|
|
969
|
+
|
|
970
|
+
Returns:
|
|
971
|
+
Decoded text.
|
|
972
|
+
"""
|
|
973
|
+
if isinstance(value, bytes):
|
|
974
|
+
return value.decode("utf-8")
|
|
975
|
+
return str(value)
|