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