Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ Short, runnable and tested plugins to copy from when writing a plugin for a real
to a simulated device: a stage with a voltmeter (``hardware/mock_serial_visa_instrument.py``) or a 3-axis controller
behind a library (``hardware/mock_dll_controller.py``). Each file starts with a header telling what to
copy, what to adapt and what to keep. The tests (``tests/test_cookbook_drivers.py``, ``tests/test_cookbook_plugins.py``,
``tests/test_cookbook_dll.py``, ``tests/test_cookbook_spectrometer.py``, ``tests/test_bs_camera.py``) show how to test a
``tests/test_cookbook_dll.py``, ``tests/test_cookbook_dotnet.py``, ``tests/test_cookbook_spectrometer.py``, ``tests/test_bs_camera.py``) show how to test a
plugin against a simulated device with the harness of ``pymodaq.utils.plugin_testing``.

Giving the same *controller ID* to an actuator and a detector of a recipe makes them share one driver: the first
Expand Down Expand Up @@ -220,6 +220,17 @@ changes, actuator slave of a detector ``daq_move_CookbookSpectrometer``,
``hardware/spectrometer.py`` and the detector share one instrument; a homing that waits for
its end and calls ``move_done``. It also has the interface of
the spectrometer extension (laser line, exposure time)
Vendor .NET assembly, multi-axis ``daq_move_CookbookDotNet`` (3 axes), A ``pythonnet`` driver: ``out`` parameter returned in a tuple,
controller, ``pythonnet`` ``daq_0Dviewer_CookbookDotNet``, an event given a Python function with ``+=`` and ``-=``, the
``hardware/dotnet_controller.py`` exception type of the assembly caught, ``IDisposable``. The
assembly is simulated by an object used like its namespace. The
end of a move is an event raised by a thread of the assembly,
turned into a Qt signal
Starting .NET and loading an assembly ``hardware/dotnet_runtime.py``, ``load_dotnet`` (runtime per platform, clear error) and
``hardware/system_dotnet_demo.py`` ``load_assembly`` (names, path from a setting or an environment
variable, an error that lists what was tried). Real calls to the
.NET libraries (``Int32.TryParse``, ``List<double>``,
``MemoryStream``, ``Timer.Elapsed`` from another thread)
====================================== ============================================== ================================================================

Patterns already shown by the mock instruments of this package
Expand All @@ -242,6 +253,10 @@ Simulated device, no hardware every plugin of this package
Patterns not yet covered: an instrument driven by a Python binding of the manufacturer (it is packaged like the
drivers of ``hardware/``: a class that wraps the binding, created by ``ini_stage``).

The .NET recipe needs ``pythonnet`` (installed with this package) and a .NET runtime only when it talks to a real assembly:
the simulated one runs anywhere. The tests that call the .NET libraries of the runtime are skipped if there is none
(Windows has the .NET Framework; elsewhere install .NET 6 or later).


PID Models
==========
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
"pymodaq_data>=5.3.0",
"pillow",
"pymeasure>=0.14", # also brings pyvisa and pyserial: needed by the cookbook recipes of the hardware folder
"pythonnet>=3.0.3", # the cookbook recipe on a .NET assembly (it needs a .NET runtime only when it talks to one)
]


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Cookbook recipe 4a: multi-axis actuator on a vendor .NET assembly (``pythonnet``), with a move that ends by an event.

