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/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
+