3.3. Your first plugin in 10 minutes
This page is the shortest path to a working instrument plugin: one actuator and one detector for a made-up
instrument, Acme, each under 80 lines, and a test that runs them without hardware. Replace Acme by the name of your
instrument (Acme becomes Xxxx in the file and class names, see the naming convention). The complete reference is the plugin tutorial.
The code of this page is tested: the files below are written to a package and checked by PyMoDAQ’s test suite.
3.3.1. Start from the template
Create your repository from the plugin template (see
Write and release a new plugin), rename the package to pymodaq_plugins_acme (the name must start with pymodaq_plugins_), then
install it from the folder of the repository:
pip install -e .
Delete the template files you do not need. The package then contains, in
src/pymodaq_plugins_acme/, the folders hardware, daq_move_plugins and daq_viewer_plugins/plugins_1D
(the paths below are relative to src/).
3.3.2. 1. Keep the instrument behind a small wrapper
Communication with the instrument belongs to a wrapper class of the hardware folder, or to the Python library of the
manufacturer. The plugin only translates between PyMoDAQ and this wrapper. Here is a made-up one, so that everything
runs without hardware:
"""Stands for the Python wrapper of the vendor library (here: made up, it needs no hardware)."""
import numpy as np
class AcmeController:
def __init__(self, port: str = 'COM1'):
self.port = port
self.position = 0. # mm
def move_to(self, mm: float):
self.position = mm
def read_position(self) -> float:
return self.position
def read_spectrum(self, npts: int) -> np.ndarray:
return np.random.random(npts)
def close(self):
pass
3.3.3. 2. The actuator
File daq_move_Acme.py holds the class DAQ_Move_Acme. Positions go in and out as DataActuator objects
with units. The five methods ini_stage, get_actuator_value, move_abs, move_rel and close are
required (see The methods of a plugin).
from pymodaq.control_modules.move_utility_classes import (
DAQ_Move_base, comon_parameters_fun, main, DataActuatorType, DataActuator)
from pymodaq_plugins_acme.hardware.acme_wrapper import AcmeController
class DAQ_Move_Acme(DAQ_Move_base):
"""Acme linear stage, one axis.
Tested with the Acme stage model S1 on COM3 (PyMoDAQ 5.3, Windows 11).
Needs the Acme driver from the manufacturer's website.
"""
is_multiaxes = False
_axis_names = ['linear']
_controller_units = 'mm'
_epsilons = [0.01] # the stage is at its target if closer than that
data_actuator_type = DataActuatorType.DataActuator # always keep this line
params = [
{'title': 'Port:', 'name': 'port', 'type': 'str', 'value': 'COM3'},
] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names,
epsilon=_epsilons)
def ini_attributes(self):
self.controller: AcmeController = None
def ini_stage(self, controller=None):
if self.is_master:
self.controller = AcmeController(self.settings['port'])
else: # a slave axis is given the controller of the master
self.controller = controller
return 'Acme stage ready', True # (info, initialized)
def close(self):
if self.is_master and self.controller is not None:
self.controller.close()
def get_actuator_value(self) -> DataActuator:
pos = DataActuator(data=self.controller.read_position(),
units=self.axis_unit)
return self.get_position_with_scaling(pos)
def move_abs(self, value: DataActuator):
value = self.check_bound(value) # stay within the bounds
self.target_value = value
value = self.set_position_with_scaling(value)
self.controller.move_to(value.value(self.axis_unit))
def move_rel(self, value: DataActuator):
self.move_abs(self.current_value + value)
def move_home(self):
self.controller.move_to(0.) # a real stage runs its homing here
self.move_done() # signal the end of the homing (see move_home)
def stop_motion(self):
pass # call the stop command of the controller here if it has one
if __name__ == '__main__':
main(__file__)
3.3.4. 3. The detector
File daq_1Dviewer_Acme.py holds the class DAQ_1DViewer_Acme. Data leave the plugin as a DataToExport
emitted with self.dte_signal, with units and axes. The required methods are ini_detector, grab_data,
stop and close.
import numpy as np
from pymodaq.control_modules.viewer_utility_classes import (
DAQ_Viewer_base, comon_parameters, main)
from pymodaq.utils.data import DataFromPlugins
from pymodaq_data.data import DataToExport, Axis
from pymodaq_plugins_acme.hardware.acme_wrapper import AcmeController
class DAQ_1DViewer_Acme(DAQ_Viewer_base):
"""Acme spectrometer, one trace of `npts` points.
Tested with the Acme spectrometer SP-1 (PyMoDAQ 5.3, Windows 11).
"""
params = comon_parameters + [
{'title': 'Port:', 'name': 'port', 'type': 'str', 'value': 'COM4'},
{'title': 'Points:', 'name': 'npts', 'type': 'int', 'value': 100,
'min': 1},
]
def ini_attributes(self):
self.controller: AcmeController = None
def ini_detector(self, controller=None):
if self.is_master:
self.controller = AcmeController(self.settings['port'])
else:
self.controller = controller
return 'Acme spectrometer ready', True # (info, initialized)
def close(self):
if self.is_master and self.controller is not None:
self.controller.close()
def grab_data(self, Naverage=1, **kwargs):
npts = self.settings['npts']
signal = self.controller.read_spectrum(npts)
wavelength = Axis('wavelength', units='nm',
data=np.linspace(400, 800, npts), index=0)
self.dte_signal.emit(DataToExport('acme', data=[
DataFromPlugins('spectrum', data=[signal], dim='Data1D',
labels=['intensity'], units='V', axes=[wavelength])
]))
def stop(self):
pass # call the stop command of the controller if it has one
def commit_settings(self, param):
pass # send the changed setting to the hardware
if __name__ == '__main__':
main(__file__)
3.3.5. 4. Test it without hardware
Replace the wrapper by a fake one that has the same methods and runs the plugin class with the helpers of
pymodaq.utils.plugin_testing. monkeypatch.setattr replaces the name AcmeController, as the plugin module
imports it, by the fake (see Testing the behaviour of your plugin without hardware). Put this file in the tests folder:
import numpy as np
import pytest
from pymodaq.utils.plugin_testing import (make_actuator, move_abs_and_wait, move_rel_and_wait,
make_detector, grab_and_wait, assert_units)
from pymodaq_plugins_acme.daq_move_plugins import daq_move_Acme
from pymodaq_plugins_acme.daq_viewer_plugins.plugins_1D import daq_1Dviewer_Acme
class FakeController:
def __init__(self, port):
self.position = 0.
def move_to(self, mm): self.position = mm
def read_position(self): return self.position
def read_spectrum(self, npts): return np.ones(npts)
def close(self): pass
def test_move_abs(qapp, monkeypatch):
monkeypatch.setattr(daq_move_Acme, 'AcmeController', FakeController)
actuator = make_actuator(daq_move_Acme.DAQ_Move_Acme)
assert actuator.initialized
position = move_abs_and_wait(actuator, 2.5)
assert position.value('mm') == pytest.approx(2.5, abs=0.01)
position = move_rel_and_wait(actuator, 0.5)
assert position.value('mm') == pytest.approx(3.0, abs=0.01)
actuator.close()
def test_grab(qapp, monkeypatch):
monkeypatch.setattr(daq_1Dviewer_Acme, 'AcmeController', FakeController)
detector = make_detector(daq_1Dviewer_Acme.DAQ_1DViewer_Acme)
dte = grab_and_wait(detector)
assert_units(dte)
assert dte[0].data[0].shape == (100,)
detector.close()
3.3.6. 5. Check and run
From the folder of the repository:
check_plugin # static report: names, entry points, required methods, units...
pytest # the template tests and yours
Both have to pass before a contribution (see The check_plugin command). Then adapt the wrapper and the parameters to your instrument, and say in your contribution what was tested on the real instrument and what only against a fake.