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,281 @@
|
|
|
1
|
+
"""Dataqueue synchronization data source.
|
|
2
|
+
|
|
3
|
+
This loader ingests the central ``*_dataqueue.csv`` log produced by the rig and
|
|
4
|
+
returns a stream describing every message with absolute and relative timing.
|
|
5
|
+
It is commonly used to align per-device clocks (treadmill, cameras, nidaq) by
|
|
6
|
+
extracting anchor pairs for downstream timeline fitting.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import numpy as np
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from mesofield.datakit.sources.register import LoadContext, TimeseriesSource
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DataqueueSource(TimeseriesSource):
|
|
18
|
+
"""Load the dataqueue CSV as a time-indexed table."""
|
|
19
|
+
tag = "dataqueue"
|
|
20
|
+
patterns = ("**/*_dataqueue.csv",)
|
|
21
|
+
camera_tag = None
|
|
22
|
+
time_column = "queue_elapsed"
|
|
23
|
+
device_id_column = "device_id"
|
|
24
|
+
device_timestamp_column = "device_ts"
|
|
25
|
+
payload_column = "payload"
|
|
26
|
+
master_device_priority: tuple[str, ...] = ("dhyana", "mesoscope")
|
|
27
|
+
device_alias_patterns: tuple[tuple[str, str], ...] = (
|
|
28
|
+
("dhyana", "meso"),
|
|
29
|
+
("mesoscope", "meso"),
|
|
30
|
+
("thorcam", "pupil"),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def build_timeseries(
|
|
34
|
+
self,
|
|
35
|
+
path: Path,
|
|
36
|
+
*,
|
|
37
|
+
context: LoadContext | None = None,
|
|
38
|
+
) -> tuple[np.ndarray, pd.DataFrame, dict]:
|
|
39
|
+
"""Read ``*_dataqueue.csv`` and return a time-indexed table."""
|
|
40
|
+
raw = pd.read_csv(path, low_memory=False)
|
|
41
|
+
|
|
42
|
+
if self.time_column not in raw.columns:
|
|
43
|
+
raise ValueError(f"Dataqueue file missing required time column '{self.time_column}': {path}")
|
|
44
|
+
|
|
45
|
+
queue_numeric = pd.to_numeric(raw[self.time_column], errors="coerce")
|
|
46
|
+
queue_elapsed = queue_numeric.to_numpy(dtype=np.float64)
|
|
47
|
+
if queue_elapsed.size == 0:
|
|
48
|
+
raise ValueError("Dataqueue has no timing samples")
|
|
49
|
+
|
|
50
|
+
queue_start = float(queue_elapsed[0])
|
|
51
|
+
t = queue_elapsed - queue_start
|
|
52
|
+
|
|
53
|
+
master_id = self._select_master_device(raw)
|
|
54
|
+
master_elapsed = self._master_elapsed(raw, master_id, queue_start)
|
|
55
|
+
if master_elapsed.size == 0:
|
|
56
|
+
master_elapsed = t
|
|
57
|
+
|
|
58
|
+
device_elapsed = self._build_device_elapsed(raw, queue_start)
|
|
59
|
+
device_ts = self._build_device_ts(raw)
|
|
60
|
+
device_aliases = self._build_device_aliases(raw)
|
|
61
|
+
affine = self._fit_master_affine(raw, master_id, queue_start)
|
|
62
|
+
device_aligned = self._apply_affine(device_elapsed, affine)
|
|
63
|
+
|
|
64
|
+
frame = raw.copy()
|
|
65
|
+
frame["time_elapsed_s"] = t.astype(np.float64)
|
|
66
|
+
per_device_start_map: dict[str, float] = {}
|
|
67
|
+
if self.device_id_column in frame.columns:
|
|
68
|
+
per_device_start = (
|
|
69
|
+
frame.groupby(self.device_id_column)[self.time_column]
|
|
70
|
+
.transform("min")
|
|
71
|
+
.astype(np.float64)
|
|
72
|
+
)
|
|
73
|
+
per_device_start_map = {
|
|
74
|
+
str(device_id): float(value)
|
|
75
|
+
for device_id, value in (
|
|
76
|
+
frame.groupby(self.device_id_column)[self.time_column]
|
|
77
|
+
.min()
|
|
78
|
+
.items()
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
# `device_elapsed_s` is `queue_elapsed - per_device_start`; keep the
|
|
82
|
+
# offsets in meta and drop the redundant full-length column.
|
|
83
|
+
|
|
84
|
+
# Memory optimizations: parse timestamp strings to datetime64, low-cardinality
|
|
85
|
+
# string columns to categoricals. These columns are stored as full-length
|
|
86
|
+
# ndarrays per row in the materialized output, so per-value bytes matter.
|
|
87
|
+
#
|
|
88
|
+
# ``payload`` is mostly numeric (TTL levels, counts) with a sparse mix of
|
|
89
|
+
# semantic strings (e.g. ``EncoderData(...)``). The dataqueue itself
|
|
90
|
+
# treats payloads as opaque — sources that need the semantic content
|
|
91
|
+
# (such as ``TreadmillSource``) re-read the raw CSV via
|
|
92
|
+
# ``context.path_for("dataqueue")`` and parse it themselves. Here we
|
|
93
|
+
# just downcast the whole column to ``float32`` (non-numeric entries
|
|
94
|
+
# become NaN) to keep the materialized output small.
|
|
95
|
+
if self.device_timestamp_column in frame.columns:
|
|
96
|
+
parsed = pd.to_datetime(frame[self.device_timestamp_column], errors="coerce", utc=True)
|
|
97
|
+
if not parsed.isna().all():
|
|
98
|
+
frame[self.device_timestamp_column] = parsed
|
|
99
|
+
if "packet_ts" in frame.columns:
|
|
100
|
+
parsed = pd.to_datetime(frame["packet_ts"], errors="coerce", utc=True)
|
|
101
|
+
if not parsed.isna().all():
|
|
102
|
+
frame["packet_ts"] = parsed
|
|
103
|
+
if self.device_id_column in frame.columns:
|
|
104
|
+
frame[self.device_id_column] = frame[self.device_id_column].astype("category")
|
|
105
|
+
if self.payload_column in frame.columns:
|
|
106
|
+
frame[self.payload_column] = (
|
|
107
|
+
pd.to_numeric(frame[self.payload_column], errors="coerce")
|
|
108
|
+
.astype(np.float32)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
meta = {
|
|
112
|
+
"source_file": str(path),
|
|
113
|
+
"n_entries": int(len(raw)),
|
|
114
|
+
"master_device_id": master_id,
|
|
115
|
+
"master_queue_start": float(queue_start),
|
|
116
|
+
"per_device_queue_start": per_device_start_map,
|
|
117
|
+
"device_sample_rate_hz": self._estimate_device_rates(device_elapsed),
|
|
118
|
+
"device_aliases": device_aliases,
|
|
119
|
+
"affine": affine,
|
|
120
|
+
"time_basis": self.time_column,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return t.astype(np.float64), frame, meta
|
|
124
|
+
|
|
125
|
+
def _select_master_device(self, df: pd.DataFrame) -> str:
|
|
126
|
+
"""Pick the device used as the master time basis."""
|
|
127
|
+
|
|
128
|
+
if self.device_id_column not in df.columns:
|
|
129
|
+
return "master"
|
|
130
|
+
|
|
131
|
+
ids = df[self.device_id_column].astype(str).fillna("")
|
|
132
|
+
for pattern in self.master_device_priority:
|
|
133
|
+
mask = ids.str.contains(pattern, case=False, regex=False)
|
|
134
|
+
if mask.any():
|
|
135
|
+
return str(ids[mask].iloc[0])
|
|
136
|
+
|
|
137
|
+
non_empty = ids[ids != ""]
|
|
138
|
+
if not non_empty.empty:
|
|
139
|
+
return str(non_empty.iloc[0])
|
|
140
|
+
return "master"
|
|
141
|
+
|
|
142
|
+
def _master_elapsed(self, df: pd.DataFrame, master_id: str, queue_start: float) -> np.ndarray:
|
|
143
|
+
"""Return elapsed seconds for the master device."""
|
|
144
|
+
|
|
145
|
+
device_series = df.get(self.device_id_column)
|
|
146
|
+
if device_series is None:
|
|
147
|
+
return np.array([], dtype=np.float64)
|
|
148
|
+
|
|
149
|
+
mask = device_series.astype(str) == master_id
|
|
150
|
+
master_rows = df.loc[mask]
|
|
151
|
+
if master_rows.empty:
|
|
152
|
+
return np.array([], dtype=np.float64)
|
|
153
|
+
|
|
154
|
+
master_queue = pd.to_numeric(master_rows[self.time_column], errors="coerce").dropna()
|
|
155
|
+
if master_queue.empty:
|
|
156
|
+
return np.array([], dtype=np.float64)
|
|
157
|
+
|
|
158
|
+
master_queue = master_queue - float(master_queue.iloc[0])
|
|
159
|
+
return master_queue.to_numpy(dtype=np.float64)
|
|
160
|
+
|
|
161
|
+
def _estimate_rate(self, timeline: np.ndarray) -> float:
|
|
162
|
+
if timeline.size < 2:
|
|
163
|
+
return 0.0
|
|
164
|
+
diffs = np.diff(timeline)
|
|
165
|
+
diffs = diffs[np.isfinite(diffs) & (diffs > 0)]
|
|
166
|
+
if diffs.size == 0:
|
|
167
|
+
return 0.0
|
|
168
|
+
return float(1.0 / np.median(diffs))
|
|
169
|
+
|
|
170
|
+
def _build_device_elapsed(self, df: pd.DataFrame, queue_start: float) -> dict[str, np.ndarray]:
|
|
171
|
+
"""Return per-device elapsed seconds from queue timestamps."""
|
|
172
|
+
|
|
173
|
+
device_elapsed: dict[str, np.ndarray] = {}
|
|
174
|
+
if self.device_id_column not in df.columns:
|
|
175
|
+
return device_elapsed
|
|
176
|
+
|
|
177
|
+
for device_id, group in df.groupby(self.device_id_column):
|
|
178
|
+
device_key = str(device_id)
|
|
179
|
+
queue_rel = pd.to_numeric(group[self.time_column], errors="coerce").to_numpy(dtype=np.float64)
|
|
180
|
+
queue_rel = queue_rel - float(queue_start)
|
|
181
|
+
order = np.argsort(queue_rel)
|
|
182
|
+
device_elapsed[device_key] = queue_rel[order]
|
|
183
|
+
return device_elapsed
|
|
184
|
+
|
|
185
|
+
def _build_device_ts(self, df: pd.DataFrame) -> dict[str, np.ndarray]:
|
|
186
|
+
"""Return per-device raw timestamps as ISO strings when possible."""
|
|
187
|
+
|
|
188
|
+
device_ts: dict[str, np.ndarray] = {}
|
|
189
|
+
if self.device_id_column not in df.columns or self.device_timestamp_column not in df.columns:
|
|
190
|
+
return device_ts
|
|
191
|
+
|
|
192
|
+
for device_id, group in df.groupby(self.device_id_column):
|
|
193
|
+
device_key = str(device_id)
|
|
194
|
+
parsed = pd.to_datetime(group[self.device_timestamp_column], errors="coerce", utc=True)
|
|
195
|
+
if parsed.isna().all():
|
|
196
|
+
device_ts[device_key] = group[self.device_timestamp_column].to_numpy()
|
|
197
|
+
else:
|
|
198
|
+
device_ts[device_key] = parsed.dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ").to_numpy()
|
|
199
|
+
return device_ts
|
|
200
|
+
|
|
201
|
+
def _build_device_aliases(self, df: pd.DataFrame) -> dict[str, str]:
|
|
202
|
+
"""Map device identifiers to configured aliases (e.g., Dhyana → meso)."""
|
|
203
|
+
|
|
204
|
+
aliases: dict[str, str] = {}
|
|
205
|
+
if self.device_id_column not in df.columns:
|
|
206
|
+
return aliases
|
|
207
|
+
|
|
208
|
+
ids = df[self.device_id_column].dropna().astype(str).unique().tolist()
|
|
209
|
+
for device_id in ids:
|
|
210
|
+
alias = self._alias_for_device(device_id)
|
|
211
|
+
if alias is not None:
|
|
212
|
+
aliases[device_id] = alias
|
|
213
|
+
return aliases
|
|
214
|
+
|
|
215
|
+
def _alias_for_device(self, device_id: str) -> Optional[str]:
|
|
216
|
+
lowered = device_id.lower()
|
|
217
|
+
for pattern, alias in self.device_alias_patterns:
|
|
218
|
+
if pattern.lower() in lowered:
|
|
219
|
+
return alias
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
def _fit_master_affine(
|
|
223
|
+
self,
|
|
224
|
+
df: pd.DataFrame,
|
|
225
|
+
master_id: str,
|
|
226
|
+
queue_start: float,
|
|
227
|
+
) -> dict[str, float] | None:
|
|
228
|
+
"""Fit affine map from elapsed seconds to absolute time using master device."""
|
|
229
|
+
|
|
230
|
+
if self.device_id_column not in df.columns or self.device_timestamp_column not in df.columns:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
master_rows = df.loc[df[self.device_id_column].astype(str) == master_id]
|
|
234
|
+
if master_rows.empty:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
elapsed = pd.to_numeric(master_rows[self.time_column], errors="coerce").to_numpy(dtype=np.float64)
|
|
238
|
+
elapsed = elapsed - float(queue_start)
|
|
239
|
+
absolute = pd.to_datetime(master_rows[self.device_timestamp_column], errors="coerce", utc=True)
|
|
240
|
+
if elapsed.size < 2 or absolute.isna().all():
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
valid_mask = np.isfinite(elapsed) & ~absolute.isna()
|
|
244
|
+
if valid_mask.sum() < 2:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
elapsed = elapsed[valid_mask]
|
|
248
|
+
absolute = absolute[valid_mask]
|
|
249
|
+
|
|
250
|
+
e0 = float(elapsed[0])
|
|
251
|
+
e1 = float(elapsed[-1])
|
|
252
|
+
a0 = float(absolute.iloc[0].value) / 1e9
|
|
253
|
+
a1 = float(absolute.iloc[-1].value) / 1e9
|
|
254
|
+
if e1 == e0:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
a = (a1 - a0) / (e1 - e0)
|
|
258
|
+
b = a0 - a * e0
|
|
259
|
+
return {"a": float(a), "b": float(b)}
|
|
260
|
+
|
|
261
|
+
def _apply_affine(
|
|
262
|
+
self,
|
|
263
|
+
device_elapsed: dict[str, np.ndarray],
|
|
264
|
+
affine: dict[str, float] | None,
|
|
265
|
+
) -> dict[str, np.ndarray]:
|
|
266
|
+
"""Project elapsed times into aligned absolute seconds (UTC)."""
|
|
267
|
+
|
|
268
|
+
if affine is None:
|
|
269
|
+
return {}
|
|
270
|
+
a = affine.get("a")
|
|
271
|
+
b = affine.get("b")
|
|
272
|
+
if a is None or b is None:
|
|
273
|
+
return {}
|
|
274
|
+
|
|
275
|
+
aligned: dict[str, np.ndarray] = {}
|
|
276
|
+
for device_id, elapsed in device_elapsed.items():
|
|
277
|
+
aligned[device_id] = (a * elapsed + b).astype(np.float64)
|
|
278
|
+
return aligned
|
|
279
|
+
|
|
280
|
+
def _estimate_device_rates(self, device_elapsed: dict[str, np.ndarray]) -> dict[str, float]:
|
|
281
|
+
return {device_id: self._estimate_rate(values) for device_id, values in device_elapsed.items()}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Psychopy behavioral event data source.
|
|
2
|
+
|
|
3
|
+
Explicit assumptions:
|
|
4
|
+
- Psychopy CSV contains at least one display_*.started column with 2+ numeric rows.
|
|
5
|
+
- A single keypress RT column exists (key_resp*.rt) and is used to align time zero.
|
|
6
|
+
- If a dataqueue timeline exists, it must include nidaq payload==1 pulses.
|
|
7
|
+
|
|
8
|
+
psychopy files have an expStart column that indicates start of the experiment in absolute clock datetime.
|
|
9
|
+
Psychopy files have a Custom_Trigger routine; a spacebar press then triggers a sync pulse to the DAQ system.
|
|
10
|
+
Thus, the true start is the `expStart` + the `key_resp_2.rt` (the key_resp_2.rt is column in the psychopy file, with 1 value, but that value may not be on the first row)
|
|
11
|
+
|
|
12
|
+
true end can be calculated using the `expStart` + the elapsed value from the last row of the column that precedes `Subject ID` and
|
|
13
|
+
|
|
14
|
+
The dataqueue file collects nidaq TTL pulses. The first pulse (payload=1) has a clock time and relative time (queue_elapsed). We can use this to align the psychopy timeline to the dataqueue timeline.
|
|
15
|
+
The experimental window, for sample_experiment3, is between the first nidaq and last nidaq.
|
|
16
|
+
|
|
17
|
+
The psychopy experiment likely runs longer than the nidaq pulse window, so we can trim the psychopy timeline to be within the nidaq pulse window.
|
|
18
|
+
|
|
19
|
+
Additional details:
|
|
20
|
+
- The loader derives its primary timeline from event start columns (e.g. display_*.started).
|
|
21
|
+
- All time-like columns are aligned by subtracting the keypress RT so time zero is the trigger.
|
|
22
|
+
- For task-gratings, rows are taken from trial metadata and extended to include the final stopped event.
|
|
23
|
+
- When dataqueue is available, the timeline is trimmed to the nidaq window but remains zero-based.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Optional
|
|
31
|
+
import re
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
import pandas as pd
|
|
35
|
+
|
|
36
|
+
from mesofield.datakit.sources.register import IntervalSeriesSource, LoadContext
|
|
37
|
+
from mesofield.datakit.timeline import DataqueueIndex
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_TASK_PATTERN = re.compile(r"task[-_]?([A-Za-z0-9]+)", re.IGNORECASE)
|
|
42
|
+
_TASK_ALIASES = {
|
|
43
|
+
"gratings": "task-gratings",
|
|
44
|
+
"movies": "task-movies",
|
|
45
|
+
}
|
|
46
|
+
_TIME_SUFFIXES = (".started", ".stopped", ".rt", ".t")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Psychopy(IntervalSeriesSource):
|
|
50
|
+
"""Load Psychopy trial windows as an interval table."""
|
|
51
|
+
|
|
52
|
+
tag = "psychopy"
|
|
53
|
+
patterns = ("**/*_psychopy.csv",)
|
|
54
|
+
requires = ("dataqueue",)
|
|
55
|
+
task_pattern = _TASK_PATTERN
|
|
56
|
+
task_aliases = _TASK_ALIASES
|
|
57
|
+
time_suffixes = _TIME_SUFFIXES
|
|
58
|
+
keypress_rt_pattern = re.compile(r"key_resp.*\.rt$")
|
|
59
|
+
time_source_prefix = "display_"
|
|
60
|
+
time_source_suffix = ".started"
|
|
61
|
+
gratings_columns = (
|
|
62
|
+
"trials.thisN",
|
|
63
|
+
"stim_grayScreen.started",
|
|
64
|
+
"stim_grayScreen.stopped",
|
|
65
|
+
"stim_grating.started",
|
|
66
|
+
"stim_grating.stopped",
|
|
67
|
+
"display_gratings.stopped",
|
|
68
|
+
)
|
|
69
|
+
movies_columns = (
|
|
70
|
+
"trials_2.thisN",
|
|
71
|
+
"trials_2.thisIndex",
|
|
72
|
+
"trials2.thisN",
|
|
73
|
+
"trials.thisIndex",
|
|
74
|
+
"description",
|
|
75
|
+
"thisRow.t",
|
|
76
|
+
"display_mov.started",
|
|
77
|
+
"natural_mov.started",
|
|
78
|
+
"display_mov.stopped",
|
|
79
|
+
"grey.started",
|
|
80
|
+
"grey.stopped",
|
|
81
|
+
)
|
|
82
|
+
dataqueue_elapsed_column = "queue_elapsed"
|
|
83
|
+
dataqueue_device_column = "device_id"
|
|
84
|
+
dataqueue_device_match = "nidaq"
|
|
85
|
+
dataqueue_payload_column = "payload"
|
|
86
|
+
dataqueue_payload_value = 1
|
|
87
|
+
def build_intervals(
|
|
88
|
+
self,
|
|
89
|
+
path: Path,
|
|
90
|
+
*,
|
|
91
|
+
context: LoadContext | None = None,
|
|
92
|
+
) -> tuple[pd.DataFrame, dict]:
|
|
93
|
+
context = self._require_context(context)
|
|
94
|
+
# 1) Read raw psychopy CSV.
|
|
95
|
+
raw = pd.read_csv(path, low_memory=False)
|
|
96
|
+
|
|
97
|
+
# 2) Infer task and choose trial table.
|
|
98
|
+
task = self._infer_task(path)
|
|
99
|
+
if task == "task-gratings":
|
|
100
|
+
trial_table = self._extract_gratings(raw)
|
|
101
|
+
task_branch = "gratings"
|
|
102
|
+
elif task == "task-movies":
|
|
103
|
+
trial_table = self._extract_movies(raw)
|
|
104
|
+
task_branch = "movies"
|
|
105
|
+
else:
|
|
106
|
+
trial_table = raw
|
|
107
|
+
task_branch = "raw"
|
|
108
|
+
|
|
109
|
+
# 3) Resolve timeline and keypress offset.
|
|
110
|
+
time_source = trial_table if any(
|
|
111
|
+
c.startswith(self.time_source_prefix) and c.endswith(self.time_source_suffix) for c in trial_table.columns
|
|
112
|
+
) else raw
|
|
113
|
+
row_time, row_col, row_meta = self._resolve_row_time(time_source)
|
|
114
|
+
key_rt, key_col = self._resolve_keypress_rt(raw)
|
|
115
|
+
|
|
116
|
+
# 4) Parse experiment start time (optional).
|
|
117
|
+
exp_start = None
|
|
118
|
+
exp_start_raw = raw.get("expStart")
|
|
119
|
+
if exp_start_raw is not None and exp_start_raw.dropna().any():
|
|
120
|
+
exp_start = self._parse_exp_start(exp_start_raw.dropna().iloc[0])
|
|
121
|
+
|
|
122
|
+
# 5) Apply dataqueue offset and align times.
|
|
123
|
+
dq_start, dq_meta = self._dataqueue_offset(context)
|
|
124
|
+
exp_start_offset = None
|
|
125
|
+
exp_start_keypress_offset = None
|
|
126
|
+
exp_start_offset = self._calc_exp_start_offset(exp_start, dq_meta.get("abs_start_time"))
|
|
127
|
+
# Offset into psychopy-zero time (exp_start + keypress RT).
|
|
128
|
+
exp_start_keypress_offset = exp_start_offset - float(key_rt)
|
|
129
|
+
dq_start = float(exp_start_keypress_offset)
|
|
130
|
+
aligned_t = self._align_time(row_time, key_rt, dq_start)
|
|
131
|
+
aligned_table = self._align_table(trial_table, key_rt, dq_start)
|
|
132
|
+
|
|
133
|
+
# 6) For gratings, build windows once from the aligned table.
|
|
134
|
+
if task_branch == "gratings":
|
|
135
|
+
if "window_grating_start" in aligned_table.columns:
|
|
136
|
+
aligned_table["gratings_windows"] = [
|
|
137
|
+
self._build_window_list(aligned_table, "window_grating_start", "window_grating_stop")
|
|
138
|
+
] * len(aligned_table)
|
|
139
|
+
if "window_gray_start" in aligned_table.columns:
|
|
140
|
+
aligned_table["gray_windows"] = [
|
|
141
|
+
self._build_window_list(aligned_table, "window_gray_start", "window_gray_stop")
|
|
142
|
+
] * len(aligned_table)
|
|
143
|
+
|
|
144
|
+
# 7) Build interval table and metrics.
|
|
145
|
+
interval_start = None
|
|
146
|
+
interval_stop = None
|
|
147
|
+
if "window_grating_start" in aligned_table.columns and "window_grating_stop" in aligned_table.columns:
|
|
148
|
+
interval_start = aligned_table["window_grating_start"]
|
|
149
|
+
interval_stop = aligned_table["window_grating_stop"]
|
|
150
|
+
elif "window_gray_start" in aligned_table.columns and "window_gray_stop" in aligned_table.columns:
|
|
151
|
+
interval_start = aligned_table["window_gray_start"]
|
|
152
|
+
interval_stop = aligned_table["window_gray_stop"]
|
|
153
|
+
else:
|
|
154
|
+
interval_start = pd.Series(aligned_t, index=aligned_table.index)
|
|
155
|
+
interval_stop = pd.Series(aligned_t, index=aligned_table.index)
|
|
156
|
+
|
|
157
|
+
intervals = aligned_table.rename(
|
|
158
|
+
columns={c: f"{task_branch}_{c}" for c in aligned_table.columns}
|
|
159
|
+
).copy()
|
|
160
|
+
intervals["start_s"] = pd.to_numeric(interval_start, errors="coerce")
|
|
161
|
+
intervals["stop_s"] = pd.to_numeric(interval_stop, errors="coerce")
|
|
162
|
+
intervals = self._apply_experiment_window(intervals, context, dq_start)
|
|
163
|
+
metrics = {
|
|
164
|
+
"source_file": str(path),
|
|
165
|
+
"n_rows": int(len(raw)),
|
|
166
|
+
"task_inferred": task,
|
|
167
|
+
"task_branch": task_branch,
|
|
168
|
+
"row_time_column": row_col,
|
|
169
|
+
"key_resp_rt": key_rt,
|
|
170
|
+
"key_resp_column": key_col,
|
|
171
|
+
"exp_start": exp_start.isoformat() if exp_start is not None else None,
|
|
172
|
+
"start_offset_s": dq_start,
|
|
173
|
+
}
|
|
174
|
+
if exp_start_offset is not None:
|
|
175
|
+
metrics["exp_start_offset_s"] = float(exp_start_offset)
|
|
176
|
+
if exp_start_keypress_offset is not None:
|
|
177
|
+
metrics["exp_start_keypress_offset_s"] = float(exp_start_keypress_offset)
|
|
178
|
+
metrics.update(row_meta)
|
|
179
|
+
metrics.update(dq_meta)
|
|
180
|
+
metrics["time_basis"] = "psychopy_zero_based" if dq_start is not None else "psychopy_native"
|
|
181
|
+
metrics["alignment_method"] = "keypress_relative"
|
|
182
|
+
|
|
183
|
+
return intervals, metrics
|
|
184
|
+
|
|
185
|
+
def _parse_exp_start(self, value: Any) -> Optional[pd.Timestamp]:
|
|
186
|
+
text = str(value).strip()
|
|
187
|
+
text = text.replace("h", ":", 1)
|
|
188
|
+
text = re.sub(r":(\d{2})\.(\d{2}\.\d+)", r":\1:\2", text)
|
|
189
|
+
parsed = pd.to_datetime(text, errors="coerce", utc=False)
|
|
190
|
+
return None if pd.isna(parsed) else parsed
|
|
191
|
+
|
|
192
|
+
def _strip_tz(self, value: pd.Timestamp) -> pd.Timestamp:
|
|
193
|
+
return value.tz_localize(None) if getattr(value, "tzinfo", None) else value
|
|
194
|
+
|
|
195
|
+
def _calc_exp_start_offset(self, exp_start: pd.Timestamp, abs_start_time: Any) -> Optional[float]:
|
|
196
|
+
if not abs_start_time:
|
|
197
|
+
return None
|
|
198
|
+
abs_start = pd.to_datetime(abs_start_time, errors="coerce", utc=False)
|
|
199
|
+
if pd.isna(abs_start):
|
|
200
|
+
return None
|
|
201
|
+
abs_start = self._strip_tz(abs_start)
|
|
202
|
+
exp_start = self._strip_tz(exp_start)
|
|
203
|
+
return (abs_start - exp_start).total_seconds()
|
|
204
|
+
|
|
205
|
+
def _infer_task(self, file_path: Path) -> str | None:
|
|
206
|
+
name = file_path.as_posix().lower()
|
|
207
|
+
match = self.task_pattern.search(name)
|
|
208
|
+
if match:
|
|
209
|
+
return self.task_aliases.get(match.group(1).lower())
|
|
210
|
+
for key, label in self.task_aliases.items():
|
|
211
|
+
if key in name:
|
|
212
|
+
return label
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
def _select_columns(self, frame: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
|
|
216
|
+
available = [col for col in columns if col in frame.columns]
|
|
217
|
+
if not available:
|
|
218
|
+
raise ValueError("Psychopy required columns are missing from the CSV.")
|
|
219
|
+
return frame.loc[:, available].copy()
|
|
220
|
+
|
|
221
|
+
def _coalesce_column(self, frame: pd.DataFrame, columns: list[str]) -> pd.Series:
|
|
222
|
+
available = [col for col in columns if col in frame.columns]
|
|
223
|
+
if not available:
|
|
224
|
+
raise ValueError(f"Psychopy required columns missing: {columns}")
|
|
225
|
+
return frame.loc[:, available].bfill(axis=1).iloc[:, 0]
|
|
226
|
+
|
|
227
|
+
def _resolve_keypress_rt(self, frame: pd.DataFrame) -> tuple[float, str]:
|
|
228
|
+
candidates = [col for col in frame.columns if self.keypress_rt_pattern.match(str(col))]
|
|
229
|
+
for col in candidates:
|
|
230
|
+
series = pd.to_numeric(frame[col], errors="coerce")
|
|
231
|
+
if series.notna().any():
|
|
232
|
+
return float(series.dropna().iloc[0]), str(col)
|
|
233
|
+
raise ValueError("Psychopy keypress RT column not found (key_resp*.rt).")
|
|
234
|
+
|
|
235
|
+
def _resolve_row_time(self, frame: pd.DataFrame) -> tuple[np.ndarray, str, dict]:
|
|
236
|
+
display_candidates = [
|
|
237
|
+
c for c in frame.columns if c.startswith(self.time_source_prefix) and c.endswith(self.time_source_suffix)
|
|
238
|
+
]
|
|
239
|
+
for col in display_candidates:
|
|
240
|
+
series = pd.to_numeric(frame[col], errors="coerce")
|
|
241
|
+
if series.notna().sum() >= 2:
|
|
242
|
+
filled = series.interpolate(limit_direction="both")
|
|
243
|
+
time_cols = [c for c in frame.columns if any(c.endswith(suf) for suf in self.time_suffixes)]
|
|
244
|
+
max_times = [pd.to_numeric(frame[c], errors="coerce").max() for c in time_cols]
|
|
245
|
+
max_time = max([t for t in max_times if pd.notna(t)], default=filled.max())
|
|
246
|
+
if max_time > filled.max():
|
|
247
|
+
last_valid_idx = filled.last_valid_index()
|
|
248
|
+
if last_valid_idx is not None and last_valid_idx < len(filled) - 1:
|
|
249
|
+
filled.loc[last_valid_idx + 1:] = max_time
|
|
250
|
+
meta = {
|
|
251
|
+
"row_time_column": col,
|
|
252
|
+
"row_time_extended_to": float(max_time),
|
|
253
|
+
}
|
|
254
|
+
return filled.to_numpy(dtype=np.float64), col, meta
|
|
255
|
+
raise ValueError("Psychopy requires a display_*.started column with at least 2 numeric values.")
|
|
256
|
+
|
|
257
|
+
def _dataqueue_offset(self, context: LoadContext) -> tuple[Optional[float], dict]:
|
|
258
|
+
#TODO: If a dataqueue does not have nidaq payload==1 but may start at nidaq==2,
|
|
259
|
+
# treat the first pulse as the start and log a warning.
|
|
260
|
+
dq_path = context.path_for("dataqueue")
|
|
261
|
+
if context.dataqueue_frame is not None:
|
|
262
|
+
frame = context.dataqueue_frame
|
|
263
|
+
queue_all = pd.to_numeric(frame.get(self.dataqueue_elapsed_column), errors="coerce").dropna()
|
|
264
|
+
if queue_all.empty:
|
|
265
|
+
raise ValueError("Dataqueue frame contains no queue_elapsed samples")
|
|
266
|
+
queue_start = float(queue_all.iloc[0])
|
|
267
|
+
device_series = frame.get(self.dataqueue_device_column, pd.Series(dtype=str)).astype(str)
|
|
268
|
+
mask = device_series.str.contains(self.dataqueue_device_match, case=False, na=False, regex=False)
|
|
269
|
+
device_rows = frame.loc[mask]
|
|
270
|
+
pulses = pd.to_numeric(device_rows.get(self.dataqueue_elapsed_column), errors="coerce")
|
|
271
|
+
abs_start = pd.to_datetime(device_rows.get("device_ts"), errors="coerce", utc=True)
|
|
272
|
+
abs_start = abs_start.dropna().dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ").to_numpy(dtype=str)
|
|
273
|
+
else:
|
|
274
|
+
if dq_path is None:
|
|
275
|
+
raise FileNotFoundError("Psychopy: dataqueue path not available")
|
|
276
|
+
timeline = DataqueueIndex.from_path(dq_path)
|
|
277
|
+
queue_all = timeline.queue_series()
|
|
278
|
+
queue_start = float(queue_all.iloc[0])
|
|
279
|
+
device_slice = timeline.slice(self.dataqueue_device_match)
|
|
280
|
+
abs_start = device_slice.packet_absolute().to_numpy(dtype=str)
|
|
281
|
+
pulses = pd.to_numeric(device_slice.rows.get(self.dataqueue_elapsed_column), errors="coerce")
|
|
282
|
+
# if self.dataqueue_payload_column in device_slice.rows.columns:
|
|
283
|
+
# payload = pd.to_numeric(device_slice.rows[self.dataqueue_payload_column], errors="coerce")
|
|
284
|
+
# pulses = pulses.loc[payload == self.dataqueue_payload_value]
|
|
285
|
+
pulses = pulses.dropna()
|
|
286
|
+
if pulses.empty:
|
|
287
|
+
raise ValueError(f"Dataqueue timeline exists {timeline.source_path} but contains no nidaq payload==1 pulses.")
|
|
288
|
+
|
|
289
|
+
start_rel = float(pulses.iloc[0]) - 0
|
|
290
|
+
meta = {
|
|
291
|
+
"dataqueue_file": str(dq_path) if dq_path is not None else None,
|
|
292
|
+
"dataqueue_pulses": int(pulses.size),
|
|
293
|
+
"dataqueue_queue_start": queue_start,
|
|
294
|
+
"abs_start_time": abs_start[0] if len(abs_start) else None,
|
|
295
|
+
}
|
|
296
|
+
return start_rel, meta
|
|
297
|
+
|
|
298
|
+
def _apply_experiment_window(
|
|
299
|
+
self,
|
|
300
|
+
intervals: pd.DataFrame,
|
|
301
|
+
context: LoadContext,
|
|
302
|
+
dq_start: float | None,
|
|
303
|
+
) -> pd.DataFrame:
|
|
304
|
+
if context.experiment_window is None or dq_start is None:
|
|
305
|
+
return intervals
|
|
306
|
+
start, stop = context.experiment_window
|
|
307
|
+
window_start = float(start) - float(dq_start)
|
|
308
|
+
window_stop = float(stop) - float(dq_start)
|
|
309
|
+
|
|
310
|
+
intervals = intervals.copy()
|
|
311
|
+
intervals["start_s"] = intervals["start_s"].clip(lower=window_start, upper=window_stop)
|
|
312
|
+
intervals["stop_s"] = intervals["stop_s"].clip(lower=window_start, upper=window_stop)
|
|
313
|
+
mask = (intervals["stop_s"] >= window_start) & (intervals["start_s"] <= window_stop)
|
|
314
|
+
return intervals.loc[mask].reset_index(drop=True)
|
|
315
|
+
|
|
316
|
+
def _align_time(self, values: np.ndarray, key_rt: float, offset: Optional[float]) -> np.ndarray:
|
|
317
|
+
aligned = values.astype(np.float64, copy=True) - float(key_rt)
|
|
318
|
+
if offset is not None:
|
|
319
|
+
aligned = aligned - float(offset)
|
|
320
|
+
return aligned
|
|
321
|
+
|
|
322
|
+
def _align_table(self, frame: pd.DataFrame, key_rt: float, offset: Optional[float]) -> pd.DataFrame:
|
|
323
|
+
aligned = frame.copy()
|
|
324
|
+
for col in aligned.columns:
|
|
325
|
+
name = str(col)
|
|
326
|
+
if not (name.lower().endswith(self.time_suffixes) or name.startswith("window_")):
|
|
327
|
+
continue
|
|
328
|
+
series = pd.to_numeric(aligned[col], errors="coerce")
|
|
329
|
+
aligned[col] = self._align_time(series.to_numpy(dtype=np.float64), key_rt, offset)
|
|
330
|
+
return aligned.dropna(axis=1, how="all")
|
|
331
|
+
|
|
332
|
+
def _build_window_list(self, frame: pd.DataFrame, start_col: str, stop_col: str) -> list[tuple[float, float]]:
|
|
333
|
+
if start_col not in frame.columns or stop_col not in frame.columns:
|
|
334
|
+
return []
|
|
335
|
+
starts = pd.to_numeric(frame[start_col], errors="coerce").to_numpy(dtype=np.float64, copy=False)
|
|
336
|
+
stops = pd.to_numeric(frame[stop_col], errors="coerce").to_numpy(dtype=np.float64, copy=False)
|
|
337
|
+
return [
|
|
338
|
+
(float(start), float(stop))
|
|
339
|
+
for start, stop in zip(starts, stops)
|
|
340
|
+
if np.isfinite(start) and np.isfinite(stop) and stop > start
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
def _extract_gratings(self, frame: pd.DataFrame) -> pd.DataFrame:
|
|
344
|
+
selected = self._select_columns(frame, list(self.gratings_columns))
|
|
345
|
+
result = selected.loc[selected.get("trials.thisN").notna()].copy()
|
|
346
|
+
if result.empty:
|
|
347
|
+
raise ValueError("Psychopy gratings trials are empty.")
|
|
348
|
+
|
|
349
|
+
gray_start = self._coalesce_column(result, ["stim_grayScreen.started"])
|
|
350
|
+
gray_stop = self._coalesce_column(result, ["stim_grating.started"])
|
|
351
|
+
grating_start = self._coalesce_column(result, ["stim_grating.started"])
|
|
352
|
+
grating_stop = self._coalesce_column(result, ["display_gratings.stopped"])
|
|
353
|
+
|
|
354
|
+
result["window_grating_start"] = pd.to_numeric(grating_start, errors="coerce")
|
|
355
|
+
result["window_grating_stop"] = pd.to_numeric(grating_stop, errors="coerce")
|
|
356
|
+
result["window_gray_start"] = pd.to_numeric(gray_start, errors="coerce")
|
|
357
|
+
result["window_gray_stop"] = pd.to_numeric(gray_stop, errors="coerce")
|
|
358
|
+
|
|
359
|
+
return result
|
|
360
|
+
|
|
361
|
+
def _extract_movies(self, frame: pd.DataFrame) -> pd.DataFrame:
|
|
362
|
+
return self._select_columns(frame, list(self.movies_columns))
|
|
363
|
+
|
|
364
|
+
|