Custom Acquisition Engines#
Important
This page assumes you have a basic understanding of how the default MDA
acquisition engine works to execute a sequence of useq.MDAEvent
objects.
If you haven't already done so, please read the Acquisition
Engine guide first.
While the default MDA acquisition engine is sufficient for many common use cases, you may find that you need to customize the acquisition engine to accomplish your goals. Cases where you may need to customize the acquisition engine include:
- Driving hardware for which a micro-manager device adapter does not exist.
- Conditionally executing arbitrary python code before, during, or after each acquisition event.
- Using an alternate high-performance camera driver
- Handling user-specific
MDAEvent.metadata
values. -
Intercepting and modifying the event sequence*.
*Note
If all you want to is to modify the event sequence (e.g. to add additional events in a non-deterministic way) but you don't need to modify the behavior of the acquisition engine itself, you likely don't need to customize the acquisition engine. See the guide on Event-Driven Acquisition for details.
The MDARunner
and MDAEngine
#
Let's start by taking a quick look at how the acquisition logic in pymmcore-plus is structured. There are two key classes involved:
- An
MDARunner
instance is responsible for receiving a sequence ofuseq.MDAEvent
objects and driving anMDAEngine
to execute them. TheMDARunner
is the object that has the actualrun()
method. It also emits all the events, such asframeReady
. Users shouldn't need to subclass or modifyMDARunner
directly. - An
MDAEngine
instance (anything that implements thePMDAEngine
protocol) is responsible for actually setting up and executing each event in the sequence. The default implementation of thePMDAEngine
protocol is theMDAEngine
class, but you can register your own custom engine, using either a subclass of the default engine, or any other object that implements thePMDAEngine
protocol.
from pymmcore_plus import CMMCorePlus
core = CMMCorePlus()
core.mda # <- The MDARunner instance
core.mda.engine # <- The MDAEngine instance
The MDAEngine
Protocol#
pymmcore-plus
defines a protocol (a.k.a. "interface" in the Java world) that
all acquisition engines must implement. Formal API docs for the protocol can be
found here, but let's discuss the three key
methods here.
setup_sequence()
- Setup state of system before an MDA is run.setup_event()
- Prepare state of system for an event.exec_event()
Execute the event.
The PMDAEngine
Protocol
class MyEngine:
def setup_sequence(self, sequence: MDASequence) -> SummaryMetaV1 | None:
"""Setup state of system (hardware, etc.) before an MDA is run.
This method is called once at the beginning of a sequence.
(The sequence object needn't be used here if not necessary)
"""
def setup_event(self, event: MDAEvent) -> None:
"""Prepare state of system (hardware, etc.) for `event`.
This method is called before each event in the sequence. It is
responsible for preparing the state of the system for the event.
The engine should be in a state where it can call `exec_event`
without any additional preparation.
"""
def exec_event(self, event: MDAEvent) -> Iterable[tuple[NDArray, MDAEvent, FrameMetaV1]]:
"""Execute `event`.
This method is called after `setup_event` and is responsible for
executing the event. The default assumption is to acquire an image,
but more elaborate events will be possible.
"""
The following methods are optional, but will be used if they are defined:
event_iterator()
- Optional wrapper on the event iterator. To customize the event sequence.teardown_event()
- Called afterexec_event()
. To clean up after an event.teardown_sequence()
Called after the sequence is complete. To clean up after an MDA.
The built-in MDAEngine
#
The default implementation of the PMDAEngine
protocol is the
MDAEngine
class. It can handle microscope
setup and image acquisition for a standard MDAEvent
, and opportunistically queues
hardware-triggered sequences. It also serves as a good base
class for custom engines if you want to extend the default behavior. (You may
also find the source
code
for the MDAEngine
class helpful as a reference when writing a custom engine.)
Customizing the MDAEngine
#
If you want to customize how the instrument sets up or executes each event,
the easiest approach is to subclass the default MDAEngine
class and override
or extend the methods you need to customize, then register your custom engine
with the runner's set_engine()
method.
from pymmcore_plus import CMMCorePlus
from pymmcore_plus.mda import MDAEngine
import useq
class MyEngine(MDAEngine): # (1)!
def setup_event(self, event: useq.MDAEvent) -> None:
"""Prepare state of system (hardware, etc.) for `event`."""
# do some custom pre-setup
super().setup_event(event) # (2)!
# do some custom post-setup
def exec_event(self, event: useq.MDAEvent) -> object:
"""Prepare state of system (hardware, etc.) for `event`."""
# do some custom pre-execution
result = super().exec_event(event) # (3)!
# do some custom post-execution
return result # (4)!
core = CMMCorePlus.instance()
core.loadSystemConfiguration()
# Register the custom engine with the runner
core.mda.set_engine(MyEngine(core)) # (5)!
# Run an MDA
core.run_mda([])
- Create a custom engine by subclassing the default engine
- Note that it's not required to call the
super()
method here if you don't want to - Note that it's not required to call the
super()
method here if you don't want to - If the object returned by
exec_event()
has animage
attribute, it will be used to emit theframeReady
event. A simple implementation might use a named tuple:class EventPayload(typing.NamedTuple): image: np.ndarray | None = None
- Note that
MDAEngine.__init__
accepts aCMMCorePlus
instance as its first argument, so you'll need to pass that in when instantiating your custom engine.
Utilizing MDAEvent
metadata#
More often than not, if you are customizing the acquisition engine, it will be because you'd like to do something other than drive the micro-manager core to set up and acquire an image. Perhaps you need to control a micro-fluidic device, or control a DAQ card, or communicate with a remote server, etc.
In all of these cases, you will likely need additional parameters (beyond
the fields defined in the MDAEvent
class) to pass to your control code.
For this, the MDAEvent
class has a metadata
attribute that is explicitly
provided for user-defined data.
from pymmcore_plus import CMMCorePlus
from pymmcore_plus.mda import MDAEngine
import useq
class MyEngine(MDAEngine):
def setup_event(self, event: useq.MDAEvent) -> None:
if 'my_key' in event.metadata: # (1)!
self._my_custom_setup(event.metadata)
else:
super().setup_event(event)
def _my_custom_setup(self, metadata: dict) -> None:
print(f"Setting up my custom device with {metadata}")
def exec_event(self, event: useq.MDAEvent) -> object:
if 'my_key' in event.metadata:
return self._my_custom_exec(event.metadata) # (2)!
else:
return super().exec_event(event)
def _my_custom_exec(self, metadata: dict) -> object:
print(f"Executing my custom stuff with {metadata}")
core = CMMCorePlus.instance()
core.loadSystemConfiguration()
core.mda.set_engine(MyEngine(core))
experiment = [
useq.MDAEvent(),
useq.MDAEvent(metadata={'my_key': {'param1': 'val1'}}), # (3)!
useq.MDAEvent(),
useq.MDAEvent(metadata={'my_key': {'param1': 'val2'}}),
]
core.run_mda(experiment)
- You can use any characteristics of the
MDAEvent
, such as theindex
, or the presence of a special key in themetadata
attribute, to determine whether you want to do something special for that event. - You don't have to return here. If you also want to do the default image
acquisition, you can call
super().exec_event(event)
as well. - Add metadata to the event. You can do this either by constructing your own
sequence of
MDAEvent
objects, or by usingMDASequence
to build the sequence for you, then editing themetadata
attributes as needed.
Example
For a real-world example of an MDAEngine
subclass that uses
MDAEvent.metadata
to drive hardware for Raman spectroscopy,
see Ian Hunt-Isaak's
raman-mda-engine.
(engine subclass
here)