From 252baba8eb7cb931ac7bb580cd9d68046371364d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Fri, 9 Oct 2026 18:00:41 +0200 Subject: [PATCH 01/16] Add AGENTS.md: guidance for AI-assisted plugin and extension development First draft: what to build (plugin, CustomExt, CustomApp), workflow, plugin and extension rules, experiment/state manager usage, legacy patterns to avoid, done checklist. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2004e85 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,122 @@ +# AGENTS.md + +Guidance for AI coding assistants (and humans) building on **PyMoDAQ 5.3.x** from this template. +PyMoDAQ is a Qt framework that drives lab experiments: hardware is added through small *plugins*, and PyMoDAQ +provides the GUI, threading, data model, scans and HDF5 saving. Python 3.10 to 3.13. + +This repository is a plugin package named `pymodaq_plugins_`. Rename everything that still says `template` +before publishing. + +## 1. Choose what to build + +Prefer the first option that fits: + +1. **Instrument plugin** (`daq_move_plugins/`, `daq_viewer_plugins/`): a new device must appear in the Dashboard. +2. **Dashboard extension** (`extensions/`, class derived from `CustomExt`): a workflow that uses instruments + (scans, feedback, custom panels, logging). It runs inside the Dashboard and reuses its stable mechanisms for + instruments: the experiment manager and the state manager (how a Dashboard setup is described, loaded and + restored; the word "preset" is obsolete), the module manager, control-module threading, shared controllers, + saving. **If the application drives instruments, build a `CustomExt`, not a `CustomApp`.** +3. **Standalone app** (`app/`, class derived from `CustomApp`): only when the application needs **no + instruments** (data viewer, analysis, h5 browsing, configuration tools). +4. A plain script, for a one-off sequence that does not need a GUI. + +## 2. Workflow + +```bash +pip install -e . # editable install of this plugin, from the repository root +check_plugin # static report on this package (hardware not needed) +check_plugin --fail-on todo -v # also list every unfinished TODO +pytest # template tests (package structure, plugin checks) +``` + +Installing PyMoDAQ itself is a separate matter. A plugin only needs the released packages (`pymodaq`, +`pymodaq_utils`, `pymodaq_data`, `pymodaq_gui`, installed as dependencies). If you also have to modify them, work in a +clone of the PyMoDAQ repository and install the four packages in editable mode with the script at its root, never with +`pip install -e .` inside each package (the script handles the order and the options): + +```bash +python install-packages.py -d # editable + dev extras, from the PyMoDAQ repository root +python install-packages.py -d -qt pyside6 # choose the Qt backend (pyqt5, pyqt6 or pyside6) +``` + +Do not consider a task finished until `check_plugin` and `pytest` pass. Do not invent PyMoDAQ APIs: if a method +or attribute is not in the base classes (`DAQ_Move_base`, `DAQ_Viewer_base`, `CustomExt`) or in an existing +plugin, look it up in the installed `pymodaq` package instead of guessing. + +## 3. Instrument plugins + +File and class names must match: `daq_move_.py` holds `DAQ_Move_`; `daq_Dviewer_.py` holds +`DAQ_DViewer_` (N = 0, 1, 2 or N). Each file ends with `if __name__ == '__main__': main(__file__)`. + +**Actuator** (`DAQ_Move_base`). Override at least `ini_attributes`, `ini_stage`, `get_actuator_value`, `close`, +`commit_settings`, `move_abs`, `move_rel`, `move_home`, `stop_motion`. + +- `ini_stage(controller=None)` returns `(info: str, initialized: bool)`. If `self.is_master`, create the controller; + otherwise store the `controller` passed in (several axes share one controller). +- Class attributes: `is_multiaxes`, `_axis_names`, `_controller_units`, `_epsilons`, + `data_actuator_type = DataActuatorType.DataActuator`, optionally `ui_type` and `has_encoder`. +- `params` = list of dicts + `comon_parameters_fun(is_multiaxes, axis_names=..., epsilon=...)`. +- Positions are `DataActuator` objects with **units** (`units=self.axis_unit`); use `check_bound`, + `set_position_with_scaling` and `get_position_with_scaling` as in the template. + +**Detector** (`DAQ_Viewer_base`). Override at least `ini_attributes`, `ini_detector`, `grab_data`, `stop`, +`close`, `commit_settings`. + +- `ini_detector(controller=None)` returns `(info, initialized)`, same master/slave logic. +- `params = comon_parameters + [...]`. +- Data leaves the plugin as `DataToExport` containing `DataFromPlugins` (with `dim='Data0D'|'Data1D'|'Data2D'|'DataND'`, + `labels`, `axes=[Axis(...)]`) emitted through `self.dte_signal` (grab) or `self.dte_signal_temp` (live preview). + +**Rules that matter** + +- Plugin code runs in a worker thread. **Never create or touch Qt widgets** from it. Report progress with + `self.emit_status(ThreadCommand('Update_Status', ['message']))`. +- Do not block for long inside `grab_data` or a move; use the asynchronous pattern shown in the template and the + polling that PyMoDAQ already does (`epsilon`). +- Always release the hardware in `close()`, only if `self.is_master`. Leave actuators in a safe state on error. +- Settings are `pyqtgraph` Parameter dicts (`{'title', 'name', 'type', 'value', ...}`). React to changes in + `commit_settings(param)` by `param.name()`. +- Units are `pint`-based. Give every actuator value and every `Axis` a unit; mismatches raise `DataUnitError`. +- Hardware access code goes in `hardware/`, behind a small wrapper class. This lets the plugin be tested with a + mock controller and keeps the plugin file readable. +- Defaults live in `resources/config_template.toml` (read with the package `Config`), not in the code. + +**Legacy patterns to avoid** (older tutorials and models still produce them; `check_plugin` flags several): +`stage_names` (use `_axis_names`), `_epsilon` (use `_epsilons`), `data_actuator_type = float` (use +`DataActuatorType.DataActuator`), the group names `multiaxes` and `multi_status` (now `controller` and +`controller_status`), and importing `CustomExt` from `pymodaq.extensions.custom_ext` (use `pymodaq.utils.custom_ext`). + +## 4. Dashboard extensions (`CustomExt`) + +- Entry point group `pymodaq.extensions` in `pyproject.toml`; each module in `extensions/` defines + `EXTENSION_NAME` (menu label), `CLASS_NAME` (class name) and a class derived from `CustomExt`. +- Import: `from pymodaq.utils.custom_ext import CustomExt`. +- `__init__(self, parent: DockArea, dashboard)`: call `super().__init__(parent, dashboard)`, then `self.setup_ui()`. +- **Experiment and state belong to the Dashboard.** The *experiment* is the list of instruments (actuators and + detectors) the Dashboard manages and controls; the *state* is the state of that experiment, mostly the values of the + instruments' settings and of the Dashboard's actuators. An extension must use the managers it gets through its + `dashboard` argument, as `self.experiment_manager` and `self.state_manager`, and never create its own, nor its own + save/load of instrument configurations. Override `do_things_after_experiment_set(experiment_name, show_dashboard)` + to react when an experiment is set (the base class refreshes `self.modules_manager` there), and use + `create_dashboard_toolbar(...)` to expose the experiment and state actions in your extension. +- The Dashboard's instruments are reached through `self.modules_manager`. Do not create control modules + yourself inside an extension, and do not talk to the hardware directly. +- Implement the lifecycle in the template: `setup_docks`/`setup_docks_and_widgets`, `setup_actions`, + `connect_things`, `value_changed`, `quit_fun`. The GUI thread runs your code; react to module signals, never + poll or sleep. + +## 5. Standalone apps (`CustomApp`) + +Only when no instrument is needed. Same lifecycle as above, `parent` is a `DockArea` or `QMainWindow`, and +`main()` creates the app with `mkQApp`. If instruments become necessary, move the logic to a `CustomExt`. + +## 6. Done checklist + +- [ ] Names (package, files, classes) match and the `template` placeholders are gone. +- [ ] `check_plugin` passes; no `NotImplementedError` or `TODO` left in the files you wrote. +- [ ] Units set on all actuator values and axes; safe behaviour in `close()` and on errors. +- [ ] Tests added for new behaviour using a mock controller (no real hardware in CI). +- [ ] Docstring of each plugin class states: compatible devices, what was tested, PyMoDAQ and OS versions, + drivers to install. +- [ ] `README.rst` updated; target PyMoDAQ version stated. From a14343b6bec78e2561bfcbe48b02f10ffe6885b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Fri, 9 Oct 2026 18:08:37 +0200 Subject: [PATCH 02/16] AGENTS.md: GlobalConfig, per-extension ModulesManager, non-deprecated hooks Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2004e85..903fef3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,12 +80,16 @@ File and class names must match: `daq_move_.py` holds `DAQ_Move_`; ` - Units are `pint`-based. Give every actuator value and every `Axis` a unit; mismatches raise `DataUnitError`. - Hardware access code goes in `hardware/`, behind a small wrapper class. This lets the plugin be tested with a mock controller and keeps the plugin file readable. -- Defaults live in `resources/config_template.toml` (read with the package `Config`), not in the code. +- Defaults live in `resources/config_template.toml`, not in the code. Read configuration values through + `GlobalConfig` from `pymodaq_utils.config`: a singleton that wraps the `Config` objects of all packages, e.g. + `GlobalConfig()('gui', 'style', 'theme')`. Do not parse the toml files yourself. **Legacy patterns to avoid** (older tutorials and models still produce them; `check_plugin` flags several): `stage_names` (use `_axis_names`), `_epsilon` (use `_epsilons`), `data_actuator_type = float` (use `DataActuatorType.DataActuator`), the group names `multiaxes` and `multi_status` (now `controller` and -`controller_status`), and importing `CustomExt` from `pymodaq.extensions.custom_ext` (use `pymodaq.utils.custom_ext`). +`controller_status`), importing `CustomExt` from `pymodaq.extensions.custom_ext` (use `pymodaq.utils.custom_ext`), +and the deprecated `CustomApp` / `CustomExt` hooks `setup_docks` and `setup_menu` (use `setup_docks_and_widgets` and +`setup_menus_and_toolbars`). ## 4. Dashboard extensions (`CustomExt`) @@ -100,15 +104,17 @@ File and class names must match: `daq_move_.py` holds `DAQ_Move_`; ` save/load of instrument configurations. Override `do_things_after_experiment_set(experiment_name, show_dashboard)` to react when an experiment is set (the base class refreshes `self.modules_manager` there), and use `create_dashboard_toolbar(...)` to expose the experiment and state actions in your extension. -- The Dashboard's instruments are reached through `self.modules_manager`. Do not create control modules - yourself inside an extension, and do not talk to the hardware directly. -- Implement the lifecycle in the template: `setup_docks`/`setup_docks_and_widgets`, `setup_actions`, - `connect_things`, `value_changed`, `quit_fun`. The GUI thread runs your code; react to module signals, never - poll or sleep. +- The Dashboard's instruments are reached through `self.modules_manager`. This `ModulesManager` is created anew for + each extension by the base class `__init__`, so that every extension can handle different instruments: never share + it between extensions, replace it, or reuse the Dashboard's one. Do not create control modules yourself inside an + extension, and do not talk to the hardware directly. +- Implement the lifecycle: `setup_docks_and_widgets`, `setup_actions`, `setup_menus_and_toolbars`, + `connect_things`, `value_changed`, `quit_fun`. `setup_docks` and `setup_menu` are deprecated. The GUI thread runs + your code; react to module signals, never poll or sleep. ## 5. Standalone apps (`CustomApp`) -Only when no instrument is needed. Same lifecycle as above, `parent` is a `DockArea` or `QMainWindow`, and +Only when no instrument is needed. Same (non-deprecated) lifecycle as above, `parent` is a `DockArea` or `QMainWindow`, and `main()` creates the app with `mkQApp`. If instruments become necessary, move the logic to a `CustomExt`. ## 6. Done checklist From ab49df679269a2252928dbf754fbafa8003542c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Fri, 9 Oct 2026 18:08:38 +0200 Subject: [PATCH 03/16] Update app and extension templates to the current patterns - extension: CustomExt from pymodaq.utils.custom_ext, GlobalConfig, working default (settings dock, example action using modules_manager), do_things_after_experiment_set, _quit_fun returning True, main() as in the core extensions (load_dashboard_with_arguments + create_extension) - app: non-deprecated setup_docks_and_widgets / setup_menus_and_toolbars, working default, no instruments (use a CustomExt for those) Co-Authored-By: Claude Sonnet 5.5 --- .../app/custom_app_template.py | 45 ++++---- .../extensions/custom_extension_template.py | 103 +++++++++++------- 2 files changed, 89 insertions(+), 59 deletions(-) diff --git a/src/pymodaq_plugins_template/app/custom_app_template.py b/src/pymodaq_plugins_template/app/custom_app_template.py index 1c2e359..fff88f9 100644 --- a/src/pymodaq_plugins_template/app/custom_app_template.py +++ b/src/pymodaq_plugins_template/app/custom_app_template.py @@ -1,16 +1,20 @@ +"""Template of a standalone application, for applications that need NO instruments. + +If your application drives instruments (actuators, detectors), write a Dashboard extension (CustomExt, see the +extensions folder) instead: it relies on the Dashboard's stable mechanisms to handle instruments (experiment and +state managers, modules manager...). +""" from qtpy import QtWidgets from pymodaq_gui import utils as gutils -from pymodaq_utils.config import Config +from pymodaq_utils.config import GlobalConfig from pymodaq_utils.logger import set_logger, get_module_name -# todo: replace here *pymodaq_plugins_template* by your plugin package name -from pymodaq_plugins_template.utils import Config as PluginConfig - logger = set_logger(get_module_name(__file__)) -main_config = Config() -plugin_config = PluginConfig() +# all the configurations (PyMoDAQ's packages and the one of your plugin package) are read from GlobalConfig, for +# instance: config('gui', 'style', 'theme') +config = GlobalConfig() # todo: modify the name of this class to reflect its application and change the name in the main @@ -25,10 +29,11 @@ class CustomAppTemplate(gutils.CustomApp): def __init__(self, parent: gutils.DockArea): super().__init__(parent) - self.setup_ui() + self.setup_ui() # calls, in this order: setup_docks_and_widgets, setup_menus_and_toolbars, + # setup_actions, connect_things and do_things_after_ui_setup - def setup_docks(self): - """Mandatory method to be subclassed to setup the docks layout + def setup_docks_and_widgets(self): + """Method to be subclassed to setup the docks layout Examples -------- @@ -41,13 +46,13 @@ def setup_docks(self): -------- pyqtgraph.dockarea.Dock """ - # todo: create docks and add them here to hold your widgets - # reminder, the attribute self.settings_tree will render the widgets in a Qtree. - # If you wish to see it in your app, add is into a Dock - raise NotImplementedError + # todo: create docks and add them here to hold your widgets. Here the settings tree in a dock + self.docks['settings'] = gutils.Dock('Settings') + self.dockarea.addDock(self.docks['settings']) + self.docks['settings'].addWidget(self.settings_tree) def setup_actions(self): - """Method where to create actions to be subclassed. Mandatory + """Method where to create actions to be subclassed Examples -------- @@ -61,14 +66,16 @@ def setup_actions(self): -------- ActionManager.add_action """ - raise NotImplementedError(f'You have to define actions here') + # todo: replace this example action by yours + self.add_action('say_hello', 'Say hello', 'add_circle', tip='Show a message in the status bar') def connect_things(self): """Connect actions and/or other widgets signal to methods""" - raise NotImplementedError + # todo: replace this example by your connections + self.connect_action('say_hello', lambda: self.update_status('Hello')) - def setup_menu(self, menubar: QtWidgets.QMenuBar = None): - """Non mandatory method to be subclassed in order to create a menubar + def setup_menus_and_toolbars(self, menubar: QtWidgets.QMenuBar = None): + """Non mandatory method to be subclassed in order to create menus and toolbars create menu for actions contained into the self._actions, for instance: @@ -106,7 +113,7 @@ def value_changed(self, param): def main(): - from pymodaq_gui.utils.utils import mkQApp + from pymodaq_gui.qt_utils import mkQApp app = mkQApp('CustomApp') mainwindow = QtWidgets.QMainWindow() diff --git a/src/pymodaq_plugins_template/extensions/custom_extension_template.py b/src/pymodaq_plugins_template/extensions/custom_extension_template.py index a05d90f..deb901d 100644 --- a/src/pymodaq_plugins_template/extensions/custom_extension_template.py +++ b/src/pymodaq_plugins_template/extensions/custom_extension_template.py @@ -1,19 +1,30 @@ +"""Template of a Dashboard extension. + +An extension is a ``CustomExt``: a small application living inside the Dashboard. Everything related to the +instruments (the experiment, i.e. the list of actuators and detectors, and its state) is handled by the Dashboard, +the extension only uses it through its ``dashboard`` argument: + +* ``self.modules_manager``: a ModulesManager created for this extension, giving access to the actuators and + detectors of the Dashboard's experiment (never create control modules yourself) +* ``self.experiment_manager`` and ``self.state_manager``: the Dashboard's managers, never create your own + +The only difference with the extensions of the PyMoDAQ core is the presence of the ``EXTENSION_NAME`` and +``CLASS_NAME`` variables below, needed by the Dashboard to recognize your extension (plus the +``pymodaq.extensions`` entry point in pyproject.toml). +""" from qtpy import QtWidgets from pymodaq_gui import utils as gutils -from pymodaq_utils.config import Config, ConfigError +from pymodaq_utils.config import GlobalConfig from pymodaq_utils.logger import set_logger, get_module_name -from pymodaq.extensions.utils import CustomExt - - -# todo: replace here *pymodaq_plugins_template* by your plugin package name -from pymodaq_plugins_template.utils import Config as PluginConfig +from pymodaq.utils.custom_ext import CustomExt logger = set_logger(get_module_name(__file__)) -main_config = Config() -plugin_config = PluginConfig() +# all the configurations (PyMoDAQ's packages and the one of your plugin package) are read from GlobalConfig, for +# instance: config('gui', 'style', 'theme') +config = GlobalConfig() # todo: modify this as you wish EXTENSION_NAME = 'MY_EXTENSION_NAME' # the name that will be displayed in the extension list in the @@ -31,15 +42,13 @@ class CustomExtensionTemplate(CustomExt): params = [] def __init__(self, parent: gutils.DockArea, dashboard): - super().__init__(parent, dashboard) - - # info: in an extension, if you want to interact with ControlModules you have to use the - # object: self.modules_manager which is a ModulesManager instance from the dashboard + super().__init__(parent, dashboard) # also creates self.modules_manager for this extension - self.setup_ui() + self.setup_ui() # calls, in this order: setup_docks_and_widgets, setup_menus_and_toolbars, + # setup_actions, connect_things and do_things_after_ui_setup def setup_docks_and_widgets(self): - """Mandatory method to be subclassed to setup the docks layout + """Method to be subclassed to setup the docks layout Examples -------- @@ -52,53 +61,57 @@ def setup_docks_and_widgets(self): -------- pyqtgraph.dockarea.Dock """ - # todo: create docks and add them here to hold your widgets - # reminder, the attribute self.settings_tree will render the widgets in a Qtree. - # If you wish to see it in your app, add is into a Dock - raise NotImplementedError + # todo: create docks and add them here to hold your widgets. Here the settings tree in a dock + self.docks['settings'] = gutils.Dock('Settings') + self.dockarea.addDock(self.docks['settings']) + self.docks['settings'].addWidget(self.settings_tree) def setup_menus_and_toolbars(self, menubar: QtWidgets.QMenuBar = None): - """Non mandatory method to be subclassed in order to create a menubar - - create menu for actions contained into the self._actions, for instance: + """Non mandatory method to be subclassed in order to create menus and toolbars Examples -------- - >>>file_menu = menubar.addMenu('File') - >>>self.affect_to('load', file_menu) - >>>self.affect_to('save', file_menu) - - >>>file_menu.addSeparator() - >>>self.affect_to('quit', file_menu) + >>>file_menu = self.add_menu('file', 'File', parent_menu=menubar) See Also -------- pymodaq.utils.managers.action_manager.ActionManager """ - # todo create and populate menu using actions defined above in self.setup_actions + # adds the toolbar showing/hiding the Dashboard, loading an experiment and a state self.create_dashboard_toolbar(add_break=False) def setup_actions(self): - """Method where to create actions to be subclassed. Mandatory + """Method where to create actions to be subclassed Examples -------- - >>> self.add_action('quit', 'Quit', 'close2', "Quit program") >>> self.add_action('grab', 'Grab', 'camera', "Grab from camera", checkable=True) - >>> self.add_action('load', 'Load', 'Open', "Load target file (.h5, .png, .jpg) or data from camera" - , checkable=False) - >>> self.add_action('save', 'Save', 'SaveAs', "Save current data", checkable=False) See Also -------- ActionManager.add_action """ - raise NotImplementedError(f'You have to define actions here') + # todo: replace this example action by yours + self.add_action('list_modules', 'List modules', 'add_circle', + tip='Show the actuators and detectors of the current experiment') def connect_things(self): """Connect actions and/or other widgets signal to methods""" - raise NotImplementedError + # todo: replace this example by your connections + self.connect_action('list_modules', self.list_modules) + def list_modules(self): + """Example: use the modules_manager to access the instruments of the Dashboard's experiment""" + self.update_status(f'Actuators: {self.modules_manager.actuators_name}, ' + f'detectors: {self.modules_manager.detectors_name}') + + def do_things_after_experiment_set(self, experiment_name: str, show_dashboard: bool = None): + """Called each time an experiment (the list of instruments) has been set in the Dashboard + + The base class updates self.modules_manager with the new instruments. + """ + super().do_things_after_experiment_set(experiment_name, show_dashboard) + # todo: update your widgets with the new instruments if needed def value_changed(self, param): """ Actions to perform when one of the param's value in self.settings is changed from the @@ -116,21 +129,31 @@ def value_changed(self, param): """ pass + def _quit_fun(self) -> bool: + """Called when the extension is closed. Return True to let it quit, False to refuse (for instance while + running). The base class returns None, which would prevent the extension from closing. + """ + return True + def main(): + """Run the extension on its own: loads a Dashboard (hidden), then the extension + + The Dashboard command line options apply, for instance ``-x EXPERIMENT_NAME -s STATE_NAME`` + """ import sys from pymodaq_gui.qt_utils import mkQApp - from pymodaq.dashboard import create_load_dashboard + from pymodaq.dashboard import load_dashboard_with_arguments from pymodaq.utils.gui_utils.loader_utils import create_extension app = mkQApp('Custom Ext') - win, dashboard = create_load_dashboard() + win, dashboard, _ = load_dashboard_with_arguments(show_dashboard=False, + load_extension=False, + ) win.mainwindow.setVisible(False) - win_ext, ext = create_extension(dashboard, CustomExtensionTemplate) - win_ext.show() - + win_ext, ext = create_extension(dashboard, CustomExtensionTemplate, show_extension=True) sys.exit(app.exec()) From 3c9e2d6852a3440e31b4fe399631041bf2fdca8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Fri, 9 Oct 2026 18:14:22 +0200 Subject: [PATCH 04/16] AGENTS.md: extension patterns (main(), _quit_fun, ExtensionWorker), Config registration Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 903fef3..263fdea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,9 @@ File and class names must match: `daq_move_.py` holds `DAQ_Move_`; ` mock controller and keeps the plugin file readable. - Defaults live in `resources/config_template.toml`, not in the code. Read configuration values through `GlobalConfig` from `pymodaq_utils.config`: a singleton that wraps the `Config` objects of all packages, e.g. - `GlobalConfig()('gui', 'style', 'theme')`. Do not parse the toml files yourself. + `GlobalConfig()('gui', 'style', 'theme')`. Do not parse the toml files yourself, and do not instantiate a `Config` + class directly (deprecated): PyMoDAQ registers the `Config` of `/utils.py` in `GlobalConfig` when it + discovers the plugin, under the package name without `pymodaq_plugins_`. **Legacy patterns to avoid** (older tutorials and models still produce them; `check_plugin` flags several): `stage_names` (use `_axis_names`), `_epsilon` (use `_epsilons`), `data_actuator_type = float` (use @@ -96,6 +98,31 @@ and the deprecated `CustomApp` / `CustomExt` hooks `setup_docks` and `setup_menu - Entry point group `pymodaq.extensions` in `pyproject.toml`; each module in `extensions/` defines `EXTENSION_NAME` (menu label), `CLASS_NAME` (class name) and a class derived from `CustomExt`. - Import: `from pymodaq.utils.custom_ext import CustomExt`. +- A plugin extension follows exactly the pattern of the core extensions (`daq_scan`, `sequencer`, `ramping`, data + mixer). The only difference is `EXTENSION_NAME` and `CLASS_NAME`, which the Dashboard needs to recognise it. +- **`_quit_fun(self) -> bool` must be overridden and return `True`** (return `False`, after telling the user, while + the extension is running). The base implementation returns `None`, which prevents closing. +- **`main()` pattern** (to run the extension alone, same as the core ones): + + ```python + def main(): + import sys + from pymodaq_gui.qt_utils import mkQApp + from pymodaq.dashboard import load_dashboard_with_arguments + from pymodaq.utils.gui_utils.loader_utils import create_extension + + app = mkQApp('My Extension') + win, dashboard, _ = load_dashboard_with_arguments(show_dashboard=False, load_extension=False) + win.mainwindow.setVisible(False) + win_ext, ext = create_extension(dashboard, MyExtension, show_extension=True) + sys.exit(app.exec()) + ``` + + `load_dashboard_with_arguments` reads the command line (`-x EXPERIMENT_NAME`, `-s STATE_NAME`). + `create_extension` already shows the window: do not call `win_ext.show()` again, and do not use `create_load_dashboard` + here. +- Long-running work (a scan, a sequence, a ramp) goes in an `ExtensionWorker` subclass run in a thread, started and + stopped from the extension's workflow actions, as in `sequencer.py` and `ramping.py`. - `__init__(self, parent: DockArea, dashboard)`: call `super().__init__(parent, dashboard)`, then `self.setup_ui()`. - **Experiment and state belong to the Dashboard.** The *experiment* is the list of instruments (actuators and detectors) the Dashboard manages and controls; the *state* is the state of that experiment, mostly the values of the From 690d394df998cc1607cea9f8036505d7f4459f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Fri, 9 Oct 2026 18:36:02 +0200 Subject: [PATCH 05/16] starting the template updateing --- README.rst | 11 +++++++++++ pyproject.toml | 14 +++++++------- .../daq_move_plugins/daq_move_Template.py | 6 +++--- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index 1ff7b03..b369419 100644 --- a/README.rst +++ b/README.rst @@ -21,6 +21,17 @@ pymodaq_plugins_template Use this template to create a repository on your account and start the development of your own PyMoDAQ plugin! +This branch targets **PyMoDAQ 5.3.x** (Python 3.10 to 3.13). Use the branch matching your PyMoDAQ version. + +Development +=========== + +* Install your plugin in editable mode: ``pip install -e .`` +* Check it, without any hardware: ``check_plugin`` (and ``pytest``) +* If you use an AI coding assistant, point it to ``AGENTS.md``: it describes the patterns to follow for instrument + plugins, Dashboard extensions (``CustomExt``, whenever instruments are involved) and standalone apps + (``CustomApp``, only if no instruments are needed). + Authors ======= diff --git a/pyproject.toml b/pyproject.toml index 750cd9b..458bdcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,10 +12,10 @@ package-url = 'https://github.com/PyMoDAQ/pymodaq_plugins_template' #todo modify name = "pymodaq_plugins_template" #todo modify template by your plugin short name description = 'some word about your plugin' dependencies = [ - "pymodaq>=5.2.0", - "pymodaq_utils>=5.2.0", - "pymodaq_gui>=5.2.0", - "pymodaq_data>=5.2.0", + "pymodaq>=5.3.1", + "pymodaq_utils>=5.3.1", + "pymodaq_gui>=5.3.1", + "pymodaq_data>=5.3.1", #todo: list here all dependencies your package may have ] @@ -33,7 +33,7 @@ maintainers = [ dynamic = ["version", "urls", "entry-points"] readme = "README.rst" license = { file="LICENSE" } -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", @@ -41,10 +41,10 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Human Machine Interfaces", "Topic :: Scientific/Engineering :: Visualization", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py b/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py index 1dc9f25..26cc6fe 100644 --- a/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py +++ b/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py @@ -46,9 +46,9 @@ class DAQ_Move_Template(DAQ_Move_base): _axis_names: Union[List[str], Dict[str, int]] = ['Axis1', 'Axis2'] # TODO for your plugin: complete the list _controller_units: Union[str, List[str]] = 'mm' # TODO for your plugin: put the correct unit here, it could be # TODO a single str (the same one is applied to all axes) or a list of str (as much as the number of axes) - _epsilon: Union[float, List[float]] = 0.1 # TODO replace this by a value that is correct depending on your controller + _epsilons: Union[float, List[float]] = 0.1 # TODO replace this by a value that is correct depending on your controller # TODO it could be a single float of a list of float (as much as the number of axes) - # _epsilon is the initial default value for the epsilon parameter allowing pymodaq to know if the controller reached + # _epsilons is the initial default value for the epsilon parameter allowing pymodaq to know if the controller reached # the target value. It is the developer responsibility to put here a meaningful value @@ -64,7 +64,7 @@ class DAQ_Move_Template(DAQ_Move_base): params = [ # TODO for your custom plugin: elements to be added here as dicts in order to control your custom stage - ] + comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilon) + ] + comon_parameters_fun(axis_names=_axis_names) def ini_attributes(self): # TODO declare the type of the wrapper (and assign it to self.controller) you're going to use for easy From dd8de50eee65f6336f7a069663c6a20d5001c1fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Fri, 9 Oct 2026 18:40:32 +0200 Subject: [PATCH 06/16] Create STATUS_AI_DEV.md --- STATUS_AI_DEV.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 STATUS_AI_DEV.md diff --git a/STATUS_AI_DEV.md b/STATUS_AI_DEV.md new file mode 100644 index 0000000..831c83e --- /dev/null +++ b/STATUS_AI_DEV.md @@ -0,0 +1,71 @@ +# Status note: AI related PyMoDAQ dev (startup context) + +Written 2026-10-09 for the Project "PyMoDAQ and better agents coding". Not part of the template: do not commit it +or include it in a PR. + +## Goal + +Make PyMoDAQ efficient for AI-assisted development: help assistants propose PyMoDAQ, and write correct plugins, +Dashboard extensions (`CustomExt`) and standalone apps (`CustomApp`). Highest priority: plugins. The full plan +(sections: plugins P1-P7, extensions E1-E5, standalone apps A1-A4, cross-cutting C1-C6, contribution policy, roadmap +in 4 phases with 3 gates) is the exported document "AI related pymodaq dev". + +## Decisions taken (by the maintainer) + +1. **Instruments => `CustomExt`.** An application that drives instruments must be a Dashboard extension, because it + relies on the Dashboard's stable mechanisms for instruments. `CustomApp` only when no instrument is needed. +2. **The word "preset" is obsolete.** The Dashboard is used through the **experiment manager** (the experiment = the + list of instruments the Dashboard manages and controls) and the **state manager** (the state = mostly the instruments' + settings values and the Dashboard's actuator values). Extensions must use the managers obtained from their + `dashboard` argument (`self.experiment_manager`, `self.state_manager`), never their own. +3. **Configuration** is read through `GlobalConfig` (`pymodaq_utils.config`), a singleton wrapping all packages' + `Config` objects. Instantiating a `Config` class directly is deprecated. +4. **`ModulesManager`** is recreated for each extension (base class `__init__`) so that each extension can handle + different instruments. Never share it. +5. **Deprecated hooks:** `setup_docks` and `setup_menu`; use `setup_docks_and_widgets` and `setup_menus_and_toolbars`. +6. **Install workflow:** PyMoDAQ packages (`pymodaq_utils`, `pymodaq_data`, `pymodaq_gui`, `pymodaq`) in editable + mode with `python install-packages.py -d` at the root of the PyMoDAQ repository; plugins with `pip install -e .`. +7. **Plugin extensions** differ from core extensions only by `EXTENSION_NAME` and `CLASS_NAME`. Reference `main()`: + `load_dashboard_with_arguments(show_dashboard=False, load_extension=False)` then + `create_extension(dashboard, Class, show_extension=True)` (no extra `win_ext.show()`). `_quit_fun` must return `True`. + +## State of the branch `ai-dev/agents-md` (from `origin/5.3.x`, nothing pushed) + +| Commit | Content | +| --- | --- | +| `252baba` | `AGENTS.md` first draft | +| `a14343b` | `AGENTS.md`: GlobalConfig, per-extension ModulesManager, non-deprecated hooks | +| `ab49df6` | App and extension templates updated to the current patterns | +| `3c9e2d6` | `AGENTS.md`: extension patterns (`main()`, `_quit_fun`, `ExtensionWorker`), Config registration | +| `690d394` | (maintainer) P2 start: `_epsilons`, README target version, `pyproject.toml` floors and Python versions | + +Uncommitted: `pyproject.toml` floors corrected to `pymodaq>=5.3.1`, `pymodaq_utils>=5.3.1`, `pymodaq_gui>=5.3.0`, +`pymodaq_data>=5.3.0` (PyPI only has gui and data up to 5.3.0, so `>=5.3.1` for them cannot install). This file +(`STATUS_AI_DEV.md`) is untracked. + +## Not verified yet + +- **Nothing has been run.** `check_plugin`, `pytest` and the two updated templates were only syntax-checked. An isolated + venv install of PyMoDAQ 5.3.1 was interrupted twice; the checks should be run in an environment with PyMoDAQ 5.3.1. +- The registered `GlobalConfig` key of a plugin's `Config` (package name without `pymodaq_plugins_`) was derived from + reading `GlobalConfig.register`, not run. +- `AGENTS.md` statements to confirm: mandatory actuator methods (the template test's longer list was used), exact + behaviour of `do_things_after_experiment_set`. + +## Findings worth acting on + +- The mock plugin (`pymodaq_plugins_mock`) still uses `_epsilon`, which `check_plugin` flags (PMQ302). +- Core extensions disagree on `main()`: `sequencer` and `ramping` also call `win_ext.show()`; `daq_scan` and `data_mixer` do not. +- About 100 "preset" occurrences remain in PyMoDAQ code and docs (plan item C4b: check and clean). +- No `llms.txt` on the docs hosts (404). Docs sample for `CustomApp` references undefined methods. +- Template CI `compatibility.yml` still lists Python 3.9; workflows appear managed by an updater. +- `check_plugin` already flags `stage_names`, `_epsilon`, `data_actuator_type`, unknown units (P5 is partly done). + +## Next steps + +1. Commit the `pyproject.toml` floor fix; run `check_plugin` and `pytest` with PyMoDAQ 5.3.1 and fix what shows up. +2. Finish P2: default branch on GitHub to track 5.3.x (a repository setting, maintainer's). +3. P3: one definition of the plugin contract (mandatory methods) shared by checks, template test and docs. +4. E1/E1b: fix extension docs and write the workflow/pattern page with diagrams; align core `main()` patterns. +5. P4: plugin cookbook with mock controllers; E2: small reference extension. +6. Later phases: `llms.txt`, docstrings and typing on the public API, "preset" cleanup, contribution policy. From 548d36c5ec3453f83c46a1f6a1babb13a105b55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Fri, 9 Oct 2026 16:49:18 +0000 Subject: [PATCH 07/16] Template installs and checks pass with PyMoDAQ 5.3.1 - pyproject: pymodaq_gui and pymodaq_data floors back to 5.3.0 (the latest releases on PyPI), >=5.3.1 could not be installed - CLAUDE.md: pointer to AGENTS.md - AGENTS.md: done checklist mentions the [features] flags (PMQ109) - compatibility workflow: Python 3.10 to 3.13, as in pyproject - STATUS_AI_DEV.md untracked (working note, not part of the template) Verified with pymodaq 5.3.1, pymodaq_utils 5.3.1, pymodaq_gui/data 5.3.0, PyQt5, Python 3.13: check_plugin exits 0, pytest 6 passed, the app and extension templates build headless (offscreen) and _quit_fun returns True. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .github/workflows/compatibility.yml | 2 +- .gitignore | 1 + AGENTS.md | 2 + CLAUDE.md | 3 ++ STATUS_AI_DEV.md | 71 ----------------------------- pyproject.toml | 4 +- 6 files changed, 9 insertions(+), 74 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 STATUS_AI_DEV.md diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 01003f5..3c20850 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -23,7 +23,7 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "windows-latest"] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] qt-backend: ["pyqt5", "pyqt6", "pyside6"] runs-on: ${{ matrix.os }} env: diff --git a/.gitignore b/.gitignore index 66bbba3..7374940 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,4 @@ venv.bak/ *yacctab.py *lextab.py +STATUS_AI_DEV.md diff --git a/AGENTS.md b/AGENTS.md index 263fdea..37adaa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,6 +147,8 @@ Only when no instrument is needed. Same (non-deprecated) lifecycle as above, `pa ## 6. Done checklist - [ ] Names (package, files, classes) match and the `template` placeholders are gone. +- [ ] The `[features]` flags at the top of `pyproject.toml` match the folders you use (`instruments`, `extensions`, + `models`...): entry points are generated only for the features set to `true` (`check_plugin` warns, PMQ109). - [ ] `check_plugin` passes; no `NotImplementedError` or `TODO` left in the files you wrote. - [ ] Units set on all actuator values and axes; safe behaviour in `close()` and on errors. - [ ] Tests added for new behaviour using a mock controller (no real hardware in CI). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bf683e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE.md + +Read and follow [AGENTS.md](AGENTS.md): it holds all the guidance for AI coding assistants working in this repository. diff --git a/STATUS_AI_DEV.md b/STATUS_AI_DEV.md deleted file mode 100644 index 831c83e..0000000 --- a/STATUS_AI_DEV.md +++ /dev/null @@ -1,71 +0,0 @@ -# Status note: AI related PyMoDAQ dev (startup context) - -Written 2026-10-09 for the Project "PyMoDAQ and better agents coding". Not part of the template: do not commit it -or include it in a PR. - -## Goal - -Make PyMoDAQ efficient for AI-assisted development: help assistants propose PyMoDAQ, and write correct plugins, -Dashboard extensions (`CustomExt`) and standalone apps (`CustomApp`). Highest priority: plugins. The full plan -(sections: plugins P1-P7, extensions E1-E5, standalone apps A1-A4, cross-cutting C1-C6, contribution policy, roadmap -in 4 phases with 3 gates) is the exported document "AI related pymodaq dev". - -## Decisions taken (by the maintainer) - -1. **Instruments => `CustomExt`.** An application that drives instruments must be a Dashboard extension, because it - relies on the Dashboard's stable mechanisms for instruments. `CustomApp` only when no instrument is needed. -2. **The word "preset" is obsolete.** The Dashboard is used through the **experiment manager** (the experiment = the - list of instruments the Dashboard manages and controls) and the **state manager** (the state = mostly the instruments' - settings values and the Dashboard's actuator values). Extensions must use the managers obtained from their - `dashboard` argument (`self.experiment_manager`, `self.state_manager`), never their own. -3. **Configuration** is read through `GlobalConfig` (`pymodaq_utils.config`), a singleton wrapping all packages' - `Config` objects. Instantiating a `Config` class directly is deprecated. -4. **`ModulesManager`** is recreated for each extension (base class `__init__`) so that each extension can handle - different instruments. Never share it. -5. **Deprecated hooks:** `setup_docks` and `setup_menu`; use `setup_docks_and_widgets` and `setup_menus_and_toolbars`. -6. **Install workflow:** PyMoDAQ packages (`pymodaq_utils`, `pymodaq_data`, `pymodaq_gui`, `pymodaq`) in editable - mode with `python install-packages.py -d` at the root of the PyMoDAQ repository; plugins with `pip install -e .`. -7. **Plugin extensions** differ from core extensions only by `EXTENSION_NAME` and `CLASS_NAME`. Reference `main()`: - `load_dashboard_with_arguments(show_dashboard=False, load_extension=False)` then - `create_extension(dashboard, Class, show_extension=True)` (no extra `win_ext.show()`). `_quit_fun` must return `True`. - -## State of the branch `ai-dev/agents-md` (from `origin/5.3.x`, nothing pushed) - -| Commit | Content | -| --- | --- | -| `252baba` | `AGENTS.md` first draft | -| `a14343b` | `AGENTS.md`: GlobalConfig, per-extension ModulesManager, non-deprecated hooks | -| `ab49df6` | App and extension templates updated to the current patterns | -| `3c9e2d6` | `AGENTS.md`: extension patterns (`main()`, `_quit_fun`, `ExtensionWorker`), Config registration | -| `690d394` | (maintainer) P2 start: `_epsilons`, README target version, `pyproject.toml` floors and Python versions | - -Uncommitted: `pyproject.toml` floors corrected to `pymodaq>=5.3.1`, `pymodaq_utils>=5.3.1`, `pymodaq_gui>=5.3.0`, -`pymodaq_data>=5.3.0` (PyPI only has gui and data up to 5.3.0, so `>=5.3.1` for them cannot install). This file -(`STATUS_AI_DEV.md`) is untracked. - -## Not verified yet - -- **Nothing has been run.** `check_plugin`, `pytest` and the two updated templates were only syntax-checked. An isolated - venv install of PyMoDAQ 5.3.1 was interrupted twice; the checks should be run in an environment with PyMoDAQ 5.3.1. -- The registered `GlobalConfig` key of a plugin's `Config` (package name without `pymodaq_plugins_`) was derived from - reading `GlobalConfig.register`, not run. -- `AGENTS.md` statements to confirm: mandatory actuator methods (the template test's longer list was used), exact - behaviour of `do_things_after_experiment_set`. - -## Findings worth acting on - -- The mock plugin (`pymodaq_plugins_mock`) still uses `_epsilon`, which `check_plugin` flags (PMQ302). -- Core extensions disagree on `main()`: `sequencer` and `ramping` also call `win_ext.show()`; `daq_scan` and `data_mixer` do not. -- About 100 "preset" occurrences remain in PyMoDAQ code and docs (plan item C4b: check and clean). -- No `llms.txt` on the docs hosts (404). Docs sample for `CustomApp` references undefined methods. -- Template CI `compatibility.yml` still lists Python 3.9; workflows appear managed by an updater. -- `check_plugin` already flags `stage_names`, `_epsilon`, `data_actuator_type`, unknown units (P5 is partly done). - -## Next steps - -1. Commit the `pyproject.toml` floor fix; run `check_plugin` and `pytest` with PyMoDAQ 5.3.1 and fix what shows up. -2. Finish P2: default branch on GitHub to track 5.3.x (a repository setting, maintainer's). -3. P3: one definition of the plugin contract (mandatory methods) shared by checks, template test and docs. -4. E1/E1b: fix extension docs and write the workflow/pattern page with diagrams; align core `main()` patterns. -5. P4: plugin cookbook with mock controllers; E2: small reference extension. -6. Later phases: `llms.txt`, docstrings and typing on the public API, "preset" cleanup, contribution policy. diff --git a/pyproject.toml b/pyproject.toml index 458bdcf..5886c5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,8 @@ description = 'some word about your plugin' dependencies = [ "pymodaq>=5.3.1", "pymodaq_utils>=5.3.1", - "pymodaq_gui>=5.3.1", - "pymodaq_data>=5.3.1", + "pymodaq_gui>=5.3.0", + "pymodaq_data>=5.3.0", #todo: list here all dependencies your package may have ] From eaaa9e678f1f9608a7327244dff4b57ec9f1b2aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Fri, 9 Oct 2026 16:56:47 +0000 Subject: [PATCH 08/16] Add a hardware-free test harness for instrument plugins (P6) - tests/plugin_harness.py: drive an actuator or detector plugin as the worker does (init, move_abs/rel/home, grab) and wait for move_done_signal / dte_signal in a local Qt event loop, with a timeout; targets are converted to the axis unit as PyMoDAQ does - tests/example_mock_plugins.py: fake controller, minimal actuator, multi-axes actuator and 1D detector, a pattern to copy - tests/test_plugin_behaviour.py: 11 behaviour tests (units, move, home, shared controller, close, grab, timeout) running offscreen - AGENTS.md: how to test behaviour without hardware Verified with pymodaq 5.3.1: pytest 17 passed, check_plugin exit 0. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- AGENTS.md | 10 ++- tests/example_mock_plugins.py | 114 +++++++++++++++++++++++++++++ tests/plugin_harness.py | 129 +++++++++++++++++++++++++++++++++ tests/test_plugin_behaviour.py | 93 ++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 tests/example_mock_plugins.py create mode 100644 tests/plugin_harness.py create mode 100644 tests/test_plugin_behaviour.py diff --git a/AGENTS.md b/AGENTS.md index 37adaa1..2854855 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Prefer the first option that fits: pip install -e . # editable install of this plugin, from the repository root check_plugin # static report on this package (hardware not needed) check_plugin --fail-on todo -v # also list every unfinished TODO -pytest # template tests (package structure, plugin checks) +pytest # template tests (package structure, plugin checks, behaviour against a fake controller) ``` Installing PyMoDAQ itself is a separate matter. A plugin only needs the released packages (`pymodaq`, @@ -86,6 +86,14 @@ File and class names must match: `daq_move_.py` holds `DAQ_Move_`; ` class directly (deprecated): PyMoDAQ registers the `Config` of `/utils.py` in `GlobalConfig` when it discovers the plugin, under the package name without `pymodaq_plugins_`. +**Testing behaviour without hardware.** `tests/plugin_harness.py` drives a plugin the way PyMoDAQ does, without a GUI: +`make_actuator` / `make_detector` (instantiate and call `ini_stage` / `ini_detector`), `move_abs_and_wait`, +`move_rel_and_wait`, `move_home_and_wait`, `grab_and_wait` (return the final position or the `DataToExport`, and fail +on a timeout) and `assert_units`. `tests/example_mock_plugins.py` shows a fake controller and the smallest actuator, +multi-axes actuator and 1D detector; `tests/test_plugin_behaviour.py` shows the tests to copy. Write the vendor +communication behind a wrapper class in `hardware/`, write a fake with the same public methods, and run your real +plugin class against it (monkeypatch the wrapper class). Targets reach the plugin in the axis unit, as in PyMoDAQ. + **Legacy patterns to avoid** (older tutorials and models still produce them; `check_plugin` flags several): `stage_names` (use `_axis_names`), `_epsilon` (use `_epsilons`), `data_actuator_type = float` (use `DataActuatorType.DataActuator`), the group names `multiaxes` and `multi_status` (now `controller` and diff --git a/tests/example_mock_plugins.py b/tests/example_mock_plugins.py new file mode 100644 index 0000000..c19bb7f --- /dev/null +++ b/tests/example_mock_plugins.py @@ -0,0 +1,114 @@ +"""Minimal fake controller and plugins used to test the harness, and as a pattern to copy. + +When you write a real plugin, keep the vendor communication behind a small wrapper class in ``hardware/`` and write a +fake of that wrapper with the same public methods: tests then run the real plugin class against the fake. +""" +import numpy as np + +from pymodaq.control_modules.move_utility_classes import (DAQ_Move_base, comon_parameters_fun, DataActuatorType) +from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters +from pymodaq.utils.data import DataActuator, DataFromPlugins +from pymodaq_data.data import DataToExport + + +class FakeController: + """Stands for the python wrapper of a real instrument: instantaneous, in memory, records what it was asked""" + def __init__(self): + self.position = 0. + self.is_open = True + self.calls = [] + + def move_at(self, value: float): + self.calls.append(('move_at', value)) + self.position = value + + def get_position(self) -> float: + return self.position + + def acquire(self, npts: int = 10) -> np.ndarray: + self.calls.append(('acquire', npts)) + return np.linspace(0., 1., npts) + + def close(self): + self.is_open = False + + +class DAQ_Move_Fake(DAQ_Move_base): + """Smallest actuator following the template: one axis, positions in mm""" + _controller_units = 'mm' + is_multiaxes = False + _axis_names = ['axis'] + _epsilons = [0.01] + data_actuator_type = DataActuatorType.DataActuator + params = comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilons) + + def ini_attributes(self): + self.controller: FakeController = None + + def ini_stage(self, controller=None): + self.controller = FakeController() if self.is_master else controller + return 'fake stage ready', True + + def get_actuator_value(self) -> DataActuator: + pos = DataActuator(data=self.controller.get_position(), units=self.axis_unit) + return self.get_position_with_scaling(pos) + + def move_abs(self, value: DataActuator): + value = self.check_bound(value) + self.target_value = value + value = self.set_position_with_scaling(value) + self.controller.move_at(value.value()) + + 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.controller.move_at(value.value()) + + def move_home(self): + self.move_abs(DataActuator(data=0., units=self.axis_unit)) + + def stop_motion(self): + self.controller.calls.append(('stop',)) + + def commit_settings(self, param): + pass + + def close(self): + if self.is_master: + self.controller.close() + + +class DAQ_MultiAxes_Fake(DAQ_Move_Fake): + """Several axes sharing one controller: the master creates it, the slaves are given it by PyMoDAQ""" + is_multiaxes = True + _axis_names = ['x', 'y'] + _epsilons = [0.01, 0.01] + params = comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilons) + + +class DAQ_1DViewer_Fake(DAQ_Viewer_base): + """Smallest 1D detector following the template""" + params = comon_parameters + [ + {'title': 'Npts:', 'name': 'npts', 'type': 'int', 'value': 10, 'min': 1}, + ] + + def ini_attributes(self): + self.controller: FakeController = None + + def ini_detector(self, controller=None): + self.controller = FakeController() if self.is_master else controller + return 'fake detector ready', True + + def commit_settings(self, param): + pass + + def grab_data(self, Naverage=1, **kwargs): + y = self.controller.acquire(self.settings['npts']) + self.dte_signal.emit(DataToExport( + name='fake', + data=[DataFromPlugins(name='trace', data=[y], dim='Data1D', labels=['signal'], units='V')])) + + def close(self): + if self.is_master: + self.controller.close() diff --git a/tests/plugin_harness.py b/tests/plugin_harness.py new file mode 100644 index 0000000..56cd890 --- /dev/null +++ b/tests/plugin_harness.py @@ -0,0 +1,129 @@ +"""Helpers to exercise instrument plugins without hardware and without any visible GUI. + +A plugin is driven the way PyMoDAQ's worker does it, but in the calling thread: the signals emitted by the plugin +(``move_done_signal``, ``dte_signal``) are waited for in a local Qt event loop, with a timeout. + +Typical use, with a plugin whose controller is replaced by a fake one (see ``example_mock_plugins.py``):: + + actuator = make_actuator(DAQ_Move_MyDevice, controller=FakeController()) + position = move_abs_and_wait(actuator, 2.5) + assert position.value() == pytest.approx(2.5) +""" +from typing import Callable, Optional, Union + +from qtpy.QtCore import QEventLoop, QTimer + +from pymodaq.control_modules.thread_commands import ControllerStatus +from pymodaq.utils.data import DataActuator +from pymodaq_data.data import DataToExport + +TIMEOUT_MS = 5000 + + +class SignalTimeout(AssertionError): + """The expected signal was not emitted before the timeout.""" + + +def wait_for_signal(signal, action: Callable[[], object], timeout_ms: int = TIMEOUT_MS): + """Call ``action`` and return the first argument of the next emission of ``signal``. + + Raises ``SignalTimeout`` if nothing is emitted within ``timeout_ms``. The signal can be emitted before + ``action`` returns (synchronous plugins): it is not missed. + """ + received = [] + loop = QEventLoop() + + def slot(*args): + received.append(args[0] if args else None) + loop.quit() + + signal.connect(slot) + try: + action() + if not received: + QTimer.singleShot(timeout_ms, loop.quit) + loop.exec() + finally: + signal.disconnect(slot) + if not received: + raise SignalTimeout(f'no signal emitted within {timeout_ms} ms') + return received[0] + + +def _set_slave(plugin, controller): + # a plugin is given an existing controller only as a slave axis of a multi-axes controller + if controller is not None: + plugin.settings.child('controller', 'controller_status').setValue(ControllerStatus.SLAVE) + + +def make_actuator(plugin_class, controller=None, **settings): + """Instantiate an actuator plugin and initialize it, as the Dashboard does. + + ``controller`` is only given for a slave axis of a multi-axes plugin (it is then set as ``Slave``), a master + creates its own. Returns the plugin, with ``initialized`` and ``init_info`` set from the return of ``ini_stage``. + """ + plugin = plugin_class() + _set_slave(plugin, controller) + for name, value in settings.items(): + plugin.settings.child(name).setValue(value) + info, plugin.initialized = plugin.ini_stage(controller) + plugin.init_info = info + return plugin + + +def make_detector(plugin_class, controller=None, **settings): + """Instantiate a detector plugin and initialize it, as the Dashboard does (see ``make_actuator``).""" + plugin = plugin_class() + _set_slave(plugin, controller) + for name, value in settings.items(): + plugin.settings.child(name).setValue(value) + info, plugin.initialized = plugin.ini_detector(controller) + plugin.init_info = info + return plugin + + +def _in_axis_unit(plugin, value: Union[float, DataActuator]) -> DataActuator: + # the worker converts the target to the axis unit before calling the plugin: a plugin never receives another unit + if not isinstance(value, DataActuator): + return DataActuator(plugin._title, data=value, units=plugin.axis_unit) + return value.units_as(plugin.axis_unit, inplace=False) + + +def _start_move(plugin, move: Callable[[], object]): + # same sequence as ActuatorWorker.move_abs: reset the flags, move, then poll until the target is reached + plugin.move_is_done = False + plugin.ispolling = True + move() + plugin.poll_moving() + + +def move_abs_and_wait(plugin, position: Union[float, DataActuator], timeout_ms: int = TIMEOUT_MS) -> DataActuator: + """Move to an absolute position (a float is taken in the axis unit, other units are converted) and return the position reported at the end""" + position = _in_axis_unit(plugin, position) + return wait_for_signal(plugin.move_done_signal, + lambda: _start_move(plugin, lambda: plugin.move_abs(position)), timeout_ms) + + +def move_rel_and_wait(plugin, shift: Union[float, DataActuator], timeout_ms: int = TIMEOUT_MS) -> DataActuator: + """Move by a relative amount (a float is taken in the axis unit) and return the position reported at the end""" + shift = _in_axis_unit(plugin, shift) + return wait_for_signal(plugin.move_done_signal, + lambda: _start_move(plugin, lambda: plugin.move_rel(shift)), timeout_ms) + + +def move_home_and_wait(plugin, timeout_ms: int = TIMEOUT_MS) -> DataActuator: + """Send the actuator home and return the position reported at the end""" + return wait_for_signal(plugin.move_done_signal, lambda: _start_move(plugin, plugin.move_home), timeout_ms) + + +def grab_and_wait(plugin, naverage: int = 1, timeout_ms: int = TIMEOUT_MS, **kwargs) -> DataToExport: + """Start a grab and return the ``DataToExport`` emitted by the detector plugin""" + return wait_for_signal(plugin.dte_signal, lambda: plugin.grab_data(naverage, **kwargs), timeout_ms) + + +def assert_units(data: DataToExport, units: Optional[str] = None): + """Check that every data object of ``data`` carries a unit (and the given one, if ``units`` is not None)""" + for dwa in data: + assert dwa.units is not None and dwa.units != '', f'{dwa.name} has no units' + if units is not None: + assert dwa.units == units, f'{dwa.name} is in {dwa.units}, expected {units}' diff --git a/tests/test_plugin_behaviour.py b/tests/test_plugin_behaviour.py new file mode 100644 index 0000000..e3dad4d --- /dev/null +++ b/tests/test_plugin_behaviour.py @@ -0,0 +1,93 @@ +"""Behaviour tests of instrument plugins against a fake controller: no hardware, no visible GUI. + +Here the harness is applied to the example fake plugins of ``example_mock_plugins.py``. To test your own plugin, copy +a test and replace the plugin class and the fake controller, for instance:: + + from pymodaq_plugins_.daq_move_plugins.daq_move_ import DAQ_Move_ + actuator = make_actuator(DAQ_Move_) # then monkeypatch the controller class with your fake before ini_stage +""" +import pytest + +from plugin_harness import (make_actuator, make_detector, move_abs_and_wait, move_rel_and_wait, move_home_and_wait, + grab_and_wait, assert_units, wait_for_signal, SignalTimeout) +from example_mock_plugins import DAQ_Move_Fake, DAQ_1DViewer_Fake, DAQ_MultiAxes_Fake + + +@pytest.fixture +def actuator(qapp): + plugin = make_actuator(DAQ_Move_Fake) + assert plugin.initialized + yield plugin + plugin.close() + + +@pytest.fixture +def detector(qapp): + plugin = make_detector(DAQ_1DViewer_Fake, npts=20) + assert plugin.initialized + yield plugin + plugin.close() + + +class TestActuator: + def test_ini_returns_info_and_flag(self, actuator): + assert isinstance(actuator.init_info, str) + assert actuator.initialized is True + + def test_value_has_units(self, actuator): + pos = actuator.get_actuator_value() + assert pos.units == 'mm' + + def test_move_abs(self, actuator): + pos = move_abs_and_wait(actuator, 2.5) + assert pos.value('mm') == pytest.approx(2.5, abs=0.01) + assert actuator.controller.calls[-1] == ('move_at', 2.5) + + def test_move_rel(self, actuator): + move_abs_and_wait(actuator, 1.) + pos = move_rel_and_wait(actuator, 0.5) + assert pos.value('mm') == pytest.approx(1.5, abs=0.01) + + def test_move_home(self, actuator): + move_abs_and_wait(actuator, 3.) + pos = move_home_and_wait(actuator) + assert pos.value('mm') == pytest.approx(0., abs=0.01) + + def test_other_units_are_converted(self, actuator): + from pymodaq.utils.data import DataActuator + pos = move_abs_and_wait(actuator, DataActuator(data=1., units='cm')) # 1 cm = 10 mm + assert pos.value('mm') == pytest.approx(10., abs=0.01) + + def test_close_releases_the_controller(self, actuator): + controller = actuator.controller + actuator.close() + assert controller.is_open is False + + def test_slave_axis_uses_the_given_controller(self, qapp): + master = make_actuator(DAQ_MultiAxes_Fake) + slave = make_actuator(DAQ_MultiAxes_Fake, controller=master.controller) + assert slave.controller is master.controller + move_abs_and_wait(slave, 1.) + assert master.controller.calls[-1] == ('move_at', 1.) + slave.close() + assert master.controller.is_open # only the master releases the shared controller + master.close() + assert not master.controller.is_open + + +class TestDetector: + def test_grab_returns_data_with_units(self, detector): + dte = grab_and_wait(detector) + assert len(dte) == 1 + assert dte[0].shape == (20,) + assert_units(dte, 'V') + + def test_close_releases_the_controller(self, detector): + controller = detector.controller + detector.close() + assert controller.is_open is False + + +def test_harness_times_out_when_nothing_happens(qapp, actuator): + with pytest.raises(SignalTimeout): + wait_for_signal(actuator.move_done_signal, lambda: None, timeout_ms=100) From 8c3954a01cccbf8cadfd99035a8fd607c225cf20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Weber?= Date: Fri, 9 Oct 2026 17:25:16 +0000 Subject: [PATCH 09/16] AGENTS.md: say what the hardware-free tests do not prove Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2854855..56aacf7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,11 @@ multi-axes actuator and 1D detector; `tests/test_plugin_behaviour.py` shows the communication behind a wrapper class in `hardware/`, write a fake with the same public methods, and run your real plugin class against it (monkeypatch the wrapper class). Targets reach the plugin in the axis unit, as in PyMoDAQ. +These tests only check your side of the PyMoDAQ contract (return types, units, signals, shared controller, `close`) +against a fake that encodes what you believe the driver does. They say nothing about the device: timing, error replies, +real ranges and units. A passing run is not a hardware validation: never state that a plugin works on an instrument +that was not used, say what was tested on the real device and what only against a fake. + **Legacy patterns to avoid** (older tutorials and models still produce them; `check_plugin` flags several): `stage_names` (use `_axis_names`), `_epsilon` (use `_epsilons`), `data_actuator_type = float` (use `DataActuatorType.DataActuator`), the group names `multiaxes` and `multi_status` (now `controller` and From 862a72d6bec16895013669f309b76388b98c5c6a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:34:31 +0000 Subject: [PATCH 10/16] Use the test harness shipped in pymodaq 5.3.2, drop the local copy Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- AGENTS.md | 2 +- pyproject.toml | 2 +- tests/plugin_harness.py | 129 --------------------------------- tests/test_plugin_behaviour.py | 7 +- 4 files changed, 6 insertions(+), 134 deletions(-) delete mode 100644 tests/plugin_harness.py diff --git a/AGENTS.md b/AGENTS.md index 56aacf7..c5198db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ File and class names must match: `daq_move_.py` holds `DAQ_Move_`; ` class directly (deprecated): PyMoDAQ registers the `Config` of `/utils.py` in `GlobalConfig` when it discovers the plugin, under the package name without `pymodaq_plugins_`. -**Testing behaviour without hardware.** `tests/plugin_harness.py` drives a plugin the way PyMoDAQ does, without a GUI: +**Testing behaviour without hardware.** `pymodaq.utils.plugin_testing` (shipped with PyMoDAQ >= 5.3.2) drives a plugin the way PyMoDAQ does, without a GUI: `make_actuator` / `make_detector` (instantiate and call `ini_stage` / `ini_detector`), `move_abs_and_wait`, `move_rel_and_wait`, `move_home_and_wait`, `grab_and_wait` (return the final position or the `DataToExport`, and fail on a timeout) and `assert_units`. `tests/example_mock_plugins.py` shows a fake controller and the smallest actuator, diff --git a/pyproject.toml b/pyproject.toml index 5886c5e..b30c619 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ package-url = 'https://github.com/PyMoDAQ/pymodaq_plugins_template' #todo modify name = "pymodaq_plugins_template" #todo modify template by your plugin short name description = 'some word about your plugin' dependencies = [ - "pymodaq>=5.3.1", + "pymodaq>=5.3.2", "pymodaq_utils>=5.3.1", "pymodaq_gui>=5.3.0", "pymodaq_data>=5.3.0", diff --git a/tests/plugin_harness.py b/tests/plugin_harness.py deleted file mode 100644 index 56cd890..0000000 --- a/tests/plugin_harness.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Helpers to exercise instrument plugins without hardware and without any visible GUI. - -A plugin is driven the way PyMoDAQ's worker does it, but in the calling thread: the signals emitted by the plugin -(``move_done_signal``, ``dte_signal``) are waited for in a local Qt event loop, with a timeout. - -Typical use, with a plugin whose controller is replaced by a fake one (see ``example_mock_plugins.py``):: - - actuator = make_actuator(DAQ_Move_MyDevice, controller=FakeController()) - position = move_abs_and_wait(actuator, 2.5) - assert position.value() == pytest.approx(2.5) -""" -from typing import Callable, Optional, Union - -from qtpy.QtCore import QEventLoop, QTimer - -from pymodaq.control_modules.thread_commands import ControllerStatus -from pymodaq.utils.data import DataActuator -from pymodaq_data.data import DataToExport - -TIMEOUT_MS = 5000 - - -class SignalTimeout(AssertionError): - """The expected signal was not emitted before the timeout.""" - - -def wait_for_signal(signal, action: Callable[[], object], timeout_ms: int = TIMEOUT_MS): - """Call ``action`` and return the first argument of the next emission of ``signal``. - - Raises ``SignalTimeout`` if nothing is emitted within ``timeout_ms``. The signal can be emitted before - ``action`` returns (synchronous plugins): it is not missed. - """ - received = [] - loop = QEventLoop() - - def slot(*args): - received.append(args[0] if args else None) - loop.quit() - - signal.connect(slot) - try: - action() - if not received: - QTimer.singleShot(timeout_ms, loop.quit) - loop.exec() - finally: - signal.disconnect(slot) - if not received: - raise SignalTimeout(f'no signal emitted within {timeout_ms} ms') - return received[0] - - -def _set_slave(plugin, controller): - # a plugin is given an existing controller only as a slave axis of a multi-axes controller - if controller is not None: - plugin.settings.child('controller', 'controller_status').setValue(ControllerStatus.SLAVE) - - -def make_actuator(plugin_class, controller=None, **settings): - """Instantiate an actuator plugin and initialize it, as the Dashboard does. - - ``controller`` is only given for a slave axis of a multi-axes plugin (it is then set as ``Slave``), a master - creates its own. Returns the plugin, with ``initialized`` and ``init_info`` set from the return of ``ini_stage``. - """ - plugin = plugin_class() - _set_slave(plugin, controller) - for name, value in settings.items(): - plugin.settings.child(name).setValue(value) - info, plugin.initialized = plugin.ini_stage(controller) - plugin.init_info = info - return plugin - - -def make_detector(plugin_class, controller=None, **settings): - """Instantiate a detector plugin and initialize it, as the Dashboard does (see ``make_actuator``).""" - plugin = plugin_class() - _set_slave(plugin, controller) - for name, value in settings.items(): - plugin.settings.child(name).setValue(value) - info, plugin.initialized = plugin.ini_detector(controller) - plugin.init_info = info - return plugin - - -def _in_axis_unit(plugin, value: Union[float, DataActuator]) -> DataActuator: - # the worker converts the target to the axis unit before calling the plugin: a plugin never receives another unit - if not isinstance(value, DataActuator): - return DataActuator(plugin._title, data=value, units=plugin.axis_unit) - return value.units_as(plugin.axis_unit, inplace=False) - - -def _start_move(plugin, move: Callable[[], object]): - # same sequence as ActuatorWorker.move_abs: reset the flags, move, then poll until the target is reached - plugin.move_is_done = False - plugin.ispolling = True - move() - plugin.poll_moving() - - -def move_abs_and_wait(plugin, position: Union[float, DataActuator], timeout_ms: int = TIMEOUT_MS) -> DataActuator: - """Move to an absolute position (a float is taken in the axis unit, other units are converted) and return the position reported at the end""" - position = _in_axis_unit(plugin, position) - return wait_for_signal(plugin.move_done_signal, - lambda: _start_move(plugin, lambda: plugin.move_abs(position)), timeout_ms) - - -def move_rel_and_wait(plugin, shift: Union[float, DataActuator], timeout_ms: int = TIMEOUT_MS) -> DataActuator: - """Move by a relative amount (a float is taken in the axis unit) and return the position reported at the end""" - shift = _in_axis_unit(plugin, shift) - return wait_for_signal(plugin.move_done_signal, - lambda: _start_move(plugin, lambda: plugin.move_rel(shift)), timeout_ms) - - -def move_home_and_wait(plugin, timeout_ms: int = TIMEOUT_MS) -> DataActuator: - """Send the actuator home and return the position reported at the end""" - return wait_for_signal(plugin.move_done_signal, lambda: _start_move(plugin, plugin.move_home), timeout_ms) - - -def grab_and_wait(plugin, naverage: int = 1, timeout_ms: int = TIMEOUT_MS, **kwargs) -> DataToExport: - """Start a grab and return the ``DataToExport`` emitted by the detector plugin""" - return wait_for_signal(plugin.dte_signal, lambda: plugin.grab_data(naverage, **kwargs), timeout_ms) - - -def assert_units(data: DataToExport, units: Optional[str] = None): - """Check that every data object of ``data`` carries a unit (and the given one, if ``units`` is not None)""" - for dwa in data: - assert dwa.units is not None and dwa.units != '', f'{dwa.name} has no units' - if units is not None: - assert dwa.units == units, f'{dwa.name} is in {dwa.units}, expected {units}' diff --git a/tests/test_plugin_behaviour.py b/tests/test_plugin_behaviour.py index e3dad4d..0d7bc85 100644 --- a/tests/test_plugin_behaviour.py +++ b/tests/test_plugin_behaviour.py @@ -1,6 +1,6 @@ """Behaviour tests of instrument plugins against a fake controller: no hardware, no visible GUI. -Here the harness is applied to the example fake plugins of ``example_mock_plugins.py``. To test your own plugin, copy +Here the harness of ``pymodaq.utils.plugin_testing`` is applied to the example fake plugins of ``example_mock_plugins.py``. To test your own plugin, copy a test and replace the plugin class and the fake controller, for instance:: from pymodaq_plugins_.daq_move_plugins.daq_move_ import DAQ_Move_ @@ -8,8 +8,9 @@ """ import pytest -from plugin_harness import (make_actuator, make_detector, move_abs_and_wait, move_rel_and_wait, move_home_and_wait, - grab_and_wait, assert_units, wait_for_signal, SignalTimeout) +from pymodaq.utils.plugin_testing import (make_actuator, make_detector, move_abs_and_wait, move_rel_and_wait, + move_home_and_wait, grab_and_wait, assert_units, wait_for_signal, + SignalTimeout) from example_mock_plugins import DAQ_Move_Fake, DAQ_1DViewer_Fake, DAQ_MultiAxes_Fake From 06370dc30a4d4bd830a9a12b946451fb83a4800b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:23:41 +0000 Subject: [PATCH 11/16] Shared workflows: tests, compatibility and publish defined once, plugins keep three-line callers Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .github/workflows/Test.yml | 10 --- .github/workflows/Testbase.yml | 44 ------------ .github/workflows/compatibility.yml | 73 ++------------------ .github/workflows/python-publish.yml | 37 ++-------- .github/workflows/reusable-compatibility.yml | 45 ++++++++++++ .github/workflows/reusable-publish.yml | 41 +++++++++++ .github/workflows/reusable-tests.yml | 43 ++++++++++++ .github/workflows/tests.yml | 8 +++ .github/workflows/updater.yml | 24 ------- 9 files changed, 146 insertions(+), 179 deletions(-) delete mode 100644 .github/workflows/Test.yml delete mode 100644 .github/workflows/Testbase.yml create mode 100644 .github/workflows/reusable-compatibility.yml create mode 100644 .github/workflows/reusable-publish.yml create mode 100644 .github/workflows/reusable-tests.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 .github/workflows/updater.yml diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml deleted file mode 100644 index ffd1522..0000000 --- a/.github/workflows/Test.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: 'Tests' - -on: [push] - -jobs: - call_workflow: - uses: ./.github/workflows/Testbase.yml - with: - python: '3.11' - qt5: 'pyqt5' \ No newline at end of file diff --git a/.github/workflows/Testbase.yml b/.github/workflows/Testbase.yml deleted file mode 100644 index 2d00e2f..0000000 --- a/.github/workflows/Testbase.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Base - -on: - workflow_call: - inputs: - python: - required: true - type: string - qt5: - required: true - type: string - -jobs: - build: - runs-on: ubuntu-latest - env: - DISPLAY: ':99.0' - QT_DEBUG_PLUGINS: 1 - steps: - - name: Set up Python ${{ inputs.python }} - uses: actions/checkout@v5.0.0 - - name: Install dependencies - uses: actions/setup-python@v6.0.0 - with: - python-version: ${{ inputs.python }} - - name: Install package - run: | - sudo apt install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils - python -m pip install --upgrade pip - export QT_DEBUG_PLUGINS=1 - pip install flake8 pytest pytest-cov pytest-qt pytest-xdist pytest-xvfb setuptools wheel numpy h5py ${{ inputs.qt5 }} toml - pip install pymodaq pyqt5 - pip install -e . - - name: create local pymodaq folder and setting permissions - run: | - sudo mkdir /etc/.pymodaq - sudo chmod uo+rw /etc/.pymodaq - - name: Linting with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=src/pymodaq/resources/QtDesigner_Ressources,docs - - name: Test with pytest - run: | - pytest -n auto -k "not test_compatibility" diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 3c20850..0e3e4df 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -1,78 +1,15 @@ +# Everything is defined once in PyMoDAQ/pymodaq_plugins_template (reusable-*.yml). The ref after @ is the branch of the PR for now: use a release tag (@v1) once it exists. name: Compatibility with pymodaq (latest release) on: - workflow_call: - pull_request: - push: - branches: - - '*' + branches: [main, master, '[0-9]*.x'] concurrency: - # github.workflow: name of the workflow - # github.event.pull_request.number || github.ref: pull request number or branch name if not a pull request group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - # Cancel in-progress runs when a new workflow with the same group name is triggered cancel-in-progress: true - -jobs: - tests: - continue-on-error: true - strategy: - fail-fast: false - matrix: - os: ["ubuntu-latest", "windows-latest"] - python-version: ["3.10", "3.11", "3.12", "3.13"] - qt-backend: ["pyqt5", "pyqt6", "pyside6"] - runs-on: ${{ matrix.os }} - env: - DISPLAY: ':99' - QT_DEBUG_PLUGINS: 1 - - steps: - - name: Set project name environment variable - run: | - echo "plugin_name=$(echo '${{ github.repository }}' | cut -d'/' -f2)" >> $GITHUB_ENV - - name: Checkout the repo - uses: actions/checkout@v5.0.0 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6.0.0 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest pytest-cov pytest-qt pytest-xvfb pytest-xdist setuptools wheel numpy h5py pymodaq ${{ matrix.qt-backend }} - pip install -e . - - # Create folder and set permissions on Ubuntu - - name: Create local pymodaq folder setup env (Linux) - if: runner.os == 'Linux' - run: | - sudo apt update - sudo apt install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-cursor0 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 libegl1 - export QT_DEBUG_PLUGINS=1 - sudo mkdir -p /etc/.pymodaq - sudo chmod uo+rw /etc/.pymodaq - - name: Exporting debug variables (Windows) - if: runner.os == 'Windows' - run: | - set QT_DEBUG_PLUGINS=1 - - - name: Compatibility tests with ${{ matrix.os }} ${{ matrix.python-version }} ${{ matrix.qt-backend}} - run: | - pytest -vv -n 1 -k "test_compatibility" - - - name: Upload compatibility report - if: failure() - uses: actions/upload-artifact@v4.6.2 - with: - name: - path: 'import_report_tests_${{ env.plugin_name }}_None.txt' - if-no-files-found: error - - \ No newline at end of file +jobs: + compatibility: + uses: PyMoDAQ/pymodaq_plugins_template/.github/workflows/reusable-compatibility.yml@agent-md diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 67c4a59..f3046d4 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,6 +1,4 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - +# Everything is defined once in PyMoDAQ/pymodaq_plugins_template (reusable-*.yml). The ref after @ is the branch of the PR for now: use a release tag (@v1) once it exists. name: Upload Python Package on: @@ -8,33 +6,6 @@ on: types: [created] jobs: - deploy: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v5.0.0 - - name: Set up Python - uses: actions/setup-python@v6.0.0 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install hatch hatchling toml twine - - name: Get history and tags for SCM versioning to work - run: | - git branch - git fetch --prune --unshallow - git fetch --depth=1 origin +refs/tags/*:refs/tags/* - hatch version - - name: Build - run: hatch build - - name: Check the build - run: twine check dist/* - - name: publish - env: - HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} - HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} - run: | - hatch publish + publish: + uses: PyMoDAQ/pymodaq_plugins_template/.github/workflows/reusable-publish.yml@agent-md + secrets: inherit diff --git a/.github/workflows/reusable-compatibility.yml b/.github/workflows/reusable-compatibility.yml new file mode 100644 index 0000000..bd3fc20 --- /dev/null +++ b/.github/workflows/reusable-compatibility.yml @@ -0,0 +1,45 @@ +# Shared by all plugins: the plugin tests on the latest PyMoDAQ release over Python versions, Qt backends and OS. +# Called from the small `compatibility.yml` of a plugin, do not copy this file into a plugin. +name: Reusable compatibility + +on: + workflow_call: + +permissions: + contents: read + +jobs: + tests: + name: ${{ matrix.os }} / Python ${{ matrix.python-version }} / ${{ matrix.qt-backend }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - {os: ubuntu-latest, python-version: '3.10', qt-backend: pyqt5} + - {os: ubuntu-latest, python-version: '3.10', qt-backend: pyside6} + - {os: ubuntu-latest, python-version: '3.13', qt-backend: pyqt6} + - {os: ubuntu-latest, python-version: '3.13', qt-backend: pyside6} + - {os: windows-latest, python-version: '3.12', qt-backend: pyqt6} + env: + QT_DEBUG_PLUGINS: 1 + steps: + - uses: actions/checkout@v6.0.3 + - uses: actions/setup-python@v6.2.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install system libraries for Qt + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-cursor0 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 libegl1 + sudo mkdir -p /etc/.pymodaq + sudo chmod uo+rw /etc/.pymodaq + - name: Install the plugin with the latest PyMoDAQ release + run: | + python -m pip install --upgrade pip + pip install pytest pytest-qt pytest-xvfb pytest-xdist ${{ matrix.qt-backend }} + pip install -e . + - name: Tests + run: pytest -vv -n 1 tests diff --git a/.github/workflows/reusable-publish.yml b/.github/workflows/reusable-publish.yml new file mode 100644 index 0000000..fb71106 --- /dev/null +++ b/.github/workflows/reusable-publish.yml @@ -0,0 +1,41 @@ +# Shared by all plugins: build the package and upload it to PyPI. +# The repository secrets PYPI_USERNAME (`__token__`) and PYPI_PASSWORD (the PyPI API token) are passed by the caller. +# Called from the small `python-publish.yml` of a plugin, do not copy this file into a plugin. +name: Reusable publish + +on: + workflow_call: + secrets: + PYPI_USERNAME: + required: true + PYPI_PASSWORD: + required: true + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.3 + with: + fetch-depth: 0 # history and tags, needed by hatch-vcs for the version + - uses: actions/setup-python@v6.2.0 + with: + python-version: '3.x' + - name: Install build tools + run: | + python -m pip install --upgrade pip + pip install hatch hatchling toml twine + - name: Show the version + run: hatch version + - name: Build + run: hatch build + - name: Check the build + run: twine check dist/* + - name: Publish + env: + HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} + HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} + run: hatch publish diff --git a/.github/workflows/reusable-tests.yml b/.github/workflows/reusable-tests.yml new file mode 100644 index 0000000..c31a571 --- /dev/null +++ b/.github/workflows/reusable-tests.yml @@ -0,0 +1,43 @@ +# Shared by all plugins: flake8 gate and unit tests on one Python version and one Qt backend. +# Called from the small `tests.yml` of a plugin, do not copy this file into a plugin. +name: Reusable tests + +on: + workflow_call: + inputs: + python-version: + type: string + default: '3.11' + qt-backend: + type: string + default: pyside6 + +permissions: + contents: read + +jobs: + tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + QT_DEBUG_PLUGINS: 1 + steps: + - uses: actions/checkout@v6.0.3 + - uses: actions/setup-python@v6.2.0 + with: + python-version: ${{ inputs.python-version }} + - name: Install system libraries for Qt + run: | + sudo apt-get update + sudo apt-get install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-cursor0 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libgl1 libegl1 + sudo mkdir -p /etc/.pymodaq + sudo chmod uo+rw /etc/.pymodaq + - name: Install the plugin + run: | + python -m pip install --upgrade pip + pip install flake8 pytest pytest-qt pytest-xvfb pytest-xdist ${{ inputs.qt-backend }} + pip install -e . + - name: Lint (syntax errors and undefined names only) + run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + - name: Tests + run: pytest -n auto tests diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9b70c4d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,8 @@ +# Everything is defined once in PyMoDAQ/pymodaq_plugins_template (reusable-*.yml). The ref after @ is the branch of the PR for now: use a release tag (@v1) once it exists. +name: Tests + +on: [push, pull_request] + +jobs: + tests: + uses: PyMoDAQ/pymodaq_plugins_template/.github/workflows/reusable-tests.yml@agent-md diff --git a/.github/workflows/updater.yml b/.github/workflows/updater.yml deleted file mode 100644 index 8c60cea..0000000 --- a/.github/workflows/updater.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: GitHub Actions Version Updater - -# Controls when the action will run. -on: - workflow_dispatch: - schedule: - # Automatically run at 00:00 on day-of-month 5. - - cron: '0 0 5 * *' - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v5.0.0 - with: - # [Required] Access token with `workflow` scope. - token: ${{ secrets.WORKFLOW_SECRET }} - - - name: Run GitHub Actions Version Updater - uses: saadmk11/github-actions-version-updater@v0.9.0 - with: - # [Required] Access token with `workflow` scope. - token: ${{ secrets.WORKFLOW_SECRET }} \ No newline at end of file From 714de9407d73f39859dfe6f9a3cb69855c578922 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:26:15 +0000 Subject: [PATCH 12/16] Shared workflows: run pytest without xdist (workers race to create the PyMoDAQ config on a fresh runner) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .github/workflows/reusable-compatibility.yml | 4 ++-- .github/workflows/reusable-tests.yml | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/reusable-compatibility.yml b/.github/workflows/reusable-compatibility.yml index bd3fc20..2e50d1f 100644 --- a/.github/workflows/reusable-compatibility.yml +++ b/.github/workflows/reusable-compatibility.yml @@ -39,7 +39,7 @@ jobs: - name: Install the plugin with the latest PyMoDAQ release run: | python -m pip install --upgrade pip - pip install pytest pytest-qt pytest-xvfb pytest-xdist ${{ matrix.qt-backend }} + pip install pytest pytest-qt pytest-xvfb ${{ matrix.qt-backend }} pip install -e . - name: Tests - run: pytest -vv -n 1 tests + run: pytest -vv tests diff --git a/.github/workflows/reusable-tests.yml b/.github/workflows/reusable-tests.yml index c31a571..c6c749b 100644 --- a/.github/workflows/reusable-tests.yml +++ b/.github/workflows/reusable-tests.yml @@ -35,9 +35,10 @@ jobs: - name: Install the plugin run: | python -m pip install --upgrade pip - pip install flake8 pytest pytest-qt pytest-xvfb pytest-xdist ${{ inputs.qt-backend }} + pip install flake8 pytest pytest-qt pytest-xvfb ${{ inputs.qt-backend }} pip install -e . - name: Lint (syntax errors and undefined names only) run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - name: Tests - run: pytest -n auto tests + # no xdist: parallel workers race to create the PyMoDAQ config folder on a fresh runner + run: pytest tests From 70a7539194e1d569ea9014fa790fb1e83688ecd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:07:17 +0000 Subject: [PATCH 13/16] Shared compatibility workflow: test every OS, Python and Qt combination Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .github/workflows/reusable-compatibility.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/reusable-compatibility.yml b/.github/workflows/reusable-compatibility.yml index 2e50d1f..7108ce6 100644 --- a/.github/workflows/reusable-compatibility.yml +++ b/.github/workflows/reusable-compatibility.yml @@ -15,13 +15,10 @@ jobs: timeout-minutes: 30 strategy: fail-fast: false - matrix: - include: - - {os: ubuntu-latest, python-version: '3.10', qt-backend: pyqt5} - - {os: ubuntu-latest, python-version: '3.10', qt-backend: pyside6} - - {os: ubuntu-latest, python-version: '3.13', qt-backend: pyqt6} - - {os: ubuntu-latest, python-version: '3.13', qt-backend: pyside6} - - {os: windows-latest, python-version: '3.12', qt-backend: pyqt6} + matrix: # every combination is tested + os: [ubuntu-latest, windows-latest] + python-version: ['3.10', '3.11', '3.12', '3.13'] + qt-backend: [pyqt5, pyqt6, pyside6] env: QT_DEBUG_PLUGINS: 1 steps: From dbd41ac124a73e74bb7c703678327a7258615c86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:07:52 +0000 Subject: [PATCH 14/16] Shared compatibility workflow: back to the subset of combinations (oldest and newest Python, every Qt binding, both OS) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- .github/workflows/reusable-compatibility.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/reusable-compatibility.yml b/.github/workflows/reusable-compatibility.yml index 7108ce6..2e50d1f 100644 --- a/.github/workflows/reusable-compatibility.yml +++ b/.github/workflows/reusable-compatibility.yml @@ -15,10 +15,13 @@ jobs: timeout-minutes: 30 strategy: fail-fast: false - matrix: # every combination is tested - os: [ubuntu-latest, windows-latest] - python-version: ['3.10', '3.11', '3.12', '3.13'] - qt-backend: [pyqt5, pyqt6, pyside6] + matrix: + include: + - {os: ubuntu-latest, python-version: '3.10', qt-backend: pyqt5} + - {os: ubuntu-latest, python-version: '3.10', qt-backend: pyside6} + - {os: ubuntu-latest, python-version: '3.13', qt-backend: pyqt6} + - {os: ubuntu-latest, python-version: '3.13', qt-backend: pyside6} + - {os: windows-latest, python-version: '3.12', qt-backend: pyqt6} env: QT_DEBUG_PLUGINS: 1 steps: From d1067b2b22acfbae2712efbae2ff06f539ec7c54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:11:57 +0000 Subject: [PATCH 15/16] Move template: send the target in controller units with value.value(self.axis_unit) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- AGENTS.md | 4 ++++ .../daq_move_plugins/daq_move_Template.py | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5198db..88cc934 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,10 @@ File and class names must match: `daq_move_.py` holds `DAQ_Move_`; ` - Settings are `pyqtgraph` Parameter dicts (`{'title', 'name', 'type', 'value', ...}`). React to changes in `commit_settings(param)` by `param.name()`. - Units are `pint`-based. Give every actuator value and every `Axis` a unit; mismatches raise `DataUnitError`. + What you send to a controller must be expressed in the controller units: use `value.value(self.axis_unit)` in + `move_abs` / `move_rel`, never `value.value()` (the magnitude in whatever unit the `DataActuator` carries, so 1 cm + would be sent as 1.0 to a controller in mm). `axis_unit` is the unit of the current axis, `axis_units` the list/dict + of all the axes of a multiaxes controller. - Hardware access code goes in `hardware/`, behind a small wrapper class. This lets the plugin be tested with a mock controller and keeps the plugin file readable. - Defaults live in `resources/config_template.toml`, not in the code. Read configuration values through diff --git a/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py b/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py index 26cc6fe..68bf5ac 100644 --- a/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py +++ b/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py @@ -162,13 +162,20 @@ def move_abs(self, value: DataActuator): Parameters ---------- - value: (float) value of the absolute target positioning + value: DataActuator + Absolute target, with units (possibly not the ones of the controller, for instance the user can type + a target in cm for an axis in mm). """ value = self.check_bound(value) #if user checked bounds, the defined bounds are applied here self.target_value = value value = self.set_position_with_scaling(value) # apply scaling if the user specified one ## TODO for your custom plugin + # IMPORTANT: the controller only understands its own units. Always convert with value.value(self.axis_unit), + # it returns the magnitude expressed in the unit of this axis (the one of _controller_units, 'mm' here). + # value.value() alone is just the magnitude in whatever unit `value` happens to carry (1 cm gives 1.0, not 10.0): + # it would silently send the wrong target to the instrument. (axis_unit is the unit of the current axis, + # axis_units is the list/dict of the units of all the axes of a multiaxes controller.) raise NotImplementedError # when writing your own plugin remove this line self.controller.your_method_to_set_an_absolute_value(value.value(self.axis_unit)) # when writing your own plugin replace this line self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log'])) @@ -178,13 +185,16 @@ def move_rel(self, value: DataActuator): Parameters ---------- - value: (float) value of the relative target positioning + value: DataActuator + Relative displacement, with units (convert it with value.value(self.axis_unit) before sending it to the + controller, see move_abs). """ value = self.check_bound(self.current_value + value) - self.current_value self.target_value = value + self.current_value value = self.set_position_relative_with_scaling(value) ## TODO for your custom plugin + # IMPORTANT: send value.value(self.axis_unit), not value.value(): the controller only understands its own units raise NotImplementedError # when writing your own plugin remove this line self.controller.your_method_to_set_a_relative_value(value.value(self.axis_unit)) # when writing your own plugin replace this line self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log'])) From ad7cbeff02c0b683bd0a967c9148678e2ae6b4aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:16:54 +0000 Subject: [PATCH 16/16] Move template: explain data_actuator_type (DataActuator kept, float is backcompat only) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tdiso1u1ZhikSutj2Qs5Cg --- AGENTS.md | 5 ++++- .../daq_move_plugins/daq_move_Template.py | 21 +++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 88cc934..e69d028 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,10 @@ File and class names must match: `daq_move_.py` holds `DAQ_Move_`; ` - `ini_stage(controller=None)` returns `(info: str, initialized: bool)`. If `self.is_master`, create the controller; otherwise store the `controller` passed in (several axes share one controller). - Class attributes: `is_multiaxes`, `_axis_names`, `_controller_units`, `_epsilons`, - `data_actuator_type = DataActuatorType.DataActuator`, optionally `ui_type` and `has_encoder`. + `data_actuator_type = DataActuatorType.DataActuator`, optionally `ui_type` and `has_encoder`. Always write the + `data_actuator_type` line: the default of the base class is `DataActuatorType.float`, a backcompatibility mode where + `move_abs`/`move_rel` receive a plain float (already in the axis unit) and `get_actuator_value` returns one. PyMoDAQ + guards that case, but do not use it in new code. - `params` = list of dicts + `comon_parameters_fun(is_multiaxes, axis_names=..., epsilon=...)`. - Positions are `DataActuator` objects with **units** (`units=self.axis_unit`); use `check_bound`, `set_position_with_scaling` and `get_position_with_scaling` as in the template. diff --git a/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py b/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py index 68bf5ac..76feb9d 100644 --- a/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py +++ b/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py @@ -52,8 +52,10 @@ class DAQ_Move_Template(DAQ_Move_base): # the target value. It is the developer responsibility to put here a meaningful value - data_actuator_type = DataActuatorType.DataActuator # wether you use the new data style for actuator otherwise set this - # as DataActuatorType.float (or entirely remove the line) + data_actuator_type = DataActuatorType.DataActuator # keep this line: move_abs / move_rel receive a DataActuator (with + # units) and get_actuator_value returns one. The default of the PyMoDAQ base class is DataActuatorType.float, only + # kept for old plugins (a plain float in the axis unit is exchanged instead): do not remove this line, and do not + # use DataActuatorType.float in a new plugin. #todo: set the correct values for these two variables (pymodaq>5.3.0 only, if developing using lower version, just # remove the two lines) @@ -163,19 +165,20 @@ def move_abs(self, value: DataActuator): Parameters ---------- value: DataActuator - Absolute target, with units (possibly not the ones of the controller, for instance the user can type - a target in cm for an axis in mm). + Absolute target, with units. (With the backcompatibility DataActuatorType.float it would be a plain float, + already expressed in the axis unit, but new plugins should not use it.) """ value = self.check_bound(value) #if user checked bounds, the defined bounds are applied here self.target_value = value value = self.set_position_with_scaling(value) # apply scaling if the user specified one ## TODO for your custom plugin - # IMPORTANT: the controller only understands its own units. Always convert with value.value(self.axis_unit), - # it returns the magnitude expressed in the unit of this axis (the one of _controller_units, 'mm' here). - # value.value() alone is just the magnitude in whatever unit `value` happens to carry (1 cm gives 1.0, not 10.0): - # it would silently send the wrong target to the instrument. (axis_unit is the unit of the current axis, - # axis_units is the list/dict of the units of all the axes of a multiaxes controller.) + # IMPORTANT: the controller only understands its own units. Always send value.value(self.axis_unit), it returns + # the magnitude expressed in the unit of this axis (the one of _controller_units, 'mm' here). PyMoDAQ already + # converts the target to the axis unit before calling move_abs, but value.value() alone is just the magnitude + # in whatever unit `value` carries (1 cm gives 1.0, not 10.0): value.value(self.axis_unit) guards against a + # mistake, and keeps the plugin correct when move_abs is called from elsewhere with another unit. + # (axis_unit is the unit of the current axis, axis_units the list/dict of the units of all the axes.) raise NotImplementedError # when writing your own plugin remove this line self.controller.your_method_to_set_an_absolute_value(value.value(self.axis_unit)) # when writing your own plugin replace this line self.emit_status(ThreadCommand('Update_Status', ['Some info you want to log']))