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,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
+ )