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
mesofield/devices/daq.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from typing import Any, ClassVar, Dict, List
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import threading
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import nidaqmx.system
|
|
9
|
+
import nidaqmx
|
|
10
|
+
from nidaqmx.constants import Edge
|
|
11
|
+
|
|
12
|
+
from mesofield.signals import DeviceSignals
|
|
13
|
+
from mesofield.utils._logger import get_logger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Nidaq:
|
|
18
|
+
"""
|
|
19
|
+
NIDAQ hardware control device.
|
|
20
|
+
|
|
21
|
+
This class implements the ControlDevice protocol via duck typing,
|
|
22
|
+
providing all the necessary methods and attributes without inheritance.
|
|
23
|
+
"""
|
|
24
|
+
device_name: str
|
|
25
|
+
lines: str
|
|
26
|
+
ctr: str
|
|
27
|
+
io_type: str
|
|
28
|
+
device_type: ClassVar[str] = "nidaq"
|
|
29
|
+
device_id: str = "nidaq"
|
|
30
|
+
bids_type: str = ""
|
|
31
|
+
file_type: str = "csv"
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
self._started: datetime # Timestamp when the device was started
|
|
35
|
+
self._stopped: datetime # Timestamp when the device was stopped
|
|
36
|
+
self.signals = DeviceSignals()
|
|
37
|
+
self.logger = get_logger(f"{__name__}.{self.__class__.__name__}[{self.device_id}]")
|
|
38
|
+
self.pulse_width = 0.001
|
|
39
|
+
self.poll_interval = 0.01
|
|
40
|
+
self._stop_event = threading.Event()
|
|
41
|
+
self._thread = None
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
self._exposure_times: list[float] = []
|
|
44
|
+
|
|
45
|
+
def initialize(self) -> None:
|
|
46
|
+
"""Initialize the device."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
def arm(self, config) -> None:
|
|
50
|
+
"""No per-run prep needed."""
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def test_connection(self):
|
|
54
|
+
"""Pulse the configured DO line high for ~3 s as a connectivity test.
|
|
55
|
+
|
|
56
|
+
Logs the outcome; does not raise on failure (errors are surfaced
|
|
57
|
+
via the logger).
|
|
58
|
+
"""
|
|
59
|
+
self.logger.info(f"Testing connection to NI-DAQ device: {self.device_name}")
|
|
60
|
+
try:
|
|
61
|
+
with nidaqmx.Task() as task:
|
|
62
|
+
task.do_channels.add_do_chan(f'{self.device_name}/{self.lines}')
|
|
63
|
+
task.write(True)
|
|
64
|
+
time.sleep(3)
|
|
65
|
+
task.write(False)
|
|
66
|
+
self.logger.info("NI-DAQ connection successful")
|
|
67
|
+
except nidaqmx.DaqError as e:
|
|
68
|
+
self.logger.error(f"NI-DAQ connection failed: {e}")
|
|
69
|
+
|
|
70
|
+
def reset(self):
|
|
71
|
+
"""Stop the worker thread (if running) and reset the NI-DAQ device."""
|
|
72
|
+
self.logger.info(f"Resetting NIDAQ device")
|
|
73
|
+
if self._thread and self._thread.is_alive():
|
|
74
|
+
self.stop()
|
|
75
|
+
nidaqmx.system.Device(self.device_name).reset_device()
|
|
76
|
+
|
|
77
|
+
def start(self):
|
|
78
|
+
"""Begin counting edges on ``ctr`` and pulsing ``lines`` from a thread.
|
|
79
|
+
|
|
80
|
+
Configures both a counter-input task (rising-edge counts) and a
|
|
81
|
+
digital-output task (camera trigger), then launches a worker
|
|
82
|
+
thread that drives them on the configured ``poll_interval``.
|
|
83
|
+
"""
|
|
84
|
+
# Configure and start the CI task
|
|
85
|
+
self._ci = nidaqmx.Task()
|
|
86
|
+
self._ci.ci_channels.add_ci_count_edges_chan(
|
|
87
|
+
f"{self.device_name}/{self.ctr}", edge=Edge.RISING, initial_count=0
|
|
88
|
+
)
|
|
89
|
+
self._ci.start()
|
|
90
|
+
self._started = datetime.now()
|
|
91
|
+
# Configure the DO task (camera trigger)
|
|
92
|
+
self._do = nidaqmx.Task()
|
|
93
|
+
self._do.do_channels.add_do_chan(f"{self.device_name}/{self.lines}")
|
|
94
|
+
|
|
95
|
+
# Launch background thread
|
|
96
|
+
self._stop_event.clear()
|
|
97
|
+
self._thread = threading.Thread(target=self._worker, daemon=True)
|
|
98
|
+
self._thread.start()
|
|
99
|
+
self.signals.started.emit()
|
|
100
|
+
|
|
101
|
+
def _worker(self):
|
|
102
|
+
prev_count = 0
|
|
103
|
+
while not self._stop_event.is_set():
|
|
104
|
+
# 1) Start Read count & timestamp
|
|
105
|
+
cnt = self._ci.read()
|
|
106
|
+
ts = time.time()
|
|
107
|
+
dt = datetime.now()
|
|
108
|
+
|
|
109
|
+
# 2) Trigger camera
|
|
110
|
+
self._do.write(True)
|
|
111
|
+
time.sleep(self.pulse_width)
|
|
112
|
+
self._do.write(False)
|
|
113
|
+
|
|
114
|
+
# 3) For each new edge, record the timestamp
|
|
115
|
+
new_count = int(cnt)
|
|
116
|
+
if new_count > prev_count:
|
|
117
|
+
event_data = [ts] * (new_count - prev_count)
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._exposure_times.extend(event_data)
|
|
120
|
+
self.signals.data.emit(cnt, ts)
|
|
121
|
+
prev_count = new_count
|
|
122
|
+
|
|
123
|
+
# 4) Wait before next trigger
|
|
124
|
+
time.sleep(self.poll_interval)
|
|
125
|
+
|
|
126
|
+
# Cleanup when stopping
|
|
127
|
+
self._ci.stop()
|
|
128
|
+
self._ci.close()
|
|
129
|
+
self._do.close()
|
|
130
|
+
|
|
131
|
+
def stop(self):
|
|
132
|
+
"""Signal the background thread to stop and wait for it.
|
|
133
|
+
|
|
134
|
+
This will also reset the NIDAQ device.
|
|
135
|
+
"""
|
|
136
|
+
self.logger.info(f"Resetting NIDAQ device")
|
|
137
|
+
self._stop_event.set()
|
|
138
|
+
if self._thread and self._thread.is_alive():
|
|
139
|
+
self._thread.join()
|
|
140
|
+
self._stopped = datetime.now()
|
|
141
|
+
nidaqmx.system.Device(self.device_name).reset_device()
|
|
142
|
+
self.signals.finished.emit()
|
|
143
|
+
|
|
144
|
+
def shutdown(self) -> None:
|
|
145
|
+
"""Close the device."""
|
|
146
|
+
self.stop()
|
|
147
|
+
|
|
148
|
+
def get_data(self) -> list[float]:
|
|
149
|
+
"""Retrieve a copy of the host-time exposure timestamps."""
|
|
150
|
+
with self._lock:
|
|
151
|
+
return list(self._exposure_times)
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Wheel encoder over USB-serial.
|
|
2
|
+
|
|
3
|
+
Subclass of :class:`mesofield.devices.base.BaseSerialDevice` that reads
|
|
4
|
+
integer click counts (one per line) from an Arduino-style firmware.
|
|
5
|
+
Emitted payload is the raw click count (``int``); speed and distance
|
|
6
|
+
are derived in analysis from the wheel diameter and CPR carried in the
|
|
7
|
+
device config.
|
|
8
|
+
|
|
9
|
+
The producer (`SerialWorker`) writes a CSV; the matching ingest-side
|
|
10
|
+
parser (`WheelEncoder`) lives at the bottom of this module so producer
|
|
11
|
+
and parser sit in the same file. `SerialWorker.Parser` resolves to
|
|
12
|
+
`WheelEncoder` for manifest-driven dispatch.
|
|
13
|
+
|
|
14
|
+
Constructor preserves the legacy keyword API
|
|
15
|
+
(``serial_port``, ``baud_rate``, ``sample_interval``,
|
|
16
|
+
``wheel_diameter``, ``cpr``, ``development_mode``) for compatibility
|
|
17
|
+
with :mod:`mesofield.hardware`.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, ClassVar, Dict, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
from mesofield import DeviceRegistry
|
|
29
|
+
from mesofield.devices.base import BaseSerialDevice
|
|
30
|
+
from mesofield.utils._logger import get_logger
|
|
31
|
+
from mesofield.datakit.sources.register import SourceContext, TimeseriesSource
|
|
32
|
+
from mesofield.datakit.timeline import DataqueueIndex
|
|
33
|
+
|
|
34
|
+
_logger = get_logger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@DeviceRegistry.register("wheel")
|
|
38
|
+
class SerialWorker(BaseSerialDevice):
|
|
39
|
+
"""Arduino wheel-encoder device."""
|
|
40
|
+
|
|
41
|
+
device_type: ClassVar[str] = "encoder"
|
|
42
|
+
file_type: ClassVar[str] = "csv"
|
|
43
|
+
bids_type: ClassVar[Optional[str]] = "beh"
|
|
44
|
+
|
|
45
|
+
# Typed contract for the parser's dataqueue lookup. The parser today
|
|
46
|
+
# hardcodes `anchor_filter_pattern = "encoder"` and matches against the
|
|
47
|
+
# device_id column; this declaration is what it'll read off the manifest
|
|
48
|
+
# in Step 4.6 instead. payload is a plain int click count.
|
|
49
|
+
dataqueue_payload_schema: ClassVar[Optional[dict]] = {
|
|
50
|
+
"device_id": "encoder",
|
|
51
|
+
"payload_format": "scalar",
|
|
52
|
+
"payload_fields": {},
|
|
53
|
+
"description": "Integer click count (the raw integer that record() pushes).",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
cfg: Optional[Dict[str, Any]] = None,
|
|
59
|
+
serial_port: Optional[str] = None,
|
|
60
|
+
baud_rate: Optional[int] = None,
|
|
61
|
+
sample_interval: Optional[int] = None,
|
|
62
|
+
wheel_diameter: Optional[float] = None,
|
|
63
|
+
cpr: Optional[int] = None,
|
|
64
|
+
development_mode: Optional[bool] = None,
|
|
65
|
+
**kwargs: Any,
|
|
66
|
+
) -> None:
|
|
67
|
+
if cfg is None:
|
|
68
|
+
cfg = {}
|
|
69
|
+
else:
|
|
70
|
+
cfg = dict(cfg)
|
|
71
|
+
if serial_port is not None:
|
|
72
|
+
cfg.setdefault("port", serial_port)
|
|
73
|
+
if baud_rate is not None:
|
|
74
|
+
cfg.setdefault("baudrate", baud_rate)
|
|
75
|
+
if sample_interval is not None:
|
|
76
|
+
cfg.setdefault("sample_interval_ms", sample_interval)
|
|
77
|
+
if wheel_diameter is not None:
|
|
78
|
+
cfg.setdefault("wheel_diameter", wheel_diameter)
|
|
79
|
+
if cpr is not None:
|
|
80
|
+
cfg.setdefault("cpr", cpr)
|
|
81
|
+
if development_mode is not None:
|
|
82
|
+
cfg.setdefault("development_mode", development_mode)
|
|
83
|
+
cfg.setdefault("id", "encoder")
|
|
84
|
+
|
|
85
|
+
super().__init__(cfg, **kwargs)
|
|
86
|
+
|
|
87
|
+
# Passive analysis metadata (consumed downstream, not by the
|
|
88
|
+
# acquisition loop).
|
|
89
|
+
self.sample_interval_ms: Optional[int] = self.cfg.get("sample_interval_ms")
|
|
90
|
+
self.wheel_diameter: Optional[float] = self.cfg.get("wheel_diameter")
|
|
91
|
+
self.cpr: Optional[int] = self.cfg.get("cpr")
|
|
92
|
+
|
|
93
|
+
self._qt_adapter = None
|
|
94
|
+
self.serialDataReceived = None
|
|
95
|
+
self.serialSpeedUpdated = None
|
|
96
|
+
try:
|
|
97
|
+
from mesofield.gui.qt_device_adapter import QtDeviceAdapter
|
|
98
|
+
|
|
99
|
+
self._qt_adapter = QtDeviceAdapter(self)
|
|
100
|
+
self.serialDataReceived = self._qt_adapter.serialDataReceived
|
|
101
|
+
self.serialSpeedUpdated = self._qt_adapter.serialSpeedUpdated
|
|
102
|
+
except Exception:
|
|
103
|
+
self.logger.debug("Qt adapter unavailable; running headless.")
|
|
104
|
+
|
|
105
|
+
# -- BaseSerialDevice hooks ----------------------------------------
|
|
106
|
+
def parse_line(self, line: bytes) -> Optional[Tuple[int, Optional[float]]]:
|
|
107
|
+
text = line.decode("utf-8", errors="replace").strip()
|
|
108
|
+
if not text:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return int(text), None
|
|
112
|
+
except ValueError:
|
|
113
|
+
self.logger.debug("Non-integer line: %r", text)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Parser (the ingest-side counterpart to SerialWorker's CSV output).
|
|
119
|
+
# Lives in the same file so a change to one is forced to confront the other.
|
|
120
|
+
# Bound below as `SerialWorker.Parser` so the registry picks it up.
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class _WheelSummary:
|
|
125
|
+
"""Aggregate metrics extracted from a wheel run."""
|
|
126
|
+
|
|
127
|
+
duration_s: float
|
|
128
|
+
total_distance_mm: float
|
|
129
|
+
total_click_delta: int
|
|
130
|
+
start_ts: Optional[str]
|
|
131
|
+
stop_ts: Optional[str]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class WheelEncoder(TimeseriesSource):
|
|
135
|
+
"""Load wheel encoder streams recorded alongside nidaq pulses.
|
|
136
|
+
|
|
137
|
+
The raw CSV emitted by the behavioral rig contains incremental click counts,
|
|
138
|
+
elapsed time in seconds, and instantaneous speed estimates. The loader
|
|
139
|
+
converts this information into a strictly increasing timeline anchored to
|
|
140
|
+
acquisition start, computes cumulative distance, and exposes rich metadata
|
|
141
|
+
for downstream alignment against the nidaq-driven master clock.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
tag = "wheel"
|
|
145
|
+
patterns = ("**/*_wheel.csv",)
|
|
146
|
+
camera_tag = None # Not bound to camera
|
|
147
|
+
|
|
148
|
+
required_columns = ("Clicks", "Time", "Speed")
|
|
149
|
+
time_column = "Time"
|
|
150
|
+
click_column = "Clicks"
|
|
151
|
+
speed_column = "Speed"
|
|
152
|
+
cumulative_column = "click_delta"
|
|
153
|
+
anchor_filter_pattern = "encoder"
|
|
154
|
+
queue_elapsed_column = "queue_elapsed"
|
|
155
|
+
dataqueue_payload_column = "payload"
|
|
156
|
+
alignment_min_points = 2
|
|
157
|
+
alignment_time_basis_dataqueue = "dataqueue"
|
|
158
|
+
alignment_time_basis_wheel = "wheel_clock"
|
|
159
|
+
alignment_poly_degree = 1
|
|
160
|
+
distance_integration_method = "trapezoid"
|
|
161
|
+
absolute_device_id = "encoder"
|
|
162
|
+
alignment_origin_device_id = "ThorCam"
|
|
163
|
+
|
|
164
|
+
def build_timeseries(
|
|
165
|
+
self,
|
|
166
|
+
path: Path,
|
|
167
|
+
*,
|
|
168
|
+
context: SourceContext | None = None,
|
|
169
|
+
) -> tuple[np.ndarray, pd.DataFrame, dict]:
|
|
170
|
+
context = self._require_context(context)
|
|
171
|
+
raw = pd.read_csv(path)
|
|
172
|
+
|
|
173
|
+
if not set(self.required_columns).issubset(raw.columns):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"Wheel file is missing required columns {self.required_columns}: {path}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
df = self._prepare_frame(raw)
|
|
179
|
+
df_aligned, alignment_meta = self._align_to_dataqueue(df, context)
|
|
180
|
+
summary = self._summarize(df_aligned, raw)
|
|
181
|
+
df_aligned = df_aligned.rename(columns={"time_s": "time_elapsed_s", "time_raw_s": "time_reference_s"})
|
|
182
|
+
|
|
183
|
+
meta = {
|
|
184
|
+
"source_file": str(path),
|
|
185
|
+
"n_samples": int(len(df_aligned)),
|
|
186
|
+
"start_time": summary.start_ts,
|
|
187
|
+
"stop_time": summary.stop_ts,
|
|
188
|
+
"duration_s": summary.duration_s,
|
|
189
|
+
"total_distance_mm": summary.total_distance_mm,
|
|
190
|
+
"total_click_delta": summary.total_click_delta,
|
|
191
|
+
"source_method": "wheel_csv_v2",
|
|
192
|
+
**alignment_meta,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return df_aligned["time_elapsed_s"].to_numpy(dtype=np.float64), df_aligned, meta
|
|
196
|
+
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
# Internal helpers
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
def _prepare_frame(self, raw: pd.DataFrame) -> pd.DataFrame:
|
|
201
|
+
"""Type cast and derive the canonical wheel dataframe."""
|
|
202
|
+
|
|
203
|
+
frame = pd.DataFrame()
|
|
204
|
+
frame["time_s"] = pd.to_numeric(raw[self.time_column], errors="coerce")
|
|
205
|
+
frame["click_delta"] = pd.to_numeric(raw[self.click_column], errors="coerce")
|
|
206
|
+
frame["speed_mm"] = pd.to_numeric(raw[self.speed_column], errors="coerce")
|
|
207
|
+
|
|
208
|
+
frame = frame.dropna(subset=["time_s"]).sort_values("time_s").reset_index(drop=True)
|
|
209
|
+
|
|
210
|
+
if frame.empty:
|
|
211
|
+
frame = pd.DataFrame(
|
|
212
|
+
{
|
|
213
|
+
"time_s": [0.0, 0.0],
|
|
214
|
+
"click_delta": [0, 0],
|
|
215
|
+
"speed_mm": [0.0, 0.0],
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
# Ensure speed/clicks missing entries become zeros
|
|
219
|
+
frame["click_delta"] = frame["click_delta"].fillna(0)
|
|
220
|
+
frame["speed_mm"] = frame["speed_mm"].fillna(0.0)
|
|
221
|
+
|
|
222
|
+
# Re-zero the timeline relative to the first available sample
|
|
223
|
+
frame["time_raw_s"] = frame["time_s"].to_numpy(dtype=np.float64)
|
|
224
|
+
t = frame["time_raw_s"].copy()
|
|
225
|
+
t0 = float(t[0])
|
|
226
|
+
t -= t0
|
|
227
|
+
frame["time_s"] = t
|
|
228
|
+
|
|
229
|
+
# Derive cumulative click position for quick sanity checks
|
|
230
|
+
frame["click_position"] = np.cumsum(frame["click_delta"].to_numpy(dtype=np.int64))
|
|
231
|
+
|
|
232
|
+
# Integrate speed to distance using trapezoidal rule
|
|
233
|
+
speed = frame["speed_mm"].to_numpy(dtype=np.float64)
|
|
234
|
+
distance = np.zeros_like(speed)
|
|
235
|
+
if speed.size > 1:
|
|
236
|
+
dt = np.diff(t)
|
|
237
|
+
trapezoids = 0.5 * (speed[:-1] + speed[1:]) * dt
|
|
238
|
+
distance[1:] = np.cumsum(trapezoids)
|
|
239
|
+
frame["distance_mm"] = distance
|
|
240
|
+
|
|
241
|
+
return frame[["time_s", "time_raw_s", "click_delta", "click_position", "speed_mm", "distance_mm"]]
|
|
242
|
+
|
|
243
|
+
def _summarize(self, frame: pd.DataFrame, raw: pd.DataFrame) -> _WheelSummary:
|
|
244
|
+
"""Build aggregate metadata for diagnostics and alignment hints."""
|
|
245
|
+
|
|
246
|
+
duration = float(frame["time_s"].iloc[-1]) if len(frame) else 0.0
|
|
247
|
+
total_distance = float(frame["distance_mm"].iloc[-1]) if len(frame) else 0.0
|
|
248
|
+
total_clicks = int(frame["click_delta"].sum())
|
|
249
|
+
|
|
250
|
+
start_ts = self._extract_timestamp(raw.get("Started"))
|
|
251
|
+
stop_ts = self._extract_timestamp(raw.get("Stopped"))
|
|
252
|
+
|
|
253
|
+
return _WheelSummary(
|
|
254
|
+
duration_s=duration,
|
|
255
|
+
total_distance_mm=total_distance,
|
|
256
|
+
total_click_delta=total_clicks,
|
|
257
|
+
start_ts=start_ts,
|
|
258
|
+
stop_ts=stop_ts,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
# Alignment helpers
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
def _align_to_dataqueue(self, frame: pd.DataFrame, context: SourceContext) -> tuple[pd.DataFrame, dict]:
|
|
265
|
+
"""Map wheel timestamps onto the nidaq master clock via dataqueue anchors."""
|
|
266
|
+
dq_path = context.path_for("dataqueue")
|
|
267
|
+
dataqueue_file = str(dq_path) if dq_path is not None else None
|
|
268
|
+
timeline = None
|
|
269
|
+
|
|
270
|
+
if context.dataqueue_frame is not None:
|
|
271
|
+
encoder_times = self._extract_times(context.dataqueue_frame, self.anchor_filter_pattern)
|
|
272
|
+
origin_times = self._extract_times(context.dataqueue_frame, self.alignment_origin_device_id)
|
|
273
|
+
else:
|
|
274
|
+
if dq_path is None:
|
|
275
|
+
raise FileNotFoundError("WheelEncoder: dataqueue path not available")
|
|
276
|
+
timeline = DataqueueIndex.from_path(dq_path)
|
|
277
|
+
encoder_slice = timeline.slice(
|
|
278
|
+
lambda ids: ids.str.contains(self.anchor_filter_pattern, case=False, na=False, regex=False)
|
|
279
|
+
)
|
|
280
|
+
encoder_times = encoder_slice.queue_elapsed().to_numpy(dtype=np.float64)
|
|
281
|
+
origin_slice = timeline.slice(
|
|
282
|
+
lambda ids: ids.str.contains(self.alignment_origin_device_id, case=False, na=False, regex=False)
|
|
283
|
+
)
|
|
284
|
+
origin_times = origin_slice.queue_elapsed().to_numpy(dtype=np.float64)
|
|
285
|
+
dataqueue_file = str(timeline.source_path)
|
|
286
|
+
if encoder_times.size < 2:
|
|
287
|
+
_logger.warning(
|
|
288
|
+
"WheelEncoder: insufficient encoder anchors (%d). "
|
|
289
|
+
"Returning unaligned wheel timeline.",
|
|
290
|
+
encoder_times.size,
|
|
291
|
+
)
|
|
292
|
+
return frame.copy(), {
|
|
293
|
+
"time_basis": self.alignment_time_basis_wheel,
|
|
294
|
+
"dataqueue_file": dataqueue_file,
|
|
295
|
+
"dataqueue_alignment": "insufficient_anchors",
|
|
296
|
+
"dataqueue_anchors": int(encoder_times.size),
|
|
297
|
+
"alignment_device": self.anchor_filter_pattern,
|
|
298
|
+
}
|
|
299
|
+
if origin_times.size == 0:
|
|
300
|
+
_logger.warning(
|
|
301
|
+
"WheelEncoder: %s anchors missing for alignment. "
|
|
302
|
+
"Returning unaligned wheel timeline.",
|
|
303
|
+
self.alignment_origin_device_id,
|
|
304
|
+
)
|
|
305
|
+
return frame.copy(), {
|
|
306
|
+
"time_basis": self.alignment_time_basis_wheel,
|
|
307
|
+
"dataqueue_file": dataqueue_file,
|
|
308
|
+
"dataqueue_alignment": "missing_origin_anchors",
|
|
309
|
+
"dataqueue_anchors": int(encoder_times.size),
|
|
310
|
+
"alignment_device": self.anchor_filter_pattern,
|
|
311
|
+
"alignment_origin_device": self.alignment_origin_device_id,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
n_samples = len(frame)
|
|
315
|
+
n_anchors = int(encoder_times.size)
|
|
316
|
+
if n_anchors == n_samples:
|
|
317
|
+
aligned_times = encoder_times
|
|
318
|
+
method = "direct"
|
|
319
|
+
else:
|
|
320
|
+
anchor_index = np.arange(n_anchors, dtype=np.float64)
|
|
321
|
+
frame_index = np.linspace(0.0, float(n_anchors - 1), num=n_samples, dtype=np.float64)
|
|
322
|
+
aligned_times = np.interp(frame_index, anchor_index, encoder_times)
|
|
323
|
+
method = "resampled"
|
|
324
|
+
|
|
325
|
+
origin = float(origin_times[0])
|
|
326
|
+
aligned = frame.copy()
|
|
327
|
+
aligned["time_s"] = aligned_times - origin
|
|
328
|
+
|
|
329
|
+
encoder_start = float(encoder_times[0])
|
|
330
|
+
origin_offset = encoder_start - origin
|
|
331
|
+
|
|
332
|
+
if timeline is not None:
|
|
333
|
+
elapsed_abs = timeline.absolute_for_device(self.absolute_device_id)
|
|
334
|
+
else:
|
|
335
|
+
elapsed_abs = None
|
|
336
|
+
if elapsed_abs:
|
|
337
|
+
elapsed, absolute = elapsed_abs
|
|
338
|
+
elapsed = elapsed + origin_offset
|
|
339
|
+
aligned_t = aligned["time_s"].to_numpy(dtype=np.float64)
|
|
340
|
+
aligned["time_absolute"] = np.interp(aligned_t, elapsed, np.arange(len(absolute))).astype(int)
|
|
341
|
+
aligned["time_absolute"] = aligned["time_absolute"].map(
|
|
342
|
+
lambda i: absolute[i] if 0 <= i < len(absolute) else None
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return aligned, {
|
|
346
|
+
"time_basis": self.alignment_time_basis_dataqueue,
|
|
347
|
+
"dataqueue_file": dataqueue_file,
|
|
348
|
+
"dataqueue_alignment": method,
|
|
349
|
+
"dataqueue_anchors": n_anchors,
|
|
350
|
+
"alignment_device": self.anchor_filter_pattern,
|
|
351
|
+
"alignment_origin_device": self.alignment_origin_device_id,
|
|
352
|
+
"alignment_origin_queue_elapsed": origin,
|
|
353
|
+
"alignment_origin_offset": float(origin_offset),
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
def _extract_times(self, frame: pd.DataFrame, pattern: str) -> np.ndarray:
|
|
357
|
+
if self.queue_elapsed_column not in frame.columns or "device_id" not in frame.columns:
|
|
358
|
+
return np.array([], dtype=np.float64)
|
|
359
|
+
device_series = frame["device_id"].astype(str)
|
|
360
|
+
mask = device_series.str.contains(pattern, case=False, na=False, regex=False)
|
|
361
|
+
times = pd.to_numeric(frame.loc[mask, self.queue_elapsed_column], errors="coerce").dropna()
|
|
362
|
+
return times.to_numpy(dtype=np.float64)
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _extract_timestamp(series: Optional[pd.Series]) -> Optional[str]:
|
|
366
|
+
"""Return an ISO8601 string if the provided column encodes a timestamp."""
|
|
367
|
+
|
|
368
|
+
if series is None:
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
first = series.dropna().astype(str).head(1)
|
|
372
|
+
if first.empty:
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
ts = pd.to_datetime(first.iloc[0], utc=False, errors="raise")
|
|
377
|
+
except (TypeError, ValueError):
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
return ts.isoformat()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# Manifest-driven dispatch: SOURCE_REGISTRY["wheel"] resolves to SerialWorker.Parser.
|
|
384
|
+
SerialWorker.Parser = WheelEncoder
|