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
docs/user_guide.md ADDED
@@ -0,0 +1,172 @@
1
+ # User Guide
2
+
3
+ This guide is for **experimenters** — people running acquisitions on a
4
+ configured rig. If you're writing a new device class or subclassing
5
+ `Procedure`, see the [Developer Guide](developer_guide.md) instead.
6
+
7
+ ## Overview
8
+
9
+ A mesofield experiment is described by two files:
10
+
11
+ | File | Owns | Usually edited by |
12
+ |------|------|-------------------|
13
+ | `hardware.yaml` | What devices exist on this rig and how to talk to them | Rig maintainer (one-time per machine) |
14
+ | `experiment.json` | Subjects, sessions, protocol, duration | Experimenter (per study / per day) |
15
+
16
+ The `mesofield` CLI loads both, brings up the GUI, and orchestrates the
17
+ acquisition.
18
+
19
+ ## Launching an acquisition
20
+
21
+ The CLI installs as both a console script and a Python module entry
22
+ point; either form works:
23
+
24
+ ```bash
25
+ mesofield launch path/to/experiment.json
26
+ # equivalent
27
+ python -m mesofield launch path/to/experiment.json
28
+ ```
29
+
30
+ Either form opens the main acquisition window with hardware initialised
31
+ and the parameters from `experiment.json` populated in the form.
32
+
33
+ ## Experiment configuration (`experiment.json`)
34
+
35
+ ```json
36
+ {
37
+ "Configuration": {
38
+ "experimenter": "you",
39
+ "protocol": "HFSA",
40
+ "experiment_directory": "/where/mesofield/writes_outputs",
41
+ "hardware_config_file": "path/to/hardware.yaml",
42
+ "duration": 1000
43
+ },
44
+ "Subjects": {
45
+ "STREHAB07": {
46
+ "sex": "F",
47
+ "session": "01",
48
+ "task": "mesoscope"
49
+ }
50
+ },
51
+ "DisplayKeys": [
52
+ "subject",
53
+ "session",
54
+ "task",
55
+ "experimenter",
56
+ "protocol",
57
+ "duration",
58
+ "start_on_trigger",
59
+ "led_pattern"
60
+ ]
61
+ }
62
+ ```
63
+
64
+ **Field notes:**
65
+
66
+ - `experiment_directory` and `hardware_config_file` are optional — both
67
+ default to siblings of the JSON file's parent directory.
68
+ - `duration` is in seconds. The MDA sequence builds
69
+ `duration × camera.fps` frames.
70
+ - `Subjects` keys become BIDS `sub-<key>` directories under
71
+ `experiment_directory/data/`. `session` and `task` become `ses-<id>`
72
+ and `task-<id>`.
73
+ - `DisplayKeys` decides which fields appear in the editable form in the
74
+ GUI. Edits persist back to `experiment.json` when the run completes
75
+ (or via the **Save** button).
76
+ - Anything you add to the JSON outside of these reserved keys is
77
+ preserved on save.
78
+
79
+ ## The acquisition window
80
+
81
+ The window has three regions:
82
+
83
+ 1. **Live Viewer (top-left)** — per-camera snap / live / progress
84
+ panels. The mesoscope view sits next to the pupil view by default.
85
+ 2. **Configuration form (top-right)** — the `DisplayKeys` you declared,
86
+ plus a subject selector, **Record**, **Add Note**, and dynamic
87
+ hardware controls (LED test, NIDAQ pulse, etc.) for whatever your
88
+ `hardware.yaml` requested.
89
+ 3. **Encoder / processor plots (bottom)** — live traces of any frame
90
+ processor with `plot=True` and any encoder / serial device with
91
+ `start_live_view` enabled.
92
+
93
+ The **Toggle Console** action in the toolbar opens an embedded IPython
94
+ shell with the live `procedure` bound — handy for inspecting state
95
+ mid-run.
96
+
97
+ ## Notes during a run
98
+
99
+ Click **Add Note** at any time. Notes are timestamped and saved to
100
+ `data/sub-<id>/ses-<id>/notes.json` when the run completes.
101
+
102
+ ## What ends up on disk
103
+
104
+ After a run, your experiment directory looks like:
105
+
106
+ ```
107
+ <experiment_dir>/
108
+ experiment.json # updated with any DisplayKeys edits
109
+ hardware.yaml
110
+ data/
111
+ sub-<id>/
112
+ ses-<id>/
113
+ manifest.json # AcquisitionManifest — the contract
114
+ notes.json
115
+ <task>/
116
+ *_meso.ome.tiff
117
+ *_meso_frame_metadata.json
118
+ *_pupil.mp4
119
+ *_pupil_frame_metadata.json
120
+ *_wheel.csv
121
+ ```
122
+
123
+ The `manifest.json` is a typed `AcquisitionManifest` (from
124
+ `mesokit-schema`) describing every producer, its output path, its
125
+ metadata sidecar, and any calibration constants. Downstream analysis
126
+ tools read the manifest instead of globbing.
127
+
128
+ ## Embedded IPython console
129
+
130
+ Toolbar → **Toggle Console**. The kernel pre-binds:
131
+
132
+ - `self` — the main window (`MainWindow`)
133
+ - `procedure` — the active [`Procedure`](api/generated/mesofield.base)
134
+ - `data` — the [`mesofield.data`](api/generated/mesofield.data) package
135
+
136
+ Common one-liners:
137
+
138
+ ```python
139
+ procedure.config.items() # all configuration values
140
+ procedure.config.set("duration", 600) # change the run length
141
+ procedure.hardware.cameras # list configured cameras
142
+ procedure.hardware.primary # the camera that drives MDA
143
+ procedure.events.procedure_started.connect(my_callback)
144
+ ```
145
+
146
+ ## Logging
147
+
148
+ All application logs flow through one `loguru` logger and land in:
149
+
150
+ ```
151
+ logs/mesofield.log
152
+ ```
153
+
154
+ - Rotates **daily at midnight**.
155
+ - The console shows colourised logs at `INFO`; the file captures
156
+ everything down to `DEBUG`.
157
+ - Uncaught exceptions are routed through the same hook so crashes leave
158
+ a trail.
159
+ - Chatty third-party libraries (`matplotlib`, `asyncio`, `traitlets`)
160
+ are pinned at `WARNING` or above.
161
+
162
+ To change the location or verbosity, see
163
+ [`mesofield/utils/_logger.py`](api/generated/mesofield.utils).
164
+
165
+ ## System requirements
166
+
167
+ Mesofield is tested on Windows 10/11. For multi-camera acquisition with
168
+ large files we recommend:
169
+
170
+ - ≥ 32 GB RAM
171
+ - 12th-gen Intel i7 or equivalent
172
+ - Fast local storage (NVMe SSD) for the experiment directory
@@ -0,0 +1,320 @@
1
+ """Example custom device: a commandable Teensy 4.0 sync pulse generator.
2
+
3
+ Targets the ``sync-pulse-generator v3-teensy40`` firmware. Protocol is
4
+ line-based and ``\\n``-terminated; the device USB-CDC speed is fixed at
5
+ 115200.
6
+
7
+ Device -> host telemetry
8
+ ========================
9
+
10
+ Pulse events (one line per rising LED edge)::
11
+
12
+ LED,<device_us>,<event_id>
13
+
14
+ Banners, status, sync replies, and errors all start with ``#``::
15
+
16
+ # sync-pulse-generator v3-teensy40 ts=micros_at_rising_edge
17
+ # status state=idle seq_len=1 run_count=0 run_count_limit=0 epoch=0 next_id=0
18
+ # pattern len=1 (20000,480000)
19
+ # sync token=<t> device_us=<micros>
20
+ # running | # stopped | # auto_stop | # epoch=<n> | # pong
21
+ # err=<reason> | # dropped=<n> | # pattern_set
22
+
23
+ Host -> device commands (case-insensitive)
24
+ ==========================================
25
+
26
+ ::
27
+
28
+ STATUS -> '# status ...'
29
+ PING -> '# pong'
30
+ SYNC <token> -> '# sync token=<token> device_us=...'
31
+
32
+ PATTERN SIMPLE <period_us> <width_us> (idle only)
33
+ PATTERN SEQ <w1> <g1> <w2> <g2> ... (idle only, up to 16 pairs)
34
+ PATTERN SHOW -> '# pattern ...'
35
+
36
+ RUN free run until STOP
37
+ RUN DURATION <us> auto-stop after wall-clock duration
38
+ RUN COUNT <n> auto-stop after N pulses
39
+ STOP force LED LOW, return to idle
40
+
41
+ RESET STOP + zero event_id, increment epoch
42
+
43
+ Limits enforced by the firmware: width >= 100 us, gap >= 100 us,
44
+ each interval <= 60_000_000 us, sequence length <= 16 pairs.
45
+
46
+ Hardware YAML
47
+ =============
48
+
49
+ ::
50
+
51
+ teensy:
52
+ type: teensy_pulses
53
+ port: COM7 # /dev/ttyACM0 on Linux
54
+ baudrate: 115200
55
+ development_mode: false # true -> no port opened, send_line no-ops
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import re
61
+ import time
62
+ from typing import Iterable, List, Optional, Sequence, Tuple
63
+
64
+ from mesofield import DeviceRegistry
65
+ from mesofield.devices.base import BaseSerialDevice
66
+
67
+
68
+ # Pulse line: LED,<device_us>,<event_id>
69
+ _PULSE_RE = re.compile(r"^LED,(\d+),(\d+)$")
70
+ # Status line: # status state=<s> seq_len=<n> run_count=<c> ...
71
+ _STATUS_RE = re.compile(r"(\w+)=(\S+)")
72
+ # Sync reply: # sync token=<t> device_us=<u>
73
+ _SYNC_RE = re.compile(r"^sync\s+token=(\S+)\s+device_us=(\d+)\s*$")
74
+
75
+
76
+ @DeviceRegistry.register("teensy_pulses")
77
+ class TeensyPulseGenerator(BaseSerialDevice):
78
+ """Sync pulse generator driver matching firmware v3-teensy40.
79
+
80
+ Each rising LED edge becomes a row::
81
+
82
+ timestamp,device_us,event_id,epoch
83
+
84
+ where ``timestamp`` is the host wall-clock at line receipt and
85
+ ``device_us`` is the Teensy's ``micros()`` clock latched in the
86
+ rising-edge ISR. ``epoch`` increments on every ``RESET`` so
87
+ ``event_id`` collisions can be disambiguated across runs.
88
+ """
89
+
90
+ device_type = "stimulator"
91
+ file_type = "csv"
92
+ bids_type = "events"
93
+
94
+ # Firmware-enforced limits (see sync-pulse-generator v3 source).
95
+ MIN_WIDTH_US: int = 100
96
+ MIN_GAP_US: int = 100
97
+ MAX_INTERVAL_US: int = 60_000_000
98
+ MAX_SEQ_LEN: int = 16
99
+
100
+ def __init__(self, cfg=None, **kwargs):
101
+ super().__init__(cfg, **kwargs)
102
+ # Firmware state mirrored from '#'-prefixed lines.
103
+ self.firmware_banner: Optional[str] = None
104
+ self.last_status: dict = {}
105
+ self.last_pattern: List[Tuple[int, int]] = []
106
+ self.last_sync: Optional[Tuple[str, int]] = None # (token, device_us)
107
+ self.last_dropped: int = 0
108
+ self.epoch: int = 0
109
+ self.is_running_firmware: bool = False
110
+
111
+ # ------------------------------------------------------------------
112
+ # High-level command API (callable from a Procedure or the GUI).
113
+ # All methods are fire-and-forget; replies arrive asynchronously
114
+ # through ``parse_line`` and update the ``last_*`` attributes.
115
+ # ------------------------------------------------------------------
116
+
117
+ def ping(self) -> None:
118
+ self.send_line("PING")
119
+
120
+ def query_status(self) -> None:
121
+ self.send_line("STATUS")
122
+
123
+ def query_pattern(self) -> None:
124
+ self.send_line("PATTERN SHOW")
125
+
126
+ def sync(self, token: Optional[str] = None) -> str:
127
+ """Send ``SYNC <token>``; the reply lands in :attr:`last_sync`."""
128
+ if token is None:
129
+ token = f"host{int(time.time() * 1000)}"
130
+ self.send_line(f"SYNC {token}")
131
+ return token
132
+
133
+ # -- pattern --------------------------------------------------------
134
+ def set_pattern_simple(self, period_us: int, width_us: int) -> None:
135
+ """Repeating single pulse: ``period_us`` cycle, ``width_us`` high.
136
+
137
+ Firmware accepts only when device is IDLE. Validates locally
138
+ against the firmware's documented limits to fail fast.
139
+ """
140
+ period_us = int(period_us)
141
+ width_us = int(width_us)
142
+ if width_us < self.MIN_WIDTH_US:
143
+ raise ValueError(f"width_us must be >= {self.MIN_WIDTH_US}")
144
+ if period_us < width_us + self.MIN_GAP_US:
145
+ raise ValueError(
146
+ f"period_us must be >= width_us + {self.MIN_GAP_US}"
147
+ )
148
+ if period_us > self.MAX_INTERVAL_US:
149
+ raise ValueError(f"period_us must be <= {self.MAX_INTERVAL_US}")
150
+ self.send_line(f"PATTERN SIMPLE {period_us} {width_us}")
151
+
152
+ def set_pattern_sequence(
153
+ self, steps: Sequence[Tuple[int, int]]
154
+ ) -> None:
155
+ """Set a (width_us, gap_us) sequence (up to 16 pairs)."""
156
+ steps = list(steps)
157
+ if not steps:
158
+ raise ValueError("steps must contain at least one (width,gap) pair")
159
+ if len(steps) > self.MAX_SEQ_LEN:
160
+ raise ValueError(f"at most {self.MAX_SEQ_LEN} pairs supported")
161
+ for w, g in steps:
162
+ if w < self.MIN_WIDTH_US or g < self.MIN_GAP_US:
163
+ raise ValueError("width/gap below firmware minimum (100 us)")
164
+ if w > self.MAX_INTERVAL_US or g > self.MAX_INTERVAL_US:
165
+ raise ValueError(
166
+ f"width/gap above firmware maximum ({self.MAX_INTERVAL_US} us)"
167
+ )
168
+ flat: Iterable[int] = (v for pair in steps for v in pair)
169
+ self.send_line("PATTERN SEQ " + " ".join(str(int(v)) for v in flat))
170
+
171
+ def set_frequency(self, hz: float, duty: float = 0.5) -> None:
172
+ """Convenience wrapper around :meth:`set_pattern_simple`.
173
+
174
+ ``duty`` is the fractional high time (0 < duty < 1).
175
+ """
176
+ if hz <= 0:
177
+ raise ValueError(f"frequency must be > 0, got {hz}")
178
+ if not 0.0 < duty < 1.0:
179
+ raise ValueError(f"duty must be in (0,1), got {duty}")
180
+ period_us = int(round(1_000_000 / hz))
181
+ width_us = max(self.MIN_WIDTH_US, int(round(period_us * duty)))
182
+ self.set_pattern_simple(period_us, width_us)
183
+
184
+ # -- run/stop -------------------------------------------------------
185
+ def run(self) -> None:
186
+ """Free-run until :meth:`stop_pulses` (or RESET)."""
187
+ self.send_line("RUN")
188
+
189
+ def run_for_duration(self, duration_us: int) -> None:
190
+ if duration_us <= 0:
191
+ raise ValueError("duration_us must be > 0")
192
+ self.send_line(f"RUN DURATION {int(duration_us)}")
193
+
194
+ def run_for_count(self, count: int) -> None:
195
+ if count <= 0:
196
+ raise ValueError("count must be > 0")
197
+ self.send_line(f"RUN COUNT {int(count)}")
198
+
199
+ def stop_pulses(self) -> None:
200
+ self.send_line("STOP")
201
+
202
+ def reset(self) -> None:
203
+ """``STOP`` + zero ``event_id`` + increment ``epoch`` on the device."""
204
+ self.send_line("RESET")
205
+
206
+ # ------------------------------------------------------------------
207
+ # BaseSerialDevice hooks
208
+ # ------------------------------------------------------------------
209
+
210
+ def setup_serial(self) -> None:
211
+ """Drain the boot banner so the first run starts from a clean slate."""
212
+ # The firmware emits its banner + status + pattern as soon as
213
+ # USB-CDC enumerates. Give it a moment, then ask for a fresh
214
+ # snapshot so ``self.last_status``/``last_pattern`` are populated
215
+ # before the experiment starts.
216
+ time.sleep(0.1)
217
+ self.send_line("STATUS")
218
+ self.send_line("PATTERN SHOW")
219
+
220
+ def parse_line(self, line: bytes) -> Optional[Tuple[dict, Optional[float]]]:
221
+ text = line.decode("utf-8", errors="replace").strip()
222
+ if not text:
223
+ return None
224
+
225
+ # ---------- pulse event ----------
226
+ m = _PULSE_RE.match(text)
227
+ if m is not None:
228
+ device_us = int(m.group(1))
229
+ event_id = int(m.group(2))
230
+ payload = {
231
+ "device_us": device_us,
232
+ "event_id": event_id,
233
+ "epoch": self.epoch,
234
+ "device_id": self.device_id,
235
+ }
236
+ # ts=None -> BaseDataProducer.record stamps with host time.
237
+ return payload, None
238
+
239
+ # ---------- '#'-prefixed control/telemetry lines ----------
240
+ if text.startswith("#"):
241
+ self._handle_meta_line(text[1:].strip())
242
+ return None
243
+
244
+ self.logger.debug("unrecognised teensy line: %r", text)
245
+ return None
246
+
247
+ # ------------------------------------------------------------------
248
+ # Internal: '#' line dispatch
249
+ # ------------------------------------------------------------------
250
+
251
+ def _handle_meta_line(self, body: str) -> None:
252
+ """Update mirrored firmware state from a ``#``-prefixed line."""
253
+ if not body:
254
+ return
255
+
256
+ head = body.split(None, 1)[0].lower()
257
+
258
+ if head.startswith("sync-pulse-generator"):
259
+ self.firmware_banner = body
260
+ self.logger.info("teensy banner: %s", body)
261
+ return
262
+
263
+ if head == "status":
264
+ self.last_status = dict(_STATUS_RE.findall(body))
265
+ self.is_running_firmware = self.last_status.get("state") == "running"
266
+ try:
267
+ self.epoch = int(self.last_status.get("epoch", self.epoch))
268
+ except ValueError:
269
+ pass
270
+ self.logger.debug("teensy status=%s", self.last_status)
271
+ return
272
+
273
+ if head == "pattern":
274
+ self.last_pattern = [
275
+ (int(w), int(g))
276
+ for w, g in re.findall(r"\((\d+),(\d+)\)", body)
277
+ ]
278
+ self.logger.debug("teensy pattern=%s", self.last_pattern)
279
+ return
280
+
281
+ m = _SYNC_RE.match(body)
282
+ if m is not None:
283
+ self.last_sync = (m.group(1), int(m.group(2)))
284
+ self.logger.debug("teensy sync token=%s device_us=%s",
285
+ *self.last_sync)
286
+ return
287
+
288
+ if body == "running":
289
+ self.is_running_firmware = True
290
+ return
291
+ if body in ("stopped", "auto_stop"):
292
+ self.is_running_firmware = False
293
+ return
294
+ if body == "pong":
295
+ return
296
+ if body == "pattern_set":
297
+ return
298
+
299
+ if body.startswith("epoch="):
300
+ try:
301
+ self.epoch = int(body.split("=", 1)[1])
302
+ except ValueError:
303
+ pass
304
+ return
305
+
306
+ if body.startswith("dropped="):
307
+ try:
308
+ self.last_dropped += int(body.split("=", 1)[1])
309
+ except ValueError:
310
+ pass
311
+ self.logger.warning("teensy dropped pulses (total=%d)",
312
+ self.last_dropped)
313
+ return
314
+
315
+ if body.startswith("err="):
316
+ self.logger.warning("teensy firmware error: %s", body[4:])
317
+ return
318
+
319
+ # Unknown '#' line -> log at debug.
320
+ self.logger.debug("teensy meta: %s", body)
@@ -0,0 +1,24 @@
1
+ {
2
+ "Configuration": {
3
+ "experimenter": "pipeline-test",
4
+ "protocol": "PIPELINE_DEMO",
5
+ "duration": 2,
6
+ "start_on_trigger": false
7
+ },
8
+ "procedure_file": "procedure.py",
9
+ "procedure_class": "PipelineDemoProcedure",
10
+ "Subjects": {
11
+ "DEMO01": {
12
+ "session": "01",
13
+ "task": "demo"
14
+ }
15
+ },
16
+ "DisplayKeys": [
17
+ "subject",
18
+ "session",
19
+ "task",
20
+ "experimenter",
21
+ "protocol",
22
+ "duration"
23
+ ]
24
+ }
@@ -0,0 +1,23 @@
1
+ # Headless demo rig for the mesokit-schema pipeline test.
2
+ #
3
+ # Two synthetic devices, no real serial or pymmcore hardware:
4
+ # - mock_wheel: BaseSerialDevice subclass producing CSV samples
5
+ # - mock_camera: BaseDataProducer subclass writing a real OME-TIFF
6
+ # stack + a _frame_metadata.json sidecar
7
+ #
8
+ # The wheel is primary; its signals.finished would normally trigger
9
+ # cleanup, but since the mock devices run forever, the procedure uses
10
+ # a wall-clock `duration` cap (see experiment.json) to call cleanup.
11
+ memory_buffer_size: 1000
12
+
13
+
14
+ camera:
15
+ type: mock_camera # registered by procedure.py at import time
16
+ primary: true
17
+ width: 32
18
+ height: 32
19
+ frame_interval_ms: 100
20
+ output:
21
+ suffix: meso
22
+ file_type: ome.tiff
23
+ bids_type: func
@@ -0,0 +1,50 @@
1
+ """Headless demo procedure for the mesokit-schema pipeline test.
2
+
3
+ Reuses the legacy SampleProcedure's wall-clock duration cap so the run
4
+ terminates cleanly without any GUI or real hardware. The
5
+ AcquisitionManifest is written by the base `Procedure._cleanup_procedure`
6
+ hook -- subclasses do not have to import or know about mesokit-schema.
7
+
8
+ Registers two synthetic device types so `hardware.yaml` can reference
9
+ them without touching real hardware:
10
+
11
+ - mock_wheel : MockEncoderDevice (CSV samples)
12
+ - mock_camera : MockFrameProducer (OME-TIFF + frame metadata JSON)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+
19
+ from mesofield import DeviceRegistry
20
+ from mesofield.base import Procedure
21
+ from mesofield.devices.mocks import MockFrameProducer
22
+ from mesofield.devices.mocks import MockEncoderDevice
23
+
24
+
25
+ DeviceRegistry._registry.setdefault("mock_wheel", MockEncoderDevice)
26
+ DeviceRegistry._registry.setdefault("mock_camera", MockFrameProducer)
27
+
28
+
29
+ class PipelineDemoProcedure(Procedure):
30
+ """Mock-encoder-only procedure with duration-gated cleanup."""
31
+
32
+ def on_started(self) -> None:
33
+ duration = self.config.get("duration")
34
+ if duration:
35
+ self.logger.info(f"Duration cap armed: {duration}s")
36
+ self._duration_timer = threading.Timer(float(duration), self.cleanup)
37
+ self._duration_timer.daemon = True
38
+ self._duration_timer.start()
39
+
40
+ def on_finished(self) -> None:
41
+ super().on_finished()
42
+ timer = getattr(self, "_duration_timer", None)
43
+ if timer is not None:
44
+ timer.cancel()
45
+ self._duration_timer = None
46
+
47
+ def main():
48
+ """Run the procedure."""
49
+ procedure = PipelineDemoProcedure("/Users/jakegronemeyer/dev/mesofield/experiments/pipeline_demo/experiment.json")
50
+ procedure.run()
@@ -0,0 +1,24 @@
1
+ {
2
+ "Configuration": {
3
+ "experimenter": "demo",
4
+ "protocol": "TWO_CAM_DEMO",
5
+ "duration": 10,
6
+ "start_on_trigger": false
7
+ },
8
+ "procedure_file": "procedure.py",
9
+ "procedure_class": "TwoCamDemoProcedure",
10
+ "Subjects": {
11
+ "DEMO": {
12
+ "session": "01",
13
+ "task": "freeview"
14
+ }
15
+ },
16
+ "DisplayKeys": [
17
+ "subject",
18
+ "session",
19
+ "task",
20
+ "experimenter",
21
+ "protocol",
22
+ "duration"
23
+ ]
24
+ }
@@ -0,0 +1,58 @@
1
+ # Two mock cameras + one mock wheel encoder.
2
+ #
3
+ # Three producers all writing real files (real OME-TIFFs, real CSV) into a
4
+ # BIDS layout under data/sub-DEMO/ses-01/. No GUI required; no real hardware
5
+ # required; the procedure runs to its `duration` cap and writes the
6
+ # AcquisitionManifest at the session root.
7
+ #
8
+ # Layout that gets produced:
9
+ # data/sub-DEMO/ses-01/
10
+ # manifest.json ← contract
11
+ # <ts>_sub-DEMO_ses-01_task-freeview_configuration.csv
12
+ # <ts>_sub-DEMO_ses-01_task-freeview_timestamps.csv
13
+ # <ts>_sub-DEMO_ses-01_task-freeview_notes.txt
14
+ # func/
15
+ # <ts>_sub-DEMO_ses-01_task-freeview_meso.ome.tiff
16
+ # <ts>_sub-DEMO_ses-01_task-freeview_meso.ome.tiff_frame_metadata.json
17
+ # behav/
18
+ # <ts>_sub-DEMO_ses-01_task-freeview_pupil.ome.tiff
19
+ # <ts>_sub-DEMO_ses-01_task-freeview_pupil.ome.tiff_frame_metadata.json
20
+ # beh/
21
+ # <ts>_sub-DEMO_ses-01_task-freeview_wheel.csv
22
+ # <ts>_sub-DEMO_ses-01_task-freeview_dataqueue.csv
23
+ memory_buffer_size: 4000
24
+
25
+ # Primary mesoscope: 128x128 @ 10 fps. Drives session-level timing.
26
+ mesoscope:
27
+ type: opencv_camera
28
+ device_index: 0
29
+ primary: true
30
+ frame_interval_ms: 100
31
+ cv_backend: "mpv4"
32
+ backend: "opencv"
33
+ output:
34
+ suffix: meso
35
+ file_type: mp4
36
+ bids_type: func
37
+
38
+ # Pupil camera: 64x64 @ 20 fps.
39
+ pupil:
40
+ type: mock_camera
41
+ width: 64
42
+ height: 64
43
+ frame_interval_ms: 50
44
+ output:
45
+ suffix: pupil
46
+ file_type: ome.tiff
47
+ bids_type: behav
48
+
49
+ # Wheel encoder: 50 Hz.
50
+ wheel:
51
+ type: mock_wheel
52
+ sample_interval_ms: 20
53
+ cpr: 2400
54
+ diameter_mm: 80
55
+ output:
56
+ suffix: wheel
57
+ file_type: csv
58
+ bids_type: beh