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
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Scaffold a new experiment directory.
|
|
2
|
+
|
|
3
|
+
The `mesofield init` CLI command (see :mod:`mesofield.cli.acquire`) calls
|
|
4
|
+
:func:`scaffold_experiment` to generate a working layout new lab users
|
|
5
|
+
can run, edit, and extend without writing anything from scratch:
|
|
6
|
+
|
|
7
|
+
my-experiment/
|
|
8
|
+
experiment.json # session / protocol / duration
|
|
9
|
+
hardware.yaml # device stanzas
|
|
10
|
+
procedure.py # Procedure subclass (Python entry point)
|
|
11
|
+
devices/
|
|
12
|
+
__init__.py
|
|
13
|
+
thermal_example.py # annotated custom-device template
|
|
14
|
+
|
|
15
|
+
The `hardware.yaml` is supplied by the caller (``mesofield init``):
|
|
16
|
+
|
|
17
|
+
- a ``Path`` to a canonical rig file -> copied in verbatim,
|
|
18
|
+
- ``"dev"`` -> a mock config (:class:`MockEncoderDevice`) that runs
|
|
19
|
+
out of the box on any machine,
|
|
20
|
+
- ``"blank"`` -> a commented real-hardware template to fill out.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import shutil
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional, Union
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def scaffold_experiment(
|
|
31
|
+
target: Path,
|
|
32
|
+
*,
|
|
33
|
+
name: Optional[str] = None,
|
|
34
|
+
force: bool = False,
|
|
35
|
+
hardware: Union[Path, str] = "blank",
|
|
36
|
+
) -> Path:
|
|
37
|
+
"""Generate a runnable experiment layout at `target`. Returns the dir.
|
|
38
|
+
|
|
39
|
+
``hardware`` selects the ``hardware.yaml`` written into the experiment:
|
|
40
|
+
a :class:`~pathlib.Path` (copied verbatim from a canonical rig file),
|
|
41
|
+
``"dev"`` (mock config), or ``"blank"`` (fill-out template).
|
|
42
|
+
"""
|
|
43
|
+
target = Path(target).resolve()
|
|
44
|
+
if target.exists() and any(target.iterdir()):
|
|
45
|
+
if not force:
|
|
46
|
+
raise FileExistsError(
|
|
47
|
+
f"{target} is not empty. Pass --force to overwrite, or pick an empty dir."
|
|
48
|
+
)
|
|
49
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
(target / "devices").mkdir(exist_ok=True)
|
|
51
|
+
|
|
52
|
+
protocol = name or _protocol_from_dir(target)
|
|
53
|
+
files = {
|
|
54
|
+
target / "experiment.json": _experiment_json(protocol),
|
|
55
|
+
target / "procedure.py": _procedure_py(protocol),
|
|
56
|
+
target / "devices" / "__init__.py": "",
|
|
57
|
+
target / "devices" / "thermal_example.py": _thermal_example_py(),
|
|
58
|
+
target / "README.md": _readme(protocol, target.name),
|
|
59
|
+
}
|
|
60
|
+
for path, content in files.items():
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
path.write_text(content, encoding="utf-8")
|
|
63
|
+
|
|
64
|
+
# hardware.yaml: copy a canonical rig file, or write a built-in template.
|
|
65
|
+
hardware_dst = target / "hardware.yaml"
|
|
66
|
+
if isinstance(hardware, Path):
|
|
67
|
+
shutil.copyfile(hardware, hardware_dst)
|
|
68
|
+
elif hardware == "dev":
|
|
69
|
+
hardware_dst.write_text(_hardware_yaml_mock(), encoding="utf-8")
|
|
70
|
+
elif hardware == "blank":
|
|
71
|
+
hardware_dst.write_text(hardware_yaml_template(), encoding="utf-8")
|
|
72
|
+
else:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"hardware must be a Path, 'dev', or 'blank'; got {hardware!r}"
|
|
75
|
+
)
|
|
76
|
+
return target
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _protocol_from_dir(target: Path) -> str:
|
|
80
|
+
return target.name.upper().replace("-", "_").replace(" ", "_")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _experiment_json(protocol: str) -> str:
|
|
84
|
+
return (
|
|
85
|
+
"{\n"
|
|
86
|
+
' "Configuration": {\n'
|
|
87
|
+
' "experimenter": "your-name",\n'
|
|
88
|
+
f' "protocol": "{protocol}",\n'
|
|
89
|
+
' "duration": 5,\n'
|
|
90
|
+
' "start_on_trigger": false\n'
|
|
91
|
+
" },\n"
|
|
92
|
+
' "procedure_file": "procedure.py",\n'
|
|
93
|
+
' "procedure_class": "MyProcedure",\n'
|
|
94
|
+
' "Subjects": {\n'
|
|
95
|
+
' "SUBJ01": {\n'
|
|
96
|
+
' "session": "01",\n'
|
|
97
|
+
' "task": "demo"\n'
|
|
98
|
+
" }\n"
|
|
99
|
+
" },\n"
|
|
100
|
+
' "DisplayKeys": [\n'
|
|
101
|
+
' "subject", "session", "task",\n'
|
|
102
|
+
' "experimenter", "protocol", "duration"\n'
|
|
103
|
+
" ]\n"
|
|
104
|
+
"}\n"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _hardware_yaml_mock() -> str:
|
|
109
|
+
"""The ``dev`` hardware config: a mock encoder, runs without hardware."""
|
|
110
|
+
return (
|
|
111
|
+
"# Hardware configuration for this experiment (DEV / mock).\n"
|
|
112
|
+
"#\n"
|
|
113
|
+
"# Each top-level stanza (other than the reserved keys at the bottom)\n"
|
|
114
|
+
"# is a device. `type:` selects which DataProducer class instantiates\n"
|
|
115
|
+
"# it (registered via @DeviceRegistry.register(\"...\")).\n"
|
|
116
|
+
"#\n"
|
|
117
|
+
"# Built-in device types include: camera (micromanager), opencv_camera,\n"
|
|
118
|
+
"# wheel, encoder, psychopy, nidaq, mock_wheel, mock_camera.\n"
|
|
119
|
+
"#\n"
|
|
120
|
+
"# Add a lab-specific device by creating devices/<name>.py with a\n"
|
|
121
|
+
"# DataProducer subclass and a nested `Parser(TimeseriesSource)`,\n"
|
|
122
|
+
"# then reference its registered `type:` here.\n"
|
|
123
|
+
"memory_buffer_size: 1000\n"
|
|
124
|
+
"\n"
|
|
125
|
+
"# Mock encoder so this runs without real hardware.\n"
|
|
126
|
+
"# Replace with your real device when ready (`type: wheel` etc.).\n"
|
|
127
|
+
"wheel:\n"
|
|
128
|
+
" type: mock_wheel\n"
|
|
129
|
+
" primary: true\n"
|
|
130
|
+
" sample_interval_ms: 50\n"
|
|
131
|
+
" cpr: 2400\n"
|
|
132
|
+
" diameter_mm: 80\n"
|
|
133
|
+
" output:\n"
|
|
134
|
+
" suffix: wheel\n"
|
|
135
|
+
" file_type: csv\n"
|
|
136
|
+
" bids_type: beh\n"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def hardware_yaml_template() -> str:
|
|
141
|
+
"""A commented real-hardware skeleton that must be filled out before use.
|
|
142
|
+
|
|
143
|
+
Used both for ``mesofield init`` with the ``blank`` choice and for
|
|
144
|
+
``mesofield rig new`` (a fresh canonical rig file to edit).
|
|
145
|
+
"""
|
|
146
|
+
return (
|
|
147
|
+
"# Canonical hardware configuration for this rig -- FILL THIS OUT.\n"
|
|
148
|
+
"#\n"
|
|
149
|
+
"# Each top-level stanza (other than the reserved keys) is a device.\n"
|
|
150
|
+
"# `type:` selects the DataProducer class (registered via\n"
|
|
151
|
+
"# @DeviceRegistry.register(\"...\")). Built-in types: camera\n"
|
|
152
|
+
"# (micromanager), opencv_camera, wheel, encoder, psychopy, nidaq,\n"
|
|
153
|
+
"# mock_wheel, mock_camera.\n"
|
|
154
|
+
"#\n"
|
|
155
|
+
"# Exactly one device must be flagged `primary: true`.\n"
|
|
156
|
+
"# Replace every <PLACEHOLDER> below with this machine's real values.\n"
|
|
157
|
+
"memory_buffer_size: 1000\n"
|
|
158
|
+
"\n"
|
|
159
|
+
"# --- Running wheel encoder (Arduino/Teensy over USB-serial) --------\n"
|
|
160
|
+
"wheel:\n"
|
|
161
|
+
" type: wheel\n"
|
|
162
|
+
" primary: true\n"
|
|
163
|
+
" port: \"COM?\" # <PLACEHOLDER> e.g. COM4 (Win) or /dev/ttyUSB0\n"
|
|
164
|
+
" baudrate: 115200\n"
|
|
165
|
+
" cpr: 2400\n"
|
|
166
|
+
" diameter_mm: 80\n"
|
|
167
|
+
" output:\n"
|
|
168
|
+
" suffix: wheel\n"
|
|
169
|
+
" file_type: csv\n"
|
|
170
|
+
" bids_type: beh\n"
|
|
171
|
+
"\n"
|
|
172
|
+
"# --- Camera (uncomment and adapt ONE of the following) -------------\n"
|
|
173
|
+
"# OpenCV / UVC camera:\n"
|
|
174
|
+
"# mesoscope:\n"
|
|
175
|
+
"# type: opencv_camera\n"
|
|
176
|
+
"# id: mesoscope\n"
|
|
177
|
+
"# name: mesoscope\n"
|
|
178
|
+
"# backend: opencv\n"
|
|
179
|
+
"# device_index: 0 # <PLACEHOLDER> OpenCV VideoCapture index\n"
|
|
180
|
+
"# fps: 30\n"
|
|
181
|
+
"# output:\n"
|
|
182
|
+
"# suffix: mesoscope\n"
|
|
183
|
+
"# file_type: mp4\n"
|
|
184
|
+
"# bids_type: func\n"
|
|
185
|
+
"#\n"
|
|
186
|
+
"# Micro-Manager camera:\n"
|
|
187
|
+
"# pupil:\n"
|
|
188
|
+
"# type: camera\n"
|
|
189
|
+
"# id: pupil\n"
|
|
190
|
+
"# name: pupil\n"
|
|
191
|
+
"# backend: micromanager\n"
|
|
192
|
+
"# configuration_path: \"<PLACEHOLDER>/MMConfig.cfg\"\n"
|
|
193
|
+
"# output:\n"
|
|
194
|
+
"# suffix: pupil\n"
|
|
195
|
+
"# file_type: ome.tiff\n"
|
|
196
|
+
"# bids_type: func\n"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _procedure_py(protocol: str) -> str:
|
|
201
|
+
return (
|
|
202
|
+
f'"""Procedure for the `{protocol}` experiment.\n'
|
|
203
|
+
'\n'
|
|
204
|
+
'The base mesofield.Procedure handles the AcquisitionManifest contract\n'
|
|
205
|
+
'for you. Override `prerun`, `on_started`, `on_finished` to add\n'
|
|
206
|
+
'experiment-specific behavior. Override `manifest_extra()` to attach\n'
|
|
207
|
+
'session-level metadata to the manifest (LED pattern, model version,\n'
|
|
208
|
+
'etc).\n'
|
|
209
|
+
'\n'
|
|
210
|
+
'To run::\n'
|
|
211
|
+
'\n'
|
|
212
|
+
' mesofield launch experiment.json # GUI\n'
|
|
213
|
+
' python -m experiment_script # headless (see __main__)\n'
|
|
214
|
+
'"""\n'
|
|
215
|
+
'\n'
|
|
216
|
+
'from __future__ import annotations\n'
|
|
217
|
+
'\n'
|
|
218
|
+
'import threading\n'
|
|
219
|
+
'\n'
|
|
220
|
+
'from mesofield import DeviceRegistry\n'
|
|
221
|
+
'from mesofield.base import Procedure\n'
|
|
222
|
+
'from mesofield.devices.mocks import MockEncoderDevice\n'
|
|
223
|
+
'\n'
|
|
224
|
+
'# Register any custom devices you have added under devices/ here.\n'
|
|
225
|
+
'# Built-in device types (camera, wheel, encoder, psychopy, nidaq)\n'
|
|
226
|
+
'# are already registered by mesofield itself.\n'
|
|
227
|
+
'DeviceRegistry._registry.setdefault("mock_wheel", MockEncoderDevice)\n'
|
|
228
|
+
'\n'
|
|
229
|
+
'\n'
|
|
230
|
+
'class MyProcedure(Procedure):\n'
|
|
231
|
+
' """Your experiment\'s procedure logic.\n'
|
|
232
|
+
'\n'
|
|
233
|
+
' Lifecycle (subclass hooks in **bold**):\n'
|
|
234
|
+
' 1. initialize_hardware\n'
|
|
235
|
+
' 2. **prerun** -- override for per-run setup\n'
|
|
236
|
+
' 3. hardware.arm_all\n'
|
|
237
|
+
' 4. **on_started** -- start timers, mark events\n'
|
|
238
|
+
' 5. hardware.start_all\n'
|
|
239
|
+
' 6. ... (acquisition runs until primary device finishes)\n'
|
|
240
|
+
' 7. save_data + _write_acquisition_manifest\n'
|
|
241
|
+
' 8. **on_finished** -- post-acquisition hook\n'
|
|
242
|
+
' """\n'
|
|
243
|
+
'\n'
|
|
244
|
+
' def on_started(self) -> None:\n'
|
|
245
|
+
' """Called immediately after start_all. Arm a wall-clock cap."""\n'
|
|
246
|
+
' duration = self.config.get("duration")\n'
|
|
247
|
+
' if duration:\n'
|
|
248
|
+
' self.logger.info(f"Duration cap armed: {duration}s")\n'
|
|
249
|
+
' self._duration_timer = threading.Timer(float(duration), self.cleanup)\n'
|
|
250
|
+
' self._duration_timer.daemon = True\n'
|
|
251
|
+
' self._duration_timer.start()\n'
|
|
252
|
+
'\n'
|
|
253
|
+
' def on_finished(self) -> None:\n'
|
|
254
|
+
' super().on_finished()\n'
|
|
255
|
+
' timer = getattr(self, "_duration_timer", None)\n'
|
|
256
|
+
' if timer is not None:\n'
|
|
257
|
+
' timer.cancel()\n'
|
|
258
|
+
' self._duration_timer = None\n'
|
|
259
|
+
'\n'
|
|
260
|
+
' def manifest_extra(self) -> dict:\n'
|
|
261
|
+
' """Attach session-level metadata to the AcquisitionManifest."""\n'
|
|
262
|
+
' return {}\n'
|
|
263
|
+
'\n'
|
|
264
|
+
'\n'
|
|
265
|
+
'def main():\n'
|
|
266
|
+
' """Run headless: `python procedure.py`."""\n'
|
|
267
|
+
' import sys\n'
|
|
268
|
+
' from pathlib import Path\n'
|
|
269
|
+
' cfg = Path(__file__).parent / "experiment.json"\n'
|
|
270
|
+
' proc = MyProcedure(str(cfg))\n'
|
|
271
|
+
' finished = proc.run_until_finished(timeout=30.0)\n'
|
|
272
|
+
' sys.exit(0 if finished else 1)\n'
|
|
273
|
+
'\n'
|
|
274
|
+
'\n'
|
|
275
|
+
'if __name__ == "__main__":\n'
|
|
276
|
+
' main()\n'
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _thermal_example_py() -> str:
|
|
281
|
+
return (
|
|
282
|
+
'"""Example custom device: a thermal sensor over USB-serial.\n'
|
|
283
|
+
'\n'
|
|
284
|
+
'This file is the canonical pattern for adding a lab-specific device\n'
|
|
285
|
+
'to mesofield. One file holds both halves of the contract:\n'
|
|
286
|
+
'\n'
|
|
287
|
+
' - The producer (`ThermalSensor`) writes data during acquisition.\n'
|
|
288
|
+
' - The parser (`_ThermalParser`) reads it back during ingest.\n'
|
|
289
|
+
'\n'
|
|
290
|
+
'The producer is registered via `@DeviceRegistry.register("thermal")`;\n'
|
|
291
|
+
'after that the device is referenced in hardware.yaml as\n'
|
|
292
|
+
'`type: thermal`. The parser is bound to the producer via\n'
|
|
293
|
+
'`ThermalSensor.Parser = _ThermalParser` so the SOURCE_REGISTRY\n'
|
|
294
|
+
'resolves it through the producer class.\n'
|
|
295
|
+
'\n'
|
|
296
|
+
'To use:\n'
|
|
297
|
+
' 1. Adapt `parse_line()` for your sensor\'s actual serial protocol.\n'
|
|
298
|
+
' 2. Import the module from your procedure.py to trigger the\n'
|
|
299
|
+
' registration:\n'
|
|
300
|
+
' from devices import thermal_example # noqa: F401\n'
|
|
301
|
+
' 3. Add a stanza to hardware.yaml:\n'
|
|
302
|
+
' thermal:\n'
|
|
303
|
+
' type: thermal\n'
|
|
304
|
+
' port: /dev/ttyUSB0\n'
|
|
305
|
+
' baudrate: 115200\n'
|
|
306
|
+
' output:\n'
|
|
307
|
+
' suffix: thermal\n'
|
|
308
|
+
' file_type: csv\n'
|
|
309
|
+
' bids_type: beh\n'
|
|
310
|
+
'"""\n'
|
|
311
|
+
'\n'
|
|
312
|
+
'from __future__ import annotations\n'
|
|
313
|
+
'\n'
|
|
314
|
+
'from pathlib import Path\n'
|
|
315
|
+
'from typing import Optional, Tuple\n'
|
|
316
|
+
'\n'
|
|
317
|
+
'import numpy as np\n'
|
|
318
|
+
'import pandas as pd\n'
|
|
319
|
+
'\n'
|
|
320
|
+
'from mesofield import DeviceRegistry\n'
|
|
321
|
+
'from mesofield.devices.base import BaseSerialDevice\n'
|
|
322
|
+
'from mesofield.datakit.sources.register import SourceContext, TimeseriesSource\n'
|
|
323
|
+
'\n'
|
|
324
|
+
'\n'
|
|
325
|
+
'@DeviceRegistry.register("thermal")\n'
|
|
326
|
+
'class ThermalSensor(BaseSerialDevice):\n'
|
|
327
|
+
' """USB-serial thermal sensor reading float Celsius values."""\n'
|
|
328
|
+
'\n'
|
|
329
|
+
' file_type = "csv"\n'
|
|
330
|
+
' bids_type = "beh"\n'
|
|
331
|
+
' data_type = "thermal"\n'
|
|
332
|
+
'\n'
|
|
333
|
+
' def parse_line(self, line: bytes) -> Optional[Tuple[float, Optional[float]]]:\n'
|
|
334
|
+
' text = line.decode("utf-8", errors="replace").strip()\n'
|
|
335
|
+
' if not text:\n'
|
|
336
|
+
' return None\n'
|
|
337
|
+
' try:\n'
|
|
338
|
+
' return float(text), None\n'
|
|
339
|
+
' except ValueError:\n'
|
|
340
|
+
' self.logger.debug("Non-float line: %r", text)\n'
|
|
341
|
+
' return None\n'
|
|
342
|
+
'\n'
|
|
343
|
+
'\n'
|
|
344
|
+
'class _ThermalParser(TimeseriesSource):\n'
|
|
345
|
+
' """Ingest-side parser for ThermalSensor CSV output."""\n'
|
|
346
|
+
'\n'
|
|
347
|
+
' tag = "thermal"\n'
|
|
348
|
+
' patterns = ("**/*_thermal.csv",)\n'
|
|
349
|
+
'\n'
|
|
350
|
+
' def build_timeseries(self, path: Path, *, context: SourceContext | None = None):\n'
|
|
351
|
+
' df = pd.read_csv(path)\n'
|
|
352
|
+
' t = df["timestamp"].to_numpy(dtype=np.float64)\n'
|
|
353
|
+
' return t, df, {"source_file": str(path), "n_samples": len(df)}\n'
|
|
354
|
+
'\n'
|
|
355
|
+
'\n'
|
|
356
|
+
'# Bind the parser to the producer so SOURCE_REGISTRY["thermal"]\n'
|
|
357
|
+
'# resolves through ThermalSensor.Parser.\n'
|
|
358
|
+
'ThermalSensor.Parser = _ThermalParser\n'
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _readme(protocol: str, dir_name: str) -> str:
|
|
363
|
+
return (
|
|
364
|
+
f"# {protocol}\n"
|
|
365
|
+
"\n"
|
|
366
|
+
"A mesofield experiment scaffolded by `mesofield init`.\n"
|
|
367
|
+
"\n"
|
|
368
|
+
"## Layout\n"
|
|
369
|
+
"\n"
|
|
370
|
+
"- `experiment.json` — session/protocol/duration; subjects.\n"
|
|
371
|
+
"- `hardware.yaml` — device stanzas. Each `type:` value names a\n"
|
|
372
|
+
" registered DataProducer class.\n"
|
|
373
|
+
"- `procedure.py` — the `MyProcedure(Procedure)` subclass. Run with\n"
|
|
374
|
+
" `python procedure.py` (headless) or `mesofield launch experiment.json`.\n"
|
|
375
|
+
"- `devices/thermal_example.py` — annotated template for adding a\n"
|
|
376
|
+
" custom hardware device. Delete it when you no longer need it.\n"
|
|
377
|
+
"\n"
|
|
378
|
+
"## Quick start\n"
|
|
379
|
+
"\n"
|
|
380
|
+
"```sh\n"
|
|
381
|
+
"python procedure.py\n"
|
|
382
|
+
"```\n"
|
|
383
|
+
"\n"
|
|
384
|
+
f"The acquisition writes a per-session BIDS layout under `data/sub-SUBJ01/ses-01/`,\n"
|
|
385
|
+
"with an `AcquisitionManifest` (`manifest.json`) at the session root.\n"
|
|
386
|
+
"Datakit ingests by reading the manifest -- no globbing of filenames.\n"
|
|
387
|
+
"\n"
|
|
388
|
+
"## Customizing\n"
|
|
389
|
+
"\n"
|
|
390
|
+
f"1. Edit `experiment.json` to set your real subject IDs and duration.\n"
|
|
391
|
+
"2. Edit `hardware.yaml` to declare your rig's devices. Replace the\n"
|
|
392
|
+
" `mock_wheel` stanza with `type: wheel` (Arduino) or `type: camera`\n"
|
|
393
|
+
" (micromanager) once you have real hardware connected.\n"
|
|
394
|
+
"3. Copy `devices/thermal_example.py` as a template for any lab-specific\n"
|
|
395
|
+
" device. Adapt `parse_line()` for your serial protocol; register\n"
|
|
396
|
+
" the type in `procedure.py` (the `DeviceRegistry._registry.setdefault`\n"
|
|
397
|
+
" call).\n"
|
|
398
|
+
"4. Add stage-specific behavior by overriding `prerun` / `on_started`\n"
|
|
399
|
+
" / `on_finished` in `MyProcedure`.\n"
|
|
400
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Machine-level store of canonical ``hardware.yaml`` rig configurations.
|
|
2
|
+
|
|
3
|
+
A ``hardware.yaml`` is rig-specific -- it pins COM ports, camera ids, device
|
|
4
|
+
indices, and Micro-Manager ``.cfg`` paths to one physical computer. Rather
|
|
5
|
+
than hand-copying the right file into every new experiment, each machine keeps
|
|
6
|
+
a small store of named canonical configs in its OS-native config directory
|
|
7
|
+
(via :mod:`platformdirs`). ``mesofield init`` copies a chosen rig into the new
|
|
8
|
+
experiment so the experiment folder stays self-contained.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import platformdirs
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from mesofield.scaffold.experiment import hardware_yaml_template
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def rigs_dir() -> Path:
|
|
23
|
+
"""Return (creating if needed) the directory holding canonical rig files."""
|
|
24
|
+
path = Path(platformdirs.user_config_dir("mesofield", appauthor=False)) / "rigs"
|
|
25
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def list_rigs() -> list[str]:
|
|
30
|
+
"""Return the sorted names of every rig in the store."""
|
|
31
|
+
return sorted(
|
|
32
|
+
p.stem for p in rigs_dir().iterdir()
|
|
33
|
+
if p.suffix in (".yaml", ".yml") and p.is_file()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def rig_path(name: str) -> Path:
|
|
38
|
+
"""Return the path a rig named ``name`` resolves to (``.yaml``)."""
|
|
39
|
+
return rigs_dir() / f"{name}.yaml"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_existing(name: str) -> Path:
|
|
43
|
+
"""Return an existing rig's path, accepting either ``.yaml`` or ``.yml``."""
|
|
44
|
+
yaml_path = rig_path(name)
|
|
45
|
+
if yaml_path.is_file():
|
|
46
|
+
return yaml_path
|
|
47
|
+
yml_path = rigs_dir() / f"{name}.yml"
|
|
48
|
+
if yml_path.is_file():
|
|
49
|
+
return yml_path
|
|
50
|
+
raise FileNotFoundError(
|
|
51
|
+
f"No rig named {name!r}. Known rigs: {', '.join(list_rigs()) or '(none)'}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add_rig(name: str, source: Path, *, force: bool = False) -> Path:
|
|
56
|
+
"""Copy an existing ``hardware.yaml`` into the store under ``name``.
|
|
57
|
+
|
|
58
|
+
The source is parsed with :func:`yaml.safe_load` first so a malformed
|
|
59
|
+
file is rejected before it lands in the store.
|
|
60
|
+
"""
|
|
61
|
+
source = Path(source)
|
|
62
|
+
with open(source, "r", encoding="utf-8") as fh:
|
|
63
|
+
yaml.safe_load(fh) # validate it parses; result intentionally unused
|
|
64
|
+
|
|
65
|
+
dst = rig_path(name)
|
|
66
|
+
if dst.exists() and not force:
|
|
67
|
+
raise FileExistsError(
|
|
68
|
+
f"Rig {name!r} already exists at {dst}. Pass force=True to overwrite."
|
|
69
|
+
)
|
|
70
|
+
shutil.copyfile(source, dst)
|
|
71
|
+
return dst
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def new_rig(name: str, *, force: bool = False) -> Path:
|
|
75
|
+
"""Write a blank fill-out hardware template into the store under ``name``."""
|
|
76
|
+
dst = rig_path(name)
|
|
77
|
+
if dst.exists() and not force:
|
|
78
|
+
raise FileExistsError(
|
|
79
|
+
f"Rig {name!r} already exists at {dst}. Pass force=True to overwrite."
|
|
80
|
+
)
|
|
81
|
+
dst.write_text(hardware_yaml_template(), encoding="utf-8")
|
|
82
|
+
return dst
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def remove_rig(name: str) -> None:
|
|
86
|
+
"""Delete a rig from the store."""
|
|
87
|
+
_resolve_existing(name).unlink()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Top-level hardware.yaml keys that configure the rig but are not devices.
|
|
91
|
+
_NON_DEVICE_KEYS = frozenset({
|
|
92
|
+
"memory_buffer_size", "viewer_type", "widgets",
|
|
93
|
+
"blue_led_power_mw", "violet_led_power_mw",
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def rig_devices(name: str) -> list[tuple[str, str]]:
|
|
98
|
+
"""Return ``(device_name, device_type)`` pairs declared by rig ``name``.
|
|
99
|
+
|
|
100
|
+
Mirrors how :class:`~mesofield.hardware.HardwareManager` reads the YAML:
|
|
101
|
+
every top-level mapping (other than scalar config keys) is a device, and
|
|
102
|
+
a ``cameras:`` list expands to one entry per camera.
|
|
103
|
+
"""
|
|
104
|
+
with open(_resolve_existing(name), "r", encoding="utf-8") as fh:
|
|
105
|
+
doc = yaml.safe_load(fh) or {}
|
|
106
|
+
|
|
107
|
+
devices: list[tuple[str, str]] = []
|
|
108
|
+
for key, value in doc.items():
|
|
109
|
+
if key in _NON_DEVICE_KEYS:
|
|
110
|
+
continue
|
|
111
|
+
if key == "cameras" and isinstance(value, list):
|
|
112
|
+
for cam in value:
|
|
113
|
+
if not isinstance(cam, dict):
|
|
114
|
+
continue
|
|
115
|
+
cam_name = cam.get("id") or cam.get("name") or "camera"
|
|
116
|
+
cam_type = cam.get("type") or cam.get("backend") or "camera"
|
|
117
|
+
devices.append((str(cam_name), str(cam_type)))
|
|
118
|
+
continue
|
|
119
|
+
if isinstance(value, dict):
|
|
120
|
+
devices.append((key, str(value.get("type", key))))
|
|
121
|
+
return devices
|
mesofield/signals.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Lightweight signaling hub used across mesofield.
|
|
2
|
+
|
|
3
|
+
Every :class:`~mesofield.protocols.HardwareDevice` exposes a
|
|
4
|
+
:class:`DeviceSignals` instance on ``self.signals`` so the rest of the
|
|
5
|
+
system (e.g. :class:`~mesofield.data.manager.DataManager`,
|
|
6
|
+
:class:`~mesofield.base.Procedure`) can connect uniformly without caring
|
|
7
|
+
which backend the device uses.
|
|
8
|
+
|
|
9
|
+
Four signals form the standard contract:
|
|
10
|
+
|
|
11
|
+
``started()``
|
|
12
|
+
Emitted once the device is actively acquiring / running.
|
|
13
|
+
``finished()``
|
|
14
|
+
Emitted when the device has stopped on its own (e.g. an MDA sequence
|
|
15
|
+
completed) *or* in response to ``stop()``. The ``primary`` device's
|
|
16
|
+
``finished`` is what triggers :meth:`Procedure._cleanup_procedure`.
|
|
17
|
+
``data(payload, device_ts)``
|
|
18
|
+
Emitted for every datum that should land on
|
|
19
|
+
:class:`~mesofield.data.manager.DataQueue`. ``payload`` is the raw
|
|
20
|
+
sample (frame index, encoder click count, NIDAQ count, ...) and
|
|
21
|
+
``device_ts`` is the device-side timestamp (float seconds, optional).
|
|
22
|
+
``frame(img, idx, device_ts)``
|
|
23
|
+
Optional. Emitted by camera-like producers carrying the raw frame
|
|
24
|
+
array in addition to the lightweight ``data`` emission. Subscribers
|
|
25
|
+
use this for real-time processing (see ``mesofield.processors``).
|
|
26
|
+
Producers without per-sample raw payloads never emit on this signal.
|
|
27
|
+
|
|
28
|
+
The implementation wraps :mod:`psygnal` so emission is Qt-free, weakly
|
|
29
|
+
referenced and thread-safe. GUI code that needs a Qt slot can use
|
|
30
|
+
:func:`qt_bridge` to forward a :class:`psygnal.Signal` into a
|
|
31
|
+
``pyqtSignal``.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import Any, Optional
|
|
37
|
+
|
|
38
|
+
from psygnal import Signal
|
|
39
|
+
|
|
40
|
+
__all__ = ["Signal", "DeviceSignals", "qt_bridge"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DeviceSignals:
|
|
44
|
+
"""Standard bundle of signals carried by every hardware device.
|
|
45
|
+
|
|
46
|
+
Defined as instance attributes (not class attributes) so each device
|
|
47
|
+
owns its own emitters. ``psygnal.Signal`` instances are descriptors
|
|
48
|
+
when declared on a class; we instantiate them directly here so they
|
|
49
|
+
behave as plain emitters on the bundle.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
__slots__ = ("started", "finished", "data", "frame")
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
from psygnal import SignalInstance
|
|
56
|
+
|
|
57
|
+
# Construct lightweight SignalInstance objects directly so the
|
|
58
|
+
# bundle is independent of any owning class.
|
|
59
|
+
self.started: SignalInstance = SignalInstance(())
|
|
60
|
+
self.finished: SignalInstance = SignalInstance(())
|
|
61
|
+
self.data: SignalInstance = SignalInstance((object, object))
|
|
62
|
+
self.frame: SignalInstance = SignalInstance((object, object, object))
|
|
63
|
+
|
|
64
|
+
def disconnect_all(self) -> None:
|
|
65
|
+
for sig in (self.started, self.finished, self.data, self.frame):
|
|
66
|
+
try:
|
|
67
|
+
sig.disconnect()
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def qt_bridge(signal: Any, qt_signal: Any) -> None:
|
|
73
|
+
"""Forward emissions of a ``psygnal`` signal to a ``pyqtSignal``.
|
|
74
|
+
|
|
75
|
+
Use from GUI code only. Both signals must accept the same argument
|
|
76
|
+
arity. The connection is one-way: psygnal -> Qt.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def _relay(*args: Any) -> None:
|
|
80
|
+
try:
|
|
81
|
+
qt_signal.emit(*args)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
signal.connect(_relay)
|
|
File without changes
|