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,318 @@
1
+ """Teensy treadmill encoder over USB-serial.
2
+
3
+ Firmware Data Format:
4
+ - With SHOW_MICROS defined: ``"micros,distance(mm),speed(mm/s)"``
5
+ - Without SHOW_MICROS: ``"distance(mm),speed(mm/s)"``
6
+
7
+ Supported Commands:
8
+ | Command | Description |
9
+ |---------|----------------------------------------------|
10
+ | '?' | Print version and header info |
11
+ | 'c' | Initiate speed output calibration |
12
+
13
+ Built on :class:`mesofield.devices.base.BaseSerialDevice`. Each parsed
14
+ line is recorded as a dict ``{"distance": float, "speed": float,
15
+ "device_us": int|None}`` so that the default
16
+ :meth:`BaseDataProducer.save_data` writes a 4-column CSV
17
+ (``timestamp,distance,speed,device_us``).
18
+
19
+ The producer (`EncoderSerialInterface`) writes the CSV; the matching
20
+ ingest-side parser (`TreadmillSource`) lives at the bottom of this
21
+ module so producer and parser sit in the same file.
22
+ `EncoderSerialInterface.Parser` resolves to `TreadmillSource` for
23
+ manifest-driven dispatch.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from pathlib import Path
29
+ from typing import Any, ClassVar, Dict, Optional, Tuple
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+
34
+ from mesofield import DeviceRegistry
35
+ from mesofield.devices.base import BaseSerialDevice
36
+ from mesofield.datakit.config import settings
37
+ from mesofield.datakit.sources.register import TimeseriesSource
38
+
39
+
40
+ @DeviceRegistry.register("encoder")
41
+ class EncoderSerialInterface(BaseSerialDevice):
42
+ """Teensy encoder/treadmill device.
43
+
44
+ Constructor accepts either a cfg dict (``BaseSerialDevice``-style) or
45
+ legacy positional/keyword args ``(port, baudrate)`` for backward
46
+ compatibility with :mod:`mesofield.hardware`.
47
+ """
48
+
49
+ device_type: ClassVar[str] = "encoder"
50
+ file_type: ClassVar[str] = "csv"
51
+ bids_type: ClassVar[Optional[str]] = "beh"
52
+ default_baudrate: ClassVar[int] = 192000
53
+
54
+ # Typed contract for the parser's dataqueue lookup. parse_line returns a
55
+ # dict {distance, speed, device_us}, so the dataqueue payload column holds
56
+ # a dict-repr. In Step 4.6 the parser will read payload_fields off the
57
+ # manifest instead of hardcoding the "EncoderData timestamp=N" regex.
58
+ dataqueue_payload_schema: ClassVar[Optional[dict]] = {
59
+ "device_id": "treadmill",
60
+ "payload_format": "dict",
61
+ "payload_fields": {
62
+ "distance": "float",
63
+ "speed": "float",
64
+ "device_us": "int",
65
+ },
66
+ "description": "Dict payload pushed by parse_line(); device_us is the master clock anchor.",
67
+ }
68
+
69
+ def __init__(
70
+ self,
71
+ cfg: Optional[Dict[str, Any]] = None,
72
+ port: Optional[str] = None,
73
+ baudrate: Optional[int] = None,
74
+ **kwargs: Any,
75
+ ) -> None:
76
+ if cfg is None:
77
+ cfg = {}
78
+ else:
79
+ cfg = dict(cfg)
80
+ if port is not None:
81
+ cfg.setdefault("port", port)
82
+ if baudrate is not None:
83
+ cfg.setdefault("baudrate", baudrate)
84
+ cfg.setdefault("id", "treadmill")
85
+
86
+ super().__init__(cfg, **kwargs)
87
+
88
+ # Optional Qt adapter for GUI live-preview signals. Lazy import
89
+ # so headless sessions don't require PyQt6.
90
+ self._qt_adapter = None
91
+ self.serialDataReceived = None
92
+ self.serialSpeedUpdated = None
93
+ try:
94
+ from mesofield.gui.qt_device_adapter import QtDeviceAdapter
95
+
96
+ self._qt_adapter = QtDeviceAdapter(self)
97
+ self.serialDataReceived = self._qt_adapter.serialDataReceived
98
+ self.serialSpeedUpdated = self._qt_adapter.serialSpeedUpdated
99
+ except Exception:
100
+ self.logger.debug("Qt adapter unavailable; running headless.")
101
+
102
+ # -- BaseSerialDevice hooks ----------------------------------------
103
+ def parse_line(
104
+ self, line: bytes
105
+ ) -> Optional[Tuple[Dict[str, Any], Optional[float]]]:
106
+ text = line.decode("utf-8", errors="replace").strip()
107
+ if not text:
108
+ return None
109
+ parts = text.split(",")
110
+ try:
111
+ if len(parts) == 3:
112
+ device_us = int(parts[0].strip())
113
+ distance = float(parts[1].strip())
114
+ speed = float(parts[2].strip())
115
+ elif len(parts) == 2:
116
+ device_us = None
117
+ distance = float(parts[0].strip())
118
+ speed = float(parts[1].strip())
119
+ else:
120
+ self.logger.debug("Ignored non-data line: %r", text)
121
+ return None
122
+ except ValueError:
123
+ self.logger.debug("Failed to parse line: %r", text)
124
+ return None
125
+
126
+ return {"distance": distance, "speed": speed, "device_us": device_us}, None
127
+
128
+ # -- Convenience wrapper for firmware commands ---------------------
129
+ def send_command(self, command: str) -> bytes:
130
+ """Send a single-character firmware command (no newline)."""
131
+ return self.send_line(command, newline=b"")
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Parser (the ingest-side counterpart). Lives in the same file so producer-
136
+ # side payload changes are forced to confront parser-side regexes.
137
+ # Bound below as `EncoderSerialInterface.Parser`.
138
+
139
+
140
+ class TreadmillSource(TimeseriesSource):
141
+ """Load treadmill samples aligned to the experiment window."""
142
+ tag = "treadmill"
143
+ patterns = ("**/*_treadmill.csv", "**/*_treadmill_data.csv")
144
+ camera_tag = None
145
+
146
+ timestamp_column = "timestamp"
147
+ distance_columns = ("distance_mm", "distance")
148
+ speed_columns = ("speed_mm", "speed_mm_s", "speed")
149
+ output_distance_column = "distance_mm"
150
+ output_speed_column = "speed_mm"
151
+
152
+ dataqueue_queue_column = settings.timeline.queue_column
153
+ dataqueue_device_id_column = "device_id"
154
+ dataqueue_payload_column = "payload"
155
+
156
+ dataqueue_payload_prefix = "EncoderData"
157
+ timeline_master_patterns = ("dhyana", "mesoscope")
158
+ _encoder_ts_re = re.compile(r"timestamp\s*=\s*(\d+)", re.IGNORECASE)
159
+
160
+ def build_timeseries(
161
+ self,
162
+ path: Path,
163
+ *,
164
+ context: SourceContext | None = None,
165
+ ) -> tuple[np.ndarray, pd.DataFrame, dict]:
166
+ context = self._require_context(context)
167
+ df = pd.read_csv(path)
168
+ dist = next((c for c in self.distance_columns if c in df.columns), None)
169
+ speed = next((c for c in self.speed_columns if c in df.columns), None)
170
+ if self.timestamp_column not in df.columns or dist is None or speed is None:
171
+ raise ValueError(f"Expected timestamp/distance/speed columns in {path}")
172
+ df = df.rename(columns={dist: self.output_distance_column, speed: self.output_speed_column})
173
+
174
+ dq_path = context.path_for("dataqueue")
175
+ aligned, meta = self.extract_treadmill_aligned(
176
+ df,
177
+ dq_path,
178
+ window=context.experiment_window,
179
+ dataqueue_frame=context.dataqueue_frame,
180
+ )
181
+ t = aligned["time_elapsed_s"].to_numpy(dtype=np.float64)
182
+ meta = {
183
+ "source_file": str(path),
184
+ "dataqueue_file": str(dq_path) if dq_path is not None else None,
185
+ "n_samples": len(aligned),
186
+ **meta,
187
+ }
188
+ return t, aligned, meta
189
+
190
+ # --- alignment --------------------------------
191
+
192
+ def extract_treadmill_aligned(
193
+ self,
194
+ treadmill_df: pd.DataFrame,
195
+ dq_path: Path | None,
196
+ *,
197
+ window: tuple[float, float] | None = None,
198
+ dataqueue_frame: pd.DataFrame | None = None,
199
+ ) -> tuple[pd.DataFrame, dict]:
200
+ t0, t1 = window or self._window(dq_path, dataqueue_frame)
201
+ duration = float(t1 - t0)
202
+ if not np.isfinite(duration) or duration <= 0:
203
+ raise ValueError(f"Invalid camera window duration from dataqueue: start={t0}, end={t1}")
204
+
205
+ enc = self._encoder_rows(dq_path, dataqueue_frame)
206
+ a, b = self._fit(enc["encoder_ts"].to_numpy(), enc["queue_elapsed"].to_numpy())
207
+
208
+ ts = pd.to_numeric(treadmill_df[self.timestamp_column], errors="coerce").to_numpy(dtype=np.float64)
209
+ time_s = a * ts + b - t0
210
+
211
+ mask = np.isfinite(time_s) & (time_s >= 0.0) & (time_s <= duration)
212
+ if not np.any(mask):
213
+ raise ValueError("No treadmill samples fall inside the Dhyana window")
214
+
215
+ aligned = treadmill_df.loc[mask].copy()
216
+ aligned["time_elapsed_s"] = time_s[mask]
217
+ aligned.sort_values("time_elapsed_s", inplace=True)
218
+ aligned.reset_index(drop=True, inplace=True)
219
+
220
+ return aligned, {
221
+ "source_method": "dataqueue_align_mvp",
222
+ "experiment_window": {"start": float(t0), "end": float(t1)},
223
+ "alignment": {"a": float(a), "b": float(b)},
224
+ }
225
+
226
+ # --- dataqueue utilities -----------------------------------------------------
227
+
228
+ def _window(
229
+ self,
230
+ dq_path: Path | None,
231
+ dataqueue_frame: pd.DataFrame | None,
232
+ ) -> tuple[float, float]:
233
+ dq = dataqueue_frame
234
+ if dq is None:
235
+ if dq_path is None:
236
+ raise FileNotFoundError("TreadmillSource: dataqueue path not available")
237
+ dq = pd.read_csv(
238
+ dq_path,
239
+ usecols=[self.dataqueue_queue_column, self.dataqueue_device_id_column],
240
+ low_memory=False,
241
+ )
242
+ device = dq.get(self.dataqueue_device_id_column, pd.Series(dtype=str)).astype(str)
243
+
244
+ mask = np.zeros(len(dq), dtype=bool)
245
+ for pattern in self.timeline_master_patterns:
246
+ mask |= device.str.contains(pattern, case=False, na=False, regex=False).to_numpy()
247
+
248
+ rows = pd.to_numeric(dq.loc[mask, self.dataqueue_queue_column], errors="coerce").dropna()
249
+ if len(rows) < 2:
250
+ raise ValueError("Could not find >=2 master camera rows in dataqueue")
251
+ return float(rows.iloc[0]), float(rows.iloc[-1])
252
+
253
+ def _encoder_rows(
254
+ self,
255
+ dq_path: Path | None,
256
+ dataqueue_frame: pd.DataFrame | None,
257
+ ) -> pd.DataFrame:
258
+ dq = dataqueue_frame
259
+ if dq is None:
260
+ if dq_path is None:
261
+ raise FileNotFoundError("TreadmillSource: dataqueue path not available")
262
+ dq = pd.read_csv(
263
+ dq_path,
264
+ usecols=[self.dataqueue_queue_column, self.dataqueue_payload_column],
265
+ low_memory=False,
266
+ )
267
+ payload = dq[self.dataqueue_payload_column].astype(str)
268
+
269
+ # strict parse only from EncoderData payloads with timestamp=...
270
+ mask = payload.str.contains(self.dataqueue_payload_prefix, na=False)
271
+ if not np.any(mask):
272
+ raise ValueError("No EncoderData payloads found in dataqueue")
273
+
274
+ enc = dq.loc[mask, [self.dataqueue_queue_column, self.dataqueue_payload_column]].copy()
275
+ enc["encoder_ts"] = enc[self.dataqueue_payload_column].apply(self._parse_encoder_ts)
276
+
277
+ enc = enc.dropna(subset=["encoder_ts", self.dataqueue_queue_column])
278
+ enc = enc.rename(columns={self.dataqueue_queue_column: "queue_elapsed"})
279
+ enc["queue_elapsed"] = pd.to_numeric(enc["queue_elapsed"], errors="coerce")
280
+ enc = enc.dropna(subset=["queue_elapsed"])
281
+
282
+ if len(enc) < 2:
283
+ raise ValueError("Insufficient encoder samples for alignment")
284
+ return enc
285
+
286
+ def _parse_encoder_ts(self, payload: str) -> int | None:
287
+ m = self._encoder_ts_re.search(payload or "")
288
+ return int(m.group(1)) if m else None
289
+
290
+ def _fit(self, encoder_ts: np.ndarray, queue_elapsed: np.ndarray) -> tuple[float, float]:
291
+ x = np.asarray(encoder_ts, dtype=np.float64)
292
+ y = np.asarray(queue_elapsed, dtype=np.float64)
293
+ mask = np.isfinite(x) & np.isfinite(y)
294
+ x = x[mask]
295
+ y = y[mask]
296
+ if x.size < 2:
297
+ raise ValueError("Insufficient encoder samples for alignment")
298
+
299
+ # Centered affine fit (stable for large microsecond timestamps)
300
+ x0 = float(x.mean())
301
+ y0 = float(y.mean())
302
+ xc = x - x0
303
+ yc = y - y0
304
+ denom = float(np.dot(xc, xc))
305
+ if denom <= 0:
306
+ raise ValueError("Degenerate encoder timestamps for alignment")
307
+
308
+ a = float(np.dot(xc, yc) / denom)
309
+ b = float(y0 - a * x0)
310
+
311
+ if not np.isfinite(a) or not np.isfinite(b):
312
+ raise ValueError("Encoder alignment fit returned non-finite coefficients")
313
+ return a, b
314
+
315
+
316
+ # Manifest-driven dispatch: SOURCE_REGISTRY["treadmill"] resolves to
317
+ # EncoderSerialInterface.Parser.
318
+ EncoderSerialInterface.Parser = TreadmillSource