COPY this file for an actuator whose manufacturer supplies a .NET assembly (``Vendor.Device.dll``) for a controller
with several axes. It is the recipe of ``daq_move_CookbookDll`` with the driver of ``hardware/dotnet_controller.py``:
read that file for what is specific to ``pythonnet`` (``out`` parameters, events, exceptions, ``IDisposable``).
ADAPT: ``params`` (what the assembly needs to connect), ``_axis_names``, ``_controller_units``, the calls of the driver
in ``move_abs``, ``move_rel``, ``get_actuator_value``, ``move_home`` and ``stop_motion``.
KEEP:
- the master/slave handling in ``ini_stage`` and ``close``: the first axis (master) opens the controller, the others
(slaves, same controller ID in the Dashboard) receive it;
- the event handling. The assembly raises the event from one of its own threads: ``_forward_event`` only emits a Qt
signal, which is delivered to ``_on_motion_event`` in the thread of the plugin. Never call ``move_done`` or touch a
widget from the thread of the assembly;
- ``user_condition_to_reach_target``: PyMoDAQ polls the position until it is within epsilon of the target, and also
waits for the end-of-move event. The homing is not polled, so ``move_done`` is called when its event arrives.

Try it without the assembly: leave the port to ``MOCK``, the assembly is then the simulated one of
``hardware/mock_dotnet_controller.py``. The runtime of .NET and the loading of the assembly are in
``hardware/dotnet_runtime.py``.
"""
from qtpy.QtCore import Signal, Slot

from pymodaq.control_modules.move_utility_classes import (DAQ_Move_base, comon_parameters_fun, DataActuatorType,
DataActuator, main)

from pymodaq_plugins_mockexamples.hardware.dotnet_controller import MotionController, open_controller
from pymodaq_plugins_mockexamples.hardware.mock_dll_controller import (EVENT_MOVE_DONE, EVENT_STOPPED,
EVENT_HOME_DONE)


class DAQ_Move_CookbookDotNet(DAQ_Move_base):
"""One axis of the simulated 3-axis controller, driven through ``MotionController`` on a .NET assembly"""
_controller_units = 'mm'
is_multiaxes = True
_axis_names = {'X': 0, 'Y': 1, 'Z': 2} # the assembly numbers its axes
_epsilons = 0.01
data_actuator_type = DataActuatorType.DataActuator
params = comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilons) + [
{'title': 'Port:', 'name': 'port', 'type': 'str', 'value': 'MOCK',
'tip': 'Port of the controller, MOCK for the simulated assembly'},
{'title': 'Assembly path:', 'name': 'assembly_path', 'type': 'browsepath', 'value': '', 'filetype': True,
'tip': 'Full path to the .dll, empty to search it (environment variable MM_ASSEMBLY, name of the assembly)'},
]

motion_event = Signal(int, int) # axis, event: emitted from the thread of the assembly, received in this thread

def ini_attributes(self):
self.controller: MotionController = None
self._move_finished = True
self._homing = False
self.motion_event.connect(self._on_motion_event)

def ini_stage(self, controller=None):
if self.is_master:
self.controller = open_controller(self.settings['port'], self.settings['assembly_path'])
else: # a master axis shares its controller
self.controller = controller
self.controller.subscribe(self._forward_event)
info = self.controller.info()
return f'{info.name}, firmware {info.firmware[0]}.{info.firmware[1]}', True

def close(self):
if self.controller is not None:
self.controller.unsubscribe(self._forward_event)
if self.is_master:
self.controller.close()

def _forward_event(self, axis: int, event: int):
"""Called from the thread of the assembly: only emit the signal"""
self.motion_event.emit(axis, event)

@Slot(int, int)
def _on_motion_event(self, axis: int, event: int):
"""Runs in the thread of the plugin"""
if axis != self.axis_value: # the controller is shared: events of the other axes come too
return
if event == EVENT_HOME_DONE and self._homing:
self._homing = False
self.move_done()
elif event in (EVENT_MOVE_DONE, EVENT_STOPPED):
self._move_finished = True

def user_condition_to_reach_target(self) -> bool:
return self._move_finished

def get_actuator_value(self) -> DataActuator:
position = DataActuator(data=self.controller.position(self.axis_value), units=self.axis_unit)
return self.get_position_with_scaling(position)

def move_abs(self, value: DataActuator):
value = self.check_bound(value)
self.target_value = value
value = self.set_position_with_scaling(value)
self._move_finished = False
self.controller.move_abs(self.axis_value, value.value(self.axis_unit))

def move_rel(self, value: DataActuator):
value = self.check_bound(self.current_value + value) - self.current_value
self.target_value = value + self.current_value
value = self.set_position_with_scaling(self.target_value)
self._move_finished = False
self.controller.move_abs(self.axis_value, value.value(self.axis_unit))

def move_home(self):
self._homing = True
self.controller.home(self.axis_value)

def stop_motion(self):
self.controller.stop(self.axis_value)

def commit_settings(self, param):
pass


if __name__ == '__main__':
main(__file__)
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Cookbook recipe 4b: 0D detector reading the positions of the axes of a controller driven by a vendor .NET assembly.

COPY this file for a detector of a controller that has several functions (here the encoders of three axes) behind one
object of the assembly. Alone it opens its own controller (master); with the same controller ID as the actuators
``CookbookDotNet`` it is a slave and shares their object: a controller must be opened once, not once per plugin.
ADAPT: ``params``, ``ini_detector``, and the acquisition in ``grab_data`` (name, labels and units of the data).
See ``daq_move_CookbookDotNet`` and ``hardware/dotnet_controller.py`` for what is specific to ``pythonnet``.
KEEP: the master/slave handling in ``ini_detector`` and ``close``, and the units given to the data.
"""
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 import DataToExport

