Developer Guide#
This guide is for developers extending mesofield — writing custom
device adapters, subclassing Procedure, building frame processors, or
contributing to the framework itself. If you’re just running
acquisitions, see the User Guide instead.
Architecture in one diagram#
experiment.json
│
▼
ExperimentConfig ─── HardwareManager ─── Devices (BaseDevice subclasses)
│ │ │
│ │ ├── DataProducer.signals.data ──┐
│ │ │ ▼
│ └── DataManager ◀───── Queues ─────── DataSaver ───--- disk
│
▼
Procedure (orchestrates lifecycle, emits signals, owns the run)
│
▼
MainWindow (Qt GUI; binds widgets to procedure.events)
The four backbone classes are:
Class |
Module |
Owns |
|---|---|---|
|
Run lifecycle (initialize → arm → start → finish), hooks, manifest |
|
|
Parameter registry + JSON I/O + BIDS paths |
|
|
YAML-driven device factory + lifecycle |
|
|
Per-run data queues, notes, timestamps, manifest writes |
Procedure lifecycle#
1. initialize_hardware — bring devices up (one-time)
2. prerun — subclass hook (default: no-op)
3. hardware.arm_all — per-run prep on every device
4. connect primary.signals.finished -> _cleanup_procedure
5. on_started — subclass hook
6. hardware.start_all
7. on_finished — subclass hook (after primary fires finished)
8. save_data + cleanup
Hooks prerun, on_started, on_finished are no-ops on Procedure
itself. Override them in your subclass under
experiments/<name>/procedure.py.
from mesofield.base import Procedure
class MyProcedure(Procedure):
def prerun(self):
self.logger.info(f"Subject {self.config.subject}, "
f"duration {self.config.duration}s")
def on_started(self):
# called after every device has started
pass
def on_finished(self):
# called after the primary camera signals finished
self.logger.info("Run complete; manifest will be written next")
load_procedure_from_config is the discovery hook called by the CLI; it
reads the optional procedure_file and procedure_class fields from
experiment.json and instantiates your subclass.
Procedure signals (procedure.events)#
Procedure.events is a ProcedureSignals
QObject exposing four pyqtSignals:
Signal |
Payload |
Fires when |
|---|---|---|
|
— |
After all devices have started |
|
|
After |
|
— |
After cleanup completes successfully |
|
|
On any uncaught run-time error |
|
— |
After |
Connect from a Qt widget or from another device:
procedure.events.procedure_started.connect(self.lock_form)
procedure.events.procedure_finished.connect(self.unlock_form)
procedure.events.procedure_error.connect(self.show_error_dialog)
Custom hardware devices#
A hardware device is any class that satisfies the
HardwareDevice protocol. The
easiest path is to subclass one of the base classes:
Base |
Use when |
|---|---|
|
Generic device with no streaming data |
|
Streaming source (timeseries / counts) with a buffer |
|
Streaming source whose transport is a serial port |
|
Anything that produces frames + writes a writer file |
Minimal example — a serial sensor#
from mesofield import DeviceRegistry
from mesofield.devices.base import BaseSerialDevice
@DeviceRegistry.register("thermal")
class ThermalSensor(BaseSerialDevice):
"""One-byte-per-sample thermal probe over serial."""
file_type = "csv"
bids_type = "beh"
data_type = "thermal"
def parse_line(self, line: bytes):
"""Parse one serial frame.
Returns:
``(payload, timestamp_or_None)`` — the payload is whatever
you want fanned out on ``self.signals.data``; pass ``None``
for the timestamp to let the framework stamp it.
"""
return float(line), None
Then in hardware.yaml:
thermal:
type: thermal
port: /dev/ttyUSB1
baudrate: 115200
output:
suffix: thermal
file_type: csv
bids_type: beh
@DeviceRegistry.register("thermal") is what binds the YAML type:
string to the class. The decorator also stamps registry_key onto the
class so any instance can report its YAML type for hardware export.
Camera-shaped devices#
For anything that produces frames, subclass
BaseCamera — it
defaults to OME-TIFF output via CustomWriter, exposes a snap() /
start_live() / stop_live() contract for the GUI preview, and
plumbs frame metadata into the manifest. The
MMCamera and
OpenCVCamera classes
are the two concrete implementations to read for reference.
Threading models#
Devices can use any concurrency model that respects the lifecycle:
# Qt-thread device (camera, GUI-driven serial)
from PyQt6.QtCore import QThread
from mesofield.protocols import HardwareDevice
class QtDevice(QThread):
device_type = "qt_device"
device_id = "qdev"
def run(self): ... # Qt thread loop
# Python threading device
from mesofield.protocols import ThreadedHardwareDevice
class ThreadedDevice(ThreadedHardwareDevice):
device_type = "thread_device"
device_id = "tdev"
def _run(self): ... # standard daemon thread
# asyncio device
from mesofield.protocols import AsyncioHardwareDevice
class AsyncDevice(AsyncioHardwareDevice):
device_type = "async_device"
device_id = "adev"
async def _run(self): ...
Pick whichever fits your hardware best — the framework only cares about the protocol, not the concurrency model.
Frame processors#
For per-frame compute (mean intensity, ROI tracking, anything that
turns an ndarray into a scalar), use the @processor decorator on a
Procedure method:
from mesofield.base import Procedure, processor
class MyProcedure(Procedure):
@processor(camera="meso", plot=True, label="Frame mean", y_range=(0, 65535))
def frame_mean(self, img, idx, ts):
return float(img.mean())
The framework wraps the function in a
FrameProcessor, attaches it to
the camera whose device_id matches "meso", registers it with
DataManager, and (when plot=True) adds a live
SerialWidget to the GUI.
Recognised plot_kwargs: label, value_label, value_units,
y_range, value_scale, max_points.
Scaffolding a new experiment#
The CLI scaffold drops a fill-out template:
mesofield new my-experiment --rig my-rig
cd my-experiment
You get:
my-experiment/
README.md
experiment.json # subjects, duration, DisplayKeys
hardware.yaml # copied from the selected rig
procedure.py # your Procedure subclass
devices/
__init__.py
thermal_example.py # annotated custom-device template
--rig selects from mesofield rig list. Use --hardware path/to/file
to use an explicit YAML; omit both to enter an interactive picker.
Rig store#
A hardware.yaml is rig-specific (COM ports, camera ids, MM .cfg
paths). Each machine keeps a small store of named canonical configs in
the OS config directory:
mesofield rig new my-rig # writes a fill-out template
mesofield rig list # show registered rigs
mesofield rig add my-rig file.yaml # adopt an existing yaml
The store lives at the platform-default config location (resolved by
platformdirs); mesofield rig where prints the path.
Logging#
Use the project logger inside your code:
from mesofield.utils._logger import get_logger
logger = get_logger(__name__)
logger.info("...")
Every device and processor should use a __name__-scoped logger so the
file traces are easy to grep.
Where to look for examples#
mesofield/devices/mocks.py— mock serial encoder + mock camera. Read these first; they’re the simplest concrete implementations of every base class.mesofield/devices/cameras.py—MMCamera(Micro-Manager backend) andOpenCVCamera(capture thread + MP4 writer). The two shapes most custom cameras start from.mesofield/scaffold/experiment.py— what themesofield newCLI emits. Reading the templates is a shortcut to understanding the expected file shape.mesofield/processors/frame_mean.py— three-line frame processor. The minimum viable processor.
Retrofitting legacy sessions#
Sessions acquired before manifests landed can be retrofitted:
mesofield process retrofit-manifest /path/to/experiment
This walks the BIDS tree, reads timestamps.csv / configuration.csv,
and synthesises an AcquisitionManifest per session. Calibration
constants aren’t recoverable (they weren’t written), but everything
else round-trips and downstream tools become happy again.