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.
Files changed (111) hide show
  1. docs/_static/custom.css +40 -0
  2. docs/_static/favicon.png +0 -0
  3. docs/_static/logo.png +0 -0
  4. docs/api/index.md +70 -0
  5. docs/conf.py +200 -0
  6. docs/developer_guide.md +303 -0
  7. docs/index.md +25 -0
  8. docs/tutorial.md +4 -0
  9. docs/user_guide.md +172 -0
  10. examples/teensy_pulse_generator.py +320 -0
  11. experiments/pipeline_demo/experiment.json +24 -0
  12. experiments/pipeline_demo/hardware.yaml +23 -0
  13. experiments/pipeline_demo/procedure.py +50 -0
  14. experiments/two_cam_demo/experiment.json +24 -0
  15. experiments/two_cam_demo/hardware.yaml +58 -0
  16. experiments/two_cam_demo/load_dataset.py +213 -0
  17. experiments/two_cam_demo/procedure.py +87 -0
  18. external/video-codecs/openh264-1.8.0-win64.dll +0 -0
  19. mesofield/__init__.py +45 -0
  20. mesofield/__main__.py +11 -0
  21. mesofield/_version.py +24 -0
  22. mesofield/base.py +750 -0
  23. mesofield/cli/__init__.py +57 -0
  24. mesofield/cli/_richhelp.py +100 -0
  25. mesofield/cli/acquire.py +254 -0
  26. mesofield/cli/datakit.py +165 -0
  27. mesofield/cli/process.py +376 -0
  28. mesofield/cli/rig.py +108 -0
  29. mesofield/cli/tools.py +347 -0
  30. mesofield/config.py +751 -0
  31. mesofield/data/__init__.py +23 -0
  32. mesofield/data/batch.py +633 -0
  33. mesofield/data/manager.py +388 -0
  34. mesofield/data/writer.py +289 -0
  35. mesofield/datakit/__init__.py +44 -0
  36. mesofield/datakit/__main__.py +35 -0
  37. mesofield/datakit/_utils/_logger.py +5 -0
  38. mesofield/datakit/_version.py +141 -0
  39. mesofield/datakit/config.py +50 -0
  40. mesofield/datakit/core.py +783 -0
  41. mesofield/datakit/datamodel.py +200 -0
  42. mesofield/datakit/discover.py +124 -0
  43. mesofield/datakit/explore.py +651 -0
  44. mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
  45. mesofield/datakit/profile.py +535 -0
  46. mesofield/datakit/shell.py +83 -0
  47. mesofield/datakit/sources/__init__.py +65 -0
  48. mesofield/datakit/sources/analysis/mesomap.py +194 -0
  49. mesofield/datakit/sources/analysis/mesoscope.py +77 -0
  50. mesofield/datakit/sources/analysis/pupil.py +246 -0
  51. mesofield/datakit/sources/behavior/__init__.py +0 -0
  52. mesofield/datakit/sources/behavior/dataqueue.py +281 -0
  53. mesofield/datakit/sources/behavior/psychopy.py +364 -0
  54. mesofield/datakit/sources/behavior/treadmill.py +323 -0
  55. mesofield/datakit/sources/behavior/wheel.py +277 -0
  56. mesofield/datakit/sources/camera/mesoscope.py +32 -0
  57. mesofield/datakit/sources/camera/metadata_json.py +130 -0
  58. mesofield/datakit/sources/camera/pupil.py +28 -0
  59. mesofield/datakit/sources/camera/suite2p.py +547 -0
  60. mesofield/datakit/sources/register.py +204 -0
  61. mesofield/datakit/sources/session/config.py +130 -0
  62. mesofield/datakit/sources/session/notes.py +63 -0
  63. mesofield/datakit/sources/session/timestamps.py +58 -0
  64. mesofield/datakit/timeline.py +306 -0
  65. mesofield/devices/__init__.py +42 -0
  66. mesofield/devices/base.py +498 -0
  67. mesofield/devices/base_camera.py +295 -0
  68. mesofield/devices/cameras.py +740 -0
  69. mesofield/devices/daq.py +151 -0
  70. mesofield/devices/encoder.py +384 -0
  71. mesofield/devices/mocks.py +275 -0
  72. mesofield/devices/psychopy_device.py +455 -0
  73. mesofield/devices/subprocesses/__init__.py +0 -0
  74. mesofield/devices/subprocesses/psychopy.py +133 -0
  75. mesofield/devices/treadmill.py +318 -0
  76. mesofield/engines.py +380 -0
  77. mesofield/gui/Mesofield_icon.png +0 -0
  78. mesofield/gui/__init__.py +76 -0
  79. mesofield/gui/config_wizard.py +724 -0
  80. mesofield/gui/controller.py +535 -0
  81. mesofield/gui/dynamic_controller.py +78 -0
  82. mesofield/gui/maingui.py +427 -0
  83. mesofield/gui/mdagui.py +285 -0
  84. mesofield/gui/qt_device_adapter.py +109 -0
  85. mesofield/gui/speedplotter.py +152 -0
  86. mesofield/gui/theme.py +445 -0
  87. mesofield/gui/tiff_viewer.py +1050 -0
  88. mesofield/gui/viewer.py +691 -0
  89. mesofield/hardware.py +549 -0
  90. mesofield/playback.py +1298 -0
  91. mesofield/processing/__init__.py +12 -0
  92. mesofield/processing/runner.py +237 -0
  93. mesofield/processors/__init__.py +13 -0
  94. mesofield/processors/base.py +287 -0
  95. mesofield/processors/frame_mean.py +19 -0
  96. mesofield/protocols.py +378 -0
  97. mesofield/scaffold/__init__.py +34 -0
  98. mesofield/scaffold/experiment.py +400 -0
  99. mesofield/scaffold/rigs.py +121 -0
  100. mesofield/signals.py +85 -0
  101. mesofield/utils/__init__.py +0 -0
  102. mesofield/utils/_logger.py +156 -0
  103. mesofield/utils/retrofit.py +309 -0
  104. mesofield/utils/utils.py +217 -0
  105. mesofield-0.3.2b0.dist-info/METADATA +178 -0
  106. mesofield-0.3.2b0.dist-info/RECORD +111 -0
  107. mesofield-0.3.2b0.dist-info/WHEEL +5 -0
  108. mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
  109. mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
  110. mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
  111. scripts/bench_frame_processor.py +103 -0
@@ -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