from pymodaq_plugins_mockexamples.hardware.dotnet_controller import MotionController, open_controller


class DAQ_0DViewer_CookbookDotNet(DAQ_Viewer_base):
"""Positions of the three axes of the simulated controller, read through ``MotionController``"""
params = comon_parameters + [
{'title': 'Port:', 'name': 'port', 'type': 'str', 'value': 'MOCK',
'tip': 'Port of the controller, MOCK for the simulated assembly'},
{'title': 'Assembly path:', 'name': 'assembly_path', 'type': 'browsepath', 'value': '', 'filetype': True,
'tip': 'Full path to the .dll, empty to search it (environment variable MM_ASSEMBLY, name of the assembly)'},
]

def ini_attributes(self):
self.controller: MotionController = None

def ini_detector(self, controller=None):
if self.is_master:
self.controller = open_controller(self.settings['port'], self.settings['assembly_path'])
else: # a master actuator or detector shares its controller
self.controller = controller
info = self.controller.info()
return f'{info.name}, firmware {info.firmware[0]}.{info.firmware[1]}', True

def close(self):
if self.is_master and self.controller is not None:
self.controller.close()

def grab_data(self, Naverage=1, **kwargs):
positions = [np.array([self.controller.position(axis)]) for axis in range(self.controller.n_axes)]
self.dte_signal.emit(DataToExport(
name='CookbookDotNet',
data=[DataFromPlugins(name='Positions', data=positions, dim='Data0D', labels=['X', 'Y', 'Z'],
units='mm')]))

def stop(self):
pass

def commit_settings(self, param):
pass


if __name__ == '__main__':
main(__file__)
112 changes: 112 additions & 0 deletions src/pymodaq_plugins_mockexamples/hardware/dotnet_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Driver of the 3-axis motion controller of ``mock_dotnet_controller.py``, written like a driver for a vendor .NET
assembly with ``pythonnet``.

What differs from a ``ctypes`` driver (``dll_controller.py``): there is no ``argtypes`` or ``restype`` to declare,
``pythonnet`` converts the arguments and the results, but some conventions must be known:

- an ``out`` parameter of a .NET method is returned by ``pythonnet`` in a tuple, after the result of the method:
``ok, position = controller.TryGetPosition(axis)``;
- an event receives a Python function with ``+=`` (it is converted to a delegate) and gives it up with ``-=``. The
function is stored in an attribute, to remove the very same object. The assembly raises the event from one of
its threads: the function must only forward it to the listeners, which must not touch Qt widgets;
- a .NET exception is a Python exception of the .NET type: catch the exception type of the assembly (here
``MmException``) and read its ``Message`` (``str(exception)`` also holds the .NET stack trace);
- the objects that hold hardware are ``IDisposable``: call ``Dispose()`` when done (in ``close``);
- an enumeration value gives its number with ``int(...)``.

