From 0b9ec25b7a947b36adf045a779b2adf483e5d90b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:55:40 +0000 Subject: [PATCH] Add the shaper contract: settings groups and Shaper read from the control module Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CAhZwmbyCSpSgfTyicUxuS --- README.rst | 3 + src/pymodaq_plugins_utils/hardware/shaper.py | 133 +++++++++++++++++++ tests/test_shaper.py | 79 +++++++++++ 3 files changed, 215 insertions(+) create mode 100644 src/pymodaq_plugins_utils/hardware/shaper.py create mode 100644 tests/test_shaper.py diff --git a/README.rst b/README.rst index 203a72b..79f835d 100644 --- a/README.rst +++ b/README.rst @@ -48,3 +48,6 @@ Utilities CameraDriverBase, whose methods are those of the pylablib cameras: write the driver of your camera against it, or use a pylablib one as it is. * CameraBasePyLabLib: CameraBase for the cameras of the pylablib package. Mostly for (S)CMOS camera +* shaper: common interface of the plugins driving a shaper (SLM, DMD...): settings groups with the number of pixels, + their size and the properties of the modulator, filled by the plugin and read back by the applications (BeamShaping + extension) from the control module, see ``pymodaq_plugins_utils.hardware.shaper`` diff --git a/src/pymodaq_plugins_utils/hardware/shaper.py b/src/pymodaq_plugins_utils/hardware/shaper.py new file mode 100644 index 0000000..44c9d44 --- /dev/null +++ b/src/pymodaq_plugins_utils/hardware/shaper.py @@ -0,0 +1,133 @@ +"""Common interface of the instrument plugins driving a *shaper* (SLM, DMD...) + +A shaper plugin adds the settings groups returned by :func:`shaper_params` to its parameters and fills them once the +instrument is initialized (:func:`fill_shaper_settings`). The applications (as the BeamShaping extension) do not call the +plugin, which lives in another thread: they read these groups from the settings of its control module +(:meth:`Shaper.from_module`). The names of the groups and of their children, and :data:`SHAPER_VERSION`, are the +contract. Evolution rule: only add optional children, never rename or remove one; a breaking change increments +:data:`SHAPER_VERSION`. +""" +import dataclasses +from typing import Optional + +from pymodaq_utils.enums import StrEnum +from pymodaq_utils.logger import set_logger, get_module_name + +logger = set_logger(get_module_name(__file__)) + +SHAPER_VERSION = 1 +GEOMETRY_NAME = 'shaper_geometry' +PROPERTIES_NAME = 'shaper_properties' + + +class ModulatorType(StrEnum): + PHASE = 'phase' + AMPLITUDE = 'amplitude' + BOTH = 'both' + + +def shaper_geometry_params() -> dict: + """Group with the number of pixels and their size (micrometer), read only""" + return {'title': 'Shaper geometry:', 'name': GEOMETRY_NAME, 'type': 'group', 'children': [ + {'title': 'Version:', 'name': 'version', 'type': 'int', 'value': SHAPER_VERSION, 'readonly': True, + 'visible': False}, + {'title': 'Width (pixels):', 'name': 'width', 'type': 'int', 'value': 0, 'readonly': True}, + {'title': 'Height (pixels):', 'name': 'height', 'type': 'int', 'value': 0, 'readonly': True}, + {'title': 'Pixel size x (um):', 'name': 'pixel_size_x', 'type': 'float', 'value': 0., 'readonly': True}, + {'title': 'Pixel size y (um):', 'name': 'pixel_size_y', 'type': 'float', 'value': 0., 'readonly': True}, + ]} + + +def shaper_properties_params() -> dict: + """Group with what the shaper does: its name, the type of modulation and whether it converts itself the phases + (radians) into its grey levels (``has_internal_calibration``), which the user may or not use""" + return {'title': 'Shaper properties:', 'name': PROPERTIES_NAME, 'type': 'group', 'children': [ + {'title': 'Version:', 'name': 'version', 'type': 'int', 'value': SHAPER_VERSION, 'readonly': True, + 'visible': False}, + {'title': 'Name:', 'name': 'name', 'type': 'str', 'value': '', 'readonly': True}, + {'title': 'Modulator type:', 'name': 'modulator_type', 'type': 'list', 'limits': [m.value for m in ModulatorType], + 'value': ModulatorType.PHASE.value, 'readonly': True}, + {'title': 'Has internal calibration:', 'name': 'has_internal_calibration', 'type': 'bool', 'value': False, + 'readonly': True}, + {'title': 'Use internal calibration:', 'name': 'use_internal_calibration', 'type': 'bool', 'value': False, + 'tip': 'Send phases in radians and let the shaper convert them, instead of using a calibration made ' + 'outside of the shaper'}, + ]} + + +def shaper_params() -> list[dict]: + """The groups to add to the parameters of a shaper plugin""" + return [shaper_geometry_params(), shaper_properties_params()] + + +def fill_shaper_settings(settings, name: str, width: int, height: int, pixel_size_x: float, + pixel_size_y: Optional[float] = None, + modulator_type: ModulatorType = ModulatorType.PHASE, + has_internal_calibration: bool = False) -> None: + """Fill the groups of :func:`shaper_params` in the ``settings`` of a plugin (to be called in ``ini_stage``) + + ``use_internal_calibration`` is set to ``has_internal_calibration``, the user can change it afterwards. + """ + geometry = settings.child(GEOMETRY_NAME) + for key, value in (('width', width), ('height', height), ('pixel_size_x', pixel_size_x), + ('pixel_size_y', pixel_size_x if pixel_size_y is None else pixel_size_y)): + geometry.child(key).setValue(value) + properties = settings.child(PROPERTIES_NAME) + for key, value in (('name', name), ('modulator_type', ModulatorType(modulator_type).value), + ('has_internal_calibration', has_internal_calibration), + ('use_internal_calibration', has_internal_calibration)): + properties.child(key).setValue(value) + + +@dataclasses.dataclass +class Shaper: + name: str + width: int + height: int + pixel_size: float # in micrometer, along x + has_internal_calibration: bool + use_internal_calibration: bool + modulator_type: ModulatorType + pixel_size_y: Optional[float] = None # in micrometer, same as pixel_size if None + + def __post_init__(self): + if self.pixel_size_y is None: + self.pixel_size_y = self.pixel_size + self.modulator_type = ModulatorType(self.modulator_type) + + @property + def shape(self) -> tuple[int, int]: + """The shape (height, width) of the arrays to send to the shaper""" + return self.height, self.width + + @classmethod + def from_settings(cls, geometry, properties) -> 'Shaper': + """Build it from the two groups (Parameter objects) of a shaper plugin""" + return cls(name=properties['name'], width=int(geometry['width']), height=int(geometry['height']), + pixel_size=float(geometry['pixel_size_x']), pixel_size_y=float(geometry['pixel_size_y']), + has_internal_calibration=bool(properties['has_internal_calibration']), + use_internal_calibration=bool(properties['use_internal_calibration']), + modulator_type=ModulatorType(properties['modulator_type'])) + + @classmethod + def from_module(cls, module) -> Optional['Shaper']: + """Read the shaper from a DAQ_Move control module + + None if its plugin has no shaper groups, is not initialized yet (empty geometry) or follows another version + of the contract (an error is logged in that case). + """ + try: + settings = module.settings.child(module._hw_settings_name) + geometry, properties = settings.child(GEOMETRY_NAME), settings.child(PROPERTIES_NAME) + except (KeyError, AttributeError): + return None + versions = [group['version'] if 'version' in [c.name() for c in group.children()] else None + for group in (geometry, properties)] + if versions != [SHAPER_VERSION] * 2: + logger.error(f'The shaper groups of {module} have versions {versions}, version {SHAPER_VERSION} is ' + f'expected: update the plugin or pymodaq_plugins_utils') + return None + shaper = cls.from_settings(geometry, properties) + if shaper.width <= 0 or shaper.height <= 0 or shaper.pixel_size <= 0: + return None + return shaper diff --git a/tests/test_shaper.py b/tests/test_shaper.py new file mode 100644 index 0000000..bae2d32 --- /dev/null +++ b/tests/test_shaper.py @@ -0,0 +1,79 @@ +"""The shaper contract: settings groups of the plugins, read back from the settings of their control module""" +import pytest +from pymodaq_gui.parameter import Parameter + +from pymodaq_plugins_utils.hardware.shaper import (Shaper, ModulatorType, shaper_params, fill_shaper_settings, + SHAPER_VERSION) + + +class FakeModule: + """What an application has: the DAQ_Move control module, with the settings tree of its plugin""" + _hw_settings_name = 'actuator_settings' + + def __init__(self, children=None): + children = shaper_params() if children is None else children + self.settings = Parameter.create(name='settings', type='group', children=[ + {'name': self._hw_settings_name, 'type': 'group', 'children': children}]) + + @property + def plugin_settings(self): + return self.settings.child(self._hw_settings_name) + + +def fill(module, **kwargs): + values = dict(name='SLM', width=1920, height=1080, pixel_size_x=8., pixel_size_y=9.) + values.update(kwargs) + fill_shaper_settings(module.plugin_settings, **values) + + +def test_not_initialized_yet(): + assert Shaper.from_module(FakeModule()) is None + + +def test_roundtrip(): + module = FakeModule() + fill(module, has_internal_calibration=True, modulator_type='both') + shaper = Shaper.from_module(module) + assert shaper == Shaper('SLM', 1920, 1080, 8., True, True, ModulatorType.BOTH, 9.) + assert shaper.shape == (1080, 1920) + + +def test_pixel_size_y_defaults_to_x(): + module = FakeModule() + fill(module, pixel_size_y=None) + assert Shaper.from_module(module).pixel_size_y == 8. + + +def test_use_internal_calibration_is_the_choice_of_the_user(): + module = FakeModule() + fill(module, has_internal_calibration=True) + module.plugin_settings.child('shaper_properties', 'use_internal_calibration').setValue(False) + shaper = Shaper.from_module(module) + assert shaper.has_internal_calibration and not shaper.use_internal_calibration + + +def test_plugin_without_the_groups(): + assert Shaper.from_module(FakeModule([])) is None + assert Shaper.from_module(object()) is None + + +def test_unknown_version_is_refused(): + module = FakeModule() + fill(module) + module.plugin_settings.child('shaper_geometry', 'version').setValue(SHAPER_VERSION + 1) + assert Shaper.from_module(module) is None + + +def test_missing_version_is_refused(): + params = shaper_params() + params[1]['children'] = [c for c in params[1]['children'] if c['name'] != 'version'] + module = FakeModule(params) + fill_params = module.plugin_settings.child('shaper_geometry') + for key, value in (('width', 4), ('height', 4), ('pixel_size_x', 8.)): + fill_params.child(key).setValue(value) + assert Shaper.from_module(module) is None + + +def test_unknown_modulator_type(): + with pytest.raises(ValueError): + Shaper('x', 1, 1, 1., False, False, 'unknown')