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
mesofield/hardware.py ADDED
@@ -0,0 +1,549 @@
1
+ VALID_BACKENDS = {"micromanager", "opencv"}
2
+
3
+ import os
4
+ from typing import Dict, Any, List, Optional, ClassVar
5
+ import yaml
6
+
7
+ from mesofield.protocols import HardwareDevice, DataProducer
8
+ from mesofield.devices import Nidaq, MMCamera, SerialWorker, EncoderSerialInterface
9
+ from mesofield.utils._logger import get_logger, log_this_fr, hyperlink
10
+ from mesofield import DeviceRegistry
11
+
12
+ class HardwareManager():
13
+ """
14
+ High-level class that initializes all hardware (cameras, encoder, etc.)
15
+ using the ParameterManager. Keeps references easily accessible.
16
+
17
+ When *config_file* is ``None`` or the file does not exist the manager
18
+ starts in an **unconfigured** default state. Call
19
+ :meth:`load_config` later to point it at a real YAML file and then
20
+ :meth:`initialize` to bring hardware up.
21
+ """
22
+
23
+ def __init__(self, config_file: Optional[str] = None, devices=None):
24
+ self.logger = get_logger(f'{__name__}.{self.__class__.__name__}')
25
+ if config_file:
26
+ self.logger.info(
27
+ "Initializing HardwareManager with config: "
28
+ f"{hyperlink(config_file, os.path.basename(os.path.normpath(config_file)))}"
29
+ )
30
+ else:
31
+ self.logger.info("Initializing HardwareManager with config: None")
32
+
33
+ self.config_file = config_file
34
+ self.devices: Dict[str, DataProducer] = {}
35
+ self._configured: bool = False
36
+ # Devices constructed programmatically (scripted procedures). When set,
37
+ # ``initialize`` registers these directly instead of parsing YAML.
38
+ self._prebuilt_devices: Optional[List[Any]] = (
39
+ list(devices) if devices else None
40
+ )
41
+
42
+ if self._prebuilt_devices:
43
+ self.yaml = {}
44
+ self._configured = True
45
+ self.logger.info(
46
+ f"Initialized with {len(self._prebuilt_devices)} pre-built device(s)."
47
+ )
48
+ elif config_file and os.path.isfile(config_file):
49
+ try:
50
+ self.yaml = self._load_yaml(config_file)
51
+ self._configured = True
52
+ self.logger.info("Successfully loaded hardware configuration")
53
+ except Exception as e:
54
+ self.logger.error(f"Failed to load hardware configuration: {e}")
55
+ self.yaml = {}
56
+ else:
57
+ self.yaml = {}
58
+ if config_file:
59
+ self.logger.debug(
60
+ "Hardware config not found: "
61
+ f"{hyperlink(config_file, os.path.basename(os.path.normpath(config_file)))}. "
62
+ "Starting in unconfigured state."
63
+ )
64
+ else:
65
+ self.logger.info("No hardware config provided. Starting in default state.")
66
+
67
+ self.widgets: List[str] = self._aggregate_widgets()
68
+ self.cameras: tuple[MMCamera, ...] = ()
69
+ self.encoder = None
70
+ self.nidaq = None
71
+ self.psychopy = None
72
+ self._viewer = self.yaml.get('viewer_type', 'static')
73
+
74
+ @property
75
+ def is_configured(self) -> bool:
76
+ """``True`` when a valid YAML config has been loaded."""
77
+ return self._configured
78
+
79
+ def load_config(self, config_file: str) -> None:
80
+ """Load (or reload) a hardware YAML file.
81
+
82
+ This does **not** initialise devices – call :meth:`initialize` afterwards.
83
+ """
84
+ self.config_file = config_file
85
+ self.yaml = self._load_yaml(config_file)
86
+ self._configured = True
87
+ self.widgets = self._aggregate_widgets()
88
+ self._viewer = self.yaml.get('viewer_type', 'static')
89
+ self.logger.info(
90
+ "Loaded hardware config: "
91
+ f"{hyperlink(config_file, os.path.basename(os.path.normpath(config_file)))}"
92
+ )
93
+
94
+ def __repr__(self):
95
+ # Compact, IPython-friendly summary. Devices are introspected
96
+ # generically: any attribute in ``_REPR_FIELDS`` that exists and
97
+ # has a meaningful value is shown. Top-level YAML config is
98
+ # summarized to its keys (full content is still in ``self.yaml``).
99
+ _REPR_FIELDS = (
100
+ "device_id", "device_type", "backend",
101
+ "port", "baudrate", "sampling_rate",
102
+ "is_primary", "is_active",
103
+ )
104
+
105
+ def _short(dev) -> str:
106
+ attrs = []
107
+ for k in _REPR_FIELDS:
108
+ if not hasattr(dev, k):
109
+ continue
110
+ v = getattr(dev, k)
111
+ if v in (None, "", False):
112
+ continue
113
+ attrs.append(f"{k}={v!r}")
114
+ # Pull fps from the device's camera-style cfg if available.
115
+ cfg = getattr(dev, "cfg", None)
116
+ if isinstance(cfg, dict):
117
+ cam_id = getattr(dev, "id", "") or getattr(dev, "device_id", "")
118
+ fps = cfg.get("properties", {}).get(cam_id, {}).get("fps") \
119
+ or cfg.get("fps")
120
+ if fps is not None:
121
+ attrs.append(f"fps={fps!r}")
122
+ output = cfg.get("output") or {}
123
+ suffix = output.get("suffix")
124
+ ftype = output.get("file_type")
125
+ if suffix or ftype:
126
+ attrs.append(
127
+ "output=" + repr(f"{suffix or '?'}.{ftype or '?'}")
128
+ )
129
+ return f"{type(dev).__name__}(" + ", ".join(attrs) + ")"
130
+
131
+ lines = ["<HardwareManager>"]
132
+ if self.cameras:
133
+ lines.append(" Cameras:")
134
+ for cam in self.cameras:
135
+ lines.append(f" - {_short(cam)}")
136
+ else:
137
+ lines.append(" Cameras: <none>")
138
+
139
+ extras = {
140
+ k: v for k, v in self.devices.items() if v not in self.cameras
141
+ }
142
+ if extras:
143
+ lines.append(" Devices:")
144
+ for name, dev in extras.items():
145
+ lines.append(f" - {name}: {_short(dev)}")
146
+
147
+ if self.config_file:
148
+ lines.append(f" Config file: {self.config_file}")
149
+ if self.yaml:
150
+ lines.append(f" Config keys: {sorted(self.yaml.keys())}")
151
+ lines.append("</HardwareManager>")
152
+ return "\n".join(lines)
153
+
154
+ # ---- Public interface --------------------------------------------------
155
+
156
+ def initialize(self, cfg) -> None:
157
+ """Initialize all devices from YAML and configure engines.
158
+
159
+ Does nothing if the manager has no loaded YAML configuration.
160
+ Validates that exactly one device is flagged ``primary: true``.
161
+ """
162
+ if not self._configured:
163
+ self.logger.warning("Cannot initialize hardware: no YAML config loaded.")
164
+ return
165
+ if self._prebuilt_devices is not None:
166
+ self.logger.info("Initializing hardware devices from pre-built objects...")
167
+ self._register_prebuilt_devices()
168
+ else:
169
+ self.logger.info("Initializing hardware devices from YAML configuration...")
170
+ self._init_cameras()
171
+ self._init_encoder()
172
+ self._init_daq()
173
+ self._init_psychopy()
174
+ self._init_extras()
175
+ self._configure_engines(cfg)
176
+ # Inject the ExperimentConfig onto every device so producers can reach
177
+ # `make_path` and experiment state outside the per-run `arm(config)`
178
+ # call (e.g. a camera's snap-and-save before a run is armed).
179
+ for device in self.devices.values():
180
+ device.config = cfg
181
+ self._validate_primary()
182
+
183
+ # Top-level YAML keys handled by dedicated initializers above.
184
+ # Anything else with a ``type:`` field is dispatched through
185
+ # ``_init_extras`` against the global :class:`DeviceRegistry`.
186
+ _RESERVED_YAML_KEYS = frozenset({
187
+ "cameras", "encoder", "nidaq", "psychopy",
188
+ "memory_buffer_size", "blue_led_power_mw", "violet_led_power_mw",
189
+ "viewer_type", "widgets",
190
+ })
191
+
192
+ def _init_extras(self) -> None:
193
+ """Instantiate any extra YAML stanza with a registered ``type:``.
194
+
195
+ Lets users add custom devices to ``hardware.yaml`` without
196
+ editing :class:`HardwareManager`. The stanza must be a mapping
197
+ whose ``type`` field matches a key registered via
198
+ ``@DeviceRegistry.register(...)``.
199
+ """
200
+ for key, params in (self.yaml or {}).items():
201
+ if key in self._RESERVED_YAML_KEYS:
202
+ continue
203
+ if not isinstance(params, dict):
204
+ continue
205
+ type_key = params.get("type")
206
+ if not type_key:
207
+ continue
208
+ Cls = DeviceRegistry.get_class(type_key)
209
+ if Cls is None:
210
+ self.logger.warning(
211
+ f"YAML stanza '{key}' has type='{type_key}' but no class "
212
+ f"is registered for it; skipping."
213
+ )
214
+ continue
215
+ cfg = dict(params)
216
+ cfg.setdefault("id", key)
217
+ try:
218
+ device = Cls(cfg)
219
+ except Exception as exc:
220
+ self.logger.error(f"Failed to construct '{key}' ({type_key}): {exc}")
221
+ continue
222
+ self._apply_output_args(device, params.get("output", {}), key)
223
+ device.is_primary = bool(params.get("primary", False))
224
+ try:
225
+ if hasattr(device, "initialize"):
226
+ device.initialize()
227
+ except Exception as exc:
228
+ self.logger.error(f"initialize() failed for '{key}': {exc}")
229
+ continue
230
+ dev_id = getattr(device, "device_id", key)
231
+ self.devices[dev_id] = device
232
+ setattr(self, dev_id, device)
233
+ # Camera-class extras land in self.cameras too so the MDA gui
234
+ # builds a live-view widget for them. This is the path
235
+ # MockFrameProducer takes; real MMCameras come through
236
+ # _init_cameras (which keys off the dedicated `cameras:` YAML
237
+ # stanza) and never touch this branch.
238
+ if getattr(device, "device_type", None) == "camera" and device not in self.cameras:
239
+ self.cameras = self.cameras + (device,)
240
+ self.logger.info(f"Registered extra device '{dev_id}' (type={type_key}).")
241
+
242
+ # ---- Pre-built device path (scripted procedures) ----------------------
243
+
244
+ def _register_prebuilt_devices(self) -> None:
245
+ """Register devices constructed programmatically by a Procedure.
246
+
247
+ Mirrors :meth:`_init_extras` but skips construction -- the objects are
248
+ already instantiated. Camera-type devices are folded into
249
+ ``self.cameras``; everything else lives in ``self.devices`` and as an
250
+ attribute keyed by ``device_id``. Dedicated slots (``self.encoder`` /
251
+ ``self.nidaq`` / ``self.psychopy``) are intentionally not populated, so
252
+ a scripted setup exported via :meth:`to_yaml` and re-loaded as YAML
253
+ extras behaves the same way.
254
+ """
255
+ cams = list(self.cameras)
256
+ for device in self._prebuilt_devices or []:
257
+ dev_id = getattr(device, "device_id", None) or getattr(device, "id", None)
258
+ if not dev_id:
259
+ self.logger.error(
260
+ f"Pre-built device {device!r} has no device_id/id; skipping."
261
+ )
262
+ continue
263
+ cfg = getattr(device, "cfg", {}) or {}
264
+ self._apply_output_args(device, cfg.get("output", {}), dev_id)
265
+ if not getattr(device, "is_primary", False):
266
+ device.is_primary = bool(cfg.get("primary", False))
267
+ try:
268
+ if hasattr(device, "initialize"):
269
+ device.initialize()
270
+ except Exception as exc:
271
+ self.logger.error(f"initialize() failed for '{dev_id}': {exc}")
272
+ continue
273
+ self.devices[dev_id] = device
274
+ setattr(self, dev_id, device)
275
+ if getattr(device, "device_type", None) == "camera" and device not in cams:
276
+ cams.append(device)
277
+ self.logger.info(
278
+ f"Registered pre-built device '{dev_id}' "
279
+ f"(device_type={getattr(device, 'device_type', None)})."
280
+ )
281
+ self.cameras = tuple(cams)
282
+
283
+ def to_yaml(self, path: Optional[str] = None) -> dict:
284
+ """Serialize the current devices into a ``hardware.yaml`` mapping.
285
+
286
+ Each device becomes a top-level ``type:``-tagged stanza keyed by its
287
+ device id, re-importable through :meth:`_init_extras`. This is the
288
+ migration path from a scripted procedure to a reusable rig file.
289
+
290
+ When *path* is given the mapping is also written to disk. Raises
291
+ :class:`RuntimeError` if any device's class was never registered via
292
+ ``@DeviceRegistry.register`` (no ``registry_key`` -> not migratable).
293
+ """
294
+ out: dict = {}
295
+ for dev_id, device in self.devices.items():
296
+ registry_key = getattr(type(device), "registry_key", None)
297
+ if not registry_key:
298
+ raise RuntimeError(
299
+ f"Device '{dev_id}' ({type(device).__name__}) has no "
300
+ f"registry_key; decorate its class with "
301
+ f"@DeviceRegistry.register(...) to make it exportable."
302
+ )
303
+ cfg = dict(getattr(device, "cfg", {}) or {})
304
+ cfg.pop("id", None)
305
+ cfg.pop("type", None)
306
+ cfg.pop("output", None)
307
+ stanza: dict = {"type": registry_key}
308
+ stanza.update(cfg)
309
+ if getattr(device, "is_primary", False):
310
+ stanza["primary"] = True
311
+ path_args = getattr(device, "path_args", None)
312
+ if path_args:
313
+ output = {
314
+ "suffix": path_args.get("suffix"),
315
+ "file_type": path_args.get("extension"),
316
+ "bids_type": path_args.get("bids_type"),
317
+ }
318
+ stanza["output"] = {k: v for k, v in output.items() if v is not None}
319
+ out[dev_id] = stanza
320
+ if path:
321
+ with open(path, "w", encoding="utf-8") as fh:
322
+ yaml.safe_dump(out, fh, sort_keys=False)
323
+ self.logger.info(f"Exported hardware configuration to: {path}")
324
+ return out
325
+
326
+ def _validate_primary(self) -> None:
327
+ """Require exactly one device flagged ``primary: true`` in YAML."""
328
+ primaries = [d for d in self.devices.values() if getattr(d, "is_primary", False)]
329
+ if len(primaries) != 1:
330
+ ids = [getattr(d, "device_id", getattr(d, "id", "?")) for d in primaries]
331
+ raise RuntimeError(
332
+ f"Exactly one device must be flagged 'primary: true' in hardware.yaml; "
333
+ f"found {len(primaries)}: {ids}."
334
+ )
335
+
336
+ @property
337
+ def primary(self):
338
+ """Return the device flagged ``primary: true`` in YAML."""
339
+ for d in self.devices.values():
340
+ if getattr(d, "is_primary", False):
341
+ return d
342
+ raise RuntimeError("No device is flagged 'primary: true'.")
343
+
344
+ def arm_all(self, cfg) -> None:
345
+ """Call ``arm(cfg)`` on every device for per-run preparation."""
346
+ for name, device in self.devices.items():
347
+ arm = getattr(device, "arm", None)
348
+ if callable(arm):
349
+ try:
350
+ arm(cfg)
351
+ except Exception as exc:
352
+ self.logger.error(f"Error arming {name}: {exc}")
353
+
354
+ def start_all(self) -> None:
355
+ """Call ``start()`` on every device."""
356
+ for name, device in self.devices.items():
357
+ try:
358
+ device.start()
359
+ except Exception as exc:
360
+ self.logger.error(f"Error starting {name}: {exc}")
361
+
362
+ def deinitialize(self):
363
+ """Tear down all devices and reset to unconfigured state.
364
+
365
+ After this call the manager can be re-initialised with a fresh
366
+ :meth:`load_config` / :meth:`initialize` cycle.
367
+ """
368
+ self.logger.info("Deinitializing hardware – shutting down all devices...")
369
+ self.shutdown()
370
+ # Release pymmcore-held hardware so cameras can be re-acquired
371
+ for cam in self.cameras:
372
+ if cam.backend == "micromanager" and cam.core is not None:
373
+ try:
374
+ cam.core.unloadAllDevices()
375
+ except Exception as e:
376
+ self.logger.error(f"Error unloading devices for {cam.id}: {e}")
377
+ self.devices.clear()
378
+ self.cameras = ()
379
+ self.encoder = None
380
+ self.nidaq = None
381
+ self.psychopy = None
382
+ self._configured = False
383
+ self.logger.info("Hardware deinitialized – ready for reconfiguration.")
384
+
385
+ def stop(self):
386
+ """Stop all devices."""
387
+ for name, device in self.devices.items():
388
+ try:
389
+ device.stop()
390
+ except Exception as e:
391
+ self.logger.error(f"Error stopping {name}: {e}")
392
+
393
+ # Symmetric alias to ``start_all`` / ``arm_all`` used by ``Procedure``.
394
+ stop_all = stop
395
+
396
+ def shutdown(self):
397
+ """Shutdown all devices."""
398
+ for name, device in self.devices.items():
399
+ try:
400
+ device.shutdown()
401
+ except Exception as e:
402
+ self.logger.error(f"Error shutting down {name}: {e}")
403
+
404
+ def get_device(self, device_id: str) -> Optional[HardwareDevice]:
405
+ """Get a device by its ID."""
406
+ return self.devices.get(device_id)
407
+
408
+ def cam_backends(self, backend):
409
+ """Generator to iterate through cameras with a specific backend."""
410
+ for cam in self.cameras:
411
+ if cam.backend == backend:
412
+ yield cam
413
+
414
+ # ---- YAML loading ------------------------------------------------------
415
+
416
+ @staticmethod
417
+ def _load_yaml(path: str) -> dict:
418
+ if not path:
419
+ raise FileNotFoundError(f"Cannot find config file at: {path}")
420
+ with open(path, "r", encoding="utf-8") as f:
421
+ return yaml.safe_load(f) or {}
422
+
423
+ def _aggregate_widgets(self) -> List[str]:
424
+ """Collect unique widget keys from all device sections."""
425
+ sources = [
426
+ self.yaml.get('widgets', []),
427
+ *[cam.get('widgets', []) for cam in self.yaml.get('cameras', [])],
428
+ self.yaml.get('encoder', {}).get('widgets', []),
429
+ self.yaml.get('nidaq', {}).get('widgets', []),
430
+ self.yaml.get('psychopy', {}).get('widgets', []),
431
+ ]
432
+ # flatten and dedupe while preserving order
433
+ seen: dict[str, None] = {}
434
+ for group in sources:
435
+ for w in group:
436
+ seen.setdefault(w, None)
437
+ return list(seen)
438
+
439
+ # ---- Device helpers ----------------------------------------------------
440
+
441
+ @staticmethod
442
+ def _apply_output_args(device, output: dict, default_suffix: str):
443
+ """Set path_args, file_type, and bids_type on a device from its YAML output block."""
444
+ device.path_args = {
445
+ 'suffix': output.get('suffix', default_suffix),
446
+ 'extension': output.get('file_type', getattr(device, 'file_type', 'csv')),
447
+ 'bids_type': output.get('bids_type', getattr(device, 'bids_type', None)),
448
+ }
449
+ device.file_type = device.path_args['extension']
450
+ device.bids_type = device.path_args['bids_type']
451
+
452
+ # ---- Device init -------------------------------------------------------
453
+
454
+ def _init_cameras(self):
455
+ cams = []
456
+ for cfg in self.yaml.get("cameras", []):
457
+ backend = str(cfg.get("backend", "")).lower()
458
+ registry_key = "opencv_camera" if backend == "opencv" else "camera"
459
+ CameraClass = DeviceRegistry.get_class(registry_key)
460
+ if CameraClass is None:
461
+ self.logger.error(
462
+ f"No camera class registered under '{registry_key}' "
463
+ f"(backend='{backend}')"
464
+ )
465
+ continue
466
+ cam = CameraClass(cfg)
467
+ cam.is_primary = bool(cfg.get("primary", False))
468
+ self._apply_output_args(cam, cfg.get('output', {}), cam.name)
469
+ setattr(self, cam.id, cam)
470
+ self.devices[cam.id] = cam
471
+ cams.append(cam)
472
+ self.cameras = tuple(cams)
473
+
474
+ def _init_encoder(self):
475
+ params = self.yaml.get("encoder")
476
+ if not params:
477
+ return
478
+
479
+ enc_type = params.get('type')
480
+ try:
481
+ if enc_type == 'wheel':
482
+ self.encoder = SerialWorker(
483
+ serial_port=params.get('port'),
484
+ baud_rate=params.get('baudrate'),
485
+ sample_interval=params.get('sample_interval_ms'),
486
+ wheel_diameter=params.get('diameter_mm'),
487
+ cpr=params.get('cpr'),
488
+ development_mode=params.get('development_mode'),
489
+ )
490
+ elif enc_type == 'treadmill':
491
+ try:
492
+ self.encoder = EncoderSerialInterface(
493
+ port=params.get('port'),
494
+ baudrate=params.get('baudrate'),
495
+ )
496
+ self.encoder.initialize()
497
+ except Exception as e:
498
+ raise RuntimeError(f"Failed to initialize EncoderSerialInterface: {e}") from e
499
+
500
+ else:
501
+ self.logger.warning(f"Unknown encoder type: {enc_type}")
502
+ return
503
+ except Exception as e:
504
+ self.logger.warning(f"Could not open encoder on {params.get('port')}: {e}")
505
+ self.encoder = None
506
+ return
507
+
508
+ self._apply_output_args(self.encoder, params.get('output', {}), 'encoder')
509
+ self.encoder.is_primary = bool(params.get("primary", False))
510
+ self.devices["encoder"] = self.encoder
511
+
512
+ def _init_daq(self):
513
+ params = self.yaml.get("nidaq")
514
+ if not params:
515
+ return
516
+ self.nidaq = Nidaq(
517
+ device_name=params.get('device_name'),
518
+ lines=params.get('lines'),
519
+ io_type=params.get('io_type'),
520
+ ctr=params.get('crt', 'ctr0'),
521
+ )
522
+ self.nidaq.is_primary = bool(params.get("primary", False))
523
+ self.devices["nidaq"] = self.nidaq
524
+
525
+ def _init_psychopy(self):
526
+ params = self.yaml.get("psychopy")
527
+ if not params:
528
+ return
529
+ Cls = DeviceRegistry.get_class("psychopy")
530
+ if Cls is None:
531
+ self.logger.error("No class registered under 'psychopy'")
532
+ return
533
+ cfg = dict(params)
534
+ cfg.setdefault("id", "psychopy")
535
+ device = Cls(cfg)
536
+ device.is_primary = bool(params.get("primary", False))
537
+ self.psychopy = device
538
+ self.devices[device.device_id] = device
539
+
540
+ # ---- Engine configuration ----------------------------------------------
541
+
542
+ def _configure_engines(self, cfg):
543
+ """If using micromanager cameras, configure the engines."""
544
+ if not self.cameras:
545
+ return
546
+ from pymmcore_plus import CMMCorePlus
547
+ for cam in self.cameras:
548
+ if isinstance(cam.core, CMMCorePlus):
549
+ cam.core.mda.engine.set_config(cfg)