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