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/base.py
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base procedure classes for implementing experimental workflows in Mesofield.
|
|
3
|
+
|
|
4
|
+
This module defines a *generic* :class:`Procedure` orchestrator that contains
|
|
5
|
+
zero device-specific logic. Custom experiment subclasses live outside the
|
|
6
|
+
package (typically under ``experiments/<name>/procedure.py``) and are
|
|
7
|
+
discovered via :func:`load_procedure_from_config`, which reads the optional
|
|
8
|
+
``procedure_file`` and ``procedure_class`` fields from ``experiment.json``.
|
|
9
|
+
|
|
10
|
+
Lifecycle (subclass hooks shown in **bold**):
|
|
11
|
+
|
|
12
|
+
1. ``initialize_hardware`` -- bring devices up
|
|
13
|
+
2. ``prerun`` -- **subclass hook** (default: no-op)
|
|
14
|
+
3. ``hardware.arm_all`` -- per-run prep on every device
|
|
15
|
+
4. connect ``hardware.primary.signals.finished`` -> ``_cleanup_procedure``
|
|
16
|
+
5. ``on_started`` -- **subclass hook** (default: no-op)
|
|
17
|
+
6. ``hardware.start_all``
|
|
18
|
+
7. ``on_finished`` -- **subclass hook** (default: no-op)
|
|
19
|
+
8. ``save_data`` + cleanup
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import importlib.util
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import uuid
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict, Optional, Type
|
|
31
|
+
|
|
32
|
+
from PyQt6.QtCore import QObject, pyqtSignal
|
|
33
|
+
|
|
34
|
+
from mesokit_schema import (
|
|
35
|
+
AcquisitionManifest,
|
|
36
|
+
DataqueuePayloadSchema,
|
|
37
|
+
ProducerEntry,
|
|
38
|
+
SessionIdentity,
|
|
39
|
+
SidecarEntry,
|
|
40
|
+
TimeBasis,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
from mesofield.config import ExperimentConfig
|
|
44
|
+
from mesofield.data.manager import DataManager
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
from mesofield._version import __version__ as _MESOFIELD_VERSION
|
|
48
|
+
except Exception: # pragma: no cover
|
|
49
|
+
_MESOFIELD_VERSION = "0.0.0+unknown"
|
|
50
|
+
from mesofield.hardware import HardwareManager
|
|
51
|
+
from mesofield.utils._logger import get_logger, hyperlink
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def processor(*, camera: str, plot: bool = False, **plot_kwargs: Any):
|
|
55
|
+
"""Mark a :class:`Procedure` method as a per-frame compute function.
|
|
56
|
+
|
|
57
|
+
The decorated function is called as ``func(self, img, idx, ts)`` and
|
|
58
|
+
should return a ``float | None``. At procedure init the framework
|
|
59
|
+
builds a :class:`~mesofield.processors.FrameProcessor` that wraps it,
|
|
60
|
+
attaches it to the hardware device whose ``device_id`` matches
|
|
61
|
+
``camera``, registers it on :class:`~mesofield.data.manager.DataManager`,
|
|
62
|
+
and (when ``plot=True``) tells the GUI to add a
|
|
63
|
+
:class:`~mesofield.gui.speedplotter.SerialWidget`.
|
|
64
|
+
|
|
65
|
+
``plot_kwargs`` are forwarded straight to the widget — recognized
|
|
66
|
+
keys: ``label``, ``value_label``, ``value_units``, ``y_range``,
|
|
67
|
+
``value_scale``, ``max_points``.
|
|
68
|
+
|
|
69
|
+
Example::
|
|
70
|
+
|
|
71
|
+
class MyProcedure(Procedure):
|
|
72
|
+
@processor(camera="meso", plot=True, label="Frame Mean")
|
|
73
|
+
def frame_mean(self, img, idx, ts):
|
|
74
|
+
return float(img.mean())
|
|
75
|
+
"""
|
|
76
|
+
def wrap(fn):
|
|
77
|
+
fn._mesofield_processor = {
|
|
78
|
+
"camera": camera,
|
|
79
|
+
"plot": plot,
|
|
80
|
+
"plot_kwargs": plot_kwargs,
|
|
81
|
+
"name": fn.__name__,
|
|
82
|
+
}
|
|
83
|
+
return fn
|
|
84
|
+
return wrap
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ProcedureSignals(QObject):
|
|
88
|
+
"""All procedure-level signals that a Qt GUI can connect to."""
|
|
89
|
+
procedure_started = pyqtSignal()
|
|
90
|
+
hardware_initialized = pyqtSignal(bool) # success
|
|
91
|
+
data_saved = pyqtSignal()
|
|
92
|
+
procedure_error = pyqtSignal(str) # emits error message
|
|
93
|
+
procedure_finished = pyqtSignal()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Procedure:
|
|
97
|
+
"""Generic orchestrator for a Mesofield experiment.
|
|
98
|
+
|
|
99
|
+
Subclass this in ``experiments/<name>/procedure.py`` and override the
|
|
100
|
+
extension hooks (:meth:`prerun`, :meth:`on_started`, :meth:`on_finished`)
|
|
101
|
+
and/or the lifecycle methods (:meth:`run`, :meth:`save_data`,
|
|
102
|
+
:meth:`cleanup`) as needed. The base class never references a specific
|
|
103
|
+
device type -- multi-camera sync is driven by the YAML ``primary: true``
|
|
104
|
+
flag and ``HardwareManager.start_all/stop_all``.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
# When True (set on subclasses like PlaybackProcedure), `run`/`cleanup`
|
|
108
|
+
# skip writer/queue-logger/manifest side effects so the on-disk session
|
|
109
|
+
# is left untouched.
|
|
110
|
+
playback: bool = False
|
|
111
|
+
|
|
112
|
+
def __init__(self, config_path: Optional[str] = None):
|
|
113
|
+
# Initialize the processor registry before anything else so the
|
|
114
|
+
# ``__setattr__`` hook can run safely from the first assignment on.
|
|
115
|
+
# We bypass the hook itself with object.__setattr__ to avoid the
|
|
116
|
+
# isinstance import dance on a guaranteed-non-processor value.
|
|
117
|
+
object.__setattr__(self, "processors", [])
|
|
118
|
+
|
|
119
|
+
self.events = ProcedureSignals()
|
|
120
|
+
self._finished_event = threading.Event()
|
|
121
|
+
self.events.procedure_finished.connect(self._finished_event.set)
|
|
122
|
+
self.events.procedure_error.connect(lambda _msg: self._finished_event.set())
|
|
123
|
+
|
|
124
|
+
self.config: ExperimentConfig
|
|
125
|
+
self._config_path = config_path
|
|
126
|
+
experiment_dir = os.path.dirname(os.path.abspath(config_path)) if config_path else None
|
|
127
|
+
self.config = ExperimentConfig(experiment_dir)
|
|
128
|
+
|
|
129
|
+
# Scripted config: a subclass may declare parameters in Python
|
|
130
|
+
# (a dataclass or mapping) instead of an experiment.json file.
|
|
131
|
+
config_data = self.define_config()
|
|
132
|
+
if config_data is not None:
|
|
133
|
+
self.config.load_dict(config_data)
|
|
134
|
+
elif config_path and config_path.endswith(".json"):
|
|
135
|
+
self.config.load_json(config_path)
|
|
136
|
+
|
|
137
|
+
# Scripted hardware: a subclass may construct device objects directly
|
|
138
|
+
# instead of relying on a hardware.yaml file.
|
|
139
|
+
devices = self.define_hardware()
|
|
140
|
+
if devices is not None:
|
|
141
|
+
self.config.hardware = HardwareManager(devices=devices)
|
|
142
|
+
|
|
143
|
+
self.protocol = self.config.get("protocol", "default_experiment")
|
|
144
|
+
self.experimenter = self.config.get("experimenter", "researcher")
|
|
145
|
+
|
|
146
|
+
self.data_dir = self.config.data_dir
|
|
147
|
+
|
|
148
|
+
self.start_time: Optional[datetime] = None
|
|
149
|
+
self.stopped_time: Optional[datetime] = None
|
|
150
|
+
|
|
151
|
+
self.logger = get_logger(f"PROCEDURE.{self.protocol}")
|
|
152
|
+
self.logger.info(f"Initialized procedure: {self.protocol}")
|
|
153
|
+
|
|
154
|
+
if self.config.hardware.is_configured:
|
|
155
|
+
self.initialize_hardware()
|
|
156
|
+
self.config.hardware._configure_engines(self.config)
|
|
157
|
+
self._materialize_decorated_processors()
|
|
158
|
+
else:
|
|
159
|
+
self.logger.info(
|
|
160
|
+
"Hardware not configured yet -- launch in default state. "
|
|
161
|
+
"Use load_config() to apply a configuration."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Convenience accessors
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def paths(self):
|
|
169
|
+
return self.data.base.read('datapaths')
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def hardware(self) -> HardwareManager:
|
|
173
|
+
return self.config.hardware
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Hardware bring-up
|
|
177
|
+
|
|
178
|
+
def initialize_hardware(self) -> None:
|
|
179
|
+
"""Boot up hardware and a :class:`DataManager`."""
|
|
180
|
+
try:
|
|
181
|
+
self.config.hardware.initialize(self.config)
|
|
182
|
+
self.data = DataManager()
|
|
183
|
+
# Register devices eagerly so iPython terminals and GUI inspectors
|
|
184
|
+
# see them on `procedure.data.devices` before run() is called.
|
|
185
|
+
# `Procedure.run()` short-circuits the re-registration via its
|
|
186
|
+
# `if not self.data.devices:` guard.
|
|
187
|
+
self.data.register_devices(self.config.hardware.devices.values())
|
|
188
|
+
self.logger.info("Hardware initialized successfully")
|
|
189
|
+
except RuntimeError as e:
|
|
190
|
+
self.logger.error(f"Failed to initialize hardware: {e}")
|
|
191
|
+
self.config.hardware.deinitialize()
|
|
192
|
+
raise
|
|
193
|
+
|
|
194
|
+
def setup_configuration(self, json_config: Optional[str]) -> None:
|
|
195
|
+
"""Load a JSON configuration into the existing :class:`ExperimentConfig`."""
|
|
196
|
+
if json_config:
|
|
197
|
+
self.config.load_json(json_config)
|
|
198
|
+
self.config.hardware._configure_engines(self.config)
|
|
199
|
+
|
|
200
|
+
def load_config(self, json_path: Optional[str] = None,
|
|
201
|
+
hardware_yaml_path: Optional[str] = None) -> None:
|
|
202
|
+
"""Hot-load an experiment configuration and/or hardware YAML."""
|
|
203
|
+
if hardware_yaml_path:
|
|
204
|
+
self.config.load_hardware(hardware_yaml_path)
|
|
205
|
+
elif json_path:
|
|
206
|
+
candidate = os.path.join(
|
|
207
|
+
os.path.dirname(os.path.abspath(json_path)), "hardware.yaml"
|
|
208
|
+
)
|
|
209
|
+
if os.path.isfile(candidate):
|
|
210
|
+
self.config.load_hardware(candidate)
|
|
211
|
+
|
|
212
|
+
if json_path:
|
|
213
|
+
self.config.load_json(json_path)
|
|
214
|
+
|
|
215
|
+
self.protocol = self.config.get("protocol", "default_experiment")
|
|
216
|
+
self.experimenter = self.config.get("experimenter", "researcher")
|
|
217
|
+
self.data_dir = self.config.data_dir
|
|
218
|
+
|
|
219
|
+
if self.config.hardware.is_configured:
|
|
220
|
+
self.initialize_hardware()
|
|
221
|
+
self.config.hardware._configure_engines(self.config)
|
|
222
|
+
self._materialize_decorated_processors()
|
|
223
|
+
|
|
224
|
+
self.events.hardware_initialized.emit(self.config.hardware.is_configured)
|
|
225
|
+
self.logger.info("Configuration hot-loaded successfully")
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# Processor auto-discovery
|
|
229
|
+
#
|
|
230
|
+
# Any :class:`FrameProcessor` instance stored on the procedure (whether
|
|
231
|
+
# by direct attribute assignment or by the ``@processor`` decorator)
|
|
232
|
+
# lands on ``self.processors`` and is registered with the DataManager
|
|
233
|
+
# automatically. Cleanup detaches them.
|
|
234
|
+
|
|
235
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
236
|
+
super().__setattr__(name, value)
|
|
237
|
+
# Avoid importing at module load (the processors package imports
|
|
238
|
+
# PyQt6, which we don't want forced on headless callers that never
|
|
239
|
+
# use processors).
|
|
240
|
+
try:
|
|
241
|
+
from mesofield.processors import FrameProcessor
|
|
242
|
+
except Exception:
|
|
243
|
+
return
|
|
244
|
+
if isinstance(value, FrameProcessor):
|
|
245
|
+
self._register_processor(value, attr_name=name)
|
|
246
|
+
|
|
247
|
+
def _register_processor(self, proc: Any, attr_name: Optional[str] = None) -> None:
|
|
248
|
+
"""Register ``proc`` on :attr:`processors` and the DataManager.
|
|
249
|
+
|
|
250
|
+
Dedupe-safe and idempotent: re-registering the same instance is a
|
|
251
|
+
no-op. When ``attr_name`` is supplied and a *different* processor
|
|
252
|
+
with the same name is already registered, the old one is detached
|
|
253
|
+
and replaced.
|
|
254
|
+
"""
|
|
255
|
+
procs = self.processors # plain attribute, no __setattr__ recursion
|
|
256
|
+
if proc in procs:
|
|
257
|
+
return
|
|
258
|
+
# If the user used the default name (lowercased class name) and
|
|
259
|
+
# assigned the processor to a specific attribute, prefer the
|
|
260
|
+
# attribute name. Explicit `name=...` at construction wins.
|
|
261
|
+
if attr_name is not None:
|
|
262
|
+
default_name = type(proc).__name__.lower()
|
|
263
|
+
if proc.device_id == default_name and attr_name != default_name:
|
|
264
|
+
proc.device_id = attr_name
|
|
265
|
+
proc.id = attr_name
|
|
266
|
+
proc.name = attr_name
|
|
267
|
+
# Replace any prior processor that shared this attribute name.
|
|
268
|
+
if attr_name is not None:
|
|
269
|
+
for existing in list(procs):
|
|
270
|
+
if existing.device_id == proc.device_id and existing is not proc:
|
|
271
|
+
try:
|
|
272
|
+
existing.detach()
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
procs.remove(existing)
|
|
276
|
+
break
|
|
277
|
+
procs.append(proc)
|
|
278
|
+
# DataManager may not exist yet during early construction; the
|
|
279
|
+
# later ``Procedure.run`` call also re-registers devices, so this
|
|
280
|
+
# is best-effort.
|
|
281
|
+
dm = getattr(self, "data", None)
|
|
282
|
+
if dm is not None:
|
|
283
|
+
try:
|
|
284
|
+
dm.register_hardware_device(proc)
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
self.logger.warning(
|
|
287
|
+
f"register_hardware_device({proc.device_id}) failed: {exc}"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _materialize_decorated_processors(self) -> None:
|
|
291
|
+
"""Instantiate every ``@processor``-decorated method on this class.
|
|
292
|
+
|
|
293
|
+
Walks the MRO so decorators on base procedures are honored.
|
|
294
|
+
Skips names that already resolve to a :class:`FrameProcessor` on
|
|
295
|
+
this instance (idempotent for ``load_config`` hot reloads).
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
from mesofield.processors import FrameProcessor
|
|
299
|
+
except Exception:
|
|
300
|
+
return
|
|
301
|
+
seen: set[str] = set()
|
|
302
|
+
for klass in type(self).__mro__:
|
|
303
|
+
for attr_name, member in klass.__dict__.items():
|
|
304
|
+
if attr_name in seen:
|
|
305
|
+
continue
|
|
306
|
+
meta = getattr(member, "_mesofield_processor", None)
|
|
307
|
+
if meta is None:
|
|
308
|
+
continue
|
|
309
|
+
seen.add(attr_name)
|
|
310
|
+
existing = getattr(self, attr_name, None)
|
|
311
|
+
if isinstance(existing, FrameProcessor):
|
|
312
|
+
continue # already materialized; skip on hot reload
|
|
313
|
+
camera = self._resolve_camera(meta["camera"], attr_name)
|
|
314
|
+
bound = member.__get__(self, type(self)) # bound method
|
|
315
|
+
cls = type(
|
|
316
|
+
f"_Decorated_{attr_name}",
|
|
317
|
+
(FrameProcessor,),
|
|
318
|
+
{"compute": lambda self, img, idx, ts, _fn=bound: _fn(img, idx, ts)},
|
|
319
|
+
)
|
|
320
|
+
kwargs = dict(meta["plot_kwargs"])
|
|
321
|
+
instance = cls(
|
|
322
|
+
name=attr_name,
|
|
323
|
+
camera=camera,
|
|
324
|
+
plot=meta["plot"],
|
|
325
|
+
**kwargs,
|
|
326
|
+
)
|
|
327
|
+
# Goes through __setattr__ -> _register_processor.
|
|
328
|
+
setattr(self, attr_name, instance)
|
|
329
|
+
|
|
330
|
+
def _resolve_camera(self, device_id: str, for_name: str) -> Any:
|
|
331
|
+
"""Look up a hardware device by ``device_id``; clear error if missing."""
|
|
332
|
+
devices = getattr(self.hardware, "devices", {}) or {}
|
|
333
|
+
# ``devices`` is a {device_id: device} mapping in HardwareManager.
|
|
334
|
+
if device_id in devices:
|
|
335
|
+
return devices[device_id]
|
|
336
|
+
# Fall back to scanning ``cameras`` tuple by their .device_id / .id.
|
|
337
|
+
for cam in getattr(self.hardware, "cameras", ()) or ():
|
|
338
|
+
if getattr(cam, "device_id", getattr(cam, "id", None)) == device_id:
|
|
339
|
+
return cam
|
|
340
|
+
available = sorted(devices.keys())
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"@processor(camera={device_id!r}) on {type(self).__name__}.{for_name}: "
|
|
343
|
+
f"no hardware device with that id. Available: {available}"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# ------------------------------------------------------------------
|
|
347
|
+
# Subclass extension hooks (no-op defaults)
|
|
348
|
+
|
|
349
|
+
def define_config(self) -> Any:
|
|
350
|
+
"""Subclass hook to declare experiment parameters in Python.
|
|
351
|
+
|
|
352
|
+
Override to return a ``@dataclass`` instance or a plain mapping; it is
|
|
353
|
+
applied to :attr:`config` via :meth:`ExperimentConfig.load_dict`,
|
|
354
|
+
superseding any ``experiment.json``. Default ``None`` -> load JSON.
|
|
355
|
+
"""
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
def define_hardware(self) -> Any:
|
|
359
|
+
"""Subclass hook to construct hardware devices in Python.
|
|
360
|
+
|
|
361
|
+
Override to return a list of pre-built device objects (imported and
|
|
362
|
+
instantiated in the procedure file). They are handed to a fresh
|
|
363
|
+
:class:`HardwareManager`, superseding any ``hardware.yaml``. Default
|
|
364
|
+
``None`` -> load YAML. Device classes should be decorated with
|
|
365
|
+
``@DeviceRegistry.register(...)`` so the setup can later be exported
|
|
366
|
+
to a ``hardware.yaml`` rig file via ``HardwareManager.to_yaml``.
|
|
367
|
+
"""
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
def prerun(self) -> None:
|
|
371
|
+
"""Subclass hook called before arming devices. Override as needed."""
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
def on_started(self) -> None:
|
|
375
|
+
"""Subclass hook called immediately after ``start_all``."""
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
def on_finished(self) -> None:
|
|
379
|
+
"""Subclass hook called immediately after the primary device finishes."""
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
# ------------------------------------------------------------------
|
|
383
|
+
# Core lifecycle
|
|
384
|
+
|
|
385
|
+
def run(self) -> None:
|
|
386
|
+
"""Drive a standard experiment run.
|
|
387
|
+
|
|
388
|
+
Subclasses may override, but the default body is generic and handles
|
|
389
|
+
any combination of devices declared in ``hardware.yaml``.
|
|
390
|
+
"""
|
|
391
|
+
self.logger.info("================= Starting experiment ===================")
|
|
392
|
+
|
|
393
|
+
# 1. DataManager / queue logger setup
|
|
394
|
+
self.data.setup(self.config)
|
|
395
|
+
if not self.data.devices:
|
|
396
|
+
self.data.register_devices(self.config.hardware.devices.values())
|
|
397
|
+
if not self.playback:
|
|
398
|
+
self.data.start_queue_logger()
|
|
399
|
+
|
|
400
|
+
# 2. Subclass pre-run hook
|
|
401
|
+
self.prerun()
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
# 3. Per-run device prep
|
|
405
|
+
self.hardware.arm_all(self.config)
|
|
406
|
+
|
|
407
|
+
# 4. Wire termination: primary device's finished signal triggers cleanup
|
|
408
|
+
self.hardware.primary.signals.finished.connect(self._cleanup_procedure)
|
|
409
|
+
|
|
410
|
+
# 5. Start everything
|
|
411
|
+
self.start_time = datetime.now(timezone.utc)
|
|
412
|
+
self.events.procedure_started.emit()
|
|
413
|
+
self.hardware.start_all()
|
|
414
|
+
|
|
415
|
+
# 6. Subclass post-start hook
|
|
416
|
+
self.on_started()
|
|
417
|
+
except Exception as e:
|
|
418
|
+
self.logger.error(f"Error during experiment: {e}")
|
|
419
|
+
self.events.procedure_error.emit(str(e))
|
|
420
|
+
raise
|
|
421
|
+
|
|
422
|
+
def save_data(self) -> None:
|
|
423
|
+
mgr = getattr(self, "data_manager", self.data)
|
|
424
|
+
mgr.save.configuration()
|
|
425
|
+
mgr.save.all_notes()
|
|
426
|
+
mgr.save.all_hardware()
|
|
427
|
+
mgr.save.save_timestamps(self.protocol, self.start_time, self.stopped_time)
|
|
428
|
+
self.config.save_json()
|
|
429
|
+
self.events.data_saved.emit()
|
|
430
|
+
self.logger.info("Data saved successfully")
|
|
431
|
+
|
|
432
|
+
def cleanup(self) -> None:
|
|
433
|
+
"""Public cleanup entry-point (manual stop)."""
|
|
434
|
+
self._cleanup_procedure()
|
|
435
|
+
|
|
436
|
+
def run_until_finished(self, timeout: Optional[float] = None) -> bool:
|
|
437
|
+
"""Run the procedure and block until cleanup completes.
|
|
438
|
+
|
|
439
|
+
Starts the procedure via :meth:`run`, then waits for the
|
|
440
|
+
``procedure_finished`` (or ``procedure_error``) signal. Handles
|
|
441
|
+
``KeyboardInterrupt`` and ``timeout`` by invoking :meth:`cleanup`
|
|
442
|
+
automatically, so callers (e.g. ``__main__`` blocks in experiment
|
|
443
|
+
scripts) do not need to wire up their own threading events.
|
|
444
|
+
|
|
445
|
+
Parameters
|
|
446
|
+
----------
|
|
447
|
+
timeout:
|
|
448
|
+
Optional hard ceiling in seconds. When ``None`` (default), waits
|
|
449
|
+
indefinitely for the primary device's ``finished`` signal. When
|
|
450
|
+
provided, forces cleanup if the deadline passes.
|
|
451
|
+
|
|
452
|
+
Returns
|
|
453
|
+
-------
|
|
454
|
+
bool
|
|
455
|
+
``True`` if the procedure finished on its own, ``False`` if
|
|
456
|
+
cleanup was forced by timeout or interrupt.
|
|
457
|
+
"""
|
|
458
|
+
self._finished_event.clear()
|
|
459
|
+
deadline = None if timeout is None else (datetime.now().timestamp() + timeout)
|
|
460
|
+
try:
|
|
461
|
+
self.run()
|
|
462
|
+
while not self._finished_event.is_set():
|
|
463
|
+
remaining = None
|
|
464
|
+
if deadline is not None:
|
|
465
|
+
remaining = deadline - datetime.now().timestamp()
|
|
466
|
+
if remaining <= 0:
|
|
467
|
+
self.logger.warning(
|
|
468
|
+
"run_until_finished: timeout reached, forcing cleanup"
|
|
469
|
+
)
|
|
470
|
+
break
|
|
471
|
+
self._finished_event.wait(timeout=min(0.5, remaining) if remaining else 0.5)
|
|
472
|
+
except KeyboardInterrupt:
|
|
473
|
+
self.logger.info("run_until_finished: KeyboardInterrupt, cleaning up")
|
|
474
|
+
finally:
|
|
475
|
+
if not self._finished_event.is_set():
|
|
476
|
+
self.cleanup()
|
|
477
|
+
return self._finished_event.is_set()
|
|
478
|
+
|
|
479
|
+
def _cleanup_procedure(self):
|
|
480
|
+
self.logger.info("Cleanup Procedure")
|
|
481
|
+
try:
|
|
482
|
+
try:
|
|
483
|
+
self.hardware.primary.signals.finished.disconnect(self._cleanup_procedure)
|
|
484
|
+
except Exception:
|
|
485
|
+
pass
|
|
486
|
+
# Detach any procedure-authored FrameProcessors so their worker
|
|
487
|
+
# threads exit and the camera frame signal is released before
|
|
488
|
+
# the hardware itself shuts down.
|
|
489
|
+
for proc in list(getattr(self, "processors", [])):
|
|
490
|
+
try:
|
|
491
|
+
proc.detach()
|
|
492
|
+
except Exception as exc:
|
|
493
|
+
self.logger.warning(f"processor detach failed: {exc}")
|
|
494
|
+
self.processors.clear()
|
|
495
|
+
self.hardware.stop_all()
|
|
496
|
+
if not self.playback:
|
|
497
|
+
self.data.stop_queue_logger()
|
|
498
|
+
self.stopped_time = datetime.now(timezone.utc)
|
|
499
|
+
if not self.playback:
|
|
500
|
+
self.save_data()
|
|
501
|
+
self._write_acquisition_manifest()
|
|
502
|
+
self.on_finished()
|
|
503
|
+
self.events.procedure_finished.emit()
|
|
504
|
+
except Exception as e:
|
|
505
|
+
self.logger.error(f"Error during cleanup: {e}")
|
|
506
|
+
self.events.procedure_error.emit(str(e))
|
|
507
|
+
finally:
|
|
508
|
+
# `events.procedure_finished` is a pyqtSignal; its Python-side
|
|
509
|
+
# `.connect`s only run with a live QApplication. Setting the
|
|
510
|
+
# event directly here keeps `run_until_finished` working in
|
|
511
|
+
# headless contexts (tests, CLI smoke runs, batch scripts).
|
|
512
|
+
self._finished_event.set()
|
|
513
|
+
|
|
514
|
+
# ------------------------------------------------------------------
|
|
515
|
+
# Acquisition manifest (mesokit-schema contract)
|
|
516
|
+
|
|
517
|
+
def manifest_extra(self) -> Dict[str, Any]:
|
|
518
|
+
"""Override to inject extra session-level metadata into the
|
|
519
|
+
AcquisitionManifest's ``extra`` block. Default: empty."""
|
|
520
|
+
return {}
|
|
521
|
+
|
|
522
|
+
def _write_acquisition_manifest(self) -> None:
|
|
523
|
+
"""Emit a `mesokit_schema.AcquisitionManifest` next to the data.
|
|
524
|
+
|
|
525
|
+
Called automatically during cleanup. Override to disable or change
|
|
526
|
+
where the manifest lands; use :meth:`manifest_extra` if you only
|
|
527
|
+
want to attach extra session metadata.
|
|
528
|
+
"""
|
|
529
|
+
session_root = (
|
|
530
|
+
Path(self.data_dir)
|
|
531
|
+
/ f"sub-{self.config.subject}"
|
|
532
|
+
/ f"ses-{self.config.session}"
|
|
533
|
+
)
|
|
534
|
+
session_root.mkdir(parents=True, exist_ok=True)
|
|
535
|
+
|
|
536
|
+
def _relativise(p: Any) -> Optional[str]:
|
|
537
|
+
if not p:
|
|
538
|
+
return None
|
|
539
|
+
try:
|
|
540
|
+
return str(Path(p).resolve().relative_to(session_root.resolve()))
|
|
541
|
+
except ValueError:
|
|
542
|
+
return str(p)
|
|
543
|
+
|
|
544
|
+
def _coerce_sidecars(raw) -> list[SidecarEntry]:
|
|
545
|
+
out: list[SidecarEntry] = []
|
|
546
|
+
for item in raw or []:
|
|
547
|
+
if isinstance(item, SidecarEntry):
|
|
548
|
+
rel = _relativise(item.path) or item.path
|
|
549
|
+
out.append(item.model_copy(update={"path": rel}))
|
|
550
|
+
else:
|
|
551
|
+
data = dict(item)
|
|
552
|
+
rel = _relativise(data.get("path"))
|
|
553
|
+
if rel is not None:
|
|
554
|
+
data["path"] = rel
|
|
555
|
+
out.append(SidecarEntry.model_validate(data))
|
|
556
|
+
return out
|
|
557
|
+
|
|
558
|
+
def _coerce_dataqueue_schema(raw) -> Optional[DataqueuePayloadSchema]:
|
|
559
|
+
if raw is None:
|
|
560
|
+
return None
|
|
561
|
+
if isinstance(raw, DataqueuePayloadSchema):
|
|
562
|
+
return raw
|
|
563
|
+
return DataqueuePayloadSchema.model_validate(raw)
|
|
564
|
+
|
|
565
|
+
producers: list[ProducerEntry] = []
|
|
566
|
+
for device_id, device in self.config.hardware.devices.items():
|
|
567
|
+
output_path = getattr(device, "output_path", None)
|
|
568
|
+
if not output_path:
|
|
569
|
+
continue
|
|
570
|
+
sidecars_method = getattr(device, "sidecars", None)
|
|
571
|
+
sidecar_list = sidecars_method() if callable(sidecars_method) else []
|
|
572
|
+
dq_schema_raw = getattr(device, "dataqueue_payload_schema", None)
|
|
573
|
+
producers.append(
|
|
574
|
+
ProducerEntry(
|
|
575
|
+
device_id=device_id,
|
|
576
|
+
device_type=getattr(device, "device_type", "device"),
|
|
577
|
+
data_type=getattr(device, "data_type", device_id),
|
|
578
|
+
bids_type=getattr(device, "bids_type", None),
|
|
579
|
+
file_type=getattr(device, "file_type", "csv"),
|
|
580
|
+
output_path=_relativise(output_path) or str(output_path),
|
|
581
|
+
metadata_path=_relativise(getattr(device, "metadata_path", None)),
|
|
582
|
+
sampling_rate_hz=getattr(device, "sampling_rate", None) or None,
|
|
583
|
+
time_basis=TimeBasis(
|
|
584
|
+
clock_source=getattr(device, "clock_source", "wall_unix_s"),
|
|
585
|
+
),
|
|
586
|
+
calibration=dict(getattr(device, "calibration", {}) or {}),
|
|
587
|
+
sidecars=_coerce_sidecars(sidecar_list),
|
|
588
|
+
dataqueue_schema=_coerce_dataqueue_schema(dq_schema_raw),
|
|
589
|
+
)
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
manifest = AcquisitionManifest(
|
|
593
|
+
mesofield_version=str(_MESOFIELD_VERSION),
|
|
594
|
+
acquisition_complete=True,
|
|
595
|
+
started_at=self.start_time,
|
|
596
|
+
ended_at=self.stopped_time,
|
|
597
|
+
session=SessionIdentity(
|
|
598
|
+
subject=str(self.config.subject),
|
|
599
|
+
session=str(self.config.session),
|
|
600
|
+
task=str(self.config.task) if self.config.task else None,
|
|
601
|
+
experimenter=self.experimenter,
|
|
602
|
+
protocol=self.protocol,
|
|
603
|
+
),
|
|
604
|
+
producers=producers,
|
|
605
|
+
extra=self.manifest_extra(),
|
|
606
|
+
)
|
|
607
|
+
out = session_root / "manifest.json"
|
|
608
|
+
manifest.write(out)
|
|
609
|
+
self.logger.info(f"Wrote AcquisitionManifest {hyperlink(out, 'AcquisitionManifest')}")
|
|
610
|
+
|
|
611
|
+
# ------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
def add_note(self, note: str) -> None:
|
|
614
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
615
|
+
self.config.notes.append(f"{timestamp}: {note}")
|
|
616
|
+
self.logger.info(f"Added note: {note}")
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# ----------------------------------------------------------------------
|
|
621
|
+
# Procedure discovery
|
|
622
|
+
|
|
623
|
+
def load_procedure_from_config(config_path: str) -> "Procedure":
|
|
624
|
+
"""Instantiate the right :class:`Procedure` subclass for a given JSON.
|
|
625
|
+
|
|
626
|
+
The experiment JSON may declare two optional fields:
|
|
627
|
+
|
|
628
|
+
``procedure_file``
|
|
629
|
+
Path to a Python file containing the subclass. Relative paths are
|
|
630
|
+
resolved against the JSON's directory.
|
|
631
|
+
``procedure_class``
|
|
632
|
+
Name of the class to import from ``procedure_file``.
|
|
633
|
+
|
|
634
|
+
When either field is missing, a base :class:`Procedure` is returned.
|
|
635
|
+
The user file is loaded via :func:`importlib.util.spec_from_file_location`
|
|
636
|
+
so ``experiments/`` does not need to be on ``sys.path``.
|
|
637
|
+
"""
|
|
638
|
+
if not config_path or not os.path.isfile(config_path):
|
|
639
|
+
return Procedure(config_path)
|
|
640
|
+
|
|
641
|
+
# A scripted procedure: config_path points straight at a Python file that
|
|
642
|
+
# defines a Procedure subclass (with define_config / define_hardware).
|
|
643
|
+
if config_path.endswith(".py"):
|
|
644
|
+
return _load_procedure_from_py(config_path)
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
with open(config_path, "r", encoding="utf-8") as fh:
|
|
648
|
+
cfg = json.load(fh)
|
|
649
|
+
except Exception:
|
|
650
|
+
return Procedure(config_path)
|
|
651
|
+
|
|
652
|
+
proc_file = cfg.get("procedure_file")
|
|
653
|
+
proc_class = cfg.get("procedure_class")
|
|
654
|
+
if not proc_file or not proc_class:
|
|
655
|
+
return Procedure(config_path)
|
|
656
|
+
|
|
657
|
+
# Resolve relative paths against the JSON's directory
|
|
658
|
+
json_dir = os.path.dirname(os.path.abspath(config_path))
|
|
659
|
+
if not os.path.isabs(proc_file):
|
|
660
|
+
proc_file = os.path.join(json_dir, proc_file)
|
|
661
|
+
|
|
662
|
+
if not os.path.isfile(proc_file):
|
|
663
|
+
raise FileNotFoundError(
|
|
664
|
+
f"procedure_file declared in {config_path} not found: {proc_file}"
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
mod_name = f"mesofield_user_procedure_{uuid.uuid4().hex}"
|
|
668
|
+
spec = importlib.util.spec_from_file_location(mod_name, proc_file)
|
|
669
|
+
if spec is None or spec.loader is None:
|
|
670
|
+
raise ImportError(f"Could not load procedure_file: {proc_file}")
|
|
671
|
+
module = importlib.util.module_from_spec(spec)
|
|
672
|
+
sys.modules[mod_name] = module
|
|
673
|
+
spec.loader.exec_module(module)
|
|
674
|
+
|
|
675
|
+
cls = getattr(module, proc_class, None)
|
|
676
|
+
if cls is None:
|
|
677
|
+
raise AttributeError(
|
|
678
|
+
f"Class '{proc_class}' not found in {proc_file}"
|
|
679
|
+
)
|
|
680
|
+
if not (isinstance(cls, type) and issubclass(cls, Procedure)):
|
|
681
|
+
raise TypeError(
|
|
682
|
+
f"{proc_class} in {proc_file} must be a subclass of mesofield.base.Procedure"
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
return cls(config_path)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _load_procedure_from_py(py_path: str) -> "Procedure":
|
|
689
|
+
"""Instantiate the Procedure subclass defined in a scripted ``procedure.py``.
|
|
690
|
+
|
|
691
|
+
The class is selected from a module-level ``PROCEDURE`` attribute when
|
|
692
|
+
present, otherwise the single :class:`Procedure` subclass *defined in*
|
|
693
|
+
that file. The ``.py`` path is passed as ``config_path`` so the
|
|
694
|
+
procedure's ``experiment_dir`` resolves to the file's directory; the
|
|
695
|
+
``define_config`` hook supplies the actual parameters.
|
|
696
|
+
"""
|
|
697
|
+
abs_path = os.path.abspath(py_path)
|
|
698
|
+
mod_name = f"mesofield_user_procedure_{uuid.uuid4().hex}"
|
|
699
|
+
spec = importlib.util.spec_from_file_location(mod_name, abs_path)
|
|
700
|
+
if spec is None or spec.loader is None:
|
|
701
|
+
raise ImportError(f"Could not load procedure file: {abs_path}")
|
|
702
|
+
module = importlib.util.module_from_spec(spec)
|
|
703
|
+
sys.modules[mod_name] = module
|
|
704
|
+
spec.loader.exec_module(module)
|
|
705
|
+
|
|
706
|
+
cls = getattr(module, "PROCEDURE", None)
|
|
707
|
+
if cls is None:
|
|
708
|
+
candidates = [
|
|
709
|
+
obj for obj in vars(module).values()
|
|
710
|
+
if isinstance(obj, type)
|
|
711
|
+
and issubclass(obj, Procedure)
|
|
712
|
+
and obj is not Procedure
|
|
713
|
+
and obj.__module__ == mod_name
|
|
714
|
+
]
|
|
715
|
+
if not candidates:
|
|
716
|
+
raise AttributeError(
|
|
717
|
+
f"No Procedure subclass found in {abs_path}. Define one, or "
|
|
718
|
+
f"set a module-level 'PROCEDURE = <class>'."
|
|
719
|
+
)
|
|
720
|
+
if len(candidates) > 1:
|
|
721
|
+
names = ", ".join(c.__name__ for c in candidates)
|
|
722
|
+
raise AttributeError(
|
|
723
|
+
f"Multiple Procedure subclasses in {abs_path} ({names}); "
|
|
724
|
+
f"set a module-level 'PROCEDURE = <class>' to disambiguate."
|
|
725
|
+
)
|
|
726
|
+
cls = candidates[0]
|
|
727
|
+
|
|
728
|
+
if not (isinstance(cls, type) and issubclass(cls, Procedure)):
|
|
729
|
+
raise TypeError(
|
|
730
|
+
f"{cls!r} in {abs_path} must be a subclass of mesofield.base.Procedure"
|
|
731
|
+
)
|
|
732
|
+
return cls(abs_path)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# Factory function for creating procedures
|
|
736
|
+
def create_procedure(
|
|
737
|
+
procedure_class: Type[Procedure],
|
|
738
|
+
config_path: Optional[str],
|
|
739
|
+
**custom_parameters: Any,
|
|
740
|
+
) -> Procedure:
|
|
741
|
+
"""Factory function to create procedure instances."""
|
|
742
|
+
procedure = procedure_class(config_path)
|
|
743
|
+
for key, value in custom_parameters.items():
|
|
744
|
+
procedure.config.set(key, value)
|
|
745
|
+
return procedure
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
# Legacy constants for backward compatibility
|
|
749
|
+
NAME = "mesofield"
|
|
750
|
+
|