mesofield 0.3.2b0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- scripts/bench_frame_processor.py +103 -0
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
|