Load the real assembly with ``MotionController(load_assembly('Mm.Net', namespace='Mm'), port)`` (see
``dotnet_runtime.py``) or the simulated one with ``MotionController.mock()``.
"""
from typing import Callable

from pymodaq_plugins_mockexamples.hardware.dll_controller import ControllerInfo, MotionControllerError
from pymodaq_plugins_mockexamples.hardware.mock_dotnet_controller import MockMmNamespace
from pymodaq_plugins_mockexamples.hardware.mock_dll_controller import PORT, N_AXES


class MotionController:
"""Handle of an opened controller and its axes (numbered from 0), on a .NET assembly"""
n_axes = N_AXES

def __init__(self, api, port: str = PORT):
"""``api`` is the namespace of the assembly: ``import Mm`` with ``pythonnet``, or its simulation"""
self._api = api
self._controller = self._call(api.Controller.Open, port)
self._listeners: list[Callable[[int, int], None]] = []
self._handler = self._on_event # kept in an attribute: ``-=`` must be given the object given to ``+=``
self._controller.MoveFinished += self._handler

@classmethod
def mock(cls) -> 'MotionController':
"""The controller on the simulated assembly (no assembly or .NET runtime needed)"""
return cls(MockMmNamespace())

@property
def _open(self):
"""The .NET controller, refused once closed"""
if self._controller is None:
raise MotionControllerError('The controller is closed')
return self._controller

def _call(self, method, *args):
try:
return method(*args)
except self._api.MmException as exc:
raise MotionControllerError(exc.Message) from None

def _on_event(self, sender, args):
"""Called by the assembly, from one of its threads"""
for listener in list(self._listeners):
listener(args.Axis, int(args.Event))

def subscribe(self, listener: Callable[[int, int], None]):
"""``listener(axis, event)`` is called, from a thread of the assembly, when a move ends (see the EVENT_*)"""
self._listeners.append(listener)

def unsubscribe(self, listener: Callable[[int, int], None]):
if listener in self._listeners:
self._listeners.remove(listener)

def info(self) -> ControllerInfo:
info = self._call(lambda: self._open.Info)
return ControllerInfo(info.Name, info.AxisCount, (info.FirmwareMajor, info.FirmwareMinor))

def position(self, axis: int) -> float:
ok, position = self._open.TryGetPosition(axis) # the ``out`` parameter comes back in a tuple
if not ok:
raise MotionControllerError(f'Could not read the position of axis {axis}')
return position

def is_moving(self, axis: int) -> bool:
return self._call(self._open.IsMoving, axis)

def move_abs(self, axis: int, position: float):
"""Start the move; the assembly raises ``MoveFinished`` when it is over"""
self._call(self._open.MoveAbs, axis, position)

def stop(self, axis: int):
self._call(self._open.Stop, axis)

def home(self, axis: int):
"""Start the homing; the assembly raises ``MoveFinished`` when it is over"""
self._call(self._open.Home, axis)

def close(self):
if self._controller is not None:
self._controller.MoveFinished -= self._handler
self._controller.Dispose()
self._controller = None
self._listeners.clear()


ASSEMBLY_NAMES = ['Mm.Net'] # name of the (imaginary) assembly, without .dll


def open_controller(port: str, assembly_path: str = '') -> MotionController:
"""The controller of the simulated assembly for the port ``MOCK``, else of the vendor assembly found by
``load_assembly`` (setting of the plugin, environment variable ``MM_ASSEMBLY``, name of the assembly)"""
if port == PORT:
return MotionController.mock()
from pymodaq_plugins_mockexamples.hardware.dotnet_runtime import load_assembly
return MotionController(load_assembly(ASSEMBLY_NAMES, path=assembly_path or None, env_var='MM_ASSEMBLY',
namespace='Mm'), port)
Loading
Loading