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 01003f5..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.9", "3.10", "3.11", "3.12"] - 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..2e50d1f --- /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 ${{ matrix.qt-backend }} + pip install -e . + - name: Tests + run: pytest -vv 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..c6c749b --- /dev/null +++ b/.github/workflows/reusable-tests.yml @@ -0,0 +1,44 @@ +# 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 ${{ 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 + # no xdist: parallel workers race to create the PyMoDAQ config folder on a fresh runner + run: pytest 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 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 new file mode 100644 index 0000000..e69d028 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,177 @@ +# 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, behaviour against a fake controller) +``` + +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`. 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. + +**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`. + 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 + `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, 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_`. + +**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, +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. + +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 +`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`) + +- 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 + 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`. 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 (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 + +- [ ] 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). +- [ ] 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. 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/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..b30c619 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.2", + "pymodaq_utils>=5.3.1", + "pymodaq_gui>=5.3.0", + "pymodaq_data>=5.3.0", #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/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/daq_move_plugins/daq_move_Template.py b/src/pymodaq_plugins_template/daq_move_plugins/daq_move_Template.py index 1dc9f25..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 @@ -46,14 +46,16 @@ 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 - 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) @@ -64,7 +66,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 @@ -162,13 +164,21 @@ def move_abs(self, value: DataActuator): Parameters ---------- - value: (float) value of the absolute target positioning + value: DataActuator + 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 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'])) @@ -178,13 +188,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'])) 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()) 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/test_plugin_behaviour.py b/tests/test_plugin_behaviour.py new file mode 100644 index 0000000..0d7bc85 --- /dev/null +++ b/tests/test_plugin_behaviour.py @@ -0,0 +1,94 @@ +"""Behaviour tests of instrument plugins against a fake controller: no hardware, no visible GUI. + +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_ + actuator = make_actuator(DAQ_Move_) # then monkeypatch the controller class with your fake before ini_stage +""" +import pytest + +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 + + +@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)