mesofield 0.3.2b0__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.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- scripts/bench_frame_processor.py +103 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""Suite2p loader with nidaq-aligned timeline and table payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Dict, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from mesofield.datakit.sources.register import LoadContext, TimeseriesSource
|
|
14
|
+
from mesofield.datakit.datamodel import StreamPayload
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Suite2pV2(TimeseriesSource):
|
|
19
|
+
"""Load Suite2p outputs using nidaq pulse alignment."""
|
|
20
|
+
|
|
21
|
+
tag = "suite2p"
|
|
22
|
+
patterns = ("**/*_suite2p/**/F.npy",)
|
|
23
|
+
requires = ("dataqueue",)
|
|
24
|
+
|
|
25
|
+
required_files: Tuple[str, ...] = ("Fneu.npy", "iscell.npy")
|
|
26
|
+
optional_files: Tuple[str, ...] = ("ops.npy", "stat.npy")
|
|
27
|
+
|
|
28
|
+
COLUMN_MAP: Dict[Tuple[str, str], str] = {
|
|
29
|
+
("raw", "cell_identifier"): "cell_identifier",
|
|
30
|
+
("raw", "stat"): "stat",
|
|
31
|
+
("raw", "ops"): "ops",
|
|
32
|
+
("raw", "plane_directory"): "plane_directory",
|
|
33
|
+
("raw", "cell_mask"): "cell_mask",
|
|
34
|
+
("processed", "roi_fluorescence"): "roi_fluorescence",
|
|
35
|
+
("processed", "neuropil_fluorescence"): "neuropil_fluorescence",
|
|
36
|
+
("processed", "deltaf_f"): "deltaf_f",
|
|
37
|
+
("toolkit", "time_native_s"): "time_native_s",
|
|
38
|
+
("toolkit", "time_elapsed_s"): "time_elapsed_s",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
NEUROPIL_SCALE = 0.3
|
|
42
|
+
BASELINE_PERCENTILE = 3.0
|
|
43
|
+
PULSES_PER_FRAME: Optional[int] = 2
|
|
44
|
+
|
|
45
|
+
def build_timeseries(
|
|
46
|
+
self,
|
|
47
|
+
path: Path,
|
|
48
|
+
*,
|
|
49
|
+
context: LoadContext | None = None,
|
|
50
|
+
) -> tuple[np.ndarray, StreamPayload, dict]:
|
|
51
|
+
"""Load Suite2p outputs and return timeline, payload, and metadata."""
|
|
52
|
+
plane_dir = path.parent
|
|
53
|
+
session_dir, subject, session, task = self._find_session_dir(plane_dir)
|
|
54
|
+
dq_path = None
|
|
55
|
+
if context is not None:
|
|
56
|
+
dq_path = context.path_for("dataqueue")
|
|
57
|
+
|
|
58
|
+
f_traces = self._ensure_float32(self._load_array(path))
|
|
59
|
+
ancillary = self._load_companions(plane_dir)
|
|
60
|
+
|
|
61
|
+
n_cells, n_frames = f_traces.shape
|
|
62
|
+
|
|
63
|
+
timeline, timing_meta = self._nidaq_timeline(
|
|
64
|
+
session_dir,
|
|
65
|
+
n_frames,
|
|
66
|
+
dq_path=dq_path,
|
|
67
|
+
dq_frame=context.dataqueue_frame if context else None,
|
|
68
|
+
subject=subject,
|
|
69
|
+
session=session,
|
|
70
|
+
task=task,
|
|
71
|
+
)
|
|
72
|
+
timeline = timeline - timeline[0]
|
|
73
|
+
|
|
74
|
+
cell_mask = ancillary["iscell"][:, 0].astype(bool)
|
|
75
|
+
accepted_cells = int(cell_mask.sum())
|
|
76
|
+
|
|
77
|
+
sections, processing_meta = self._process_traces(
|
|
78
|
+
raw_traces=f_traces,
|
|
79
|
+
neuropil_traces=ancillary["Fneu"],
|
|
80
|
+
cell_mask=cell_mask,
|
|
81
|
+
timeline=timeline,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
raw_section = {
|
|
85
|
+
"cell_identifier": ancillary["iscell"],
|
|
86
|
+
"stat": ancillary["stat"],
|
|
87
|
+
"ops": ancillary["ops"],
|
|
88
|
+
"plane_directory": str(plane_dir),
|
|
89
|
+
"cell_mask": cell_mask,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
sections = {"raw": raw_section, **sections}
|
|
93
|
+
|
|
94
|
+
table = self._build_payload_table(sections)
|
|
95
|
+
payload = StreamPayload.table(table)
|
|
96
|
+
|
|
97
|
+
meta = {
|
|
98
|
+
"source_file": str(path),
|
|
99
|
+
"plane": plane_dir.name,
|
|
100
|
+
"session_dir": str(session_dir) if session_dir else None,
|
|
101
|
+
"n_rois": int(n_cells),
|
|
102
|
+
"n_cells": accepted_cells,
|
|
103
|
+
"n_frames": int(n_frames),
|
|
104
|
+
}
|
|
105
|
+
meta.update(timing_meta)
|
|
106
|
+
meta.update(processing_meta)
|
|
107
|
+
|
|
108
|
+
return timeline, payload, meta
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Processing helpers
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
def _process_traces(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
raw_traces: np.ndarray,
|
|
117
|
+
neuropil_traces: np.ndarray,
|
|
118
|
+
cell_mask: np.ndarray,
|
|
119
|
+
timeline: np.ndarray,
|
|
120
|
+
) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Any]]:
|
|
121
|
+
timeline = np.asarray(timeline, dtype=np.float64)
|
|
122
|
+
|
|
123
|
+
defaults = {
|
|
124
|
+
"neuropil_scale": self.NEUROPIL_SCALE,
|
|
125
|
+
"baseline_percentile": self.BASELINE_PERCENTILE,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if raw_traces.size == 0 or neuropil_traces.size == 0:
|
|
129
|
+
raise ValueError("Suite2pV2: empty fluorescence arrays")
|
|
130
|
+
|
|
131
|
+
if timeline.shape[0] != raw_traces.shape[1]:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"Suite2pV2: timeline length mismatch; expected one timestamp per frame"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
roi_fluo = raw_traces[cell_mask].astype(np.float32, copy=False)
|
|
137
|
+
neuropil_fluo = neuropil_traces[cell_mask].astype(np.float32, copy=False)
|
|
138
|
+
|
|
139
|
+
filtered_meta = {
|
|
140
|
+
"total_rois": int(raw_traces.shape[0]),
|
|
141
|
+
"filtered_cells": int(roi_fluo.shape[0]),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if roi_fluo.size == 0:
|
|
145
|
+
raise ValueError("Suite2pV2: no accepted cells after masking")
|
|
146
|
+
|
|
147
|
+
# Step 1: baseline and native $\Delta F / F$
|
|
148
|
+
baseline = np.percentile(
|
|
149
|
+
roi_fluo,
|
|
150
|
+
self.BASELINE_PERCENTILE,
|
|
151
|
+
axis=1,
|
|
152
|
+
keepdims=True,
|
|
153
|
+
).astype(np.float32, copy=False)
|
|
154
|
+
baseline_mask = np.abs(baseline) > np.finfo(np.float64).eps
|
|
155
|
+
deltaf_f = np.full_like(roi_fluo, np.nan, dtype=np.float32)
|
|
156
|
+
np.divide(
|
|
157
|
+
roi_fluo - baseline,
|
|
158
|
+
baseline,
|
|
159
|
+
out=deltaf_f,
|
|
160
|
+
where=baseline_mask,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
sections = {
|
|
164
|
+
"processed": {
|
|
165
|
+
"roi_fluorescence": roi_fluo,
|
|
166
|
+
"neuropil_fluorescence": neuropil_fluo,
|
|
167
|
+
"deltaf_f": deltaf_f,
|
|
168
|
+
},
|
|
169
|
+
"toolkit": {
|
|
170
|
+
"time_native_s": self._ensure_float32(timeline),
|
|
171
|
+
"time_elapsed_s": self._ensure_float32(timeline),
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
meta = {
|
|
176
|
+
**filtered_meta,
|
|
177
|
+
"processing_status": "ok",
|
|
178
|
+
**defaults,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if baseline.size:
|
|
182
|
+
meta["baseline_percentile_mean"] = float(np.nanmean(baseline))
|
|
183
|
+
return sections, meta
|
|
184
|
+
|
|
185
|
+
def _build_payload_table(self, sections: Dict[str, Dict[str, Any]]) -> pd.DataFrame:
|
|
186
|
+
flat_data: Dict[str, list[Any]] = {}
|
|
187
|
+
|
|
188
|
+
for section, items in sections.items():
|
|
189
|
+
if not items:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
for field, value in items.items():
|
|
193
|
+
column_name = self.COLUMN_MAP.get((section, field))
|
|
194
|
+
if column_name is None:
|
|
195
|
+
raise KeyError(f"Suite2pV2 column mapping missing for section '{section}' field '{field}'")
|
|
196
|
+
|
|
197
|
+
row_labels: list[str] | None = None
|
|
198
|
+
data_value = value
|
|
199
|
+
|
|
200
|
+
if isinstance(value, tuple) and len(value) == 2:
|
|
201
|
+
candidate, labels = value
|
|
202
|
+
if isinstance(labels, Iterable) and not isinstance(labels, (str, bytes)):
|
|
203
|
+
row_labels = []
|
|
204
|
+
for idx, lbl in enumerate(labels):
|
|
205
|
+
label_text = str(lbl).strip().replace(" ", "_")
|
|
206
|
+
row_labels.append(label_text or f"component_{idx}")
|
|
207
|
+
data_value = candidate
|
|
208
|
+
else:
|
|
209
|
+
data_value = candidate
|
|
210
|
+
|
|
211
|
+
coerced = self._coerce_table_value(data_value)
|
|
212
|
+
|
|
213
|
+
if row_labels:
|
|
214
|
+
components = self._expand_components(coerced, len(row_labels))
|
|
215
|
+
for idx, label in enumerate(row_labels):
|
|
216
|
+
flat_data[f"{column_name}_{label}"] = [components[idx]]
|
|
217
|
+
else:
|
|
218
|
+
flat_data[column_name] = [coerced]
|
|
219
|
+
|
|
220
|
+
frame = pd.DataFrame(flat_data)
|
|
221
|
+
frame.index = pd.Index([0], name="record")
|
|
222
|
+
return frame
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _coerce_table_value(value: Any) -> Any:
|
|
226
|
+
if isinstance(value, pd.Series):
|
|
227
|
+
return Suite2pV2._coerce_table_value(value.to_numpy(copy=False))
|
|
228
|
+
if isinstance(value, pd.Index):
|
|
229
|
+
return Suite2pV2._coerce_table_value(value.to_numpy(copy=False))
|
|
230
|
+
if isinstance(value, np.ndarray):
|
|
231
|
+
if value.dtype == object:
|
|
232
|
+
return [Suite2pV2._coerce_table_value(item) for item in value.tolist()]
|
|
233
|
+
return Suite2pV2._ensure_numeric_dtype(value)
|
|
234
|
+
if isinstance(value, (list, tuple)):
|
|
235
|
+
if not value:
|
|
236
|
+
return np.asarray(value)
|
|
237
|
+
try:
|
|
238
|
+
array = np.asarray(value)
|
|
239
|
+
except Exception:
|
|
240
|
+
array = None
|
|
241
|
+
if array is not None and array.dtype != object:
|
|
242
|
+
return Suite2pV2._ensure_numeric_dtype(array)
|
|
243
|
+
return [Suite2pV2._coerce_table_value(item) for item in value]
|
|
244
|
+
if isinstance(value, np.generic):
|
|
245
|
+
return value.item()
|
|
246
|
+
return value
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _expand_components(value: Any, expected: int) -> list[Any]:
|
|
250
|
+
if isinstance(value, list) and len(value) >= expected:
|
|
251
|
+
return value
|
|
252
|
+
if isinstance(value, list) and value:
|
|
253
|
+
first = value[0]
|
|
254
|
+
return [first for _ in range(expected)]
|
|
255
|
+
if isinstance(value, np.ndarray):
|
|
256
|
+
if value.ndim >= 1 and value.shape[0] >= expected:
|
|
257
|
+
return [value[idx] for idx in range(expected)]
|
|
258
|
+
return [value for _ in range(expected)]
|
|
259
|
+
return [value for _ in range(expected)]
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def _ensure_numeric_dtype(array: np.ndarray) -> np.ndarray:
|
|
263
|
+
if array.dtype.kind in {"i", "u"}:
|
|
264
|
+
return array.astype(np.int32, copy=False)
|
|
265
|
+
if array.dtype.kind in {"f"} and array.dtype != np.float32:
|
|
266
|
+
return array.astype(np.float32, copy=False)
|
|
267
|
+
return array
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _ensure_float32(array: np.ndarray | Iterable[float]) -> np.ndarray:
|
|
271
|
+
arr = np.asarray(array)
|
|
272
|
+
if arr.dtype == np.float32:
|
|
273
|
+
return arr
|
|
274
|
+
return arr.astype(np.float32, copy=False)
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _cast_nested_numeric(value: Any) -> Any:
|
|
278
|
+
if isinstance(value, dict):
|
|
279
|
+
return {key: Suite2pV2._cast_nested_numeric(val) for key, val in value.items()}
|
|
280
|
+
if isinstance(value, list):
|
|
281
|
+
return [Suite2pV2._cast_nested_numeric(val) for val in value]
|
|
282
|
+
if isinstance(value, tuple):
|
|
283
|
+
return tuple(Suite2pV2._cast_nested_numeric(val) for val in value)
|
|
284
|
+
if isinstance(value, np.ndarray):
|
|
285
|
+
if value.dtype == object:
|
|
286
|
+
return [Suite2pV2._cast_nested_numeric(item) for item in value.tolist()]
|
|
287
|
+
return Suite2pV2._ensure_numeric_dtype(value)
|
|
288
|
+
if isinstance(value, np.generic):
|
|
289
|
+
kind = value.dtype.kind
|
|
290
|
+
if kind == "f" and value.dtype != np.float32:
|
|
291
|
+
return np.float32(value)
|
|
292
|
+
if kind in {"i", "u"} and value.itemsize > np.dtype(np.int32).itemsize:
|
|
293
|
+
return np.int32(value)
|
|
294
|
+
return value.item()
|
|
295
|
+
return value
|
|
296
|
+
|
|
297
|
+
# ------------------------------------------------------------------
|
|
298
|
+
# Loading helpers
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _load_array(path: Path) -> np.ndarray:
|
|
302
|
+
arr = np.load(path, allow_pickle=False)
|
|
303
|
+
if not isinstance(arr, np.ndarray):
|
|
304
|
+
raise ValueError(f"Expected numpy array in {path}, got {type(arr)}")
|
|
305
|
+
return arr
|
|
306
|
+
|
|
307
|
+
def _load_companions(self, plane_dir: Path) -> Dict[str, Any]:
|
|
308
|
+
required = tuple(self.required_files)
|
|
309
|
+
|
|
310
|
+
missing = [name for name in required if not (plane_dir / name).exists()]
|
|
311
|
+
if missing:
|
|
312
|
+
raise FileNotFoundError(f"Suite2p plane missing companion files {missing} in {plane_dir}")
|
|
313
|
+
|
|
314
|
+
companions: Dict[str, Any] = {}
|
|
315
|
+
companions["Fneu"] = self._ensure_float32(self._load_array(plane_dir / "Fneu.npy"))
|
|
316
|
+
companions["iscell"] = self._ensure_numeric_dtype(
|
|
317
|
+
np.load(plane_dir / "iscell.npy", allow_pickle=False)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
stat_file = "stat.npy"
|
|
321
|
+
stat_path = plane_dir / stat_file
|
|
322
|
+
if stat_path.exists():
|
|
323
|
+
stat_raw = np.load(stat_path, allow_pickle=True)
|
|
324
|
+
stat_payload = stat_raw.tolist() if hasattr(stat_raw, "tolist") else stat_raw
|
|
325
|
+
companions["stat"] = self._cast_nested_numeric(stat_payload)
|
|
326
|
+
else:
|
|
327
|
+
companions["stat"] = []
|
|
328
|
+
|
|
329
|
+
ops_file = "ops.npy"
|
|
330
|
+
ops_path = plane_dir / ops_file
|
|
331
|
+
if ops_path.exists():
|
|
332
|
+
ops_raw = np.load(ops_path, allow_pickle=True)
|
|
333
|
+
try:
|
|
334
|
+
ops_payload = ops_raw.item()
|
|
335
|
+
except (ValueError, AttributeError):
|
|
336
|
+
ops_payload = ops_raw
|
|
337
|
+
companions["ops"] = self._cast_nested_numeric(ops_payload)
|
|
338
|
+
else:
|
|
339
|
+
companions["ops"] = {}
|
|
340
|
+
|
|
341
|
+
return companions
|
|
342
|
+
|
|
343
|
+
def _nidaq_timeline(
|
|
344
|
+
self,
|
|
345
|
+
session_dir: Optional[Path],
|
|
346
|
+
n_frames: int,
|
|
347
|
+
*,
|
|
348
|
+
dq_path: Optional[Path] = None,
|
|
349
|
+
dq_frame: Optional[pd.DataFrame] = None,
|
|
350
|
+
subject: Optional[str] = None,
|
|
351
|
+
session: Optional[str] = None,
|
|
352
|
+
task: Optional[str] = None,
|
|
353
|
+
) -> tuple[np.ndarray, dict]:
|
|
354
|
+
"""Build timeline using nidaq pulse timestamps from dataqueue."""
|
|
355
|
+
if dq_frame is None and dq_path is None:
|
|
356
|
+
if session_dir is None:
|
|
357
|
+
raise FileNotFoundError("Suite2pV2: session directory not found for nidaq timeline")
|
|
358
|
+
dq_path = self._find_dataqueue(session_dir, subject=subject, session=session, task=task)
|
|
359
|
+
if dq_path is None:
|
|
360
|
+
raise FileNotFoundError("Suite2pV2: dataqueue file not found for nidaq timeline")
|
|
361
|
+
if dq_frame is None:
|
|
362
|
+
df = pd.read_csv(dq_path)
|
|
363
|
+
else:
|
|
364
|
+
df = dq_frame
|
|
365
|
+
|
|
366
|
+
if not {"device_id", "queue_elapsed", "payload"}.issubset(df.columns):
|
|
367
|
+
raise ValueError("Suite2pV2: dataqueue missing device_id/queue_elapsed/payload columns")
|
|
368
|
+
|
|
369
|
+
nidaq = df.loc[df["device_id"].astype(str).str.contains("nidaq", case=False, na=False)]
|
|
370
|
+
if nidaq.empty:
|
|
371
|
+
raise ValueError("Suite2pV2: no nidaq rows found in dataqueue")
|
|
372
|
+
|
|
373
|
+
nidaq_times = pd.to_numeric(nidaq["queue_elapsed"], errors="coerce")
|
|
374
|
+
nidaq_payload = pd.to_numeric(nidaq["payload"], errors="coerce")
|
|
375
|
+
|
|
376
|
+
valid_mask = nidaq_times.notna() & nidaq_payload.notna()
|
|
377
|
+
nidaq_times = nidaq_times[valid_mask].to_numpy(dtype=np.float64)
|
|
378
|
+
nidaq_payload = nidaq_payload[valid_mask].to_numpy(dtype=np.float64)
|
|
379
|
+
|
|
380
|
+
if nidaq_times.size == 0:
|
|
381
|
+
raise ValueError("Suite2pV2: nidaq timestamps empty after filtering")
|
|
382
|
+
|
|
383
|
+
nidaq_times_rel = nidaq_times - nidaq_times[0]
|
|
384
|
+
|
|
385
|
+
order = np.argsort(nidaq_payload)
|
|
386
|
+
pulse_counter = nidaq_payload[order]
|
|
387
|
+
pulse_times = nidaq_times_rel[order]
|
|
388
|
+
|
|
389
|
+
if np.any(np.diff(pulse_counter) <= 0):
|
|
390
|
+
raise ValueError("Suite2pV2: nidaq payload must be strictly increasing")
|
|
391
|
+
|
|
392
|
+
pulse_span = pulse_counter[-1] - pulse_counter[0] + 1
|
|
393
|
+
pulses_per_frame = self.PULSES_PER_FRAME
|
|
394
|
+
if pulses_per_frame is None:
|
|
395
|
+
inferred = pulse_span / n_frames
|
|
396
|
+
if not np.isclose(inferred, round(inferred)):
|
|
397
|
+
raise ValueError("Suite2pV2: nidaq payload does not match expected frame count")
|
|
398
|
+
pulses_per_frame = int(round(inferred))
|
|
399
|
+
|
|
400
|
+
if pulses_per_frame < 1:
|
|
401
|
+
raise ValueError("Suite2pV2: pulses_per_frame must be >= 1")
|
|
402
|
+
|
|
403
|
+
expected_span = int(pulses_per_frame * n_frames)
|
|
404
|
+
if pulse_counter.size < 2:
|
|
405
|
+
raise ValueError("Suite2pV2: nidaq payload requires at least two pulses for interpolation")
|
|
406
|
+
|
|
407
|
+
expected_pulses = np.arange(expected_span, dtype=np.float64) + pulse_counter[0]
|
|
408
|
+
expected_times = self._interpolate_pulse_times(pulse_counter, pulse_times, expected_pulses)
|
|
409
|
+
timeline = expected_times[::pulses_per_frame]
|
|
410
|
+
|
|
411
|
+
if timeline.size != n_frames:
|
|
412
|
+
raise ValueError("Suite2pV2: nidaq interpolation produced unexpected frame count")
|
|
413
|
+
|
|
414
|
+
derived_frames = int(n_frames)
|
|
415
|
+
|
|
416
|
+
meta = {
|
|
417
|
+
"time_basis": "nidaq_interpolated",
|
|
418
|
+
"dataqueue_file": str(dq_path),
|
|
419
|
+
"nidaq_pulses_received": int(len(nidaq_times)),
|
|
420
|
+
"pulses_per_frame": int(pulses_per_frame),
|
|
421
|
+
"pulses_per_frame_source": "configured" if self.PULSES_PER_FRAME is not None else "inferred",
|
|
422
|
+
"dq_nidaq_pulses": int(len(nidaq_times)),
|
|
423
|
+
"dq_nidaq_frames_derived": derived_frames,
|
|
424
|
+
"dq_pulse_span": int(pulse_span),
|
|
425
|
+
"dq_expected_pulse_span": int(expected_span),
|
|
426
|
+
"dq_pulse_count": int(pulse_counter.size),
|
|
427
|
+
"dq_pulse_span_match": bool(int(pulse_span) == expected_span),
|
|
428
|
+
"dq_pulses_per_frame": int(pulses_per_frame),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return timeline, meta
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def _interpolate_pulse_times(
|
|
435
|
+
pulse_counter: np.ndarray,
|
|
436
|
+
pulse_times: np.ndarray,
|
|
437
|
+
expected_pulses: np.ndarray,
|
|
438
|
+
) -> np.ndarray:
|
|
439
|
+
if pulse_counter.size < 2:
|
|
440
|
+
raise ValueError("Suite2pV2: insufficient nidaq pulses for interpolation")
|
|
441
|
+
|
|
442
|
+
expected_times = np.interp(expected_pulses, pulse_counter, pulse_times)
|
|
443
|
+
|
|
444
|
+
start_slope = (pulse_times[1] - pulse_times[0]) / (pulse_counter[1] - pulse_counter[0])
|
|
445
|
+
end_slope = (pulse_times[-1] - pulse_times[-2]) / (pulse_counter[-1] - pulse_counter[-2])
|
|
446
|
+
|
|
447
|
+
left_mask = expected_pulses < pulse_counter[0]
|
|
448
|
+
right_mask = expected_pulses > pulse_counter[-1]
|
|
449
|
+
|
|
450
|
+
if left_mask.any():
|
|
451
|
+
expected_times[left_mask] = pulse_times[0] + start_slope * (
|
|
452
|
+
expected_pulses[left_mask] - pulse_counter[0]
|
|
453
|
+
)
|
|
454
|
+
if right_mask.any():
|
|
455
|
+
expected_times[right_mask] = pulse_times[-1] + end_slope * (
|
|
456
|
+
expected_pulses[right_mask] - pulse_counter[-1]
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
return expected_times
|
|
460
|
+
|
|
461
|
+
def _find_session_dir(self, plane_dir: Path) -> tuple[Optional[Path], Optional[str], Optional[str], Optional[str]]:
|
|
462
|
+
"""Walk up from plane_dir to find session directory or use inventory hint."""
|
|
463
|
+
subject = None
|
|
464
|
+
session = None
|
|
465
|
+
task = None
|
|
466
|
+
|
|
467
|
+
suite2p_dir = plane_dir.parent if plane_dir.name.startswith("plane") else plane_dir
|
|
468
|
+
suite2p_name = suite2p_dir.name
|
|
469
|
+
if suite2p_name:
|
|
470
|
+
subject_match = re.search(r"sub-([A-Za-z0-9]+)", suite2p_name)
|
|
471
|
+
session_match = re.search(r"ses-([A-Za-z0-9]+)", suite2p_name)
|
|
472
|
+
task_match = re.search(r"task-([A-Za-z0-9]+)", suite2p_name)
|
|
473
|
+
if subject_match:
|
|
474
|
+
subject = subject_match.group(1)
|
|
475
|
+
if session_match:
|
|
476
|
+
session = session_match.group(1)
|
|
477
|
+
if task_match:
|
|
478
|
+
task = task_match.group(1)
|
|
479
|
+
|
|
480
|
+
# Try walking up from plane_dir
|
|
481
|
+
current = plane_dir
|
|
482
|
+
max_levels = 5
|
|
483
|
+
for _ in range(max_levels):
|
|
484
|
+
if current == current.parent:
|
|
485
|
+
break
|
|
486
|
+
if (current / "beh").is_dir() and (current / "func").is_dir():
|
|
487
|
+
return current, subject, session, task
|
|
488
|
+
current = current.parent
|
|
489
|
+
|
|
490
|
+
# If suite2p is in processed/, try to find the data/ sibling
|
|
491
|
+
if "processed" in plane_dir.parts:
|
|
492
|
+
# Navigate to experiment root and check data/
|
|
493
|
+
idx = plane_dir.parts.index("processed")
|
|
494
|
+
exp_root = Path(*plane_dir.parts[:idx])
|
|
495
|
+
data_dir = exp_root / "data"
|
|
496
|
+
if data_dir.is_dir():
|
|
497
|
+
subject_dir = None
|
|
498
|
+
session_dir = None
|
|
499
|
+
|
|
500
|
+
if subject:
|
|
501
|
+
subject_dir = data_dir / f"sub-{subject}"
|
|
502
|
+
if not subject_dir.is_dir():
|
|
503
|
+
subject_dir = None
|
|
504
|
+
|
|
505
|
+
if subject_dir is not None and session:
|
|
506
|
+
session_dir = subject_dir / f"ses-{session}"
|
|
507
|
+
if not session_dir.is_dir():
|
|
508
|
+
session_dir = None
|
|
509
|
+
|
|
510
|
+
if session_dir is not None and (session_dir / "beh").is_dir():
|
|
511
|
+
return session_dir, subject, session, task
|
|
512
|
+
|
|
513
|
+
# Look for subject/session structure
|
|
514
|
+
for subject_dir in data_dir.iterdir():
|
|
515
|
+
if subject_dir.is_dir() and subject_dir.name.startswith("sub-"):
|
|
516
|
+
for session_dir in subject_dir.iterdir():
|
|
517
|
+
if session_dir.is_dir() and session_dir.name.startswith("ses-"):
|
|
518
|
+
if (session_dir / "beh").is_dir():
|
|
519
|
+
return session_dir, subject, session, task
|
|
520
|
+
return None, subject, session, task
|
|
521
|
+
|
|
522
|
+
def _find_dataqueue(
|
|
523
|
+
self,
|
|
524
|
+
session_dir: Path,
|
|
525
|
+
*,
|
|
526
|
+
subject: Optional[str] = None,
|
|
527
|
+
session: Optional[str] = None,
|
|
528
|
+
task: Optional[str] = None,
|
|
529
|
+
) -> Optional[Path]:
|
|
530
|
+
"""Find dataqueue CSV in session/beh directory."""
|
|
531
|
+
candidates = sorted((session_dir / "beh").glob("*_dataqueue.csv"))
|
|
532
|
+
if not candidates:
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
def matches(candidate: Path) -> bool:
|
|
536
|
+
name = candidate.name
|
|
537
|
+
if subject and f"sub-{subject}" not in name:
|
|
538
|
+
return False
|
|
539
|
+
if session and f"ses-{session}" not in name:
|
|
540
|
+
return False
|
|
541
|
+
if task and f"task-{task}" not in name:
|
|
542
|
+
return False
|
|
543
|
+
return True
|
|
544
|
+
|
|
545
|
+
filtered = [path for path in candidates if matches(path)]
|
|
546
|
+
return filtered[0] if filtered else candidates[0]
|
|
547
|
+
|