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,323 @@
|
|
|
1
|
+
"""Treadmill loader.
|
|
2
|
+
|
|
3
|
+
Data is reconstructed entirely from the central ``*_dataqueue.csv`` log: each
|
|
4
|
+
``EncoderData(timestamp=..., distance=..., speed=...)`` payload row carries the
|
|
5
|
+
full sample, already published on the master clock via ``queue_elapsed``. The
|
|
6
|
+
per-session ``*_treadmill.csv`` is only consulted as a fallback when no
|
|
7
|
+
dataqueue is available for that session (e.g. legacy fixtures used by the
|
|
8
|
+
``treadmill_csv_fallback`` test).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from mesofield.datakit.config import settings
|
|
20
|
+
from mesofield.datakit.sources.register import LoadContext, TimeseriesSource
|
|
21
|
+
from mesofield.datakit.timeline import DataqueueIndex
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TreadmillSource(TimeseriesSource):
|
|
25
|
+
"""Load treadmill samples aligned to the experiment window."""
|
|
26
|
+
|
|
27
|
+
tag = "treadmill"
|
|
28
|
+
# Discovered from either the dataqueue (primary, parsed from EncoderData
|
|
29
|
+
# payloads) or — when no dataqueue exists for the session — the standalone
|
|
30
|
+
# treadmill CSV.
|
|
31
|
+
patterns = (
|
|
32
|
+
"**/*_dataqueue.csv",
|
|
33
|
+
"**/*_treadmill.csv",
|
|
34
|
+
"**/*_treadmill_data.csv",
|
|
35
|
+
)
|
|
36
|
+
camera_tag = None
|
|
37
|
+
requires = ("dataqueue",)
|
|
38
|
+
|
|
39
|
+
timestamp_column = "timestamp"
|
|
40
|
+
distance_columns = ("distance_mm", "distance")
|
|
41
|
+
speed_columns = ("speed_mm", "speed_mm_s", "speed")
|
|
42
|
+
output_distance_column = "distance_mm"
|
|
43
|
+
output_speed_column = "speed_mm"
|
|
44
|
+
timestamp_to_seconds = 1e-6
|
|
45
|
+
|
|
46
|
+
dataqueue_queue_column = settings.timeline.queue_column
|
|
47
|
+
dataqueue_device_id_column = "device_id"
|
|
48
|
+
dataqueue_payload_column = "payload"
|
|
49
|
+
|
|
50
|
+
dataqueue_payload_prefix = "EncoderData"
|
|
51
|
+
timeline_master_patterns = ("dhyana", "mesoscope")
|
|
52
|
+
_encoder_ts_re = re.compile(r"timestamp\s*=\s*(\d+)", re.IGNORECASE)
|
|
53
|
+
_encoder_payload_re = re.compile(
|
|
54
|
+
r"EncoderData\s*\("
|
|
55
|
+
r"\s*timestamp\s*=\s*(?P<ts>\d+)\s*,"
|
|
56
|
+
r"\s*distance\s*=\s*(?P<dist>-?\d+(?:\.\d+)?)"
|
|
57
|
+
r"(?:\s*[A-Za-z/]+)?\s*,"
|
|
58
|
+
r"\s*speed\s*=\s*(?P<speed>-?\d+(?:\.\d+)?)"
|
|
59
|
+
r"(?:\s*[A-Za-z/]+)?\s*\)",
|
|
60
|
+
re.IGNORECASE,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def build_timeseries(
|
|
64
|
+
self,
|
|
65
|
+
path: Path,
|
|
66
|
+
*,
|
|
67
|
+
context: LoadContext | None = None,
|
|
68
|
+
) -> tuple[np.ndarray, pd.DataFrame, dict]:
|
|
69
|
+
context = self._require_context(context)
|
|
70
|
+
dq_path = context.path_for("dataqueue")
|
|
71
|
+
|
|
72
|
+
# Primary path: build from the dataqueue alone — the per-session
|
|
73
|
+
# treadmill CSV (if it exists) is ignored.
|
|
74
|
+
if dq_path is not None or context.dataqueue_frame is not None:
|
|
75
|
+
aligned, meta = self._build_from_dataqueue(
|
|
76
|
+
dq_path=dq_path,
|
|
77
|
+
dataqueue_frame=context.dataqueue_frame,
|
|
78
|
+
window=context.experiment_window,
|
|
79
|
+
)
|
|
80
|
+
t = aligned["time_elapsed_s"].to_numpy(dtype=np.float64)
|
|
81
|
+
meta = {
|
|
82
|
+
"source_file": str(dq_path) if dq_path is not None else None,
|
|
83
|
+
"dataqueue_file": str(dq_path) if dq_path is not None else None,
|
|
84
|
+
"n_samples": int(len(aligned)),
|
|
85
|
+
**meta,
|
|
86
|
+
}
|
|
87
|
+
return t, aligned, meta
|
|
88
|
+
|
|
89
|
+
# Fallback path: standalone treadmill CSV with no dataqueue available.
|
|
90
|
+
if path is None or not Path(path).exists() or Path(path).name.endswith("_dataqueue.csv"):
|
|
91
|
+
raise FileNotFoundError(
|
|
92
|
+
"TreadmillSource: no dataqueue available and no treadmill CSV path"
|
|
93
|
+
)
|
|
94
|
+
df = pd.read_csv(path)
|
|
95
|
+
dist = next((c for c in self.distance_columns if c in df.columns), None)
|
|
96
|
+
speed = next((c for c in self.speed_columns if c in df.columns), None)
|
|
97
|
+
if self.timestamp_column not in df.columns or dist is None or speed is None:
|
|
98
|
+
raise ValueError(f"Expected timestamp/distance/speed columns in {path}")
|
|
99
|
+
df = df.rename(columns={dist: self.output_distance_column, speed: self.output_speed_column})
|
|
100
|
+
|
|
101
|
+
aligned, meta = self._build_from_csv_fallback(df)
|
|
102
|
+
t = aligned["time_elapsed_s"].to_numpy(dtype=np.float64)
|
|
103
|
+
meta = {
|
|
104
|
+
"source_file": str(path),
|
|
105
|
+
"dataqueue_file": None,
|
|
106
|
+
"n_samples": int(len(aligned)),
|
|
107
|
+
**meta,
|
|
108
|
+
}
|
|
109
|
+
return t, aligned, meta
|
|
110
|
+
|
|
111
|
+
# --- dataqueue path -------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def _build_from_dataqueue(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
dq_path: Path | None,
|
|
117
|
+
dataqueue_frame: pd.DataFrame | None,
|
|
118
|
+
window: tuple[float, float] | None,
|
|
119
|
+
) -> tuple[pd.DataFrame, dict]:
|
|
120
|
+
t0, t1 = window or self._window(dq_path, dataqueue_frame)
|
|
121
|
+
duration = float(t1 - t0)
|
|
122
|
+
if not np.isfinite(duration) or duration <= 0:
|
|
123
|
+
raise ValueError(f"Invalid camera window duration from dataqueue: start={t0}, end={t1}")
|
|
124
|
+
|
|
125
|
+
samples = self._encoder_samples(dq_path, dataqueue_frame)
|
|
126
|
+
queue_elapsed = samples["queue_elapsed"]
|
|
127
|
+
distance = samples["distance_mm"]
|
|
128
|
+
speed = samples["speed_mm"]
|
|
129
|
+
encoder_ts = samples["encoder_ts"]
|
|
130
|
+
|
|
131
|
+
time_s = queue_elapsed - t0
|
|
132
|
+
finite_vals = np.isfinite(distance) & np.isfinite(speed)
|
|
133
|
+
mask = (
|
|
134
|
+
np.isfinite(time_s)
|
|
135
|
+
& finite_vals
|
|
136
|
+
& (time_s >= 0.0)
|
|
137
|
+
& (time_s <= duration)
|
|
138
|
+
)
|
|
139
|
+
if not np.any(mask):
|
|
140
|
+
# No treadmill samples in this session: return empty DataFrame and meta
|
|
141
|
+
aligned = pd.DataFrame({
|
|
142
|
+
self.timestamp_column: np.array([], dtype=np.float64),
|
|
143
|
+
self.output_distance_column: np.array([], dtype=np.float64),
|
|
144
|
+
self.output_speed_column: np.array([], dtype=np.float64),
|
|
145
|
+
"time_elapsed_s": np.array([], dtype=np.float64),
|
|
146
|
+
})
|
|
147
|
+
meta = {
|
|
148
|
+
"source_method": "dataqueue_encoder_payload",
|
|
149
|
+
"experiment_window": {"start": float(t0), "end": float(t1)},
|
|
150
|
+
"n_encoder_payloads": int(queue_elapsed.size),
|
|
151
|
+
"n_kept": 0,
|
|
152
|
+
}
|
|
153
|
+
return aligned, meta
|
|
154
|
+
|
|
155
|
+
aligned = pd.DataFrame(
|
|
156
|
+
{
|
|
157
|
+
self.timestamp_column: encoder_ts[mask],
|
|
158
|
+
self.output_distance_column: distance[mask],
|
|
159
|
+
self.output_speed_column: speed[mask],
|
|
160
|
+
"time_elapsed_s": time_s[mask],
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
aligned.sort_values("time_elapsed_s", inplace=True)
|
|
164
|
+
aligned.reset_index(drop=True, inplace=True)
|
|
165
|
+
|
|
166
|
+
meta = {
|
|
167
|
+
"source_method": "dataqueue_encoder_payload",
|
|
168
|
+
"experiment_window": {"start": float(t0), "end": float(t1)},
|
|
169
|
+
"n_encoder_payloads": int(queue_elapsed.size),
|
|
170
|
+
"n_kept": int(np.count_nonzero(mask)),
|
|
171
|
+
}
|
|
172
|
+
return aligned, meta
|
|
173
|
+
|
|
174
|
+
def _encoder_samples(
|
|
175
|
+
self,
|
|
176
|
+
dq_path: Path | None,
|
|
177
|
+
dataqueue_frame: pd.DataFrame | None,
|
|
178
|
+
) -> dict[str, np.ndarray]:
|
|
179
|
+
"""Return parallel arrays parsed from EncoderData payloads.
|
|
180
|
+
|
|
181
|
+
Treadmill owns this parsing — the ``dataqueue`` source intentionally
|
|
182
|
+
treats payloads as opaque. We use the upstream frame only if the
|
|
183
|
+
payload column is still in its original string dtype (which is no
|
|
184
|
+
longer the case once ``DataqueueSource`` has run), otherwise we
|
|
185
|
+
re-read the raw CSV via ``dq_path``.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
dq = dataqueue_frame
|
|
189
|
+
if dq is not None:
|
|
190
|
+
payload = dq.get(self.dataqueue_payload_column)
|
|
191
|
+
if payload is not None and (
|
|
192
|
+
pd.api.types.is_object_dtype(payload) or pd.api.types.is_string_dtype(payload)
|
|
193
|
+
):
|
|
194
|
+
return self._parse_dataqueue_payloads(dq)
|
|
195
|
+
if dq_path is None:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
"TreadmillSource: dataqueue payload is not string-typed and no path is "
|
|
198
|
+
"available to re-read EncoderData strings"
|
|
199
|
+
)
|
|
200
|
+
raw = pd.read_csv(
|
|
201
|
+
dq_path,
|
|
202
|
+
usecols=[self.dataqueue_queue_column, self.dataqueue_payload_column],
|
|
203
|
+
low_memory=False,
|
|
204
|
+
)
|
|
205
|
+
return self._parse_dataqueue_payloads(raw)
|
|
206
|
+
|
|
207
|
+
def _parse_dataqueue_payloads(self, dq: pd.DataFrame) -> dict[str, np.ndarray]:
|
|
208
|
+
if (
|
|
209
|
+
self.dataqueue_payload_column not in dq.columns
|
|
210
|
+
or self.dataqueue_queue_column not in dq.columns
|
|
211
|
+
):
|
|
212
|
+
raise ValueError("Dataqueue frame missing payload or queue_elapsed column")
|
|
213
|
+
|
|
214
|
+
payload = dq[self.dataqueue_payload_column].astype(str)
|
|
215
|
+
mask = payload.str.contains(self.dataqueue_payload_prefix, na=False)
|
|
216
|
+
if not np.any(mask):
|
|
217
|
+
raise ValueError("No EncoderData payloads found in dataqueue")
|
|
218
|
+
|
|
219
|
+
sub_payloads = payload.loc[mask].to_numpy()
|
|
220
|
+
queue_full = pd.to_numeric(dq.loc[mask, self.dataqueue_queue_column], errors="coerce").to_numpy(dtype=np.float64)
|
|
221
|
+
|
|
222
|
+
ts_list: list[float] = []
|
|
223
|
+
dist_list: list[float] = []
|
|
224
|
+
speed_list: list[float] = []
|
|
225
|
+
q_list: list[float] = []
|
|
226
|
+
for value, q in zip(sub_payloads, queue_full):
|
|
227
|
+
if not np.isfinite(q):
|
|
228
|
+
continue
|
|
229
|
+
m = self._encoder_payload_re.search(value or "")
|
|
230
|
+
if m is None:
|
|
231
|
+
continue
|
|
232
|
+
ts_list.append(float(int(m.group("ts"))))
|
|
233
|
+
dist_list.append(float(m.group("dist")))
|
|
234
|
+
speed_list.append(float(m.group("speed")))
|
|
235
|
+
q_list.append(float(q))
|
|
236
|
+
|
|
237
|
+
if not ts_list:
|
|
238
|
+
raise ValueError("No parseable EncoderData payloads in dataqueue")
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
"queue_elapsed": np.asarray(q_list, dtype=np.float64),
|
|
242
|
+
"encoder_ts": np.asarray(ts_list, dtype=np.float64),
|
|
243
|
+
"distance_mm": np.asarray(dist_list, dtype=np.float64),
|
|
244
|
+
"speed_mm": np.asarray(speed_list, dtype=np.float64),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
def _window(
|
|
248
|
+
self,
|
|
249
|
+
dq_path: Path | None,
|
|
250
|
+
dataqueue_frame: pd.DataFrame | None,
|
|
251
|
+
) -> tuple[float, float]:
|
|
252
|
+
dq = dataqueue_frame
|
|
253
|
+
if dq is None:
|
|
254
|
+
if dq_path is None:
|
|
255
|
+
raise FileNotFoundError("TreadmillSource: dataqueue path not available")
|
|
256
|
+
dq = pd.read_csv(
|
|
257
|
+
dq_path,
|
|
258
|
+
usecols=[self.dataqueue_queue_column, self.dataqueue_device_id_column],
|
|
259
|
+
low_memory=False,
|
|
260
|
+
)
|
|
261
|
+
device = dq.get(self.dataqueue_device_id_column, pd.Series(dtype=str)).astype(str)
|
|
262
|
+
|
|
263
|
+
mask = np.zeros(len(dq), dtype=bool)
|
|
264
|
+
for pattern in self.timeline_master_patterns:
|
|
265
|
+
mask |= device.str.contains(pattern, case=False, na=False, regex=False).to_numpy()
|
|
266
|
+
|
|
267
|
+
rows = pd.to_numeric(dq.loc[mask, self.dataqueue_queue_column], errors="coerce").dropna()
|
|
268
|
+
if len(rows) < 2:
|
|
269
|
+
raise ValueError("Could not find >=2 master camera rows in dataqueue")
|
|
270
|
+
return float(rows.iloc[0]), float(rows.iloc[-1])
|
|
271
|
+
|
|
272
|
+
# --- CSV-only fallback ----------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def _build_from_csv_fallback(
|
|
275
|
+
self, treadmill_df: pd.DataFrame
|
|
276
|
+
) -> tuple[pd.DataFrame, dict]:
|
|
277
|
+
ts = pd.to_numeric(treadmill_df[self.timestamp_column], errors="coerce").to_numpy(dtype=np.float64)
|
|
278
|
+
finite = np.isfinite(ts)
|
|
279
|
+
if not np.any(finite):
|
|
280
|
+
raise ValueError("No valid treadmill timestamp samples")
|
|
281
|
+
|
|
282
|
+
aligned = treadmill_df.loc[finite].copy()
|
|
283
|
+
ts = ts[finite]
|
|
284
|
+
ts_i64 = np.rint(ts).astype(np.int64)
|
|
285
|
+
|
|
286
|
+
unwrapped = DataqueueIndex.fix_32bit_wraparound(ts_i64).astype(np.float64)
|
|
287
|
+
# If the file contains a wrap, samples after the wrap may be out of order.
|
|
288
|
+
# Sort by unwrapped timestamp before computing elapsed time.
|
|
289
|
+
sort_idx = np.argsort(unwrapped)
|
|
290
|
+
unwrapped = unwrapped[sort_idx]
|
|
291
|
+
aligned = aligned.iloc[sort_idx].reset_index(drop=True)
|
|
292
|
+
elapsed = DataqueueIndex.relative(unwrapped) * self.timestamp_to_seconds
|
|
293
|
+
aligned["_elapsed"] = elapsed
|
|
294
|
+
|
|
295
|
+
aligned[self.output_distance_column] = pd.to_numeric(
|
|
296
|
+
aligned[self.output_distance_column], errors="coerce"
|
|
297
|
+
)
|
|
298
|
+
aligned[self.output_speed_column] = pd.to_numeric(
|
|
299
|
+
aligned[self.output_speed_column], errors="coerce"
|
|
300
|
+
)
|
|
301
|
+
aligned = aligned.dropna(
|
|
302
|
+
subset=[self.output_distance_column, self.output_speed_column, "_elapsed"]
|
|
303
|
+
)
|
|
304
|
+
if aligned.empty:
|
|
305
|
+
raise ValueError("Treadmill fallback produced no finite elapsed samples")
|
|
306
|
+
|
|
307
|
+
elapsed = aligned.pop("_elapsed").to_numpy(dtype=np.float64)
|
|
308
|
+
if elapsed.size > 1:
|
|
309
|
+
keep = np.concatenate(([True], np.diff(elapsed) > 0))
|
|
310
|
+
aligned = aligned.iloc[keep].copy()
|
|
311
|
+
elapsed = elapsed[keep]
|
|
312
|
+
if elapsed.size == 0:
|
|
313
|
+
raise ValueError("Treadmill fallback produced no usable samples after cleaning")
|
|
314
|
+
|
|
315
|
+
aligned["time_elapsed_s"] = elapsed
|
|
316
|
+
aligned.sort_values("time_elapsed_s", inplace=True)
|
|
317
|
+
aligned.reset_index(drop=True, inplace=True)
|
|
318
|
+
|
|
319
|
+
return aligned, {
|
|
320
|
+
"source_method": "treadmill_csv_fallback",
|
|
321
|
+
"experiment_window": None,
|
|
322
|
+
"alignment": None,
|
|
323
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Wheel encoder behavioral data source with dda synchronized timeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from ..._utils._logger import get_logger
|
|
13
|
+
|
|
14
|
+
from mesofield.datakit.sources.register import LoadContext, TimeseriesSource
|
|
15
|
+
from mesofield.datakit.timeline import DataqueueIndex
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class _WheelSummary:
|
|
22
|
+
"""Aggregate metrics extracted from a wheel run."""
|
|
23
|
+
|
|
24
|
+
duration_s: float
|
|
25
|
+
total_distance_mm: float
|
|
26
|
+
total_click_delta: int
|
|
27
|
+
start_ts: Optional[str]
|
|
28
|
+
stop_ts: Optional[str]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WheelEncoder(TimeseriesSource):
|
|
32
|
+
"""Load wheel encoder streams recorded alongside nidaq pulses.
|
|
33
|
+
|
|
34
|
+
The raw CSV emitted by the behavioral rig contains incremental click counts,
|
|
35
|
+
elapsed time in seconds, and instantaneous speed estimates. The loader
|
|
36
|
+
converts this information into a strictly increasing timeline anchored to
|
|
37
|
+
acquisition start, computes cumulative distance, and exposes rich metadata
|
|
38
|
+
for downstream alignment against the nidaq-driven master clock.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
tag = "wheel"
|
|
42
|
+
patterns = ("**/*_wheel.csv",)
|
|
43
|
+
camera_tag = None # Not bound to camera
|
|
44
|
+
requires = ("dataqueue",)
|
|
45
|
+
|
|
46
|
+
required_columns = ("Clicks", "Time", "Speed")
|
|
47
|
+
time_column = "Time"
|
|
48
|
+
click_column = "Clicks"
|
|
49
|
+
speed_column = "Speed"
|
|
50
|
+
cumulative_column = "click_delta"
|
|
51
|
+
anchor_filter_pattern = "encoder"
|
|
52
|
+
queue_elapsed_column = "queue_elapsed"
|
|
53
|
+
dataqueue_payload_column = "payload"
|
|
54
|
+
alignment_min_points = 2
|
|
55
|
+
alignment_time_basis_dataqueue = "dataqueue"
|
|
56
|
+
alignment_time_basis_wheel = "wheel_clock"
|
|
57
|
+
alignment_poly_degree = 1
|
|
58
|
+
distance_integration_method = "trapezoid"
|
|
59
|
+
absolute_device_id = "encoder"
|
|
60
|
+
alignment_origin_device_id = "ThorCam"
|
|
61
|
+
def build_timeseries(
|
|
62
|
+
self,
|
|
63
|
+
path: Path,
|
|
64
|
+
*,
|
|
65
|
+
context: LoadContext | None = None,
|
|
66
|
+
) -> tuple[np.ndarray, pd.DataFrame, dict]:
|
|
67
|
+
context = self._require_context(context)
|
|
68
|
+
raw = pd.read_csv(path)
|
|
69
|
+
|
|
70
|
+
if not set(self.required_columns).issubset(raw.columns):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Wheel file is missing required columns {self.required_columns}: {path}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
df = self._prepare_frame(raw)
|
|
76
|
+
df_aligned, alignment_meta = self._align_to_dataqueue(df, context)
|
|
77
|
+
summary = self._summarize(df_aligned, raw)
|
|
78
|
+
df_aligned = df_aligned.rename(columns={"time_s": "time_elapsed_s", "time_raw_s": "time_reference_s"})
|
|
79
|
+
|
|
80
|
+
meta = {
|
|
81
|
+
"source_file": str(path),
|
|
82
|
+
"n_samples": int(len(df_aligned)),
|
|
83
|
+
"start_time": summary.start_ts,
|
|
84
|
+
"stop_time": summary.stop_ts,
|
|
85
|
+
"duration_s": summary.duration_s,
|
|
86
|
+
"total_distance_mm": summary.total_distance_mm,
|
|
87
|
+
"total_click_delta": summary.total_click_delta,
|
|
88
|
+
"source_method": "wheel_csv_v2",
|
|
89
|
+
**alignment_meta,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return df_aligned["time_elapsed_s"].to_numpy(dtype=np.float64), df_aligned, meta
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# Internal helpers
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
def _prepare_frame(self, raw: pd.DataFrame) -> pd.DataFrame:
|
|
98
|
+
"""Type cast and derive the canonical wheel dataframe."""
|
|
99
|
+
|
|
100
|
+
frame = pd.DataFrame()
|
|
101
|
+
frame["time_s"] = pd.to_numeric(raw[self.time_column], errors="coerce")
|
|
102
|
+
frame["click_delta"] = pd.to_numeric(raw[self.click_column], errors="coerce")
|
|
103
|
+
frame["speed_mm"] = pd.to_numeric(raw[self.speed_column], errors="coerce")
|
|
104
|
+
|
|
105
|
+
frame = frame.dropna(subset=["time_s"]).sort_values("time_s").reset_index(drop=True)
|
|
106
|
+
|
|
107
|
+
if frame.empty:
|
|
108
|
+
frame = pd.DataFrame(
|
|
109
|
+
{
|
|
110
|
+
"time_s": [0.0, 0.0],
|
|
111
|
+
"click_delta": [0, 0],
|
|
112
|
+
"speed_mm": [0.0, 0.0],
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
# Ensure speed/clicks missing entries become zeros
|
|
116
|
+
frame["click_delta"] = frame["click_delta"].fillna(0)
|
|
117
|
+
frame["speed_mm"] = frame["speed_mm"].fillna(0.0)
|
|
118
|
+
|
|
119
|
+
# Re-zero the timeline relative to the first available sample
|
|
120
|
+
frame["time_raw_s"] = frame["time_s"].to_numpy(dtype=np.float64)
|
|
121
|
+
t = frame["time_raw_s"].copy()
|
|
122
|
+
t0 = float(t[0])
|
|
123
|
+
t -= t0
|
|
124
|
+
frame["time_s"] = t
|
|
125
|
+
|
|
126
|
+
# Derive cumulative click position for quick sanity checks
|
|
127
|
+
frame["click_position"] = np.cumsum(frame["click_delta"].to_numpy(dtype=np.int64))
|
|
128
|
+
|
|
129
|
+
# Integrate speed to distance using trapezoidal rule
|
|
130
|
+
speed = frame["speed_mm"].to_numpy(dtype=np.float64)
|
|
131
|
+
distance = np.zeros_like(speed)
|
|
132
|
+
if speed.size > 1:
|
|
133
|
+
dt = np.diff(t)
|
|
134
|
+
trapezoids = 0.5 * (speed[:-1] + speed[1:]) * dt
|
|
135
|
+
distance[1:] = np.cumsum(trapezoids)
|
|
136
|
+
frame["distance_mm"] = distance
|
|
137
|
+
|
|
138
|
+
return frame[["time_s", "time_raw_s", "click_delta", "click_position", "speed_mm", "distance_mm"]]
|
|
139
|
+
|
|
140
|
+
def _summarize(self, frame: pd.DataFrame, raw: pd.DataFrame) -> _WheelSummary:
|
|
141
|
+
"""Build aggregate metadata for diagnostics and alignment hints."""
|
|
142
|
+
|
|
143
|
+
duration = float(frame["time_s"].iloc[-1]) if len(frame) else 0.0
|
|
144
|
+
total_distance = float(frame["distance_mm"].iloc[-1]) if len(frame) else 0.0
|
|
145
|
+
total_clicks = int(frame["click_delta"].sum())
|
|
146
|
+
|
|
147
|
+
start_ts = self._extract_timestamp(raw.get("Started"))
|
|
148
|
+
stop_ts = self._extract_timestamp(raw.get("Stopped"))
|
|
149
|
+
|
|
150
|
+
return _WheelSummary(
|
|
151
|
+
duration_s=duration,
|
|
152
|
+
total_distance_mm=total_distance,
|
|
153
|
+
total_click_delta=total_clicks,
|
|
154
|
+
start_ts=start_ts,
|
|
155
|
+
stop_ts=stop_ts,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
# Alignment helpers
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
def _align_to_dataqueue(self, frame: pd.DataFrame, context: LoadContext) -> tuple[pd.DataFrame, dict]:
|
|
162
|
+
"""Map wheel timestamps onto the nidaq master clock via dataqueue anchors."""
|
|
163
|
+
dq_path = context.path_for("dataqueue")
|
|
164
|
+
dataqueue_file = str(dq_path) if dq_path is not None else None
|
|
165
|
+
timeline = None
|
|
166
|
+
|
|
167
|
+
if context.dataqueue_frame is not None:
|
|
168
|
+
encoder_times = self._extract_times(context.dataqueue_frame, self.anchor_filter_pattern)
|
|
169
|
+
origin_times = self._extract_times(context.dataqueue_frame, self.alignment_origin_device_id)
|
|
170
|
+
else:
|
|
171
|
+
if dq_path is None:
|
|
172
|
+
raise FileNotFoundError("WheelEncoder: dataqueue path not available")
|
|
173
|
+
timeline = DataqueueIndex.from_path(dq_path)
|
|
174
|
+
encoder_slice = timeline.slice(
|
|
175
|
+
lambda ids: ids.str.contains(self.anchor_filter_pattern, case=False, na=False, regex=False)
|
|
176
|
+
)
|
|
177
|
+
encoder_times = encoder_slice.queue_elapsed().to_numpy(dtype=np.float64)
|
|
178
|
+
origin_slice = timeline.slice(
|
|
179
|
+
lambda ids: ids.str.contains(self.alignment_origin_device_id, case=False, na=False, regex=False)
|
|
180
|
+
)
|
|
181
|
+
origin_times = origin_slice.queue_elapsed().to_numpy(dtype=np.float64)
|
|
182
|
+
dataqueue_file = str(timeline.source_path)
|
|
183
|
+
if encoder_times.size < 2:
|
|
184
|
+
logger.warning(
|
|
185
|
+
"WheelEncoder: insufficient encoder anchors (%d). "
|
|
186
|
+
"Returning unaligned wheel timeline.",
|
|
187
|
+
encoder_times.size,
|
|
188
|
+
)
|
|
189
|
+
return frame.copy(), {
|
|
190
|
+
"time_basis": self.alignment_time_basis_wheel,
|
|
191
|
+
"dataqueue_file": dataqueue_file,
|
|
192
|
+
"dataqueue_alignment": "insufficient_anchors",
|
|
193
|
+
"dataqueue_anchors": int(encoder_times.size),
|
|
194
|
+
"alignment_device": self.anchor_filter_pattern,
|
|
195
|
+
}
|
|
196
|
+
if origin_times.size == 0:
|
|
197
|
+
logger.warning(
|
|
198
|
+
"WheelEncoder: %s anchors missing for alignment. "
|
|
199
|
+
"Returning unaligned wheel timeline.",
|
|
200
|
+
self.alignment_origin_device_id,
|
|
201
|
+
)
|
|
202
|
+
return frame.copy(), {
|
|
203
|
+
"time_basis": self.alignment_time_basis_wheel,
|
|
204
|
+
"dataqueue_file": dataqueue_file,
|
|
205
|
+
"dataqueue_alignment": "missing_origin_anchors",
|
|
206
|
+
"dataqueue_anchors": int(encoder_times.size),
|
|
207
|
+
"alignment_device": self.anchor_filter_pattern,
|
|
208
|
+
"alignment_origin_device": self.alignment_origin_device_id,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
n_samples = len(frame)
|
|
212
|
+
n_anchors = int(encoder_times.size)
|
|
213
|
+
if n_anchors == n_samples:
|
|
214
|
+
aligned_times = encoder_times
|
|
215
|
+
method = "direct"
|
|
216
|
+
else:
|
|
217
|
+
anchor_index = np.arange(n_anchors, dtype=np.float64)
|
|
218
|
+
frame_index = np.linspace(0.0, float(n_anchors - 1), num=n_samples, dtype=np.float64)
|
|
219
|
+
aligned_times = np.interp(frame_index, anchor_index, encoder_times)
|
|
220
|
+
method = "resampled"
|
|
221
|
+
|
|
222
|
+
origin = float(origin_times[0])
|
|
223
|
+
aligned = frame.copy()
|
|
224
|
+
aligned["time_s"] = aligned_times - origin
|
|
225
|
+
|
|
226
|
+
encoder_start = float(encoder_times[0])
|
|
227
|
+
origin_offset = encoder_start - origin
|
|
228
|
+
|
|
229
|
+
if timeline is not None:
|
|
230
|
+
elapsed_abs = timeline.absolute_for_device(self.absolute_device_id)
|
|
231
|
+
else:
|
|
232
|
+
elapsed_abs = None
|
|
233
|
+
if elapsed_abs:
|
|
234
|
+
elapsed, absolute = elapsed_abs
|
|
235
|
+
elapsed = elapsed + origin_offset
|
|
236
|
+
aligned_t = aligned["time_s"].to_numpy(dtype=np.float64)
|
|
237
|
+
aligned["time_absolute"] = np.interp(aligned_t, elapsed, np.arange(len(absolute))).astype(int)
|
|
238
|
+
aligned["time_absolute"] = aligned["time_absolute"].map(
|
|
239
|
+
lambda i: absolute[i] if 0 <= i < len(absolute) else None
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return aligned, {
|
|
243
|
+
"time_basis": self.alignment_time_basis_dataqueue,
|
|
244
|
+
"dataqueue_file": dataqueue_file,
|
|
245
|
+
"dataqueue_alignment": method,
|
|
246
|
+
"dataqueue_anchors": n_anchors,
|
|
247
|
+
"alignment_device": self.anchor_filter_pattern,
|
|
248
|
+
"alignment_origin_device": self.alignment_origin_device_id,
|
|
249
|
+
"alignment_origin_queue_elapsed": origin,
|
|
250
|
+
"alignment_origin_offset": float(origin_offset),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
def _extract_times(self, frame: pd.DataFrame, pattern: str) -> np.ndarray:
|
|
254
|
+
if self.queue_elapsed_column not in frame.columns or "device_id" not in frame.columns:
|
|
255
|
+
return np.array([], dtype=np.float64)
|
|
256
|
+
device_series = frame["device_id"].astype(str)
|
|
257
|
+
mask = device_series.str.contains(pattern, case=False, na=False, regex=False)
|
|
258
|
+
times = pd.to_numeric(frame.loc[mask, self.queue_elapsed_column], errors="coerce").dropna()
|
|
259
|
+
return times.to_numpy(dtype=np.float64)
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def _extract_timestamp(series: Optional[pd.Series]) -> Optional[str]:
|
|
263
|
+
"""Return an ISO8601 string if the provided column encodes a timestamp."""
|
|
264
|
+
|
|
265
|
+
if series is None:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
first = series.dropna().astype(str).head(1)
|
|
269
|
+
if first.empty:
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
ts = pd.to_datetime(first.iloc[0], utc=False, errors="raise")
|
|
274
|
+
except (TypeError, ValueError):
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
return ts.isoformat()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Mesoscope camera metadata data source."""
|
|
2
|
+
|
|
3
|
+
from mesofield.datakit.sources.camera.metadata_json import MetadataJSON
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MesoMetadataSource(MetadataJSON):
|
|
7
|
+
"""Load mesoscope camera metadata JSON files as a table."""
|
|
8
|
+
tag = "meso_metadata"
|
|
9
|
+
patterns = ("**/*_mesoscope.ome.tiff_frame_metadata.json",
|
|
10
|
+
"**/*_meso.ome.tiff_frame_metadata.json")
|
|
11
|
+
camera_tag = "meso_metadata"
|
|
12
|
+
flatten_payload = False
|
|
13
|
+
drop_columns = (
|
|
14
|
+
"camera_metadata",
|
|
15
|
+
"property_values",
|
|
16
|
+
"version",
|
|
17
|
+
"format",
|
|
18
|
+
"ROI-X-start",
|
|
19
|
+
"ROI-Y-start",
|
|
20
|
+
"mda_event",
|
|
21
|
+
"Height",
|
|
22
|
+
"Width",
|
|
23
|
+
"camera_device",
|
|
24
|
+
"pixel_size_um",
|
|
25
|
+
"images_remaining_in_buffer",
|
|
26
|
+
"PixelType",
|
|
27
|
+
"hardware_triggered",
|
|
28
|
+
# Redundant timestamp columns: time_elapsed_s + time_absolute already
|
|
29
|
+
# cover the same clock at smaller dtypes.
|
|
30
|
+
"ElapsedTime-ms",
|
|
31
|
+
"TimeReceivedByCore",
|
|
32
|
+
)
|