1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-09 18:20:55 +02:00

Compare commits

..

71 Commits

Author SHA1 Message Date
768acba338 feat:added launch script 2024-06-17 16:43:09 +02:00
f9b3e6264e feat:added example plugin from pyside ticktactoe 2024-06-17 14:49:36 +02:00
b140d3c9a8 fix(crosshair): wip plugin for motor selections widget 2024-05-29 10:53:16 +02:00
ab689a76ed fix(crosshair): fixed signals 2024-05-28 16:04:16 +02:00
55083aac40 fix(motor_table): fixed ui loading + tests 2024-05-28 15:24:24 +02:00
7a4eb1d3a6 fix(eiger_plot): fixed ui loading 2024-05-28 14:58:56 +02:00
d7b83d0357 ci: added tests for pyside6, pyqt6 and pyqt5 2024-05-28 14:48:26 +02:00
01e90d181e build: added pyside6 as dependency 2024-05-28 14:48:26 +02:00
ddabcd62e9 fix: scripts with .ui adjusted to fit both pyside and pyqt 2024-05-28 14:48:26 +02:00
0fea8d6065 feat(utils/ui_loader): universal ui loader for pyside/pyqt 2024-05-28 10:59:31 +02:00
semantic-release
2a67f1667a 0.55.0
Automatically generated by python-semantic-release
2024-05-24 12:05:40 +00:00
76bd0d339a feat(widgets/progressbar): SpiralProgressBar added with rpc interface 2024-05-24 13:59:10 +02:00
semantic-release
43759082dd 0.54.0
Automatically generated by python-semantic-release
2024-05-24 09:05:54 +00:00
fc4d0f3bb2 feat(figure): changes to support direct plot functionality 2024-05-24 10:50:00 +02:00
a47a8ec413 build: added pyqt6 as sphinx build dependency 2024-05-23 11:43:48 +02:00
3455c60236 refactor(reconstruction): repository structure is changed to separate assets needed for each widget 2024-05-21 16:31:55 +02:00
edc25fbf9d refactor(clean-up): 1st generation widgets are removed 2024-05-21 16:31:55 +02:00
semantic-release
dc38f2308b 0.53.3
Automatically generated by python-semantic-release
2024-05-16 08:27:45 +00:00
7d64cac661 fix: removed apparently unnecessary sleep while waiting for an rpc response 2024-05-15 14:14:25 +02:00
semantic-release
ab4f1acd75 0.53.2
Automatically generated by python-semantic-release
2024-05-15 10:39:21 +00:00
9f8fbdd5fc fix: check device class without importing to speed up initial import time 2024-05-15 10:10:14 +02:00
d1e6cd388c fix: speed up initial import times using lazy import (from bec_lib) 2024-05-15 10:10:14 +02:00
5d09a13d88 fix: adapt to bec_lib changes (no more submodules in __init__.py) 2024-05-15 10:10:14 +02:00
0490e80c48 ci: added echo to highlight the current branch 2024-05-13 11:46:36 +02:00
semantic-release
48553ba9b1 0.53.1
Automatically generated by python-semantic-release
2024-05-09 18:58:27 +00:00
0f6a5e5fa9 fix: docs config 2024-05-09 20:51:26 +02:00
8ff36105d1 ci: fixed rtd pages url 2024-05-09 19:44:44 +02:00
semantic-release
ce78271af4 0.53.0
Automatically generated by python-semantic-release
2024-05-09 16:03:51 +00:00
57ee735e5c docs: update install instructions 2024-05-09 14:53:57 +02:00
32e1a9d847 fix: fixed semver job and upgraded to v9 2024-05-09 14:11:42 +02:00
5cc816d0af ci: use formatter config of toml file 2024-05-09 11:29:42 +02:00
4117fd7b5b refactor: applied formatter 2024-05-09 11:29:42 +02:00
c86ce302a9 feat: moved to pyproject.toml; closes #162 2024-05-09 11:21:18 +02:00
semantic-release
c33ce05951 0.52.1
Automatically generated by python-semantic-release
2024-05-08 13:56:39 +00:00
7f2f7cd07a fix(docstrings): docstrings formating fixed for sphinx to properly format readdocs 2024-05-08 15:31:22 +02:00
semantic-release
1454f6192b 0.52.0
Automatically generated by python-semantic-release
2024-05-07 14:37:46 +00:00
ceae979f37 fix(widgets/dock): BECDockArea close overwrites the default pyqtgraph Container close + minor improvements 2024-05-07 16:31:12 +02:00
fcd6ef0975 feat(utils/layout_manager): added GridLayoutManager to extend functionalities of native QGridLayout 2024-05-07 16:30:21 +02:00
d8ff8afcd4 feat(widget/dock): BECDock and BECDock area for dockable windows 2024-05-07 16:30:21 +02:00
03fa1f26d0 refactor(widget/plots): WidgetConfig changed to SubplotConfig 2024-05-07 16:30:21 +02:00
e65c7f3be8 ci: fixed support for child pipelines 2024-05-07 12:48:24 +02:00
semantic-release
20d6352351 0.51.0
Automatically generated by python-semantic-release
2024-05-07 10:13:25 +00:00
5ece269adb feat(utils): added plugin helper to find and load 2024-05-07 12:10:53 +02:00
e0851250ee ci: added rule for parent-child pipelines 2024-05-07 10:26:18 +02:00
799ea554de build(cli): changed repo name to bec_widgets 2024-05-06 16:00:46 +02:00
df323504fe build(setup): fakeredis added to dev env 2024-05-01 15:05:22 +02:00
0ab8aa3a2f build(setup): PyQt6 version is set to 6.7 2024-05-01 15:05:22 +02:00
semantic-release
dae8a3409a 0.50.2
Automatically generated by python-semantic-release
2024-04-30 16:08:47 +00:00
0dfcaa4b70 fix: 'disconnect_slot' has to be symmetric with 'connect_slot' regarding QtThreadSafeCallback 2024-04-30 17:27:02 +02:00
semantic-release
98cb2c08ea 0.50.1
Automatically generated by python-semantic-release
2024-04-29 15:58:30 +00:00
57cb136a09 fix(cli): BECFigure takes the port to connect to redis from the current BECClient, supporting plugins 2024-04-29 16:53:26 +02:00
semantic-release
1d84bca753 0.50.0
Automatically generated by python-semantic-release
2024-04-29 08:26:54 +00:00
4f261be4c7 test(cli/rpc_register): e2e RPCRegister 2024-04-28 18:54:20 +02:00
40eb75f85a test(cli/rpc_register): rpc_register tests added 2024-04-28 12:47:17 +02:00
13c018a797 fix(widgets/figure): access pattern changed for getting widgets by coordinates for rpc 2024-04-28 12:42:58 +02:00
8f20a0b3b1 fix(plots): cleanup policy reviewed for children items 2024-04-28 12:42:58 +02:00
6b6a6b2249 fix(rpc/client_utils): getoutput more transparent + error handling 2024-04-28 12:42:58 +02:00
2ca32675ec fix(rpc_register): thread lock for listign all connections 2024-04-28 12:42:58 +02:00
381d713837 feat(plots): universal cleanup and remove also for children items 2024-04-28 12:42:58 +02:00
a898e7e4f1 feat(rpc/rpc_register): singleton rpc register for all rpc connections for session 2024-04-28 12:42:58 +02:00
semantic-release
6d13a3283b 0.49.1
Automatically generated by python-semantic-release
2024-04-26 16:17:32 +00:00
ab8537483d fix(widgets/editor): qscintilla editor removed 2024-04-26 17:57:54 +02:00
a22229849c build(pyqt6): fixing PyQt6-Qt6 package to 6.6.3 2024-04-25 17:07:29 +02:00
semantic-release
1ba266080c 0.49.0
Automatically generated by python-semantic-release
2024-04-24 15:57:14 +00:00
6500a00682 feat(rpc/client_utils): timeout for rpc response 2024-04-24 17:49:23 +02:00
9602085f82 fix(rpc/client_utils): close clean up policy for BECFigure 2024-04-24 10:54:24 +02:00
semantic-release
a1c369de9b 0.48.0
Automatically generated by python-semantic-release
2024-04-24 05:29:44 +00:00
6238693ffb feat(cli): added auto updates plugin support 2024-04-23 15:22:45 +02:00
semantic-release
f3a387e77f 0.47.0
Automatically generated by python-semantic-release
2024-04-23 13:12:39 +00:00
71cb80d544 feat(utils/thread_checker): util class to check the thread leakage for closeEvent in qt 2024-04-23 14:53:13 +02:00
77ff7962cc refactor(utils/container_utils): part of the logic regarding locating widgets moved from BECFigure to utility class 2024-04-22 12:07:37 +02:00
133 changed files with 6644 additions and 7707 deletions

View File

@@ -1,18 +1,20 @@
# This file is a template, and might need editing before it works on your project.
# Official language image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/python/tags/
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
#commands to run in the Docker container before starting each job.
variables:
DOCKER_TLS_CERTDIR: ""
BEC_CORE_BRANCH: "main"
OPHYD_DEVICES_BRANCH: "main"
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_PIPELINE_SOURCE == "pipeline"
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
@@ -21,7 +23,6 @@ workflow:
include:
- template: Security/Secret-Detection.gitlab-ci.yml
# different stages in the pipeline
stages:
- Formatter
@@ -30,19 +31,28 @@ stages:
- End2End
- Deploy
before_script:
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
fi
formatter:
stage: Formatter
needs: []
script:
- pip install black isort
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color --line-length=100 ./
- isort --check --diff ./
- black --check --diff --color ./
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint:
stage: Formatter
needs: []
before_script:
- pip install pylint pylint-exit anybadge
- pip install -e .[dev]
- pip install -e .[dev,pyqt6]
script:
- mkdir ./pylint
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
@@ -53,6 +63,8 @@ pylint:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint-check:
stage: Formatter
@@ -84,6 +96,8 @@ pylint-check:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
tests:
stage: test
@@ -97,7 +111,7 @@ tests:
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev]
- pip install -e .[dev,pyside6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
@@ -109,17 +123,140 @@ tests:
coverage_format: cobertura
path: coverage.xml
tests-3.11:
tests-3.10-pyside6:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyside6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
allow_failure: true
tests-3.12:
tests-3.12-pyside6:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyside6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
allow_failure: true
tests-3.10-pyqt5:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt5]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
allow_failure: true
tests-3.11-pyqt5:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt5]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
allow_failure: true
tests-3.12-pyqt5:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt5]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
allow_failure: true
tests-3.10-pyqt6:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
allow_failure: true
tests-3.11-pyqt6:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
allow_failure: true
tests-3.12-pyqt6:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
allow_failure: true
end-2-end-conda:
@@ -150,7 +287,7 @@ end-2-end-conda:
- pip install -e ./bec_lib[dev]
- pip install -e ./bec_ipython_client[dev]
- cd ../
- pip install -e .[dev]
- pip install -e .[dev,pyside6]
- cd ./tests/end-2-end
- pytest --start-servers --flush-redis --random-order
@@ -164,10 +301,10 @@ end-2-end-conda:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
semver:
stage: Deploy
needs: ["tests"]
@@ -182,20 +319,19 @@ semver:
- git fetch --tags
- git tag
# build
- pip install python-semantic-release==7.* wheel
# build and publish package
- pip install python-semantic-release==9.* wheel build twine
- export GL_TOKEN=$CI_UPDATES
- export REPOSITORY_USERNAME=__token__
- export REPOSITORY_PASSWORD=$CI_PYPI_TOKEN
- >
semantic-release publish -v DEBUG
-D version_variable=./setup.py:__version__
-D hvcs=gitlab
-D branch=main
- semantic-release -vv version
# check if any artifacts were created
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
- semantic-release publish
allow_failure: false
rules:
- if: '$CI_COMMIT_REF_NAME == "main"'
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
pages:
stage: Deploy
@@ -206,6 +342,6 @@ pages:
- if: '$CI_COMMIT_TAG != null'
variables:
TARGET_BRANCH: $CI_COMMIT_TAG
- if: '$CI_COMMIT_REF_NAME == "main"'
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
script:
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/

File diff suppressed because it is too large Load Diff

View File

@@ -6,32 +6,33 @@ BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Expe
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
```bash
pip install bec-widgets
pip install bec_widgets PyQt6
```
For development purposes, you can clone the repository and install the package locally in editable mode:
```bash
git clone https://gitlab.psi.ch/bec/bec-widgets
cd bec-widgets
pip install -e .[dev]
cd bec_widgets
pip install -e .[dev,pyqt6]
```
BEC Widgets currently supports both PyQt5 and PyQt6. By default, PyQt6 is installed.
BEC Widgets currently supports both PyQt5 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
Python Qt distributions manually.
To select a specific Python Qt distribution, install the package with an additional tag:
```bash
pip install bec-widgets[pyqt6]
pip install bec_widgets[pyqt6]
```
or
```bash
pip install bec-widgets[pyqt5]
pip install bec_widgets[pyqt5]
```
## Documentation
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://beamline-experiment-control.readthedocs.io/en/latest/).
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
## Contributing

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1 +1,8 @@
from .client import BECFigure
from bec_lib.utils.import_utils import lazy_import_from
# from .auto_updates import AutoUpdates, ScanInfo
# TODO: put back when Pydantic gets faster
AutoUpdates, ScanInfo = lazy_import_from(
"bec_widgets.cli.auto_updates", ("AutoUpdates", "ScanInfo")
)
from .client import BECDockArea, BECFigure

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel
if TYPE_CHECKING:
from .client import BECFigure
class ScanInfo(BaseModel):
scan_id: str
scan_number: int
scan_name: str
scan_report_devices: list
monitored_devices: list
status: str
class AutoUpdates:
def __init__(self, figure: BECFigure, enabled: bool = True):
self.enabled = enabled
self.figure = figure
@staticmethod
def get_scan_info(msg) -> ScanInfo:
"""
Update the script with the given data.
"""
info = msg.info
status = msg.status
scan_id = msg.scan_id
scan_number = info.get("scan_number", 0)
scan_name = info.get("scan_name", "Unknown")
scan_report_devices = info.get("scan_report_devices", [])
monitored_devices = info.get("readout_priority", {}).get("monitored", [])
monitored_devices = [dev for dev in monitored_devices if dev not in scan_report_devices]
return ScanInfo(
scan_id=scan_id,
scan_number=scan_number,
scan_name=scan_name,
scan_report_devices=scan_report_devices,
monitored_devices=monitored_devices,
status=status,
)
def run(self, msg):
"""
Run the update function if enabled.
"""
if not self.enabled:
return
if msg.status != "open":
return
info = self.get_scan_info(msg)
self.handler(info)
@staticmethod
def get_selected_device(monitored_devices, selected_device):
"""
Get the selected device for the plot. If no device is selected, the first
device in the monitored devices list is selected.
"""
if selected_device:
return selected_device
if len(monitored_devices) > 0:
sel_device = monitored_devices[0]
return sel_device
return None
def handler(self, info: ScanInfo) -> None:
"""
Default update function.
"""
if info.scan_name == "line_scan" and info.scan_report_devices:
self.simple_line_scan(info)
return
if info.scan_name == "grid_scan" and info.scan_report_devices:
self.simple_grid_scan(info)
return
if info.scan_report_devices:
self.best_effort(info)
return
def simple_line_scan(self, info: ScanInfo) -> None:
"""
Simple line scan.
"""
dev_x = info.scan_report_devices[0]
dev_y = self.get_selected_device(info.monitored_devices, self.figure.selected_device)
if not dev_y:
return
self.figure.clear_all()
plt = self.figure.plot(dev_x, dev_y)
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def simple_grid_scan(self, info: ScanInfo) -> None:
"""
Simple grid scan.
"""
dev_x = info.scan_report_devices[0]
dev_y = info.scan_report_devices[1]
dev_z = self.get_selected_device(info.monitored_devices, self.figure.selected_device)
self.figure.clear_all()
plt = self.figure.plot(dev_x, dev_y, dev_z, label=f"Scan {info.scan_number}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def best_effort(self, info: ScanInfo) -> None:
"""
Best effort scan.
"""
dev_x = info.scan_report_devices[0]
dev_y = self.get_selected_device(info.monitored_devices, self.figure.selected_device)
if not dev_y:
return
self.figure.clear_all()
plt = self.figure.plot(dev_x, dev_y, label=f"Scan {info.scan_number}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import importlib
import importlib.metadata as imd
import os
import select
import subprocess
@@ -11,16 +12,19 @@ import uuid
from functools import wraps
from typing import TYPE_CHECKING
from bec_lib import MessageEndpoints, messages
from bec_lib.connector import MessageObject
from bec_lib.device import DeviceBase
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
from qtpy.QtCore import QCoreApplication
import bec_widgets.cli.client as client
from bec_widgets.utils.bec_dispatcher import BECDispatcher
if TYPE_CHECKING:
from bec_widgets.cli.client import BECFigure
from bec_lib.device import DeviceBase
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
def rpc_call(func):
@@ -54,68 +58,22 @@ def rpc_call(func):
return wrapper
def get_selected_device(monitored_devices, selected_device):
"""
Get the selected device for the plot. If no device is selected, the first
device in the monitored devices list is selected.
"""
if selected_device:
return selected_device
if len(monitored_devices) > 0:
sel_device = monitored_devices[0]
return sel_device
return None
def update_script(figure: BECFigure, msg):
"""
Update the script with the given data.
"""
info = msg.info
status = msg.status
scan_id = msg.scan_id
scan_number = info.get("scan_number", 0)
scan_name = info.get("scan_name", "Unknown")
scan_report_devices = info.get("scan_report_devices", [])
monitored_devices = info.get("readout_priority", {}).get("monitored", [])
monitored_devices = [dev for dev in monitored_devices if dev not in scan_report_devices]
if scan_name == "line_scan" and scan_report_devices:
dev_x = scan_report_devices[0]
dev_y = get_selected_device(monitored_devices, figure.selected_device)
print(f"Selected device: {dev_y}")
if not dev_y:
return
figure.clear_all()
plt = figure.plot(dev_x, dev_y)
plt.set(title=f"Scan {scan_number}", x_label=dev_x, y_label=dev_y)
elif scan_name == "grid_scan" and scan_report_devices:
print(f"Scan {scan_number} is running")
dev_x = scan_report_devices[0]
dev_y = scan_report_devices[1]
dev_z = get_selected_device(monitored_devices, figure.selected_device)
figure.clear_all()
plt = figure.plot(dev_x, dev_y, dev_z, label=f"Scan {scan_number}")
plt.set(title=f"Scan {scan_number}", x_label=dev_x, y_label=dev_y)
elif scan_report_devices:
dev_x = scan_report_devices[0]
dev_y = get_selected_device(monitored_devices, figure.selected_device)
if not dev_y:
return
figure.clear_all()
plt = figure.plot(dev_x, dev_y, label=f"Scan {scan_number}")
plt.set(title=f"Scan {scan_number}", x_label=dev_x, y_label=dev_y)
class BECFigureClientMixin:
class BECGuiClientMixin:
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._process = None
self.update_script = update_script
self.update_script = self._get_update_script()
self._target_endpoint = MessageEndpoints.scan_status()
self._selected_device = None
self.stderr_output = []
def _get_update_script(self) -> AutoUpdates:
eps = imd.entry_points(group="bec.widgets.auto_updates")
for ep in eps:
if ep.name == "plugin_widgets_update":
return ep.load()(figure=self)
return None
@property
def selected_device(self):
"""
@@ -125,7 +83,7 @@ class BECFigureClientMixin:
@selected_device.setter
def selected_device(self, device: str | DeviceBase):
if isinstance(device, DeviceBase):
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
self._selected_device = device.name
elif isinstance(device, str):
self._selected_device = device
@@ -138,7 +96,7 @@ class BECFigureClientMixin:
)
@staticmethod
def _handle_msg_update(msg: MessageObject, parent: BECFigureClientMixin) -> None:
def _handle_msg_update(msg: MessageObject, parent: BECGuiClientMixin) -> None:
if parent.update_script is not None:
# pylint: disable=protected-access
parent._update_script_msg_parser(msg.value)
@@ -147,8 +105,7 @@ class BECFigureClientMixin:
if isinstance(msg, messages.ScanStatusMessage):
if not self.gui_is_alive():
return
if msg.status == "open":
self.update_script(self, msg)
self.update_script.run(msg)
def show(self) -> None:
"""
@@ -166,7 +123,10 @@ class BECFigureClientMixin:
"""
if self._process is None:
return
self._run_rpc("close", (), wait_for_rpc_response=False)
if self.gui_is_alive():
self._run_rpc("close", (), wait_for_rpc_response=True)
else:
self._run_rpc("close", (), wait_for_rpc_response=False)
self._process.terminate()
self._process_output_processing_thread.join()
self._process = None
@@ -178,10 +138,22 @@ class BECFigureClientMixin:
"""
self._start_update_script()
# pylint: disable=subprocess-run-check
config = self._client._service_config.redis
monitor_module = importlib.import_module("bec_widgets.cli.server")
monitor_path = monitor_module.__file__
gui_class = self.__class__.__name__
command = [sys.executable, "-u", monitor_path, "--id", self._gui_id]
command = [
sys.executable,
"-u",
monitor_path,
"--id",
self._gui_id,
"--config",
config,
"--gui_class",
gui_class,
]
self._process = subprocess.Popen(
command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
@@ -199,17 +171,33 @@ class BECFigureClientMixin:
self.stderr_output.clear()
def _get_output(self) -> str:
os.set_blocking(self._process.stdout.fileno(), False)
os.set_blocking(self._process.stderr.fileno(), False)
while self._process.poll() is None:
readylist, _, _ = select.select([self._process.stdout, self._process.stderr], [], [], 1)
if self._process.stdout in readylist:
# print("*"*10, self._process.stdout.read(1024), flush=True, end="")
self._process.stdout.read(1024)
if self._process.stderr in readylist:
# print("!"*10, self._process.stderr.read(1024), flush=True, end="", file=sys.stderr)
print(self._process.stderr.read(1024), flush=True, end="", file=sys.stderr)
self.stderr_output.append(self._process.stderr.read(1024))
try:
os.set_blocking(self._process.stdout.fileno(), False)
os.set_blocking(self._process.stderr.fileno(), False)
while self._process.poll() is None:
readylist, _, _ = select.select(
[self._process.stdout, self._process.stderr], [], [], 1
)
if self._process.stdout in readylist:
output = self._process.stdout.read(1024)
if output:
print(output, end="")
if self._process.stderr in readylist:
error_output = self._process.stderr.read(1024)
if error_output:
print(error_output, end="", file=sys.stderr)
self.stderr_output.append(error_output)
except Exception as e:
print(f"Error reading process output: {str(e)}")
class RPCResponseTimeoutError(Exception):
"""Exception raised when an RPC response is not received within the expected time."""
def __init__(self, request_id, timeout):
super().__init__(
f"RPC response not received within {timeout} seconds for request ID {request_id}"
)
class RPCBase:
@@ -257,7 +245,7 @@ class RPCBase:
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
metadata={"request_id": request_id},
)
# print(f"RPCBase: {rpc_msg}")
# pylint: disable=protected-access
receiver = self._root._gui_id
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
@@ -292,16 +280,28 @@ class RPCBase:
return cls(parent=self, **msg_result)
return msg_result
def _wait_for_response(self, request_id):
def _wait_for_response(self, request_id: str, timeout: int = 5):
"""
Wait for the response from the server.
Args:
request_id(str): The request ID.
timeout(int): The timeout in seconds.
Returns:
The response from the server.
"""
start_time = time.time()
response = None
while response is None and self.gui_is_alive():
while response is None and self.gui_is_alive() and (time.time() - start_time) < timeout:
response = self._client.connector.get(
MessageEndpoints.gui_instruction_response(request_id)
)
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
if response is None and (time.time() - start_time) >= timeout:
raise RPCResponseTimeoutError(request_id, timeout)
return response
def gui_is_alive(self):

View File

@@ -22,7 +22,7 @@ else:
class ClientGenerator:
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py\n
from bec_widgets.cli.client_utils import rpc_call, RPCBase, BECFigureClientMixin
from bec_widgets.cli.client_utils import rpc_call, RPCBase, BECGuiClientMixin
from typing import Literal, Optional, overload"""
self.content = ""
@@ -41,6 +41,7 @@ from typing import Literal, Optional, overload"""
def generate_content_for_class(self, cls):
"""
Generate the content for the class.
Args:
cls: The class for which to generate the content.
"""
@@ -53,9 +54,9 @@ from typing import Literal, Optional, overload"""
# from {module} import {class_name}"""
# Generate the content
if cls.__name__ == "BECFigure":
if cls.__name__ == "BECDockArea":
self.content += f"""
class {class_name}(RPCBase, BECFigureClientMixin):"""
class {class_name}(RPCBase, BECGuiClientMixin):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
@@ -108,10 +109,14 @@ if __name__ == "__main__": # pragma: no cover
import os
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.plots import BECImageShow, BECMotorMap, BECPlotBase, BECWaveform
from bec_widgets.widgets.plots.image import BECImageItem
from bec_widgets.widgets.plots.waveform import BECCurve
from bec_widgets.widgets import BECDock, BECDockArea, BECFigure, SpiralProgressBar
from bec_widgets.widgets.figure.plots.image.image import BECImageShow
from bec_widgets.widgets.figure.plots.image.image_item import BECImageItem
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform
from bec_widgets.widgets.figure.plots.waveform.waveform_curve import BECCurve
from bec_widgets.widgets.spiral_progress_bar.ring import Ring
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
@@ -124,6 +129,10 @@ if __name__ == "__main__": # pragma: no cover
BECConnector,
BECImageItem,
BECMotorMap,
BECDock,
BECDockArea,
SpiralProgressBar,
Ring,
]
generator = ClientGenerator()
generator.generate_client(clss)

View File

@@ -0,0 +1,80 @@
from threading import Lock
from weakref import WeakValueDictionary
from qtpy.QtCore import QObject
class RPCRegister:
"""
A singleton class that keeps track of all the RPC objects registered in the system for CLI usage.
"""
_instance = None
_initialized = False
_lock = Lock()
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(RPCRegister, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._rpc_register = WeakValueDictionary()
self._initialized = True
def add_rpc(self, rpc: QObject):
"""
Add an RPC object to the register.
Args:
rpc(QObject): The RPC object to be added to the register.
"""
if not hasattr(rpc, "gui_id"):
raise ValueError("RPC object must have a 'gui_id' attribute.")
self._rpc_register[rpc.gui_id] = rpc
def remove_rpc(self, rpc: str):
"""
Remove an RPC object from the register.
Args:
rpc(str): The RPC object to be removed from the register.
"""
if not hasattr(rpc, "gui_id"):
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
self._rpc_register.pop(rpc.gui_id, None)
def get_rpc_by_id(self, gui_id: str) -> QObject:
"""
Get an RPC object by its ID.
Args:
gui_id(str): The ID of the RPC object to be retrieved.
Returns:
QObject: The RPC object with the given ID.
"""
rpc_object = self._rpc_register.get(gui_id, None)
return rpc_object
def list_all_connections(self) -> dict:
"""
List all the registered RPC objects.
Returns:
dict: A dictionary containing all the registered RPC objects.
"""
with self._lock:
connections = dict(self._rpc_register)
return connections
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance.
"""
cls._instance = None
cls._initialized = False

View File

@@ -0,0 +1,26 @@
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.spiral_progress_bar.spiral_progress_bar import SpiralProgressBar
class RPCWidgetHandler:
"""Handler class for creating widgets from RPC messages."""
widget_classes = {"BECFigure": BECFigure, "SpiralProgressBar": SpiralProgressBar}
@staticmethod
def create_widget(widget_type, **kwargs) -> BECConnector:
"""
Create a widget from an RPC message.
Args:
widget_type(str): The type of the widget.
**kwargs: The keyword arguments for the widget.
Returns:
widget(BECConnector): The created widget.
"""
widget_class = RPCWidgetHandler.widget_classes.get(widget_type)
if widget_class:
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")

View File

@@ -1,23 +1,36 @@
import inspect
from typing import Union
from bec_lib import MessageEndpoints, messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.plots import BECCurve, BECImageShow, BECWaveform
messages = lazy_import("bec_lib.messages")
class BECWidgetsCLIServer:
WIDGETS = [BECWaveform, BECFigure, BECCurve, BECImageShow]
def __init__(self, gui_id: str = None, dispatcher: BECDispatcher = None, client=None) -> None:
self.dispatcher = BECDispatcher() if dispatcher is None else dispatcher
def __init__(
self,
gui_id: str = None,
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: Union["BECFigure", "BECDockArea"] = BECFigure,
) -> None:
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
self.fig = BECFigure(gui_id=self.gui_id)
self.gui = gui_class(gui_id=self.gui_id)
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self.gui)
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
@@ -27,15 +40,15 @@ class BECWidgetsCLIServer:
self._shutdown_event = False
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(1000) # Emit heartbeat every 1 seconds
self._heartbeat_timer.start(200) # Emit heartbeat every 1 seconds
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
obj = self.get_object_from_config(msg["parameter"])
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
print(e)
@@ -52,20 +65,10 @@ class BECWidgetsCLIServer:
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
# check if the object is the figure
if gui_id == self.fig.gui_id:
return self.fig
# check if the object is a widget
if gui_id in self.fig._widgets:
obj = self.fig._widgets[config["gui_id"]]
return obj
if self.fig._widgets:
for widget in self.fig._widgets.values():
item = widget.find_widget_by_id(gui_id)
if item:
return item
raise ValueError(f"Object with gui_id {gui_id} not found")
obj = self.rpc_register.get_rpc_by_id(gui_id)
if obj is None:
raise ValueError(f"Object with gui_id {gui_id} not found")
return obj
def run_rpc(self, obj, method, args, kwargs):
method_obj = getattr(obj, method)
@@ -104,33 +107,61 @@ class BECWidgetsCLIServer:
messages.StatusMessage(name=self.gui_id, status=1, info={}),
expire=10,
)
print("Heartbeat emitted")
def shutdown(self):
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self._shutdown_event = True
self._heartbeat_timer.stop()
self.client.shutdown()
if __name__ == "__main__": # pragma: no cover
import argparse
import os
import sys
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QMainWindow
import bec_widgets
app = QApplication(sys.argv)
app.setApplicationName("BEC Figure")
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, help="The id of the server")
parser.add_argument(
"--gui_class",
type=str,
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
)
parser.add_argument("--config", type=str, help="Config to connect to redis.")
args = parser.parse_args()
server = BECWidgetsCLIServer(gui_id=args.id)
# server = BECWidgetsCLIServer(gui_id="test")
if args.gui_class == "BECFigure":
gui_class = BECFigure
elif args.gui_class == "BECDockArea":
gui_class = BECDockArea
else:
print(
"Please specify a valid gui_class to run. Use -h for help."
"\n Starting with default gui_class BECFigure."
)
gui_class = BECFigure
fig = server.fig
win.setCentralWidget(fig)
server = BECWidgetsCLIServer(gui_id=args.id, config=args.config, gui_class=gui_class)
gui = server.gui
win.setCentralWidget(gui)
win.resize(800, 600)
win.show()
app.aboutToQuit.connect(server.shutdown)

View File

@@ -6,12 +6,13 @@ import h5py
import numpy as np
import pyqtgraph as pg
import zmq
from pyqtgraph.Qt import uic
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import QDialog, QFileDialog, QFrame, QLabel, QShortcut, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
# from scipy.stats import multivariate_normal
@@ -23,7 +24,7 @@ class EigerPlot(QWidget):
# pg.setConfigOptions(background="w", foreground="k", antialias=True)
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "eiger_plot.ui"), self)
self.ui = UILoader().load_ui(os.path.join(current_path, "eiger_plot.ui"), self)
# Set widow name
self.setWindowTitle("Eiger Plot")
@@ -60,19 +61,22 @@ class EigerPlot(QWidget):
self.update_hist()
# Adding Items to Graphical Layout
self.glw_layout = QVBoxLayout(self.ui.glw_placeholder)
self.glw = pg.GraphicsLayoutWidget()
self.glw_layout.addWidget(self.glw)
self.glw.addItem(self.plot_item)
self.glw.addItem(self.hist)
def hook_signals(self):
# Buttons
# self.pushButton_test.clicked.connect(self.start_sim_stream)
self.pushButton_mask.clicked.connect(self.load_mask_dialog)
self.pushButton_delete_mask.clicked.connect(self.delete_mask)
self.pushButton_help.clicked.connect(self.show_help_dialog)
self.ui.pushButton_mask.clicked.connect(self.load_mask_dialog)
self.ui.pushButton_delete_mask.clicked.connect(self.delete_mask)
self.ui.pushButton_help.clicked.connect(self.show_help_dialog)
# SpinBoxes
self.doubleSpinBox_hist_min.valueChanged.connect(self.update_hist)
self.doubleSpinBox_hist_max.valueChanged.connect(self.update_hist)
self.ui.doubleSpinBox_hist_min.valueChanged.connect(self.update_hist)
self.ui.doubleSpinBox_hist_max.valueChanged.connect(self.update_hist)
# Signal/Slots
self.update_signal.connect(self.on_image_update)
@@ -81,47 +85,47 @@ class EigerPlot(QWidget):
# Key bindings for rotation
rotate_plus = QShortcut(QKeySequence("Ctrl+A"), self)
rotate_minus = QShortcut(QKeySequence("Ctrl+Z"), self)
self.comboBox_rotation.setToolTip("Increase rotation: Ctrl+A\nDecrease rotation: Ctrl+Z")
self.checkBox_transpose.setToolTip("Toggle transpose: Ctrl+T")
self.ui.comboBox_rotation.setToolTip("Increase rotation: Ctrl+A\nDecrease rotation: Ctrl+Z")
self.ui.checkBox_transpose.setToolTip("Toggle transpose: Ctrl+T")
max_index = self.comboBox_rotation.count() - 1 # Maximum valid index
max_index = self.ui.comboBox_rotation.count() - 1 # Maximum valid index
rotate_plus.activated.connect(
lambda: self.comboBox_rotation.setCurrentIndex(
min(self.comboBox_rotation.currentIndex() + 1, max_index)
lambda: self.ui.comboBox_rotation.setCurrentIndex(
min(self.ui.comboBox_rotation.currentIndex() + 1, max_index)
)
)
rotate_minus.activated.connect(
lambda: self.comboBox_rotation.setCurrentIndex(
max(self.comboBox_rotation.currentIndex() - 1, 0)
lambda: self.ui.comboBox_rotation.setCurrentIndex(
max(self.ui.comboBox_rotation.currentIndex() - 1, 0)
)
)
# Key bindings for transpose
transpose = QShortcut(QKeySequence("Ctrl+T"), self)
transpose.activated.connect(self.checkBox_transpose.toggle)
transpose.activated.connect(self.ui.checkBox_transpose.toggle)
FFT = QShortcut(QKeySequence("Ctrl+F"), self)
FFT.activated.connect(self.checkBox_FFT.toggle)
self.checkBox_FFT.setToolTip("Toggle FFT: Ctrl+F")
FFT.activated.connect(self.ui.checkBox_FFT.toggle)
self.ui.checkBox_FFT.setToolTip("Toggle FFT: Ctrl+F")
log = QShortcut(QKeySequence("Ctrl+L"), self)
log.activated.connect(self.checkBox_log.toggle)
self.checkBox_log.setToolTip("Toggle log: Ctrl+L")
log.activated.connect(self.ui.checkBox_log.toggle)
self.ui.checkBox_log.setToolTip("Toggle log: Ctrl+L")
mask = QShortcut(QKeySequence("Ctrl+M"), self)
mask.activated.connect(self.pushButton_mask.click)
self.pushButton_mask.setToolTip("Load mask: Ctrl+M")
mask.activated.connect(self.ui.pushButton_mask.click)
self.ui.pushButton_mask.setToolTip("Load mask: Ctrl+M")
delete_mask = QShortcut(QKeySequence("Ctrl+D"), self)
delete_mask.activated.connect(self.pushButton_delete_mask.click)
self.pushButton_delete_mask.setToolTip("Delete mask: Ctrl+D")
delete_mask.activated.connect(self.ui.pushButton_delete_mask.click)
self.ui.pushButton_delete_mask.setToolTip("Delete mask: Ctrl+D")
def update_hist(self):
self.hist_levels = [
self.doubleSpinBox_hist_min.value(),
self.doubleSpinBox_hist_max.value(),
self.ui.doubleSpinBox_hist_min.value(),
self.ui.doubleSpinBox_hist_max.value(),
]
self.hist.setLevels(min=self.hist_levels[0], max=self.hist_levels[1])
self.hist.setHistogramRange(
@@ -160,16 +164,18 @@ class EigerPlot(QWidget):
# self.image = np.ma.masked_array(self.image, mask=self.mask) #TODO test if np works
self.image = self.image * (1 - self.mask) + 1
if self.checkBox_FFT.isChecked():
if self.ui.checkBox_FFT.isChecked():
self.image = np.abs(np.fft.fftshift(np.fft.fft2(self.image)))
if self.comboBox_rotation.currentIndex() > 0: # rotate
self.image = np.rot90(self.image, k=self.comboBox_rotation.currentIndex(), axes=(0, 1))
if self.ui.comboBox_rotation.currentIndex() > 0: # rotate
self.image = np.rot90(
self.image, k=self.ui.comboBox_rotation.currentIndex(), axes=(0, 1)
)
if self.checkBox_transpose.isChecked(): # transpose
if self.ui.checkBox_transpose.isChecked(): # transpose
self.image = np.transpose(self.image)
if self.checkBox_log.isChecked():
if self.ui.checkBox_log.isChecked():
self.image = np.log10(self.image)
self.imageItem.setImage(self.image, autoLevels=False)

View File

@@ -13,7 +13,7 @@
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<layout class="QVBoxLayout" name="verticalLayout_2" stretch="1,4">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
@@ -191,17 +191,10 @@
</layout>
</item>
<item>
<widget class="GraphicsLayoutWidget" name="glw"/>
<widget class="QWidget" name="glw_placeholder" native="true"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsLayoutWidget</class>
<extends>QGraphicsView</extends>
<header>pyqtgraph.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -2,13 +2,18 @@ import os
import numpy as np
import pyqtgraph as pg
from pyqtgraph.Qt import uic
from pyqtgraph.Qt import QtWidgets
from qtconsole.inprocess import QtInProcessKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils import BECDispatcher
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher, UILoader
from bec_widgets.widgets import BECFigure
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.spiral_progress_bar.spiral_progress_bar import SpiralProgressBar
class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
@@ -35,21 +40,33 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
super().__init__(parent)
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "jupyter_console_window.ui"), self)
self.ui = UILoader().load_ui(os.path.join(current_path, "jupyter_console_window.ui"), self)
self._init_ui()
self.splitter.setSizes([200, 100])
self.ui.splitter.setSizes([200, 100])
self.safe_close = False
# self.figure.clean_signal.connect(self.confirm_close)
self.register = RPCRegister()
self.register.add_rpc(self.figure)
# console push
self.console.kernel_manager.kernel.shell.push(
{
"fig": self.figure,
"register": self.register,
"dock": self.dock,
"w1": self.w1,
"w2": self.w2,
"w3": self.w3,
"d1": self.d1,
"d2": self.d2,
"d3": self.d3,
"bar": self.bar,
"b2a": self.button_2_a,
"b2b": self.button_2_b,
"b2c": self.button_2_c,
"bec": self.figure.client,
"scans": self.figure.client.scans,
"dev": self.figure.client.device_manager.devices,
@@ -58,20 +75,27 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
def _init_ui(self):
# Plotting window
self.glw_1_layout = QVBoxLayout(self.glw) # Create a new QVBoxLayout
self.glw_1_layout = QVBoxLayout(self.ui.glw) # Create a new QVBoxLayout
self.figure = BECFigure(parent=self, gui_id="remote") # Create a new BECDeviceMonitor
self.glw_1_layout.addWidget(self.figure) # Add BECDeviceMonitor to the layout
self.dock_layout = QVBoxLayout(self.ui.dock_placeholder)
self.dock = BECDockArea(gui_id="remote")
self.dock_layout.addWidget(self.dock)
# add stuff to figure
self._init_figure()
self.console_layout = QVBoxLayout(self.widget_console)
# init dock for testing
self._init_dock()
self.console_layout = QVBoxLayout(self.ui.widget_console)
self.console = JupyterConsoleWidget()
self.console_layout.addWidget(self.console)
self.console.set_default_style("linux")
def _init_figure(self):
self.figure.plot("samx", "bpm4d")
self.figure.plot(x_name="samx", y_name="bpm4d")
self.figure.motor_map("samx", "samy")
self.figure.image("eiger", color_map="viridis", vrange=(0, 100))
@@ -86,17 +110,56 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.w1.add_curve_scan("samx", "samy", "bpm3a", pen_style="dash")
self.c1 = self.w1.get_config()
def _init_dock(self):
self.button_1 = QtWidgets.QPushButton("Button 1 ")
self.button_2_a = QtWidgets.QPushButton("Button to be added at place 0,0 in d3")
self.button_2_b = QtWidgets.QPushButton("button after without postions specified")
self.button_2_c = QtWidgets.QPushButton("button super late")
self.button_3 = QtWidgets.QPushButton("Button above Figure ")
self.bar = SpiralProgressBar()
self.label_2 = QtWidgets.QLabel("label which is added separately")
self.label_3 = QtWidgets.QLabel("Label above figure")
self.d1 = self.dock.add_dock(widget=self.button_1, position="left")
self.d1.addWidget(self.label_2)
self.d2 = self.dock.add_dock(widget=self.bar, position="right")
self.d3 = self.dock.add_dock(name="figure")
self.fig_dock3 = BECFigure()
self.fig_dock3.plot(x_name="samx", y_name="bpm4d")
self.d3.add_widget(self.label_3)
self.d3.add_widget(self.button_3)
self.d3.add_widget(self.fig_dock3)
self.dock.save_state()
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
self.dock.cleanup()
self.figure.clear_all()
self.figure.client.shutdown()
super().closeEvent(event)
if __name__ == "__main__": # pragma: no cover
import sys
import bec_widgets
module_path = os.path.dirname(bec_widgets.__file__)
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
win = JupyterConsoleWindow()
win.show()
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -13,13 +13,37 @@
<property name="windowTitle">
<string>Plotting Console</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QWidget" name="glw" native="true"/>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab_1">
<attribute name="title">
<string>BECDock</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QWidget" name="dock_placeholder" native="true"/>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_2">
<attribute name="title">
<string>BECFigure</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QWidget" name="glw" native="true"/>
</item>
</layout>
</widget>
</widget>
<widget class="QWidget" name="widget_console" native="true"/>
</widget>
</item>

View File

@@ -1,7 +1,8 @@
# import simulation_progress as SP
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints, messages
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
@@ -140,7 +141,7 @@ class StreamApp(QWidget):
if __name__ == "__main__":
import argparse
from bec_lib import RedisConnector
from bec_lib.redis_connector import RedisConnector
parser = argparse.ArgumentParser(description="Stream App.")
parser.add_argument("--port", type=str, default="pc15543:6379", help="Port for RedisConnector")

View File

@@ -1,6 +1,8 @@
import time
from bec_lib import MessageEndpoints, RedisConnector, messages
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.redis_connector import RedisConnector
connector = RedisConnector("localhost:6379")
metadata = {}

View File

@@ -1,92 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1433</width>
<height>689</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="2">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Plot Config 2</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="BECMonitor" name="plot_1"/>
</item>
<item row="1" column="3">
<widget class="QPushButton" name="pushButton_setting_2">
<property name="text">
<string>Setting Plot 2</string>
</property>
</widget>
</item>
<item row="3" column="2" colspan="2">
<widget class="BECMonitor" name="plot_2"/>
</item>
<item row="1" column="4">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Plot Scan Types = True</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="pushButton_setting_1">
<property name="text">
<string>Setting Plot 1</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Plot Config 1</string>
</property>
</widget>
</item>
<item row="1" column="5">
<widget class="QPushButton" name="pushButton_setting_3">
<property name="text">
<string>Setting Plot 3</string>
</property>
</widget>
</item>
<item row="3" column="4" colspan="2">
<widget class="BECMonitor" name="plot_3"/>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1433</width>
<height>37</height>
</rect>
</property>
</widget>
<widget class="QStatusBar" name="statusbar"/>
</widget>
<customwidgets>
<customwidget>
<class>BECMonitor</class>
<extends>QGraphicsView</extends>
<header location="global">bec_widgets.widgets.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,200 +0,0 @@
import os
from qtpy import uic
from qtpy.QtWidgets import QApplication, QMainWindow
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets import BECMonitor
# some default configs for demonstration purposes
CONFIG_SIMPLE = {
"plot_settings": {
"background_color": "black",
"num_columns": 2,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor X",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
# {
# "type": "history",
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
# {
# "type": "dap",
# 'worker':'some_worker',
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
}
CONFIG_SCAN_MODE = {
"plot_settings": {
"background_color": "white",
"num_columns": 3,
"colormap": "plasma",
"scan_types": True,
},
"plot_data": {
"grid_scan": [
{
"plot_name": "Grid plot 1",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}],
},
}
],
},
{
"plot_name": "Grid plot 2",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_adc1"}],
},
}
],
},
{
"plot_name": "Grid plot 3",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "gauss_adc2"}],
},
}
],
},
{
"plot_name": "Grid plot 4",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "gauss_adc3"}],
},
}
],
},
],
"line_scan": [
{
"plot_name": "BPM plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
},
}
class ModularApp(QMainWindow):
def __init__(self, client=None, parent=None):
super(ModularApp, self).__init__(parent)
# Client and device manager from BEC
self.client = BECDispatcher().client if client is None else client
# Loading UI
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "modular.ui"), self)
self._init_plots()
def _init_plots(self):
"""Initialize plots and connect the buttons to the config dialogs"""
plots = [self.plot_1, self.plot_2, self.plot_3]
configs = [CONFIG_SIMPLE, CONFIG_SCAN_MODE, CONFIG_SCAN_MODE]
buttons = [self.pushButton_setting_1, self.pushButton_setting_2, self.pushButton_setting_3]
# hook plots, configs and buttons together
for plot, config, button in zip(plots, configs, buttons):
plot.on_config_update(config)
button.clicked.connect(plot.show_config_dialog)
if __name__ == "__main__":
# BECclient global variables
client = BECDispatcher().client
client.start()
app = QApplication([])
modularApp = ModularApp(client=client)
window = modularApp
window.show()
app.exec()

View File

@@ -5,14 +5,15 @@ from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QSplitter, QVBoxLayout, QWidget
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets import (
from bec_widgets.widgets.motor_control.motor_control import MotorThread
from bec_widgets.widgets.motor_control.motor_table.motor_table import MotorCoordinateTable
from bec_widgets.widgets.motor_control.movement_absolute.movement_absolute import (
MotorControlAbsolute,
MotorControlRelative,
MotorControlSelection,
MotorCoordinateTable,
MotorMap,
MotorThread,
)
from bec_widgets.widgets.motor_control.movement_relative.movement_relative import (
MotorControlRelative,
)
from bec_widgets.widgets.motor_control.selection.selection import MotorControlSelection
CONFIG_DEFAULT = {
"motor_control": {
@@ -42,7 +43,7 @@ CONFIG_DEFAULT = {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "samy", "entry": "samy"}],
},
},
}
],
}
@@ -58,13 +59,13 @@ class MotorControlApp(QWidget):
# Widgets
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
# Create MotorMap
self.motion_map = MotorMap(client=self.client, config=self.config)
# self.motion_map = MotorMap(client=self.client, config=self.config)
# Create MotorCoordinateTable
self.motor_table = MotorCoordinateTable(client=self.client, config=self.config)
# Create the splitter and add MotorMap and MotorControlPanel
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(self.motion_map)
# splitter.addWidget(self.motion_map)
splitter.addWidget(self.motor_control_panel)
splitter.addWidget(self.motor_table)
@@ -74,9 +75,9 @@ class MotorControlApp(QWidget):
self.setLayout(layout)
# Connecting signals and slots
self.motor_control_panel.selection_widget.selected_motors_signal.connect(
lambda x, y: self.motion_map.change_motors(x, y, 0)
)
# self.motor_control_panel.selection_widget.selected_motors_signal.connect(
# lambda x, y: self.motion_map.change_motors(x, y, 0)
# )
self.motor_control_panel.absolute_widget.coordinates_signal.connect(
self.motor_table.add_coordinate
)
@@ -87,7 +88,7 @@ class MotorControlApp(QWidget):
self.motor_control_panel.absolute_widget.set_precision
)
self.motor_table.plot_coordinates_signal.connect(self.motion_map.plot_saved_coordinates)
# self.motor_table.plot_coordinates_signal.connect(self.motion_map.plot_saved_coordinates)
class MotorControlMap(QWidget):
@@ -101,11 +102,11 @@ class MotorControlMap(QWidget):
# Widgets
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
# Create MotorMap
self.motion_map = MotorMap(client=self.client, config=self.config)
# self.motion_map = MotorMap(client=self.client, config=self.config)
# Create the splitter and add MotorMap and MotorControlPanel
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(self.motion_map)
# splitter.addWidget(self.motion_map)
splitter.addWidget(self.motor_control_panel)
# Set the main layout
@@ -114,9 +115,9 @@ class MotorControlMap(QWidget):
self.setLayout(layout)
# Connecting signals and slots
self.motor_control_panel.selection_widget.selected_motors_signal.connect(
lambda x, y: self.motion_map.change_motors(x, y, 0)
)
# self.motor_control_panel.selection_widget.selected_motors_signal.connect(
# lambda x, y: self.motion_map.change_motors(x, y, 0)
# )
class MotorControlPanel(QWidget):
@@ -150,7 +151,7 @@ class MotorControlPanel(QWidget):
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
# Set the window to a fixed size based on its contents
self.layout().setSizeConstraint(layout.SetFixedSize)
# self.layout().setSizeConstraint(layout.SetFixedSize)
class MotorControlPanelAbsolute(QWidget):
@@ -177,9 +178,6 @@ class MotorControlPanelAbsolute(QWidget):
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
# Set the window to a fixed size based on its contents
self.layout().setSizeConstraint(layout.SetFixedSize)
class MotorControlPanelRelative(QWidget):
def __init__(self, parent=None, client=None, config=None):
@@ -205,9 +203,6 @@ class MotorControlPanelRelative(QWidget):
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
# Set the window to a fixed size based on its contents
self.layout().setSizeConstraint(layout.SetFixedSize)
if __name__ == "__main__": # pragma: no cover
import argparse

View File

@@ -5,7 +5,8 @@ from functools import partial
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints, messages
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from pyqtgraph.Qt import QtCore, QtWidgets, uic
from qtpy import QtGui
from qtpy.QtCore import Qt, QThread
@@ -1068,10 +1069,7 @@ class MotorApp(QWidget):
# PyQtGraph Controls
layout.addWidget(QLabel("Graph Window Controls:"))
graph_controls = [
("Left Drag", "Pan the view"),
("Right Drag or Scroll", "Zoom in/out"),
]
graph_controls = [("Left Drag", "Pan the view"), ("Right Drag or Scroll", "Zoom in/out")]
for action, description in graph_controls:
layout.addWidget(QLabel(f"{action} - {description}"))
@@ -1111,10 +1109,7 @@ class MotorControl(QThread):
motors_selected = pyqtSignal(object, object) # Signal to emit when the motors are selected
# progress_updated = pyqtSignal(int) #TODO Signal to emit progress percentage
def __init__(
self,
parent=None,
):
def __init__(self, parent=None):
super().__init__(parent)
self.action = None
@@ -1131,10 +1126,7 @@ class MotorControl(QThread):
self.motor_x_name = motor_x_name
self.motor_y_name = motor_y_name
self.motor_x, self.motor_y = (
dev[self.motor_x_name],
dev[self.motor_y_name],
)
self.motor_x, self.motor_y = (dev[self.motor_x_name], dev[self.motor_y_name])
(self.current_x, self.current_y) = self.get_coordinates()

View File

@@ -29,10 +29,10 @@
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="GraphicsLayoutWidget" name="glw_plot"/>
<widget class="GraphicsLayoutWidget" name="glw_image"/>
<widget class="QWidget" name="glw_plot_placeholder" native="true"/>
<widget class="QWidget" name="glw_image_placeholder" native="true"/>
</widget>
<widget class="QWidget" name="">
<widget class="QWidget" name="layoutWidget">
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,1,1,15">
<item>
<widget class="QPushButton" name="pushButton_generate">
@@ -143,13 +143,6 @@
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsLayoutWidget</class>
<extends>QGraphicsView</extends>
<header>pyqtgraph.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -5,21 +5,21 @@ import time
import numpy as np
import pyqtgraph
import pyqtgraph as pg
from bec_lib import MessageEndpoints, messages
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.redis_connector import RedisConnector
from pyqtgraph import mkBrush, mkPen
from pyqtgraph.Qt import QtCore, QtWidgets, uic
from pyqtgraph.Qt.QtCore import pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QTableWidgetItem
from pyqtgraph.Qt import QtCore, QtWidgets
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QTableWidgetItem, QVBoxLayout
from bec_widgets.utils import Colors, Crosshair
from bec_widgets.utils import Colors, Crosshair, UILoader
from bec_widgets.utils.bec_dispatcher import BECDispatcher
class StreamPlot(QtWidgets.QWidget):
update_signal = pyqtSignal()
roi_signal = pyqtSignal(tuple)
update_signal = Signal()
roi_signal = Signal(tuple)
def __init__(self, name="", y_value_list=["gauss_bpm"], client=None, parent=None) -> None:
"""
@@ -38,7 +38,7 @@ class StreamPlot(QtWidgets.QWidget):
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "line_plot.ui"), self)
self.ui = UILoader().load_ui(os.path.join(current_path, "line_plot.ui"), self)
self._idle_time = 100
self.connector = RedisConnector(["localhost:6379"])
@@ -81,6 +81,9 @@ class StreamPlot(QtWidgets.QWidget):
# LabelItem for ROI
self.label_plot = pg.LabelItem(justify="center")
self.glw_plot_layout = QVBoxLayout(self.ui.glw_plot_placeholder)
self.glw_plot = pg.GraphicsLayoutWidget()
self.glw_plot_layout.addWidget(self.glw_plot)
self.glw_plot.addItem(self.label_plot)
self.label_plot.setText("ROI region")
@@ -111,6 +114,9 @@ class StreamPlot(QtWidgets.QWidget):
# Label for coordinates moved
self.label_image_moved = pg.LabelItem(justify="center")
self.glw_image_layout = QVBoxLayout(self.ui.glw_image_placeholder)
self.glw_image = pg.GraphicsLayoutWidget()
self.glw_plot_layout.addWidget(self.glw_image)
self.glw_image.addItem(self.label_image_moved)
self.label_image_moved.setText("Actual coordinates (X, Y)")
@@ -220,10 +226,10 @@ class StreamPlot(QtWidgets.QWidget):
def init_table(self):
# Init number of rows in table according to n of devices
self.cursor_table.setRowCount(len(self.y_value_list))
self.ui.cursor_table.setRowCount(len(self.y_value_list))
# self.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"]) #TODO can be dynamic
self.cursor_table.setVerticalHeaderLabels(self.y_value_list)
self.cursor_table.resizeColumnsToContents()
self.ui.cursor_table.setVerticalHeaderLabels(self.y_value_list)
self.ui.cursor_table.resizeColumnsToContents()
def update_table(self, table_widget, x, y_values):
for i, y in enumerate(y_values):
@@ -286,13 +292,13 @@ class StreamPlot(QtWidgets.QWidget):
self.update_signal.emit()
@pyqtSlot(dict, dict)
@Slot(dict, dict)
def on_dap_update(self, data: dict, metadata: dict):
flipped_data = self.flip_even_rows(data["data"]["z"])
self.img.setImage(flipped_data)
@pyqtSlot(dict, dict)
@Slot(dict, dict)
def new_proj(self, content: dict, _metadata: dict):
proj_nr = content["signals"]["proj_nr"]
endpoint = f"px_stream/projection_{proj_nr}/metadata"

View File

@@ -0,0 +1,17 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
"""PySide6 port of the Qt Designer taskmenuextension example from Qt v6.x"""
import sys
from bec_ipython_client.main import BECIPythonClient
from PySide6.QtWidgets import QApplication
from tictactoe import TicTacToe
if __name__ == "__main__":
app = QApplication(sys.argv)
window = TicTacToe()
window.state = "-X-XO----"
window.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,24 @@
import os
import subprocess
import sys
from PySide6.scripts.pyside_tool import designer
import bec_widgets
def main():
# os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.path.join(
# "/Users/janwyzula/PSI/bec_widgets/bec_widgets/plugin"
# )
os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.path.join(
os.path.dirname(bec_widgets.__file__), "widgets/motor_control/selection"
)
# os.environ["PYTHONFRAMEWORKPREFIX"] = os.path.join(
# os.path.dirname(bec_widgets.__file__), "widgets/motor_control/selection"
# )
designer()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,12 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from tictactoe import TicTacToe
from tictactoeplugin import TicTacToePlugin
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin
if __name__ == "__main__":
QPyDesignerCustomWidgetCollection.addCustomWidget(TicTacToePlugin())

View File

@@ -0,0 +1,4 @@
{
"files": ["tictactoe.py", "main.py", "registertictactoe.py", "tictactoeplugin.py",
"tictactoetaskmenu.py"]
}

View File

@@ -0,0 +1,135 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from PySide6.QtCore import Property, QPoint, QRect, QSize, Qt, Slot
from PySide6.QtGui import QPainter, QPen
from PySide6.QtWidgets import QWidget
EMPTY = "-"
CROSS = "X"
NOUGHT = "O"
DEFAULT_STATE = "---------"
class TicTacToe(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._state = DEFAULT_STATE
self._turn_number = 0
def minimumSizeHint(self):
return QSize(200, 200)
def sizeHint(self):
return QSize(200, 200)
def setState(self, new_state):
self._turn_number = 0
self._state = DEFAULT_STATE
for position in range(min(9, len(new_state))):
mark = new_state[position]
if mark == CROSS or mark == NOUGHT:
self._turn_number += 1
self._change_state_at(position, mark)
position += 1
self.update()
def state(self):
return self._state
@Slot()
def clear_board(self):
self._state = DEFAULT_STATE
self._turn_number = 0
self.update()
def _change_state_at(self, pos, new_state):
self._state = self._state[:pos] + new_state + self._state[pos + 1 :]
def mousePressEvent(self, event):
if self._turn_number == 9:
self.clear_board()
return
for position in range(9):
cell = self._cell_rect(position)
if cell.contains(event.position().toPoint()):
if self._state[position] == EMPTY:
new_state = CROSS if self._turn_number % 2 == 0 else NOUGHT
self._change_state_at(position, new_state)
self._turn_number += 1
self.update()
def paintEvent(self, event):
with QPainter(self) as painter:
painter.setRenderHint(QPainter.Antialiasing)
painter.setPen(QPen(Qt.darkGreen, 1))
painter.drawLine(self._cell_width(), 0, self._cell_width(), self.height())
painter.drawLine(2 * self._cell_width(), 0, 2 * self._cell_width(), self.height())
painter.drawLine(0, self._cell_height(), self.width(), self._cell_height())
painter.drawLine(0, 2 * self._cell_height(), self.width(), 2 * self._cell_height())
painter.setPen(QPen(Qt.darkBlue, 2))
for position in range(9):
cell = self._cell_rect(position)
if self._state[position] == CROSS:
painter.drawLine(cell.topLeft(), cell.bottomRight())
painter.drawLine(cell.topRight(), cell.bottomLeft())
elif self._state[position] == NOUGHT:
painter.drawEllipse(cell)
painter.setPen(QPen(Qt.yellow, 3))
for position in range(0, 8, 3):
if (
self._state[position] != EMPTY
and self._state[position + 1] == self._state[position]
and self._state[position + 2] == self._state[position]
):
y = self._cell_rect(position).center().y()
painter.drawLine(0, y, self.width(), y)
self._turn_number = 9
for position in range(3):
if (
self._state[position] != EMPTY
and self._state[position + 3] == self._state[position]
and self._state[position + 6] == self._state[position]
):
x = self._cell_rect(position).center().x()
painter.drawLine(x, 0, x, self.height())
self._turn_number = 9
if (
self._state[0] != EMPTY
and self._state[4] == self._state[0]
and self._state[8] == self._state[0]
):
painter.drawLine(0, 0, self.width(), self.height())
self._turn_number = 9
if (
self._state[2] != EMPTY
and self._state[4] == self._state[2]
and self._state[6] == self._state[2]
):
painter.drawLine(0, self.height(), self.width(), 0)
self._turn_number = 9
def _cell_rect(self, position):
h_margin = self.width() / 30
v_margin = self.height() / 30
row = int(position / 3)
column = position - 3 * row
pos = QPoint(column * self._cell_width() + h_margin, row * self._cell_height() + v_margin)
size = QSize(self._cell_width() - 2 * h_margin, self._cell_height() - 2 * v_margin)
return QRect(pos, size)
def _cell_width(self):
return self.width() / 3
def _cell_height(self):
return self.height() / 3
state = Property(str, state, setState)

View File

@@ -0,0 +1,68 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from PySide6.QtDesigner import QDesignerCustomWidgetInterface
from PySide6.QtGui import QIcon
from tictactoe import TicTacToe
from tictactoetaskmenu import TicTacToeTaskMenuFactory
DOM_XML = """
<ui language='c++'>
<widget class='TicTacToe' name='ticTacToe'>
<property name='geometry'>
<rect>
<x>0</x>
<y>0</y>
<width>200</width>
<height>200</height>
</rect>
</property>
<property name='state'>
<string>-X-XO----</string>
</property>
</widget>
</ui>
"""
class TicTacToePlugin(QDesignerCustomWidgetInterface):
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = TicTacToe(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return QIcon()
def includeFile(self):
return "tictactoe"
def initialize(self, form_editor):
self._form_editor = form_editor
manager = form_editor.extensionManager()
iid = TicTacToeTaskMenuFactory.task_menu_iid()
manager.registerExtensions(TicTacToeTaskMenuFactory(manager), iid)
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "TicTacToe"
def toolTip(self):
return "Tic Tac Toe Example, demonstrating class QDesignerTaskMenuExtension (Python)"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,67 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from PySide6.QtCore import Slot
from PySide6.QtDesigner import QExtensionFactory, QPyDesignerTaskMenuExtension
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from tictactoe import TicTacToe
class TicTacToeDialog(QDialog):
def __init__(self, parent):
super().__init__(parent)
layout = QVBoxLayout(self)
self._ticTacToe = TicTacToe(self)
layout.addWidget(self._ticTacToe)
button_box = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Reset
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
reset_button = button_box.button(QDialogButtonBox.Reset)
reset_button.clicked.connect(self._ticTacToe.clear_board)
layout.addWidget(button_box)
def set_state(self, new_state):
self._ticTacToe.setState(new_state)
def state(self):
return self._ticTacToe.state
class TicTacToeTaskMenu(QPyDesignerTaskMenuExtension):
def __init__(self, ticTacToe, parent):
super().__init__(parent)
self._ticTacToe = ticTacToe
self._edit_state_action = QAction("Edit State...", None)
self._edit_state_action.triggered.connect(self._edit_state)
def taskActions(self):
return [self._edit_state_action]
def preferredEditAction(self):
return self._edit_state_action
@Slot()
def _edit_state(self):
dialog = TicTacToeDialog(self._ticTacToe)
dialog.set_state(self._ticTacToe.state)
if dialog.exec() == QDialog.Accepted:
self._ticTacToe.state = dialog.state()
class TicTacToeTaskMenuFactory(QExtensionFactory):
def __init__(self, extension_manager):
super().__init__(extension_manager)
@staticmethod
def task_menu_iid():
return "org.qt-project.Qt.Designer.TaskMenu"
def createExtension(self, object, iid, parent):
if iid != TicTacToeTaskMenuFactory.task_menu_iid():
return None
if object.__class__.__name__ != "TicTacToe":
return None
return TicTacToeTaskMenu(object, parent)

View File

@@ -2,7 +2,10 @@ from .bec_connector import BECConnector, ConnectionConfig
from .bec_dispatcher import BECDispatcher
from .bec_table import BECTable
from .colors import Colors
from .container_utils import WidgetContainerUtils
from .crosshair import Crosshair
from .entry_validator import EntryValidator
from .layout_manager import GridLayoutManager
from .rpc_decorator import register_rpc_methods, rpc_public
from .ui_loader import UILoader
from .validator_delegate import DoubleValidationDelegate

View File

@@ -4,10 +4,13 @@ from __future__ import annotations
import time
from typing import Optional, Type
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import Slot as pyqtSlot
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.cli.rpc_register import RPCRegister
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
class ConnectionConfig(BaseModel):
@@ -31,7 +34,7 @@ class ConnectionConfig(BaseModel):
class BECConnector:
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
USER_ACCESS = ["config_dict"]
USER_ACCESS = ["config_dict", "get_all_rpc"]
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
# BEC related connections
@@ -54,10 +57,30 @@ class BECConnector:
else:
self.gui_id = self.config.gui_id
# register widget to rpc register
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
def get_all_rpc(self) -> dict:
"""Get all registered RPC objects."""
all_connections = self.rpc_register.list_all_connections()
return dict(all_connections)
@property
def rpc_id(self) -> str:
"""Get the RPC ID of the widget."""
return self.gui_id
@rpc_id.setter
def rpc_id(self, rpc_id: str) -> None:
"""Set the RPC ID of the widget."""
self.gui_id = rpc_id
@property
def config_dict(self) -> dict:
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@@ -67,6 +90,7 @@ class BECConnector:
def config_dict(self, config: BaseModel) -> None:
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@@ -76,6 +100,7 @@ class BECConnector:
def set_gui_id(self, gui_id: str) -> None:
"""
Set the GUI ID for the widget.
Args:
gui_id(str): GUI ID
"""
@@ -96,6 +121,7 @@ class BECConnector:
def update_client(self, client) -> None:
"""Update the client and device manager from BEC and create object for BEC shortcuts.
Args:
client: BEC client
"""
@@ -106,6 +132,7 @@ class BECConnector:
def on_config_update(self, config: ConnectionConfig | dict) -> None:
"""
Update the configuration for the widget.
Args:
config(ConnectionConfig): Configuration settings.
"""
@@ -118,8 +145,10 @@ class BECConnector:
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
"""
Get the configuration of the widget.
Args:
dict_output(bool): If True, return the configuration as a dictionary. If False, return the configuration as a pydantic model.
Returns:
dict: The configuration of the plot widget.
"""
@@ -127,3 +156,15 @@ class BECConnector:
return self.config.model_dump()
else:
return self.config
def cleanup(self):
"""Cleanup the widget."""
self.rpc_register.remove_rpc(self)
all_connections = self.rpc_register.list_all_connections()
if len(all_connections) == 0:
print("No more connections. Shutting down GUI BEC client.")
self.client.shutdown()
# def closeEvent(self, event):
# self.cleanup()
# super().closeEvent(event)

View File

@@ -6,8 +6,9 @@ from collections.abc import Callable
from typing import TYPE_CHECKING, Union
import redis
from bec_lib import BECClient
from bec_lib.client import BECClient
from bec_lib.redis_connector import MessageObject, RedisConnector
from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
@@ -71,13 +72,13 @@ class BECDispatcher:
_instance = None
_initialized = False
def __new__(cls, client=None, *args, **kwargs):
def __new__(cls, client=None, config: str = None, *args, **kwargs):
if cls._instance is None:
cls._instance = super(BECDispatcher, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self, client=None):
def __init__(self, client=None, config: str = None):
if self._initialized:
return
@@ -85,7 +86,14 @@ class BECDispatcher:
self.client = client
if self.client is None:
self.client = BECClient(connector_cls=QtRedisConnector, forced=True)
if config is not None:
host, port = config.split(":")
redis_config = {"host": host, "port": port}
self.client = BECClient(
config=ServiceConfig(redis=redis_config), connector_cls=QtRedisConnector
) # , forced=True)
else:
self.client = BECClient(connector_cls=QtRedisConnector) # , forced=True)
else:
if self.client.started:
# have to reinitialize client to use proper connector
@@ -105,9 +113,7 @@ class BECDispatcher:
cls._initialized = False
def connect_slot(
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
self, slot: Callable, topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]]
) -> None:
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message.
@@ -122,7 +128,15 @@ class BECDispatcher:
self._slots[slot].update(set(topics_str))
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
self.client.connector.unregister(topics, cb=slot)
# find the right slot to disconnect from ;
# slot callbacks are wrapped in QtThreadSafeCallback objects,
# but the slot we receive here is the original callable
for connected_slot in self._slots:
if connected_slot.cb == slot:
break
else:
return
self.client.connector.unregister(topics, cb=connected_slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].difference_update(set(topics_str))
if not self._slots[slot]:

View File

@@ -8,6 +8,7 @@ class BECTable(QTableWidget):
def keyPressEvent(self, event) -> None:
"""
Delete selected rows with backspace or delete key
Args:
event: keyPressEvent
"""

View File

@@ -0,0 +1,48 @@
import itertools
from typing import Type
from qtpy.QtWidgets import QWidget
class WidgetContainerUtils:
@staticmethod
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
"""
Generate a unique widget ID.
Args:
container(dict): The container of widgets.
prefix(str): The prefix of the widget ID.
Returns:
widget_id(str): The unique widget ID.
"""
existing_ids = set(container.keys())
for i in itertools.count(1):
widget_id = f"{prefix}_{i}"
if widget_id not in existing_ids:
return widget_id
@staticmethod
def find_first_widget_by_class(
container: dict, widget_class: Type[QWidget], can_fail: bool = True
) -> QWidget | None:
"""
Find the first widget of a given class in the figure.
Args:
container(dict): The container of widgets.
widget_class(Type): The class of the widget to find.
can_fail(bool): If True, the method will return None if no widget is found. If False, it will raise an error.
Returns:
widget: The widget of the given class.
"""
for widget_id, widget in container.items():
if isinstance(widget, widget_class):
return widget
if can_fail:
return None
else:
raise ValueError(f"No widget of class {widget_class} found.")

View File

@@ -8,15 +8,16 @@ from qtpy.QtCore import Signal as pyqtSignal
class Crosshair(QObject):
# Signal for 1D plot
coordinatesChanged1D = pyqtSignal(float, list)
coordinatesClicked1D = pyqtSignal(float, list)
coordinatesChanged1D = pyqtSignal(tuple)
coordinatesClicked1D = pyqtSignal(tuple)
# Signal for 2D plot
coordinatesChanged2D = pyqtSignal(float, float)
coordinatesClicked2D = pyqtSignal(float, float)
coordinatesChanged2D = pyqtSignal(tuple)
coordinatesClicked2D = pyqtSignal(tuple)
def __init__(self, plot_item: pg.PlotItem, precision: int = None, parent=None):
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
"""
Crosshair for 1D and 2D plots.
Args:
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
@@ -173,10 +174,11 @@ class Crosshair(QObject):
if isinstance(item, pg.PlotDataItem):
if x is None or all(v is None for v in y_values):
return
self.coordinatesChanged1D.emit(
coordinance_to_emit = (
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
self.coordinatesChanged1D.emit(coordinance_to_emit)
for i, y_val in enumerate(y_values):
self.marker_moved_1d[i].setData(
[x if not self.is_log_x else np.log10(x)],
@@ -185,7 +187,8 @@ class Crosshair(QObject):
elif isinstance(item, pg.ImageItem):
if x is None or y_values is None:
return
self.coordinatesChanged2D.emit(x, y_values)
coordinance_to_emit = (x, y_values)
self.coordinatesChanged2D.emit(coordinance_to_emit)
def mouse_clicked(self, event):
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
@@ -208,10 +211,11 @@ class Crosshair(QObject):
if isinstance(item, pg.PlotDataItem):
if x is None or all(v is None for v in y_values):
return
self.coordinatesClicked1D.emit(
coordinate_to_emit = (
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
for i, y_val in enumerate(y_values):
for marker in self.marker_clicked_1d[i]:
marker.setData(
@@ -221,7 +225,8 @@ class Crosshair(QObject):
elif isinstance(item, pg.ImageItem):
if x is None or y_values is None:
return
self.coordinatesClicked2D.emit(x, y_values)
coordinate_to_emit = (x, y_values)
self.coordinatesClicked2D.emit(coordinate_to_emit)
self.marker_2d.setPos([x, y_values])
def check_log(self):

View File

@@ -1,39 +0,0 @@
# TODO haven't found yet how to deal with QAbstractSocket in qtpy
# import signal
# import socket
# from PyQt5.QtNetwork import QAbstractSocket
#
#
# def setup(app):
# app.signalwatchdog = SignalWatchdog() # need to store to keep socket pair alive
# signal.signal(signal.SIGINT, make_quit_handler(app))
#
#
# def make_quit_handler(app):
# def handler(*args):
# print() # make ^C appear on its own line
# app.quit()
#
# return handler
#
#
# class SignalWatchdog(QAbstractSocket):
# def __init__(self):
# """
# Propagates system signals from Python to QEventLoop
# adapted from https://stackoverflow.com/a/65802260/655404
# """
# super().__init__(QAbstractSocket.SctpSocket, None)
#
# self.writer, self.reader = writer, reader = socket.socketpair()
# writer.setblocking(False)
#
# fd_writer = writer.fileno()
# fd_reader = reader.fileno()
#
# signal.set_wakeup_fd(fd_writer) # Python hook
# self.setSocketDescriptor(fd_reader) # Qt hook
#
# self.readyRead.connect(
# lambda: None
# ) # dummy function call that lets the Python interpreter run

View File

@@ -5,6 +5,7 @@ class EntryValidator:
def validate_signal(self, name: str, entry: str = None) -> str:
"""
Validate a signal entry for a given device. If the entry is not provided, the first signal entry will be used from the device hints.
Args:
name(str): Device name
entry(str): Signal entry
@@ -28,6 +29,7 @@ class EntryValidator:
def validate_monitor(self, monitor: str) -> str:
"""
Validate a monitor entry for a given device.
Args:
monitor(str): Monitor entry

View File

@@ -0,0 +1,121 @@
from collections import OrderedDict
from typing import Literal
from qtpy.QtWidgets import QGridLayout, QWidget
class GridLayoutManager:
"""
GridLayoutManager class is used to manage widgets in a QGridLayout and extend its functionality.
The GridLayoutManager class provides methods to add, move, and check the position of widgets in a QGridLayout.
It also provides a method to get the positions of all widgets in the layout.
Args:
layout(QGridLayout): The layout to manage.
"""
def __init__(self, layout: QGridLayout):
self.layout = layout
def is_position_occupied(self, row: int, col: int) -> bool:
"""
Check if the position in the layout is occupied by a widget.
Args:
row(int): The row to check.
col(int): The column to check.
Returns:
bool: True if the position is occupied, False otherwise.
"""
for i in range(self.layout.count()):
widget_row, widget_col, _, _ = self.layout.getItemPosition(i)
if widget_row == row and widget_col == col:
return True
return False
def shift_widgets(
self,
direction: Literal["down", "up", "left", "right"] = "down",
start_row: int = 0,
start_col: int = 0,
):
"""
Shift widgets in the layout in the specified direction starting from the specified position.
Args:
direction(str): The direction to shift the widgets. Can be "down", "up", "left", or "right".
start_row(int): The row to start shifting from. Default is 0.
start_col(int): The column to start shifting from. Default is 0.
"""
for i in reversed(range(self.layout.count())):
widget_item = self.layout.itemAt(i)
widget = widget_item.widget()
row, col, rowspan, colspan = self.layout.getItemPosition(i)
if direction == "down" and row >= start_row:
self.layout.addWidget(widget, row + 1, col, rowspan, colspan)
elif direction == "up" and row > start_row:
self.layout.addWidget(widget, row - 1, col, rowspan, colspan)
elif direction == "right" and col >= start_col:
self.layout.addWidget(widget, row, col + 1, rowspan, colspan)
elif direction == "left" and col > start_col:
self.layout.addWidget(widget, row, col - 1, rowspan, colspan)
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
"""
Move a widget to a new position in the layout.
Args:
widget(QWidget): The widget to move.
new_row(int): The new row to move the widget to.
new_col(int): The new column to move the widget to.
"""
self.layout.removeWidget(widget)
self.layout.addWidget(widget, new_row, new_col)
def add_widget(
self,
widget: QWidget,
row=None,
col=0,
rowspan=1,
colspan=1,
shift: Literal["down", "up", "left", "right"] = "down",
):
"""
Add a widget to the layout at the specified position.
Args:
widget(QWidget): The widget to add.
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
col(int): The column to add the widget to. Default is 0.
rowspan(int): The number of rows the widget will span. Default is 1.
colspan(int): The number of columns the widget will span. Default is 1.
shift(str): The direction to shift the widgets if the position is occupied. Can be "down", "up", "left", or "right".
"""
if row is None:
row = self.layout.rowCount()
if self.is_position_occupied(row, col):
self.shift_widgets(shift, start_row=row)
self.layout.addWidget(widget, row, col, rowspan, colspan)
def get_widgets_positions(self) -> dict:
"""
Get the positions of all widgets in the layout.
Returns:
dict: A dictionary with the positions of the widgets in the layout.
"""
positions = []
for i in range(self.layout.count()):
widget_item = self.layout.itemAt(i)
widget = widget_item.widget()
if widget:
position = self.layout.getItemPosition(i)
positions.append((position, widget))
positions.sort(key=lambda x: (x[0][0], x[0][1], x[0][2], x[0][3]))
ordered_positions = OrderedDict()
for pos, widget in positions:
ordered_positions[pos] = widget
return ordered_positions

View File

@@ -0,0 +1,40 @@
import inspect
from bec_lib.plugin_helper import _get_available_plugins
from bec_widgets.utils import BECConnector
def get_plugin_widgets() -> dict[str, BECConnector]:
"""
Get all available widgets from the plugin directory. Widgets are classes that inherit from BECConnector.
The plugins are provided through python plugins and specified in the respective pyproject.toml file using
the following key:
[project.entry-points."bec.widgets.user_widgets"]
plugin_widgets = "path.to.plugin.module"
e.g.
[project.entry-points."bec.widgets.user_widgets"]
plugin_widgets = "pxiii_bec.bec_widgets.widgets"
assuming that the widgets module for the package pxiii_bec is located at pxiii_bec/bec_widgets/widgets and
contains the widgets to be loaded within the pxiii_bec/bec_widgets/widgets/__init__.py file.
Returns:
dict[str, BECConnector]: A dictionary of widget names and their respective classes.
"""
modules = _get_available_plugins("bec.widgets.user_widgets")
loaded_plugins = {}
print(modules)
for module in modules:
mods = inspect.getmembers(module, predicate=_filter_plugins)
for name, mod_cls in mods:
if name in loaded_plugins:
print(f"Duplicated widgets plugin {name}.")
loaded_plugins[name] = mod_cls
return loaded_plugins
def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)

View File

@@ -0,0 +1,37 @@
import threading
class ThreadTracker:
def __init__(self, exclude_names=None):
self.exclude_names = exclude_names if exclude_names else []
self.initial_threads = self._capture_threads()
def _capture_threads(self):
return set(
th
for th in threading.enumerate()
if not any(ex_name in th.name for ex_name in self.exclude_names)
and th is not threading.main_thread()
)
def _thread_info(self, threads):
return ", \n".join(f"{th.name}(ID: {th.ident})" for th in threads)
def check_unfinished_threads(self):
current_threads = self._capture_threads()
additional_threads = current_threads - self.initial_threads
closed_threads = self.initial_threads - current_threads
if additional_threads:
raise Exception(
f"###### Initial threads ######:\n {self._thread_info(self.initial_threads)}\n"
f"###### Current threads ######:\n {self._thread_info(current_threads)}\n"
f"###### Closed threads ######:\n {self._thread_info(closed_threads)}\n"
f"###### Unfinished threads ######:\n {self._thread_info(additional_threads)}"
)
else:
print(
"All threads properly closed.\n"
f"###### Initial threads ######:\n {self._thread_info(self.initial_threads)}\n"
f"###### Current threads ######:\n {self._thread_info(current_threads)}\n"
f"###### Closed threads ######:\n {self._thread_info(closed_threads)}"
)

View File

@@ -0,0 +1,58 @@
from qtpy import QT_VERSION
from qtpy.QtCore import QFile, QIODevice
class UILoader:
"""Universal UI loader for PyQt5, PyQt6, PySide2, and PySide6."""
def __init__(self, parent=None):
self.parent = parent
if QT_VERSION.startswith("5"):
# PyQt5 or PySide2
from qtpy import uic
self.loader = uic.loadUi
elif QT_VERSION.startswith("6"):
# PyQt6 or PySide6
try:
from PySide6.QtUiTools import QUiLoader
self.loader = self.load_ui_pyside6
except ImportError:
from PyQt6.uic import loadUi
self.loader = loadUi
def load_ui_pyside6(self, ui_file, parent=None):
"""
Specific loader for PySide6 using QUiLoader.
Args:
ui_file(str): Path to the .ui file.
parent(QWidget): Parent widget.
Returns:
QWidget: The loaded widget.
"""
from PySide6.QtUiTools import QUiLoader
loader = QUiLoader(parent)
file = QFile(ui_file)
if not file.open(QIODevice.ReadOnly):
raise IOError(f"Cannot open file: {ui_file}")
widget = loader.load(file, parent)
file.close()
return widget
def load_ui(self, ui_file, parent=None):
"""
Universal UI loader method.
Args:
ui_file(str): Path to the .ui file.
parent(QWidget): Parent widget.
Returns:
QWidget: The loaded widget.
"""
if parent is None:
parent = self.parent
return self.loader(ui_file, parent)

View File

@@ -114,6 +114,7 @@ class WidgetIO:
def get_value(widget, ignore_errors=False):
"""
Retrieve value from the widget instance.
Args:
widget: Widget instance.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
@@ -129,6 +130,7 @@ class WidgetIO:
def set_value(widget, value, ignore_errors=False):
"""
Set a value on the widget instance.
Args:
widget: Widget instance.
value: Value to set.
@@ -155,6 +157,7 @@ class WidgetHierarchy:
) -> None:
"""
Print the widget hierarchy to the console.
Args:
widget: Widget to print the hierarchy of
indent(int, optional): Level of indentation.
@@ -196,6 +199,7 @@ class WidgetHierarchy:
) -> dict:
"""
Export the widget hierarchy to a dictionary.
Args:
widget: Widget to print the hierarchy of.
config(dict,optional): Dictionary to export the hierarchy to.
@@ -245,6 +249,7 @@ class WidgetHierarchy:
def import_config_from_dict(widget, config: dict, set_values: bool = False) -> None:
"""
Import the widget hierarchy from a dictionary.
Args:
widget: Widget to import the hierarchy to.
config:

View File

@@ -9,6 +9,7 @@ from qtpy.QtWidgets import QFileDialog
def load_yaml(instance) -> Union[dict, None]:
"""
Load YAML file from disk.
Args:
instance: Instance of the calling widget.
@@ -40,6 +41,7 @@ def load_yaml(instance) -> Union[dict, None]:
def save_yaml(instance, config: dict) -> None:
"""
Save YAML file to disk.
Args:
instance: Instance of the calling widget.
config: Configuration data to be saved.

View File

@@ -1,2 +0,0 @@
# from .monitor_config import validate_monitor_config, ValidationError
from .monitor_config_validator import MonitorConfigValidator

View File

@@ -1,257 +0,0 @@
from typing import Literal, Optional, Union
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
from pydantic_core import PydanticCustomError
class Signal(BaseModel):
"""
Represents a signal in a plot configuration.
Attributes:
name (str): The name of the signal.
entry (Optional[str]): The entry point of the signal, optional.
"""
name: str
entry: Optional[str] = Field(None, validate_default=True)
@model_validator(mode="before")
@classmethod
def validate_fields(cls, values):
"""Validate the fields of the model.
First validate the 'name' field, then validate the 'entry' field.
Args:
values (dict): The values to be validated."""
devices = MonitorConfigValidator.devices
# Validate 'name'
name = values.get("name")
# Check if device name provided
if name is None:
raise PydanticCustomError(
"no_device_name", "Device name must be provided", {"wrong_value": name}
)
# Check if device exists in BEC
if name not in devices:
raise PydanticCustomError(
"no_device_bec",
'Device "{wrong_value}" not found in current BEC session',
{"wrong_value": name},
)
device = devices[name] # get the device to check if it has signals
# Get device description
description = device.describe()
# Validate 'entry'
entry = values.get("entry")
# Set entry based on hints if not provided
if entry is None:
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
raise PydanticCustomError(
"no_entry_for_device",
'Entry "{wrong_value}" not found in device "{device_name}" signals',
{"wrong_value": entry, "device_name": name},
)
values["entry"] = entry
return values
class AxisSignal(BaseModel):
"""
Configuration signal axis for a single plot.
Attributes:
x (list): Signal for the X axis.
y (list): Signals for the Y axis.
"""
x: list[Signal] = Field(default_factory=list)
y: list[Signal] = Field(default_factory=list)
@field_validator("x")
@classmethod
def validate_x_signals(cls, v):
"""Ensure that there is only one signal for x-axis."""
if len(v) != 1:
raise PydanticCustomError(
"x_axis_multiple_signals",
'There must be exactly one signal for x axis. Number of x signals: "{wrong_value}"',
{"wrong_value": v},
)
return v
class SourceHistoryValidator(BaseModel):
"""History source validator
Attributes:
type (str): type of source - history
scan_id (str): Scan ID for history source.
signals (list): Signal for the source.
"""
type: Literal["history"]
scan_id: str # TODO can be validated if it is a valid scan_id
signals: AxisSignal
class SourceSegmentValidator(BaseModel):
"""Scan Segment source validator
Attributes:
type (str): type of source - scan_segment
signals (AxisSignal): Signal for the source.
"""
type: Literal["scan_segment"]
signals: AxisSignal
class SourceRedisValidator(BaseModel):
"""Scan Segment source validator
Attributes:
type (str): type of source - scan_segment
endpoint (str): Endpoint reference in redis.
update (str): Update type.
"""
type: Literal["redis"]
endpoint: str
update: str
signals: dict
class Source(BaseModel): # TODO decide if it should stay for general Source validation
"""
General source validation, includes all Optional arguments of all other sources.
Attributes:
type (list): type of source (scan_segment, history)
scan_id (Optional[str]): Scan ID for history source.
signals (Optional[AxisSignal]): Signal for the source.
"""
type: Literal["scan_segment", "history", "redis"]
scan_id: Optional[str] = None
signals: Optional[dict] = None
class PlotConfig(BaseModel):
"""
Configuration for a single plot.
Attributes:
plot_name (Optional[str]): Name of the plot.
x_label (Optional[str]): The label for the x-axis.
y_label (Optional[str]): The label for the y-axis.
sources (list): A list of sources to be plotted on this axis.
"""
plot_name: Optional[str] = None
x_label: Optional[str] = None
y_label: Optional[str] = None
sources: list = Field(default_factory=list)
@field_validator("sources")
@classmethod
def validate_sources(cls, values):
"""Validate the sources of the plot configuration, based on the type of source."""
validated_sources = []
for source in values:
# Check if source type is supported
Source(**source)
source_type = source.get("type", None)
# Validate source based on type
if source_type == "scan_segment":
validated_sources.append(SourceSegmentValidator(**source))
elif source_type == "history":
validated_sources.append(SourceHistoryValidator(**source))
elif source_type == "redis":
validated_sources.append(SourceRedisValidator(**source))
return validated_sources
class PlotSettings(BaseModel):
"""
Global settings for plotting affecting mostly visuals.
Attributes:
background_color (str): Color of the plot background. Default is black.
axis_width (Optional[int]): Width of the plot axes. Default is 2.
axis_color (Optional[str]): Color of the plot axes. Default is None.
num_columns (int): Number of columns in the plot layout. Default is 1.
colormap (str): Colormap to be used. Default is magma.
scan_types (bool): Indicates if the configuration is for different scan types. Default is False.
"""
background_color: Literal["black", "white"] = "black"
axis_width: Optional[int] = 2
axis_color: Optional[str] = None
num_columns: Optional[int] = 1
colormap: Optional[str] = "magma"
scan_types: Optional[bool] = False
class DeviceMonitorConfig(BaseModel):
"""
Configuration model for the device monitor mode.
Attributes:
plot_settings (PlotSettings): Global settings for plotting.
plot_data (list[PlotConfig]): List of plot configurations.
"""
plot_settings: PlotSettings
plot_data: list[PlotConfig]
class ScanModeConfig(BaseModel):
"""
Configuration model for scan mode.
Attributes:
plot_settings (PlotSettings): Global settings for plotting.
plot_data (dict[str, list[PlotConfig]]): Dictionary of plot configurations,
keyed by scan type.
"""
plot_settings: PlotSettings
plot_data: dict[str, list[PlotConfig]]
class MonitorConfigValidator:
"""Validates the configuration data for the BECMonitor."""
devices = None
def __init__(self, devices):
# self.device_manager = device_manager
MonitorConfigValidator.devices = devices
def validate_monitor_config(
self, config_data: dict
) -> Union[DeviceMonitorConfig, ScanModeConfig]:
"""
Validates the configuration data based on the provided schema.
Args:
config_data (dict): Configuration data to be validated.
Returns:
Union[DeviceMonitorConfig, ScanModeConfig]: Validated configuration object.
Raises:
ValidationError: If the configuration data does not conform to the schema.
"""
config_type = config_data.get("plot_settings", {}).get("scan_types", False)
if config_type:
validated_config = ScanModeConfig(**config_data)
else:
validated_config = DeviceMonitorConfig(**config_data)
return validated_config

View File

@@ -1,13 +1,4 @@
from .editor import BECEditor
from .dock import BECDock, BECDockArea
from .figure import BECFigure, FigureConfig
from .monitor import BECMonitor
from .motor_control import (
MotorControlAbsolute,
MotorControlRelative,
MotorControlSelection,
MotorCoordinateTable,
MotorThread,
)
from .motor_map import MotorMap
from .plots import BECCurve, BECMotorMap, BECWaveform
from .scan_control import ScanControl
from .spiral_progress_bar import SpiralProgressBar

View File

@@ -0,0 +1,14 @@
from qtpy.QtWidgets import QComboBox
from bec_widgets.utils import BECConnector, ConnectionConfig
class DeviceCombobox(BECConnector, QComboBox):
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QComboBox.__init__(self, parent=parent)
self.get_bec_shortcuts()
def get_device(self):
return getattr(self.dev, self.text().lower())

View File

@@ -0,0 +1,2 @@
from .dock import BECDock
from .dock_area import BECDockArea

View File

@@ -0,0 +1,269 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
from pydantic import Field
from pyqtgraph.dockarea import Dock
from bec_widgets.cli.rpc_wigdet_handler import RPCWidgetHandler
from bec_widgets.utils import BECConnector, ConnectionConfig, GridLayoutManager
if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets import BECDockArea
class DockConfig(ConnectionConfig):
widgets: dict[str, ConnectionConfig] = Field({}, description="The widgets in the dock.")
position: Literal["bottom", "top", "left", "right", "above", "below"] = Field(
"bottom", description="The position of the dock."
)
parent_dock_area: Optional[str] = Field(
None, description="The GUI ID of parent dock area of the dock."
)
class BECDock(BECConnector, Dock):
USER_ACCESS = [
"rpc_id",
"widget_list",
"show_title_bar",
"hide_title_bar",
"get_widgets_positions",
"set_title",
"add_widget_bec",
"list_eligible_widgets",
"move_widget",
"remove_widget",
"remove",
"attach",
"detach",
]
def __init__(
self,
parent: QWidget | None = None,
parent_dock_area: BECDockArea | None = None,
config: DockConfig | None = None,
name: str | None = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
if config is None:
config = DockConfig(
widget_class=self.__class__.__name__, parent_dock_area=parent_dock_area.gui_id
)
else:
if isinstance(config, dict):
config = DockConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
Dock.__init__(self, name=name, **kwargs)
self.parent_dock_area = parent_dock_area
# Layout Manager
self.layout_manager = GridLayoutManager(self.layout)
def dropEvent(self, event):
source = event.source()
old_area = source.area
self.setOrientation("horizontal", force=True)
super().dropEvent(event)
if old_area in self.parent_dock_area.tempAreas and old_area != self.parent_dock_area:
self.parent_dock_area.removeTempArea(old_area)
def float(self):
"""
Float the dock.
Overwrites the default pyqtgraph dock float.
"""
# need to check if the dock is temporary and if it is the only dock in the area
# fixes bug in pyqtgraph detaching
if self.area.temporary == True and len(self.area.docks) <= 1:
return
elif self.area.temporary == True and len(self.area.docks) > 1:
self.area.docks.pop(self.name(), None)
super().float()
else:
super().float()
@property
def widget_list(self) -> list:
"""
Get the widgets in the dock.
Returns:
widgets(list): The widgets in the dock.
"""
return self.widgets
@widget_list.setter
def widget_list(self, value: list):
self.widgets = value
def hide_title_bar(self):
"""
Hide the title bar of the dock.
"""
# self.hideTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation
self.label.hide()
self.labelHidden = True
def show_title_bar(self):
"""
Hide the title bar of the dock.
"""
# self.showTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation
self.label.show()
self.labelHidden = False
def set_title(self, title: str):
"""
Set the title of the dock.
Args:
title(str): The title of the dock.
"""
self.parent_dock_area.docks[title] = self.parent_dock_area.docks.pop(self.name())
self.setTitle(title)
def get_widgets_positions(self) -> dict:
"""
Get the positions of the widgets in the dock.
Returns:
dict: The positions of the widgets in the dock as dict -> {(row, col, rowspan, colspan):widget}
"""
return self.layout_manager.get_widgets_positions()
def list_eligible_widgets(
self,
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
"""
List all widgets that can be added to the dock.
Returns:
list: The list of eligible widgets.
"""
return list(RPCWidgetHandler.widget_classes.keys())
def add_widget_bec(
self,
widget_type: str,
row=None,
col=0,
rowspan=1,
colspan=1,
shift: Literal["down", "up", "left", "right"] = "down",
):
"""
Add a widget to the dock.
Args:
widget_type(str): The widget to add. Only BEC RPC widgets from RPCWidgetHandler are allowed.
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
col(int): The column to add the widget to.
rowspan(int): The number of rows the widget should span.
colspan(int): The number of columns the widget should span.
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if row is None:
row = self.layout.rowCount()
if self.layout_manager.is_position_occupied(row, col):
self.layout_manager.shift_widgets(shift, start_row=row)
widget = RPCWidgetHandler.create_widget(widget_type)
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
return widget
def add_widget(
self,
widget: QWidget,
row=None,
col=0,
rowspan=1,
colspan=1,
shift: Literal["down", "up", "left", "right"] = "down",
):
"""
Add a widget to the dock.
Args:
widget(QWidget): The widget to add.
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
col(int): The column to add the widget to.
rowspan(int): The number of rows the widget should span.
colspan(int): The number of columns the widget should span.
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if row is None:
row = self.layout.rowCount()
if self.layout_manager.is_position_occupied(row, col):
self.layout_manager.shift_widgets(shift, start_row=row)
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
"""
Move a widget to a new position in the layout.
Args:
widget(QWidget): The widget to move.
new_row(int): The new row to move the widget to.
new_col(int): The new column to move the widget to.
"""
self.layout_manager.move_widget(widget, new_row, new_col)
def attach(self):
"""
Attach the dock to the parent dock area.
"""
self.parent_dock_area.removeTempArea(self.area)
def detach(self):
"""
Detach the dock from the parent dock area.
"""
self.float()
def remove_widget(self, widget_rpc_id: str):
"""
Remove a widget from the dock.
Args:
widget_rpc_id(str): The ID of the widget to remove.
"""
widget = self.rpc_register.get_rpc_by_id(widget_rpc_id)
self.layout.removeWidget(widget)
widget.close()
def remove(self):
"""
Remove the dock from the parent dock area.
"""
# self.cleanup()
self.parent_dock_area.remove_dock(self.name())
def cleanup(self):
"""
Clean up the dock, including all its widgets.
"""
for widget in self.widgets:
if hasattr(widget, "cleanup"):
widget.cleanup()
super().cleanup()
def close(self):
"""
Close the dock area and cleanup.
Has to be implemented to overwrite pyqtgraph event accept in Container close.
"""
self.cleanup()
super().close()

View File

@@ -0,0 +1,222 @@
from __future__ import annotations
from typing import Literal, Optional
from weakref import WeakValueDictionary
from pydantic import Field
from pyqtgraph.dockarea.DockArea import DockArea
from qtpy.QtCore import Qt
from qtpy.QtGui import QPainter, QPaintEvent
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig, WidgetContainerUtils
from .dock import BECDock, DockConfig
class DockAreaConfig(ConnectionConfig):
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
class BECDockArea(BECConnector, DockArea):
USER_ACCESS = [
"panels",
"save_state",
"remove_dock",
"restore_state",
"add_dock",
"clear_all",
"detach_dock",
"attach_all",
"get_all_rpc",
]
def __init__(
self,
parent: QWidget | None = None,
config: DockAreaConfig | None = None,
client=None,
gui_id: str = None,
) -> None:
if config is None:
config = DockAreaConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DockAreaConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
DockArea.__init__(self, parent=parent)
self._instructions_visible = True
def paintEvent(self, event: QPaintEvent):
super().paintEvent(event)
if self._instructions_visible:
painter = QPainter(self)
painter.drawText(self.rect(), Qt.AlignCenter, "Add docks using 'add_dock' method")
@property
def panels(self) -> dict:
"""
Get the docks in the dock area.
Returns:
dock_dict(dict): The docks in the dock area.
"""
return dict(self.docks)
@panels.setter
def panels(self, value: dict):
self.docks = WeakValueDictionary(value)
def restore_state(
self, state: dict = None, missing: Literal["ignore", "error"] = "ignore", extra="bottom"
):
"""
Restore the state of the dock area. If no state is provided, the last state is restored.
Args:
state(dict): The state to restore.
missing(Literal['ignore','error']): What to do if a dock is missing.
extra(str): Extra docks that are in the dockarea but that are not mentioned in state will be added to the bottom of the dockarea, unless otherwise specified by the extra argument.
"""
if state is None:
state = self._last_state
self.restoreState(state, missing=missing, extra=extra)
def save_state(self) -> dict:
"""
Save the state of the dock area.
Returns:
dict: The state of the dock area.
"""
self._last_state = self.saveState()
return self._last_state
def remove_dock(self, name: str):
"""
Remove a dock by name and ensure it is properly closed and cleaned up.
Args:
name(str): The name of the dock to remove.
"""
dock = self.docks.pop(name, None)
if dock:
dock.close()
if len(self.docks) <= 1:
for dock in self.docks.values():
dock.hide_title_bar()
else:
raise ValueError(f"Dock with name {name} does not exist.")
def add_dock(
self,
name: str = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
relative_to: BECDock | None = None,
closable: bool = False,
prefix: str = "dock",
widget: str | QWidget | None = None,
row: int = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
) -> BECDock:
"""
Add a dock to the dock area. Dock has QGridLayout as layout manager by default.
Args:
name(str): The name of the dock to be displayed and for further references. Has to be unique.
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
relative_to(BECDock): The dock to which the new dock should be added relative to.
closable(bool): Whether the dock is closable.
prefix(str): The prefix for the dock name if no name is provided.
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
row(int): The row of the added widget.
col(int): The column of the added widget.
rowspan(int): The rowspan of the added widget.
colspan(int): The colspan of the added widget.
Returns:
BECDock: The created dock.
"""
if name is None:
name = WidgetContainerUtils.generate_unique_widget_id(
container=self.docks, prefix=prefix
)
if name in set(self.docks.keys()):
raise ValueError(f"Dock with name {name} already exists.")
if position is None:
position = "bottom"
dock = BECDock(name=name, parent_dock_area=self, closable=closable)
dock.config.position = position
self.config.docks[name] = dock.config
self.addDock(dock=dock, position=position, relativeTo=relative_to)
if len(self.docks) <= 1:
dock.hide_title_bar()
elif len(self.docks) > 1:
for dock in self.docks.values():
dock.show_title_bar()
if widget is not None and isinstance(widget, str):
dock.add_widget_bec(
widget_type=widget, row=row, col=col, rowspan=rowspan, colspan=colspan
)
elif widget is not None and isinstance(widget, QWidget):
dock.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
if self._instructions_visible:
self._instructions_visible = False
self.update()
return dock
def detach_dock(self, dock_name: str) -> BECDock:
"""
Undock a dock from the dock area.
Args:
dock_name(str): The dock to undock.
Returns:
BECDock: The undocked dock.
"""
dock = self.docks[dock_name]
self.floatDock(dock)
return dock
def attach_all(self):
"""
Return all floating docks to the dock area.
"""
while self.tempAreas:
for temp_area in self.tempAreas:
self.removeTempArea(temp_area)
def clear_all(self):
"""
Close all docks and remove all temp areas.
"""
self.attach_all()
for dock in dict(self.docks).values():
dock.remove()
def cleanup(self):
"""
Cleanup the dock area.
"""
self.clear_all()
super().cleanup()
def close(self):
"""
Close the dock area and cleanup.
Has to be implemented to overwrite pyqtgraph event accept in Container close.
"""
self.cleanup()
super().close()

View File

@@ -1 +0,0 @@
from .editor import BECEditor

View File

@@ -1,407 +0,0 @@
import subprocess
import qdarktheme
from jedi import Script
from jedi.api import Completion
from qtconsole.manager import QtKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
# pylint: disable=no-name-in-module
from qtpy.Qsci import QsciAPIs, QsciLexerPython, QsciScintilla
from qtpy.QtCore import Qt, QThread, Signal
from qtpy.QtGui import QColor, QFont
from qtpy.QtWidgets import QApplication, QFileDialog, QSplitter, QTextEdit, QVBoxLayout, QWidget
from bec_widgets.widgets.toolbar import ModularToolBar
class AutoCompleter(QThread):
"""Initializes the AutoCompleter thread for handling autocompletion and signature help.
Args:
file_path (str): The path to the file for which autocompletion is required.
api (QsciAPIs): The QScintilla API instance used for managing autocompletions.
enable_docstring (bool, optional): Flag to determine if docstrings should be included in the signatures.
"""
def __init__(self, file_path: str, api: QsciAPIs, enable_docstring: bool = False):
super().__init__(None)
self.file_path = file_path
self.script: Script = None
self.api: QsciAPIs = api
self.completions: list[Completion] = None
self.line = 0
self.index = 0
self.text = ""
# TODO so far disabled, quite buggy, docstring extraction has to be generalised
self.enable_docstring = enable_docstring
def update_script(self, text: str):
"""Updates the script for Jedi completion based on the current editor text.
Args:
text (str): The current text of the editor.
"""
if self.script is None or self.script.path != text:
self.script = Script(text, path=self.file_path)
def run(self):
"""Runs the thread for generating autocompletions. Overrides QThread.run."""
self.update_script(self.text)
try:
self.completions = self.script.complete(self.line, self.index)
self.load_autocomplete(self.completions)
except Exception as err:
print(err)
self.finished.emit()
def get_function_signature(self, line: int, index: int, text: str) -> str:
"""Fetches the function signature for a given position in the text.
Args:
line (int): The line number in the editor.
index (int): The index (column number) in the line.
text (str): The current text of the editor.
Returns:
str: A string containing the function signature or an empty string if not available.
"""
self.update_script(text)
try:
signatures = self.script.get_signatures(line, index)
if signatures and self.enable_docstring is True:
full_docstring = signatures[0].docstring(raw=True)
compact_docstring = self.get_compact_docstring(full_docstring)
return compact_docstring
if signatures and self.enable_docstring is False:
return signatures[0].to_string()
except Exception as err:
print(f"Signature Error:{err}")
return ""
def load_autocomplete(self, completions: list):
"""Loads the autocomplete suggestions into the QScintilla API.
Args:
completions (list[Completion]): A list of Completion objects to be added to the API.
"""
self.api.clear()
for i in completions:
self.api.add(i.name)
self.api.prepare()
def get_completions(self, line: int, index: int, text: str):
"""Starts the autocompletion process for a given position in the text.
Args:
line (int): The line number in the editor.
index (int): The index (column number) in the line.
text (str): The current text of the editor.
"""
self.line = line
self.index = index
self.text = text
self.start()
def get_compact_docstring(self, full_docstring):
"""Generates a compact version of a function's docstring.
Args:
full_docstring (str): The full docstring of a function.
Returns:
str: A compact version of the docstring.
"""
lines = full_docstring.split("\n")
# TODO make it also for different docstring styles, now it is only for numpy style
cutoff_indices = [
i
for i, line in enumerate(lines)
if line.strip().lower() in ["parameters", "returns", "examples", "see also", "warnings"]
]
if cutoff_indices:
lines = lines[: cutoff_indices[0]]
compact_docstring = "\n".join(lines).strip()
return compact_docstring
class ScriptRunnerThread(QThread):
"""Initializes the thread for running a Python script.
Args:
script (str): The script to be executed.
"""
outputSignal = Signal(str)
def __init__(self, script):
super().__init__()
self.script = script
def run(self):
"""Executes the script in a subprocess and emits output through a signal. Overrides QThread.run."""
process = subprocess.Popen(
["python", "-u", "-c", self.script],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
universal_newlines=True,
text=True,
)
while True:
output = process.stdout.readline()
if output == "" and process.poll() is not None:
break
if output:
self.outputSignal.emit(output)
error = process.communicate()[1]
if error:
self.outputSignal.emit(error)
class BECEditor(QWidget):
"""Initializes the BEC Editor widget.
Args:
toolbar_enabled (bool, optional): Determines if the toolbar should be enabled. Defaults to True.
"""
def __init__(
self, toolbar_enabled=True, jupyter_terminal_enabled=False, docstring_tooltip=False
):
super().__init__()
self.script_runner_thread = None
self.file_path = None
self.docstring_tooltip = docstring_tooltip
self.jupyter_terminal_enabled = jupyter_terminal_enabled
# TODO just temporary solution, could be extended to other languages
self.is_python_file = True
# Initialize the editor and terminal
self.editor = QsciScintilla()
if self.jupyter_terminal_enabled:
self.terminal = self.make_jupyter_widget_with_kernel()
else:
self.terminal = QTextEdit()
self.terminal.setReadOnly(True)
# Layout
self.layout = QVBoxLayout()
# Initialize and add the toolbar if enabled
if toolbar_enabled:
self.toolbar = ModularToolBar(self)
self.layout.addWidget(self.toolbar)
# Initialize the splitter
self.splitter = QSplitter(Qt.Orientation.Vertical, self)
self.splitter.addWidget(self.editor)
self.splitter.addWidget(self.terminal)
self.splitter.setSizes([400, 200])
# Add Splitter to layout
self.layout.addWidget(self.splitter)
self.setLayout(self.layout)
self.setup_editor()
def setup_editor(self):
"""Sets up the editor with necessary configurations like lexer, auto indentation, and line numbers."""
# Set the lexer for Python
self.lexer = QsciLexerPython()
self.editor.setLexer(self.lexer)
# Enable auto indentation and competition within the editor
self.editor.setAutoIndent(True)
self.editor.setIndentationsUseTabs(False)
self.editor.setIndentationWidth(4)
self.editor.setAutoCompletionSource(QsciScintilla.AutoCompletionSource.AcsAll)
self.editor.setAutoCompletionThreshold(1)
# Autocomplete for python file
# Connect cursor position change signal for autocompletion
self.editor.cursorPositionChanged.connect(self.on_cursor_position_changed)
# if self.is_python_file: #TODO can be changed depending on supported languages
self.__api = QsciAPIs(self.lexer)
self.auto_completer = AutoCompleter(
self.editor.text(), self.__api, enable_docstring=self.docstring_tooltip
)
self.auto_completer.finished.connect(self.loaded_autocomplete)
# Enable line numbers in the margin
self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin)
self.editor.setMarginWidth(0, "0000") # Adjust the width as needed
# Additional UI elements like menu for load/save can be added here
self.set_editor_style()
@staticmethod
def make_jupyter_widget_with_kernel() -> object:
"""Start a kernel, connect to it, and create a RichJupyterWidget to use it"""
kernel_manager = QtKernelManager(kernel_name="python3")
kernel_manager.start_kernel()
kernel_client = kernel_manager.client()
kernel_client.start_channels()
jupyter_widget = RichJupyterWidget()
jupyter_widget.set_default_style("linux")
jupyter_widget.kernel_manager = kernel_manager
jupyter_widget.kernel_client = kernel_client
return jupyter_widget
def show_call_tip(self, position):
"""Shows a call tip at the given position in the editor.
Args:
position (int): The position in the editor where the call tip should be shown.
"""
line, index = self.editor.lineIndexFromPosition(position)
signature = self.auto_completer.get_function_signature(line + 1, index, self.editor.text())
if signature:
self.editor.showUserList(1, [signature])
def on_cursor_position_changed(self, line, index):
"""Handles the event of cursor position change in the editor.
Args:
line (int): The current line number where the cursor is.
index (int): The current column index where the cursor is.
"""
# if self.is_python_file: #TODO can be changed depending on supported languages
# Get completions
self.auto_completer.get_completions(line + 1, index, self.editor.text())
self.editor.autoCompleteFromAPIs()
# Show call tip - signature
position = self.editor.positionFromLineIndex(line, index)
self.show_call_tip(position)
def loaded_autocomplete(self):
"""Placeholder method for actions after autocompletion data is loaded."""
def set_editor_style(self):
"""Sets the style and color scheme for the editor."""
# Dracula Theme Colors
background_color = QColor("#282a36")
text_color = QColor("#f8f8f2")
keyword_color = QColor("#8be9fd")
string_color = QColor("#f1fa8c")
comment_color = QColor("#6272a4")
class_function_color = QColor("#50fa7b")
# Set Font
font = QFont()
font.setFamily("Consolas")
font.setPointSize(10)
self.editor.setFont(font)
self.editor.setMarginsFont(font)
# Set Editor Colors
self.editor.setMarginsBackgroundColor(background_color)
self.editor.setMarginsForegroundColor(text_color)
self.editor.setCaretForegroundColor(text_color)
self.editor.setCaretLineBackgroundColor(QColor("#44475a"))
self.editor.setPaper(background_color) # Set the background color for the entire paper
self.editor.setColor(text_color)
# Set editor
# Syntax Highlighting Colors
lexer = self.editor.lexer()
if lexer:
lexer.setDefaultPaper(background_color) # Set the background color for the text area
lexer.setDefaultColor(text_color)
lexer.setColor(keyword_color, QsciLexerPython.Keyword)
lexer.setColor(string_color, QsciLexerPython.DoubleQuotedString)
lexer.setColor(string_color, QsciLexerPython.SingleQuotedString)
lexer.setColor(comment_color, QsciLexerPython.Comment)
lexer.setColor(class_function_color, QsciLexerPython.ClassName)
lexer.setColor(class_function_color, QsciLexerPython.FunctionMethodName)
# Set the style for all text to have a transparent background
# TODO find better way how to do it!
for style in range(
128
): # QsciScintilla supports 128 styles by default, this set all to transparent background
self.lexer.setPaper(background_color, style)
def run_script(self):
"""Runs the current script in the editor."""
if self.jupyter_terminal_enabled:
script = self.editor.text()
self.terminal.execute(script)
else:
script = self.editor.text()
self.script_runner_thread = ScriptRunnerThread(script)
self.script_runner_thread.outputSignal.connect(self.update_terminal)
self.script_runner_thread.start()
def update_terminal(self, text):
"""Updates the terminal with new text.
Args:
text (str): The text to be appended to the terminal.
"""
self.terminal.append(text)
def enable_docstring_tooltip(self):
"""Enables the docstring tooltip."""
self.docstring_tooltip = True
self.auto_completer.enable_docstring = True
def open_file(self):
"""Opens a file dialog for selecting and opening a Python file in the editor."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getOpenFileName(
self, "Open file", "", "Python files (*.py);;All Files (*)", options=options
)
if not file_path:
return
try:
with open(file_path, "r") as file:
text = file.read()
self.editor.setText(text)
except FileNotFoundError:
print(f"The file {file_path} was not found.")
except Exception as e:
print(f"An error occurred while opening the file {file_path}: {e}")
def save_file(self):
"""Opens a save file dialog for saving the current script in the editor."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getSaveFileName(
self, "Save file", "", "Python files (*.py);;All Files (*)", options=options
)
if not file_path:
return
try:
if not file_path.endswith(".py"):
file_path += ".py"
with open(file_path, "w") as file:
text = self.editor.text()
file.write(text)
print(f"File saved to {file_path}")
except Exception as e:
print(f"An error occurred while saving the file to {file_path}: {e}")
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
qdarktheme.setup_theme("auto")
mainWin = BECEditor(jupyter_terminal_enabled=True)
mainWin.show()
app.exec()

View File

@@ -1,30 +1,23 @@
# pylint: disable = no-name-in-module,missing-module-docstring
from __future__ import annotations
import itertools
import os
import uuid
from collections import defaultdict
from typing import Literal, Optional, Type
from typing import Literal, Optional
import numpy as np
import pyqtgraph as pg
import qdarktheme
from pydantic import Field
from pyqtgraph.Qt import uic
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
from qtpy.QtWidgets import QWidget
from typeguard import typechecked
from bec_widgets.utils import BECConnector, BECDispatcher, ConnectionConfig
from bec_widgets.widgets.plots import (
BECImageShow,
BECMotorMap,
BECPlotBase,
BECWaveform,
Waveform1DConfig,
WidgetConfig,
)
from bec_widgets.widgets.plots.image import ImageConfig
from bec_widgets.widgets.plots.motor_map import MotorMapConfig
from bec_widgets.utils import BECConnector, ConnectionConfig, WidgetContainerUtils
from bec_widgets.widgets.figure.plots.image.image import BECImageShow, ImageConfig
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap, MotorMapConfig
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform, Waveform1DConfig
class FigureConfig(ConnectionConfig):
@@ -33,7 +26,7 @@ class FigureConfig(ConnectionConfig):
theme: Literal["dark", "light"] = Field("dark", description="The theme of the figure widget.")
num_cols: int = Field(1, description="The number of columns in the figure widget.")
num_rows: int = Field(1, description="The number of rows in the figure widget.")
widgets: dict[str, WidgetConfig] = Field(
widgets: dict[str, SubplotConfig] = Field(
{}, description="The list of widgets to be added to the figure widget."
)
@@ -43,7 +36,7 @@ class WidgetHandler:
def __init__(self):
self.widget_factory = {
"PlotBase": (BECPlotBase, WidgetConfig),
"PlotBase": (BECPlotBase, SubplotConfig),
"Waveform1D": (BECWaveform, Waveform1DConfig),
"ImShow": (BECImageShow, ImageConfig),
"MotorMap": (BECMotorMap, MotorMapConfig),
@@ -97,6 +90,7 @@ class WidgetHandler:
class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
USER_ACCESS = [
"rpc_id",
"config_dict",
"axes",
"widgets",
@@ -110,6 +104,8 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
"change_layout",
"change_theme",
"clear_all",
"get_all_rpc",
"widget_list",
]
clean_signal = pyqtSignal()
@@ -138,8 +134,21 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
# Container to keep track of the grid
self.grid = []
def __getitem__(self, key: tuple | str):
if isinstance(key, tuple) and len(key) == 2:
return self.axes(*key)
elif isinstance(key, str):
widget = self._widgets.get(key)
if widget is None:
raise KeyError(f"No widget with ID {key}")
return self._widgets.get(key)
else:
raise TypeError(
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
)
@property
def axes(self) -> list[BECPlotBase]:
def widget_list(self) -> list[BECPlotBase]:
"""
Access all widget in BECFigure as a list
Returns:
@@ -148,16 +157,31 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
axes = [value for value in self._widgets.values() if isinstance(value, BECPlotBase)]
return axes
@axes.setter
def axes(self, value: list[BECPlotBase]):
@widget_list.setter
def widget_list(self, value: list[BECPlotBase]):
"""
Access all widget in BECFigure as a list
Returns:
list[BECPlotBase]: List of all widgets in the figure.
"""
self._axes = value
@property
def widgets(self) -> dict:
"""
All widgets within the figure with gui ids as keys.
Returns:
dict: All widgets within the figure.
"""
return self._widgets
@widgets.setter
def widgets(self, value: dict):
"""
All widgets within the figure with gui ids as keys.
Returns:
dict: All widgets within the figure.
"""
self._widgets = value
def add_plot(
@@ -181,6 +205,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
) -> BECWaveform:
"""
Add a Waveform1D plot to the figure at the specified position.
Args:
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
@@ -188,7 +213,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
config(dict): Additional configuration for the widget.
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
"""
widget_id = self._generate_unique_widget_id()
widget_id = str(uuid.uuid4())
waveform = self.add_widget(
widget_type="Waveform1D",
widget_id=widget_id,
@@ -233,42 +258,39 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
)
# User wants to add custom curve
elif x is not None and y is not None and x_name is None and y_name is None:
waveform.add_curve_custom(
x=x,
y=y,
color=color,
label=label,
)
waveform.add_curve_custom(x=x, y=y, color=color, label=label)
return waveform
@typechecked
def plot(
self,
x_name: str = None,
y_name: str = None,
z_name: str = None,
x_entry: str = None,
y_entry: str = None,
z_entry: str = None,
x: list | np.ndarray = None,
y: list | np.ndarray = None,
color: Optional[str] = None,
color_map_z: Optional[str] = "plasma",
label: Optional[str] = None,
x: list | np.ndarray | None = None,
y: list | np.ndarray | None = None,
x_name: str | None = None,
y_name: str | None = None,
z_name: str | None = None,
x_entry: str | None = None,
y_entry: str | None = None,
z_entry: str | None = None,
color: str | None = None,
color_map_z: str | None = "plasma",
label: str | None = None,
validate: bool = True,
**axis_kwargs,
) -> BECWaveform:
"""
Add a 1D waveform plot to the figure. Always access the first waveform widget in the figure.
Args:
x(list | np.ndarray): Custom x data to plot.
y(list | np.ndarray): Custom y data to plot.
x_name(str): The name of the device for the x-axis.
y_name(str): The name of the device for the y-axis.
z_name(str): The name of the device for the z-axis.
x_entry(str): The name of the entry for the x-axis.
y_entry(str): The name of the entry for the y-axis.
z_entry(str): The name of the entry for the z-axis.
x(list | np.ndarray): Custom x data to plot.
y(list | np.ndarray): Custom y data to plot.
color(str): The color of the curve.
color_map_z(str): The color map to use for the z-axis.
label(str): The label of the curve.
@@ -278,13 +300,36 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
Returns:
BECWaveform: The waveform plot widget.
"""
waveform = self._find_first_widget_by_class(BECWaveform, can_fail=True)
waveform = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, BECWaveform, can_fail=True
)
if waveform is not None:
if axis_kwargs:
waveform.set(**axis_kwargs)
else:
waveform = self.add_plot(**axis_kwargs)
if x is not None and y is None:
if isinstance(x, np.ndarray):
if x.ndim == 1:
y = np.arange(x.size)
waveform.add_curve_custom(x=np.arange(x.size), y=x, color=color, label=label)
return waveform
if x.ndim == 2:
waveform.add_curve_custom(x=x[:, 0], y=x[:, 1], color=color, label=label)
return waveform
elif isinstance(x, list):
y = np.arange(len(x))
waveform.add_curve_custom(x=np.arange(len(x)), y=x, color=color, label=label)
return waveform
else:
raise ValueError(
"Invalid input. Provide either device names (x_name, y_name) or custom data."
)
if x is not None and y is not None:
waveform.add_curve_custom(x=x, y=y, color=color, label=label)
return waveform
# User wants to add scan curve -> 1D Waveform
if x_name is not None and y_name is not None and z_name is None and x is None and y is None:
waveform.add_curve_scan(
@@ -321,12 +366,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
elif (
x is not None and y is not None and x_name is None and y_name is None and z_name is None
):
waveform.add_curve_custom(
x=x,
y=y,
color=color,
label=label,
)
waveform.add_curve_custom(x=x, y=y, color=color, label=label)
else:
raise ValueError(
"Invalid input. Provide either device names (x_name, y_name) or custom data."
@@ -344,6 +384,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
) -> BECImageShow:
"""
Add an image to the figure. Always access the first image widget in the figure.
Args:
monitor(str): The name of the monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
@@ -355,7 +396,9 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
Returns:
BECImageShow: The image widget.
"""
image = self._find_first_widget_by_class(BECImageShow, can_fail=True)
image = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, BECImageShow, can_fail=True
)
if image is not None:
if axis_kwargs:
image.set(**axis_kwargs)
@@ -395,6 +438,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
) -> BECImageShow:
"""
Add an image to the figure at the specified position.
Args:
monitor(str): The name of the monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
@@ -410,7 +454,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
BECImageShow: The image widget.
"""
widget_id = self._generate_unique_widget_id()
widget_id = str(uuid.uuid4())
if config is None:
config = ImageConfig(
widget_class="BECImageShow",
@@ -449,6 +493,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
def motor_map(self, motor_x: str = None, motor_y: str = None, **axis_kwargs) -> BECMotorMap:
"""
Add a motor map to the figure. Always access the first motor map widget in the figure.
Args:
motor_x(str): The name of the motor for the X axis.
motor_y(str): The name of the motor for the Y axis.
@@ -457,7 +502,9 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
Returns:
BECMotorMap: The motor map widget.
"""
motor_map = self._find_first_widget_by_class(BECMotorMap, can_fail=True)
motor_map = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, BECMotorMap, can_fail=True
)
if motor_map is not None:
if axis_kwargs:
motor_map.set(**axis_kwargs)
@@ -491,12 +538,10 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
Returns:
BECMotorMap: The motor map widget.
"""
widget_id = self._generate_unique_widget_id()
widget_id = str(uuid.uuid4())
if config is None:
config = MotorMapConfig(
widget_class="BECMotorMap",
gui_id=widget_id,
parent_id=self.gui_id,
widget_class="BECMotorMap", gui_id=widget_id, parent_id=self.gui_id
)
motor_map = self.add_widget(
widget_type="MotorMap",
@@ -523,6 +568,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
) -> BECPlotBase:
"""
Add a widget to the figure at the specified position.
Args:
widget_type(Literal["PlotBase","Waveform1D"]): The type of the widget to add.
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
@@ -532,7 +578,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
"""
if not widget_id:
widget_id = self._generate_unique_widget_id()
widget_id = str(uuid.uuid4())
if widget_id in self._widgets:
raise ValueError(f"Widget with ID '{widget_id}' already exists.")
@@ -585,6 +631,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
) -> None:
"""
Remove a widget from the figure. Can be removed by its unique identifier or by its coordinates.
Args:
row(int): The row coordinate of the widget to remove.
col(int): The column coordinate of the widget to remove.
@@ -603,6 +650,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
def change_theme(self, theme: Literal["dark", "light"]) -> None:
"""
Change the theme of the figure widget.
Args:
theme(Literal["dark","light"]): The theme to set for the figure widget.
"""
@@ -610,33 +658,15 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
self.setBackground("k" if theme == "dark" else "w")
self.config.theme = theme
def _find_first_widget_by_class(
self, widget_class: Type[BECPlotBase], can_fail: bool = True
) -> BECPlotBase | None:
"""
Find the first widget of a given class in the figure.
Args:
widget_class(Type[BECPlotBase]): The class of the widget to find.
can_fail(bool): If True, the method will return None if no widget is found. If False, it will raise an error.
Returns:
BECPlotBase: The widget of the given class.
"""
for widget_id, widget in self._widgets.items():
if isinstance(widget, widget_class):
return widget
if can_fail:
return None
else:
raise ValueError(f"No widget of class {widget_class} found.")
def _remove_by_coordinates(self, row: int, col: int) -> None:
"""
Remove a widget from the figure by its coordinates.
Args:
row(int): The row coordinate of the widget to remove.
col(int): The column coordinate of the widget to remove.
"""
widget = self._get_widget_by_coordinates(row, col)
widget = self.axes(row, col)
if widget:
widget_id = widget.config.gui_id
if widget_id in self._widgets:
@@ -645,6 +675,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
def _remove_by_id(self, widget_id: str) -> None:
"""
Remove a widget from the figure by its unique identifier.
Args:
widget_id(str): The unique identifier of the widget to remove.
"""
@@ -656,26 +687,13 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
self._reindex_grid()
if widget_id in self.config.widgets:
self.config.widgets.pop(widget_id)
print(f"Removed widget {widget_id}.")
else:
raise ValueError(f"Widget with ID '{widget_id}' does not exist.")
def __getitem__(self, key: tuple | str):
if isinstance(key, tuple) and len(key) == 2:
return self._get_widget_by_coordinates(*key)
elif isinstance(key, str):
widget = self._widgets.get(key)
if widget is None:
raise KeyError(f"No widget with ID {key}")
return self._widgets.get(key)
else:
raise TypeError(
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
)
def _get_widget_by_coordinates(self, row: int, col: int) -> BECPlotBase:
def axes(self, row: int, col: int) -> BECPlotBase:
"""
Get widget by its coordinates in the figure.
Args:
row(int): the row coordinate
col(int): the column coordinate
@@ -695,17 +713,10 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
row += 1
return row, col
def _generate_unique_widget_id(self):
"""Generate a unique widget ID."""
existing_ids = set(self._widgets.keys())
for i in itertools.count(1):
widget_id = f"widget_{i}"
if widget_id not in existing_ids:
return widget_id
def _change_grid(self, widget_id: str, row: int, col: int):
"""
Change the grid to reflect the new position of the widget.
Args:
widget_id(str): The unique identifier of the widget.
row(int): The new row coordinate of the widget in the figure.
@@ -720,7 +731,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
def _reindex_grid(self):
"""Reindex the grid to remove empty rows and columns."""
print(f"old grid: {self.grid}")
new_grid = []
for row in self.grid:
new_row = [widget for widget in row if widget is not None]
@@ -787,12 +797,16 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
def clear_all(self):
"""Clear all widgets from the figure and reset to default state"""
# for widget in self._widgets.values():
# widget.cleanup()
self.clear()
for widget in list(self._widgets.values()):
widget.remove()
# self.clear()
self._widgets = defaultdict(dict)
self.grid = []
theme = self.config.theme
self.config = FigureConfig(
widget_class=self.__class__.__name__, gui_id=self.gui_id, theme=theme
)
def cleanup(self):
self.clear_all()
super().cleanup()

View File

@@ -4,292 +4,28 @@ from collections import defaultdict
from typing import Any, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints
from pydantic import BaseModel, Field, ValidationError
from qtpy.QtCore import QObject, QThread
from qtpy.QtCore import Signal as pyqtSignal
from bec_lib.endpoints import MessageEndpoints
from pydantic import Field, ValidationError
from qtpy.QtCore import QThread
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig, EntryValidator
from .plot_base import BECPlotBase, WidgetConfig
from bec_widgets.utils import EntryValidator
from bec_widgets.widgets.figure.plots.image.image_item import BECImageItem, ImageItemConfig
from bec_widgets.widgets.figure.plots.image.image_processor import ImageProcessor, ProcessorWorker
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
class ProcessingConfig(BaseModel):
fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.")
log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.")
center_of_mass: Optional[bool] = Field(
False, description="Whether to calculate the center of mass of the monitor data."
)
transpose: Optional[bool] = Field(
False, description="Whether to transpose the monitor data before displaying."
)
rotation: Optional[int] = Field(
None, description="The rotation angle of the monitor data before displaying."
)
class ImageItemConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the image.")
monitor: Optional[str] = Field(None, description="The name of the monitor.")
source: Optional[str] = Field(None, description="The source of the curve.")
color_map: Optional[str] = Field("magma", description="The color map of the image.")
downsample: Optional[bool] = Field(True, description="Whether to downsample the image.")
opacity: Optional[float] = Field(1.0, description="The opacity of the image.")
vrange: Optional[tuple[int, int]] = Field(
None, description="The range of the color bar. If None, the range is automatically set."
)
color_bar: Optional[Literal["simple", "full"]] = Field(
"simple", description="The type of the color bar."
)
autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.")
processing: ProcessingConfig = Field(
default_factory=ProcessingConfig, description="The post processing of the image."
)
class ImageConfig(WidgetConfig):
class ImageConfig(SubplotConfig):
images: dict[str, ImageItemConfig] = Field(
{},
description="The configuration of the images. The key is the name of the image (source).",
)
class BECImageItem(BECConnector, pg.ImageItem):
USER_ACCESS = [
"config_dict",
"set",
"set_fft",
"set_log",
"set_rotation",
"set_transpose",
"set_opacity",
"set_autorange",
"set_color_map",
"set_auto_downsample",
"set_monitor",
"set_vrange",
"get_data",
]
def __init__(
self,
config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None,
parent_image: Optional[BECImageItem] = None,
**kwargs,
):
if config is None:
config = ImageItemConfig(widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
super().__init__(config=config, gui_id=gui_id)
pg.ImageItem.__init__(self)
self.parent_image = parent_image
self.colorbar_bar = None
self._add_color_bar(
self.config.color_bar, self.config.vrange
) # TODO can also support None to not have any colorbar
self.apply_config()
if kwargs:
self.set(**kwargs)
def apply_config(self):
"""
Apply current configuration.
"""
self.set_color_map(self.config.color_map)
self.set_auto_downsample(self.config.downsample)
if self.config.vrange is not None:
self.set_vrange(vrange=self.config.vrange)
def set(self, **kwargs):
"""
Set the properties of the image.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- downsample
- color_map
- monitor
- opacity
- vrange
- fft
- log
- rot
- transpose
"""
method_map = {
"downsample": self.set_auto_downsample,
"color_map": self.set_color_map,
"monitor": self.set_monitor,
"opacity": self.set_opacity,
"vrange": self.set_vrange,
"fft": self.set_fft,
"log": self.set_log,
"rot": self.set_rotation,
"transpose": self.set_transpose,
}
for key, value in kwargs.items():
if key in method_map:
method_map[key](value)
else:
print(f"Warning: '{key}' is not a recognized property.")
def set_fft(self, enable: bool = False):
"""
Set the FFT of the image.
Args:
enable(bool): Whether to perform FFT on the monitor data.
"""
self.config.processing.fft = enable
def set_log(self, enable: bool = False):
"""
Set the log of the image.
Args:
enable(bool): Whether to perform log on the monitor data.
"""
self.config.processing.log = enable
if enable and self.color_bar and self.config.color_bar == "full":
self.color_bar.autoHistogramRange()
def set_rotation(self, deg_90: int = 0):
"""
Set the rotation of the image.
Args:
deg_90(int): The rotation angle of the monitor data before displaying.
"""
self.config.processing.rotation = deg_90
def set_transpose(self, enable: bool = False):
"""
Set the transpose of the image.
Args:
enable(bool): Whether to transpose the image.
"""
self.config.processing.transpose = enable
def set_opacity(self, opacity: float = 1.0):
"""
Set the opacity of the image.
Args:
opacity(float): The opacity of the image.
"""
self.setOpacity(opacity)
self.config.opacity = opacity
def set_autorange(self, autorange: bool = False):
"""
Set the autorange of the color bar.
Args:
autorange(bool): Whether to autorange the color bar.
"""
self.config.autorange = autorange
if self.color_bar is not None:
self.color_bar.autoHistogramRange()
def set_color_map(self, cmap: str = "magma"):
"""
Set the color map of the image.
Args:
cmap(str): The color map of the image.
"""
self.setColorMap(cmap)
if self.color_bar is not None:
if self.config.color_bar == "simple":
self.color_bar.setColorMap(cmap)
elif self.config.color_bar == "full":
self.color_bar.gradient.loadPreset(cmap)
self.config.color_map = cmap
def set_auto_downsample(self, auto: bool = True):
"""
Set the auto downsample of the image.
Args:
auto(bool): Whether to downsample the image.
"""
self.setAutoDownsample(auto)
self.config.downsample = auto
def set_monitor(self, monitor: str):
"""
Set the monitor of the image.
Args:
monitor(str): The name of the monitor.
"""
self.config.monitor = monitor
def set_vrange(self, vmin: float = None, vmax: float = None, vrange: tuple[int, int] = None):
"""
Set the range of the color bar.
Args:
vmin(float): Minimum value of the color bar.
vmax(float): Maximum value of the color bar.
"""
if vrange is not None:
vmin, vmax = vrange
self.setLevels([vmin, vmax])
self.config.vrange = (vmin, vmax)
self.config.autorange = False
if self.color_bar is not None:
if self.config.color_bar == "simple":
self.color_bar.setLevels(low=vmin, high=vmax)
elif self.config.color_bar == "full":
self.color_bar.setLevels(min=vmin, max=vmax)
self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
def get_data(self) -> np.ndarray:
"""
Get the data of the image.
Returns:
np.ndarray: The data of the image.
"""
return self.image
def _add_color_bar(
self, color_bar_style: str = "simple", vrange: Optional[tuple[int, int]] = None
):
"""
Add color bar to the layout.
Args:
style(Literal["simple,full"]): The style of the color bar.
vrange(tuple[int,int]): The range of the color bar.
"""
if color_bar_style == "simple":
self.color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
if vrange is not None:
self.color_bar.setLevels(low=vrange[0], high=vrange[1])
self.color_bar.setImageItem(self)
self.parent_image.addItem(self.color_bar) # , row=0, col=1)
self.config.color_bar = "simple"
elif color_bar_style == "full":
# Setting histogram
self.color_bar = pg.HistogramLUTItem()
self.color_bar.setImageItem(self)
self.color_bar.gradient.loadPreset(self.config.color_map)
if vrange is not None:
self.color_bar.setLevels(min=vrange[0], max=vrange[1])
self.color_bar.setHistogramRange(
vrange[0] - 0.1 * vrange[0], vrange[1] + 0.1 * vrange[1]
)
# Adding histogram to the layout
self.parent_image.addItem(self.color_bar) # , row=0, col=1)
# save settings
self.config.color_bar = "full"
else:
raise ValueError("style should be 'simple' or 'full'")
class BECImageShow(BECPlotBase):
USER_ACCESS = [
"rpc_id",
"config_dict",
"add_image_by_config",
"get_image_config",
@@ -358,23 +94,10 @@ class BECImageShow(BECPlotBase):
thread.start()
def find_widget_by_id(self, item_id: str) -> BECImageItem:
"""
Find the widget by its gui_id.
Args:
item_id(str): The gui_id of the widget.
Returns:
BECImageItem: The widget with the given gui_id.
"""
for source, images in self._images.items():
for monitor, image_item in images.items():
if image_item.gui_id == item_id:
return image_item
def find_image_by_monitor(self, item_id: str) -> BECImageItem:
"""
Find the widget by its gui_id.
Args:
item_id(str): The gui_id of the widget.
@@ -390,11 +113,12 @@ class BECImageShow(BECPlotBase):
if result is not None:
return result
def apply_config(self, config: dict | WidgetConfig):
def apply_config(self, config: dict | SubplotConfig):
"""
Apply the configuration to the 1D waveform widget.
Args:
config(dict|WidgetConfig): Configuration settings.
config(dict|SubplotConfig): Configuration settings.
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
"""
if isinstance(config, dict):
@@ -428,6 +152,7 @@ class BECImageShow(BECPlotBase):
def add_image_by_config(self, config: ImageItemConfig | dict) -> BECImageItem:
"""
Add an image to the widget by configuration.
Args:
config(ImageItemConfig|dict): The configuration of the image.
@@ -444,6 +169,7 @@ class BECImageShow(BECPlotBase):
def get_image_config(self, image_id, dict_output: bool = True) -> ImageItemConfig | dict:
"""
Get the configuration of the image.
Args:
image_id(str): The ID of the image.
dict_output(bool): Whether to return the configuration as a dictionary. Defaults to True.
@@ -485,6 +211,7 @@ class BECImageShow(BECPlotBase):
def get_image_dict(self) -> dict[str, dict[str, BECImageItem]]:
"""
Get all images.
Returns:
dict[str, dict[str, BECImageItem]]: The dictionary of images.
"""
@@ -586,6 +313,7 @@ class BECImageShow(BECPlotBase):
"""
Set the range of the color bar.
If name is not specified, then set vrange for all images.
Args:
vmin(float): Minimum value of the color bar.
vmax(float): Maximum value of the color bar.
@@ -597,6 +325,7 @@ class BECImageShow(BECPlotBase):
"""
Set the color map of the image.
If name is not specified, then set color map for all images.
Args:
cmap(str): The color map of the image.
name(str): The name of the image. If None, apply to all images.
@@ -606,6 +335,7 @@ class BECImageShow(BECPlotBase):
def set_autorange(self, enable: bool = False, name: str = None):
"""
Set the autoscale of the image.
Args:
enable(bool): Whether to autoscale the color bar.
name(str): The name of the image. If None, apply to all images.
@@ -616,6 +346,7 @@ class BECImageShow(BECPlotBase):
"""
Set the monitor of the image.
If name is not specified, then set monitor for all images.
Args:
monitor(str): The name of the monitor.
name(str): The name of the image. If None, apply to all images.
@@ -626,6 +357,7 @@ class BECImageShow(BECPlotBase):
"""
Set the post processing of the image.
If name is not specified, then set post processing for all images.
Args:
name(str): The name of the image. If None, apply to all images.
**kwargs: Keyword arguments for the properties to be set.
@@ -640,6 +372,7 @@ class BECImageShow(BECPlotBase):
def set_image_properties(self, name: str = None, **kwargs):
"""
Set the properties of the image.
Args:
name(str): The name of the image. If None, apply to all images.
**kwargs: Keyword arguments for the properties to be set.
@@ -660,6 +393,7 @@ class BECImageShow(BECPlotBase):
"""
Set the FFT of the image.
If name is not specified, then set FFT for all images.
Args:
enable(bool): Whether to perform FFT on the monitor data.
name(str): The name of the image. If None, apply to all images.
@@ -670,6 +404,7 @@ class BECImageShow(BECPlotBase):
"""
Set the log of the image.
If name is not specified, then set log for all images.
Args:
enable(bool): Whether to perform log on the monitor data.
name(str): The name of the image. If None, apply to all images.
@@ -680,6 +415,7 @@ class BECImageShow(BECPlotBase):
"""
Set the rotation of the image.
If name is not specified, then set rotation for all images.
Args:
deg_90(int): The rotation angle of the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
@@ -690,6 +426,7 @@ class BECImageShow(BECPlotBase):
"""
Set the transpose of the image.
If name is not specified, then set transpose for all images.
Args:
enable(bool): Whether to transpose the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
@@ -699,6 +436,7 @@ class BECImageShow(BECPlotBase):
def toggle_threading(self, use_threading: bool):
"""
Toggle threading for the widgets postprocessing and updating.
Args:
use_threading(bool): Whether to use threading.
"""
@@ -710,6 +448,7 @@ class BECImageShow(BECPlotBase):
def on_image_update(self, msg: dict):
"""
Update the image of the device monitor from bec.
Args:
msg(dict): The message from bec.
"""
@@ -719,10 +458,8 @@ class BECImageShow(BECPlotBase):
processing_config = image_to_update.config.processing
self.processor.set_config(processing_config)
if self.use_threading:
print("using threaded version")
self._create_thread_worker(device, data)
else:
print("using NON-threaded version")
data = self.processor.process_image(data)
self.update_image(device, data)
@@ -730,6 +467,7 @@ class BECImageShow(BECPlotBase):
def update_image(self, device: str, data: np.ndarray):
"""
Update the image of the device monitor.
Args:
device(str): The name of the device.
data(np.ndarray): The data to be updated.
@@ -740,6 +478,7 @@ class BECImageShow(BECPlotBase):
def _connect_device_monitor(self, monitor: str):
"""
Connect to the device monitor.
Args:
monitor(str): The name of the monitor.
"""
@@ -774,6 +513,7 @@ class BECImageShow(BECPlotBase):
def _check_image_id(self, val: Any, dict_to_check: dict) -> bool:
"""
Check if val is in the values of the dict_to_check or in the values of the nested dictionaries.
Args:
val(Any): Value to check.
dict_to_check(dict): Dictionary to check.
@@ -792,6 +532,7 @@ class BECImageShow(BECPlotBase):
def _validate_monitor(self, monitor: str, validate_bec: bool = True):
"""
Validate the monitor name.
Args:
monitor(str): The name of the monitor.
validate_bec(bool): Whether to validate the monitor name with BEC.
@@ -809,135 +550,11 @@ class BECImageShow(BECPlotBase):
"""
Clean up the widget.
"""
print(f"Cleaning up {self.gui_id}")
# for monitor in self._images["device_monitor"]:
# self.bec_dispatcher.disconnect_slot(
# self.on_image_update, MessageEndpoints.device_monitor(monitor)
# )
# if self.thread is not None and self.thread.isRunning():
# self.thread.quit()
# self.thread.wait()
for monitor in self._images["device_monitor"]:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor(monitor)
)
for image in self.images:
image.cleanup()
class ImageProcessor:
"""
Class for processing the image data.
"""
def __init__(self, config: ProcessingConfig = None):
if config is None:
config = ProcessingConfig()
self.config = config
def set_config(self, config: ProcessingConfig):
"""
Set the configuration of the processor.
Args:
config(ProcessingConfig): The configuration of the processor.
"""
self.config = config
def FFT(self, data: np.ndarray) -> np.ndarray:
"""
Perform FFT on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.abs(np.fft.fftshift(np.fft.fft2(data)))
def rotation(self, data: np.ndarray, rotate_90: int) -> np.ndarray:
"""
Rotate the data by 90 degrees n times.
Args:
data(np.ndarray): The data to be processed.
rotate_90(int): The number of 90 degree rotations.
Returns:
np.ndarray: The processed data.
"""
return np.rot90(data, k=rotate_90, axes=(0, 1))
def transpose(self, data: np.ndarray) -> np.ndarray:
"""
Transpose the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.transpose(data)
def log(self, data: np.ndarray) -> np.ndarray:
"""
Perform log on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
# TODO this is not final solution -> data should stay as int16
data = data.astype(np.float32)
offset = 1e-6
data_offset = data + offset
return np.log10(data_offset)
# def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality
# return np.unravel_index(np.argmax(data), data.shape)
def process_image(self, data: np.ndarray) -> np.ndarray:
"""
Process the data according to the configuration.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
if self.config.fft:
data = self.FFT(data)
if self.config.rotation is not None:
data = self.rotation(data, self.config.rotation)
if self.config.transpose:
data = self.transpose(data)
if self.config.log:
data = self.log(data)
return data
class ProcessorWorker(QObject):
"""
Worker for processing the image data.
"""
processed = pyqtSignal(str, np.ndarray)
stopRequested = pyqtSignal()
finished = pyqtSignal()
def __init__(self, processor):
super().__init__()
self.processor = processor
self._isRunning = False
self.stopRequested.connect(self.stop)
@pyqtSlot(str, np.ndarray)
def process_image(self, device: str, image: np.ndarray):
"""
Process the image data.
Args:
device(str): The name of the device.
image(np.ndarray): The image data.
"""
self._isRunning = True
processed_image = self.processor.process_image(image)
self._isRunning = False
if not self._isRunning:
self.processed.emit(device, processed_image)
self.finished.emit()
def stop(self):
self._isRunning = False
super().cleanup()

View File

@@ -0,0 +1,277 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
import numpy as np
import pyqtgraph as pg
from pydantic import Field
from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.widgets.figure.plots.image.image_processor import ProcessingConfig
if TYPE_CHECKING:
from bec_widgets.widgets.figure.plots.image.image import BECImageShow
class ImageItemConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the image.")
monitor: Optional[str] = Field(None, description="The name of the monitor.")
source: Optional[str] = Field(None, description="The source of the curve.")
color_map: Optional[str] = Field("magma", description="The color map of the image.")
downsample: Optional[bool] = Field(True, description="Whether to downsample the image.")
opacity: Optional[float] = Field(1.0, description="The opacity of the image.")
vrange: Optional[tuple[int, int]] = Field(
None, description="The range of the color bar. If None, the range is automatically set."
)
color_bar: Optional[Literal["simple", "full"]] = Field(
"simple", description="The type of the color bar."
)
autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.")
processing: ProcessingConfig = Field(
default_factory=ProcessingConfig, description="The post processing of the image."
)
class BECImageItem(BECConnector, pg.ImageItem):
USER_ACCESS = [
"rpc_id",
"config_dict",
"set",
"set_fft",
"set_log",
"set_rotation",
"set_transpose",
"set_opacity",
"set_autorange",
"set_color_map",
"set_auto_downsample",
"set_monitor",
"set_vrange",
"get_data",
]
def __init__(
self,
config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None,
parent_image: Optional[BECImageShow] = None,
**kwargs,
):
if config is None:
config = ImageItemConfig(widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
super().__init__(config=config, gui_id=gui_id)
pg.ImageItem.__init__(self)
self.parent_image = parent_image
self.colorbar_bar = None
self._add_color_bar(
self.config.color_bar, self.config.vrange
) # TODO can also support None to not have any colorbar
self.apply_config()
if kwargs:
self.set(**kwargs)
def apply_config(self):
"""
Apply current configuration.
"""
self.set_color_map(self.config.color_map)
self.set_auto_downsample(self.config.downsample)
if self.config.vrange is not None:
self.set_vrange(vrange=self.config.vrange)
def set(self, **kwargs):
"""
Set the properties of the image.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- downsample
- color_map
- monitor
- opacity
- vrange
- fft
- log
- rot
- transpose
"""
method_map = {
"downsample": self.set_auto_downsample,
"color_map": self.set_color_map,
"monitor": self.set_monitor,
"opacity": self.set_opacity,
"vrange": self.set_vrange,
"fft": self.set_fft,
"log": self.set_log,
"rot": self.set_rotation,
"transpose": self.set_transpose,
}
for key, value in kwargs.items():
if key in method_map:
method_map[key](value)
else:
print(f"Warning: '{key}' is not a recognized property.")
def set_fft(self, enable: bool = False):
"""
Set the FFT of the image.
Args:
enable(bool): Whether to perform FFT on the monitor data.
"""
self.config.processing.fft = enable
def set_log(self, enable: bool = False):
"""
Set the log of the image.
Args:
enable(bool): Whether to perform log on the monitor data.
"""
self.config.processing.log = enable
if enable and self.color_bar and self.config.color_bar == "full":
self.color_bar.autoHistogramRange()
def set_rotation(self, deg_90: int = 0):
"""
Set the rotation of the image.
Args:
deg_90(int): The rotation angle of the monitor data before displaying.
"""
self.config.processing.rotation = deg_90
def set_transpose(self, enable: bool = False):
"""
Set the transpose of the image.
Args:
enable(bool): Whether to transpose the image.
"""
self.config.processing.transpose = enable
def set_opacity(self, opacity: float = 1.0):
"""
Set the opacity of the image.
Args:
opacity(float): The opacity of the image.
"""
self.setOpacity(opacity)
self.config.opacity = opacity
def set_autorange(self, autorange: bool = False):
"""
Set the autorange of the color bar.
Args:
autorange(bool): Whether to autorange the color bar.
"""
self.config.autorange = autorange
if self.color_bar is not None:
self.color_bar.autoHistogramRange()
def set_color_map(self, cmap: str = "magma"):
"""
Set the color map of the image.
Args:
cmap(str): The color map of the image.
"""
self.setColorMap(cmap)
if self.color_bar is not None:
if self.config.color_bar == "simple":
self.color_bar.setColorMap(cmap)
elif self.config.color_bar == "full":
self.color_bar.gradient.loadPreset(cmap)
self.config.color_map = cmap
def set_auto_downsample(self, auto: bool = True):
"""
Set the auto downsample of the image.
Args:
auto(bool): Whether to downsample the image.
"""
self.setAutoDownsample(auto)
self.config.downsample = auto
def set_monitor(self, monitor: str):
"""
Set the monitor of the image.
Args:
monitor(str): The name of the monitor.
"""
self.config.monitor = monitor
def set_vrange(self, vmin: float = None, vmax: float = None, vrange: tuple[int, int] = None):
"""
Set the range of the color bar.
Args:
vmin(float): Minimum value of the color bar.
vmax(float): Maximum value of the color bar.
"""
if vrange is not None:
vmin, vmax = vrange
self.setLevels([vmin, vmax])
self.config.vrange = (vmin, vmax)
self.config.autorange = False
if self.color_bar is not None:
if self.config.color_bar == "simple":
self.color_bar.setLevels(low=vmin, high=vmax)
elif self.config.color_bar == "full":
self.color_bar.setLevels(min=vmin, max=vmax)
self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
def get_data(self) -> np.ndarray:
"""
Get the data of the image.
Returns:
np.ndarray: The data of the image.
"""
return self.image
def _add_color_bar(
self, color_bar_style: str = "simple", vrange: Optional[tuple[int, int]] = None
):
"""
Add color bar to the layout.
Args:
style(Literal["simple,full"]): The style of the color bar.
vrange(tuple[int,int]): The range of the color bar.
"""
if color_bar_style == "simple":
self.color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
if vrange is not None:
self.color_bar.setLevels(low=vrange[0], high=vrange[1])
self.color_bar.setImageItem(self)
self.parent_image.addItem(self.color_bar) # , row=0, col=1)
self.config.color_bar = "simple"
elif color_bar_style == "full":
# Setting histogram
self.color_bar = pg.HistogramLUTItem()
self.color_bar.setImageItem(self)
self.color_bar.gradient.loadPreset(self.config.color_map)
if vrange is not None:
self.color_bar.setLevels(min=vrange[0], max=vrange[1])
self.color_bar.setHistogramRange(
vrange[0] - 0.1 * vrange[0], vrange[1] + 0.1 * vrange[1]
)
# Adding histogram to the layout
self.parent_image.addItem(self.color_bar) # , row=0, col=1)
# save settings
self.config.color_bar = "full"
else:
raise ValueError("style should be 'simple' or 'full'")

View File

@@ -0,0 +1,152 @@
from __future__ import annotations
from typing import Optional
import numpy as np
from pydantic import BaseModel, Field
from qtpy.QtCore import QObject, Signal, Slot
class ProcessingConfig(BaseModel):
fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.")
log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.")
center_of_mass: Optional[bool] = Field(
False, description="Whether to calculate the center of mass of the monitor data."
)
transpose: Optional[bool] = Field(
False, description="Whether to transpose the monitor data before displaying."
)
rotation: Optional[int] = Field(
None, description="The rotation angle of the monitor data before displaying."
)
class ImageProcessor:
"""
Class for processing the image data.
"""
def __init__(self, config: ProcessingConfig = None):
if config is None:
config = ProcessingConfig()
self.config = config
def set_config(self, config: ProcessingConfig):
"""
Set the configuration of the processor.
Args:
config(ProcessingConfig): The configuration of the processor.
"""
self.config = config
def FFT(self, data: np.ndarray) -> np.ndarray:
"""
Perform FFT on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.abs(np.fft.fftshift(np.fft.fft2(data)))
def rotation(self, data: np.ndarray, rotate_90: int) -> np.ndarray:
"""
Rotate the data by 90 degrees n times.
Args:
data(np.ndarray): The data to be processed.
rotate_90(int): The number of 90 degree rotations.
Returns:
np.ndarray: The processed data.
"""
return np.rot90(data, k=rotate_90, axes=(0, 1))
def transpose(self, data: np.ndarray) -> np.ndarray:
"""
Transpose the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.transpose(data)
def log(self, data: np.ndarray) -> np.ndarray:
"""
Perform log on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
# TODO this is not final solution -> data should stay as int16
data = data.astype(np.float32)
offset = 1e-6
data_offset = data + offset
return np.log10(data_offset)
# def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality
# return np.unravel_index(np.argmax(data), data.shape)
def process_image(self, data: np.ndarray) -> np.ndarray:
"""
Process the data according to the configuration.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
if self.config.fft:
data = self.FFT(data)
if self.config.rotation is not None:
data = self.rotation(data, self.config.rotation)
if self.config.transpose:
data = self.transpose(data)
if self.config.log:
data = self.log(data)
return data
class ProcessorWorker(QObject):
"""
Worker for processing the image data.
"""
processed = Signal(str, np.ndarray)
stopRequested = Signal()
finished = Signal()
def __init__(self, processor):
super().__init__()
self.processor = processor
self._isRunning = False
self.stopRequested.connect(self.stop)
@Slot(str, np.ndarray)
def process_image(self, device: str, image: np.ndarray):
"""
Process the image data.
Args:
device(str): The name of the device.
image(np.ndarray): The image data.
"""
self._isRunning = True
processed_image = self.processor.process_image(image)
self._isRunning = False
if not self._isRunning:
self.processed.emit(device, processed_image)
self.finished.emit()
def stop(self):
self._isRunning = False

View File

@@ -5,7 +5,7 @@ from typing import Optional, Union
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints
from bec_lib.endpoints import MessageEndpoints
from pydantic import Field
from qtpy import QtCore, QtGui
from qtpy.QtCore import Signal as pyqtSignal
@@ -13,11 +13,11 @@ from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import EntryValidator
from bec_widgets.widgets.plots.plot_base import BECPlotBase, WidgetConfig
from bec_widgets.widgets.plots.waveform import Signal, SignalData
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform import Signal, SignalData
class MotorMapConfig(WidgetConfig):
class MotorMapConfig(SubplotConfig):
signals: Optional[Signal] = Field(None, description="Signals of the motor map")
color_map: Optional[str] = Field(
"Greys", description="Color scheme of the motor position gradient."
@@ -102,6 +102,7 @@ class BECMotorMap(BECPlotBase):
) -> None:
"""
Change the active motors for the plot.
Args:
motor_x(str): Motor name for the X axis.
motor_y(str): Motor name for the Y axis.
@@ -135,16 +136,14 @@ class BECMotorMap(BECPlotBase):
Returns:
dict: Data of the motor map.
"""
data = {
"x": self.database_buffer["x"],
"y": self.database_buffer["y"],
}
data = {"x": self.database_buffer["x"], "y": self.database_buffer["y"]}
return data
# TODO setup all visual properties
def set_max_points(self, max_points: int) -> None:
"""
Set the maximum number of points to display.
Args:
max_points(int): Maximum number of points to display.
"""
@@ -153,6 +152,7 @@ class BECMotorMap(BECPlotBase):
def set_precision(self, precision: int) -> None:
"""
Set the decimal precision of the motor position.
Args:
precision(int): Decimal precision of the motor position.
"""
@@ -161,6 +161,7 @@ class BECMotorMap(BECPlotBase):
def set_num_dim_points(self, num_dim_points: int) -> None:
"""
Set the number of dim points for the motor map.
Args:
num_dim_points(int): Number of dim points.
"""
@@ -169,6 +170,7 @@ class BECMotorMap(BECPlotBase):
def set_background_value(self, background_value: int) -> None:
"""
Set the background value of the motor map.
Args:
background_value(int): Background value of the motor map.
"""
@@ -177,19 +179,24 @@ class BECMotorMap(BECPlotBase):
def set_scatter_size(self, scatter_size: int) -> None:
"""
Set the scatter size of the motor map plot.
Args:
scatter_size(int): Size of the scatter points.
"""
self.config.scatter_size = scatter_size
def _connect_motor_to_slots(self):
"""Connect motors to slots."""
def _disconnect_current_motors(self):
"""Disconnect the current motors from the slots."""
if self.motor_x is not None and self.motor_y is not None:
old_endpoints = [
endpoints = [
MessageEndpoints.device_readback(self.motor_x),
MessageEndpoints.device_readback(self.motor_y),
]
self.bec_dispatcher.disconnect_slot(self.on_device_readback, old_endpoints)
self.bec_dispatcher.disconnect_slot(self.on_device_readback, endpoints)
def _connect_motor_to_slots(self):
"""Connect motors to slots."""
self._disconnect_current_motors()
self.motor_x = self.config.signals.x.name
self.motor_y = self.config.signals.y.name
@@ -243,6 +250,7 @@ class BECMotorMap(BECPlotBase):
def _add_coordinantes_crosshair(self, x: float, y: float) -> None:
"""
Add crosshair to the plot to highlight the current position.
Args:
x(float): X coordinate.
y(float): Y coordinate.
@@ -270,6 +278,7 @@ class BECMotorMap(BECPlotBase):
def _make_limit_map(self, limits_x: list, limits_y: list) -> pg.ImageItem:
"""
Create a limit map for the motor map plot.
Args:
limits_x(list): Motor limits for the x axis.
limits_y(list): Motor limits for the y axis.
@@ -299,10 +308,12 @@ class BECMotorMap(BECPlotBase):
def _get_motor_init_position(self, name: str, entry: str, precision: int) -> float:
"""
Get the motor initial position from the config.
Args:
name(str): Motor name.
entry(str): Motor entry.
precision(int): Decimal precision of the motor position.
Returns:
float: Motor initial position.
"""
@@ -319,12 +330,14 @@ class BECMotorMap(BECPlotBase):
) -> tuple[str, str]:
"""
Validate the signal name and entry.
Args:
x_name(str): Name of the x signal.
y_name(str): Name of the y signal.
x_entry(str|None): Entry of the x signal.
y_entry(str|None): Entry of the y signal.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
Returns:
tuple[str,str]: Validated x and y entries.
"""
@@ -339,6 +352,7 @@ class BECMotorMap(BECPlotBase):
def _get_motor_limit(self, motor: str) -> Union[list | None]: # TODO check if works correctly
"""
Get the motor limit from the config.
Args:
motor(str): Motor name.
@@ -374,11 +388,7 @@ class BECMotorMap(BECPlotBase):
# Update the scatter plot
self.plot_components["scatter"].setData(
x=x,
y=y,
brush=brushes,
pen=None,
size=scatter_size,
x=x, y=y, brush=brushes, pen=None, size=scatter_size
)
# Get last know position for crosshair
@@ -400,6 +410,7 @@ class BECMotorMap(BECPlotBase):
def on_device_readback(self, msg: dict) -> None:
"""
Update the motor map plot with the new motor position.
Args:
msg(dict): Message from the device readback.
"""
@@ -418,18 +429,7 @@ class BECMotorMap(BECPlotBase):
self.update_signal.emit()
if __name__ == "__main__": # pragma: no cover
import sys
import pyqtgraph as pg
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
glw = pg.GraphicsLayoutWidget()
motor_map = BECMotorMap()
motor_map.change_motors("samx", "samy")
glw.addItem(motor_map)
widget = glw
widget.show()
sys.exit(app.exec_())
def cleanup(self):
"""Cleanup the widget."""
self._disconnect_current_motors()
super().cleanup()

View File

@@ -22,7 +22,7 @@ class AxisConfig(BaseModel):
y_grid: bool = Field(False, description="Show grid on the y-axis.")
class WidgetConfig(ConnectionConfig):
class SubplotConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent figure of the plot.")
# Coordinates in the figure
@@ -56,12 +56,12 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
self,
parent: Optional[QWidget] = None, # TODO decide if needed for this class
parent_figure=None,
config: Optional[WidgetConfig] = None,
config: Optional[SubplotConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = WidgetConfig(widget_class=self.__class__.__name__)
config = SubplotConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, config=config, gui_id=gui_id)
pg.GraphicsLayout.__init__(self, parent)
@@ -73,8 +73,10 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def set(self, **kwargs) -> None:
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
@@ -117,6 +119,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def set_title(self, title: str):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
"""
@@ -126,6 +129,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def set_x_label(self, label: str):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
"""
@@ -135,6 +139,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def set_y_label(self, label: str):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
"""
@@ -144,6 +149,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def set_x_scale(self, scale: Literal["linear", "log"] = "linear"):
"""
Set the scale of the x-axis.
Args:
scale(Literal["linear", "log"]): Scale of the x-axis.
"""
@@ -153,6 +159,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def set_y_scale(self, scale: Literal["linear", "log"] = "linear"):
"""
Set the scale of the y-axis.
Args:
scale(Literal["linear", "log"]): Scale of the y-axis.
"""
@@ -208,6 +215,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def set_grid(self, x: bool = False, y: bool = False):
"""
Set the grid of the plot widget.
Args:
x(bool): Show grid on the x-axis.
y(bool): Show grid on the y-axis.
@@ -223,6 +231,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def lock_aspect_ratio(self, lock):
"""
Lock aspect ratio.
Args:
lock(bool): True to lock, False to unlock.
"""
@@ -231,6 +240,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def plot(self, data_x: list | np.ndarray, data_y: list | np.ndarray, **kwargs):
"""
Plot custom data on the plot widget. These data are not saved in config.
Args:
data_x(list|np.ndarray): x-axis data
data_y(list|np.ndarray): y-axis data
@@ -243,7 +253,9 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def remove(self):
"""Remove the plot widget from the figure."""
if self.figure is not None:
self.cleanup()
self.figure.remove(widget_id=self.gui_id)
def cleanup(self):
"""Cleanup the plot widget."""
super().cleanup()

View File

@@ -5,55 +5,24 @@ from typing import Any, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints
from bec_lib.endpoints import MessageEndpoints
from bec_lib.scan_data import ScanData
from pydantic import BaseModel, Field, ValidationError
from pyqtgraph import mkBrush
from qtpy import QtCore
from pydantic import Field, ValidationError
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig, EntryValidator
from bec_widgets.widgets.plots.plot_base import BECPlotBase, WidgetConfig
from bec_widgets.utils import Colors, EntryValidator
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform_curve import (
BECCurve,
CurveConfig,
Signal,
SignalData,
)
class SignalData(BaseModel):
"""The data configuration of a signal in the 1D waveform widget for x and y axis."""
name: str
entry: str
unit: Optional[str] = None # todo implement later
modifier: Optional[str] = None # todo implement later
limits: Optional[list[float]] = None # todo implement later
class Signal(BaseModel):
"""The configuration of a signal in the 1D waveform widget."""
source: str
x: SignalData # TODO maybe add metadata for config gui later
y: SignalData
z: Optional[SignalData] = None
class CurveConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the curve.")
label: Optional[str] = Field(None, description="The label of the curve.")
color: Optional[Any] = Field(None, description="The color of the curve.")
symbol: Optional[str] = Field("o", description="The symbol of the curve.")
symbol_color: Optional[str] = Field(None, description="The color of the symbol of the curve.")
symbol_size: Optional[int] = Field(5, description="The size of the symbol of the curve.")
pen_width: Optional[int] = Field(2, description="The width of the pen of the curve.")
pen_style: Optional[Literal["solid", "dash", "dot", "dashdot"]] = Field(
"solid", description="The style of the pen of the curve."
)
source: Optional[str] = Field(None, description="The source of the curve.")
signals: Optional[Signal] = Field(None, description="The signal of the curve.")
colormap: Optional[str] = Field("plasma", description="The colormap of the curves z gradient.")
class Waveform1DConfig(WidgetConfig):
class Waveform1DConfig(SubplotConfig):
color_palette: Literal["plasma", "viridis", "inferno", "magma"] = Field(
"plasma", description="The color palette of the figure widget."
) # TODO can be extended to all colormaps from current pyqtgraph session
@@ -62,172 +31,9 @@ class Waveform1DConfig(WidgetConfig):
)
class BECCurve(BECConnector, pg.PlotDataItem):
USER_ACCESS = [
"config_dict",
"set",
"set_data",
"set_color",
"set_colormap",
"set_symbol",
"set_symbol_color",
"set_symbol_size",
"set_pen_width",
"set_pen_style",
"get_data",
]
def __init__(
self,
name: Optional[str] = None,
config: Optional[CurveConfig] = None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = CurveConfig(label=name, widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
# config.widget_class = self.__class__.__name__
super().__init__(config=config, gui_id=gui_id)
pg.PlotDataItem.__init__(self, name=name)
self.apply_config()
if kwargs:
self.set(**kwargs)
def apply_config(self):
pen_style_map = {
"solid": QtCore.Qt.SolidLine,
"dash": QtCore.Qt.DashLine,
"dot": QtCore.Qt.DotLine,
"dashdot": QtCore.Qt.DashDotLine,
}
pen_style = pen_style_map.get(self.config.pen_style, QtCore.Qt.SolidLine)
pen = pg.mkPen(color=self.config.color, width=self.config.pen_width, style=pen_style)
self.setPen(pen)
if self.config.symbol:
symbol_color = self.config.symbol_color or self.config.color
brush = mkBrush(color=symbol_color)
self.setSymbolBrush(brush)
self.setSymbolSize(self.config.symbol_size)
self.setSymbol(self.config.symbol)
def set_data(self, x, y):
if self.config.source == "custom":
self.setData(x, y)
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
def set(self, **kwargs):
"""
Set the properties of the curve.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- color: str
- symbol: str
- symbol_color: str
- symbol_size: int
- pen_width: int
- pen_style: Literal["solid", "dash", "dot", "dashdot"]
"""
# Mapping of keywords to setter methods
method_map = {
"color": self.set_color,
"colormap": self.set_colormap,
"symbol": self.set_symbol,
"symbol_color": self.set_symbol_color,
"symbol_size": self.set_symbol_size,
"pen_width": self.set_pen_width,
"pen_style": self.set_pen_style,
}
for key, value in kwargs.items():
if key in method_map:
method_map[key](value)
else:
print(f"Warning: '{key}' is not a recognized property.")
def set_color(self, color: str, symbol_color: Optional[str] = None):
"""
Change the color of the curve.
Args:
color(str): Color of the curve.
symbol_color(str, optional): Color of the symbol. Defaults to None.
"""
self.config.color = color
self.config.symbol_color = symbol_color or color
self.apply_config()
def set_symbol(self, symbol: str):
"""
Change the symbol of the curve.
Args:
symbol(str): Symbol of the curve.
"""
self.config.symbol = symbol
self.apply_config()
def set_symbol_color(self, symbol_color: str):
"""
Change the symbol color of the curve.
Args:
symbol_color(str): Color of the symbol.
"""
self.config.symbol_color = symbol_color
self.apply_config()
def set_symbol_size(self, symbol_size: int):
"""
Change the symbol size of the curve.
Args:
symbol_size(int): Size of the symbol.
"""
self.config.symbol_size = symbol_size
self.apply_config()
def set_pen_width(self, pen_width: int):
"""
Change the pen width of the curve.
Args:
pen_width(int): Width of the pen.
"""
self.config.pen_width = pen_width
self.apply_config()
def set_pen_style(self, pen_style: Literal["solid", "dash", "dot", "dashdot"]):
"""
Change the pen style of the curve.
Args:
pen_style(Literal["solid", "dash", "dot", "dashdot"]): Style of the pen.
"""
self.config.pen_style = pen_style
self.apply_config()
def set_colormap(self, colormap: str):
"""
Set the colormap for the scatter plot z gradient.
Args:
colormap(str): Colormap for the scatter plot.
"""
self.config.colormap = colormap
def get_data(self) -> tuple[np.ndarray, np.ndarray]:
"""
Get the data of the curve.
Returns:
tuple[np.ndarray,np.ndarray]: X and Y data of the curve.
"""
x_data, y_data = self.getData()
return x_data, y_data
class BECWaveform(BECPlotBase):
USER_ACCESS = [
"rpc_id",
"config_dict",
"add_curve_scan",
"add_curve_custom",
@@ -286,24 +92,12 @@ class BECWaveform(BECPlotBase):
self.add_legend()
self.apply_config(self.config)
def find_widget_by_id(self, item_id: str) -> BECCurve:
"""
Find the curve by its ID.
Args:
item_id(str): ID of the curve.
Returns:
BECCurve: The curve object.
"""
for curve in self.plot_item.curves:
if curve.gui_id == item_id:
return curve
def apply_config(self, config: dict | WidgetConfig, replot_last_scan: bool = False):
def apply_config(self, config: dict | SubplotConfig, replot_last_scan: bool = False):
"""
Apply the configuration to the 1D waveform widget.
Args:
config(dict|WidgetConfig): Configuration settings.
config(dict|SubplotConfig): Configuration settings.
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
"""
if isinstance(config, dict):
@@ -342,8 +136,10 @@ class BECWaveform(BECPlotBase):
def add_curve_by_config(self, curve_config: CurveConfig | dict) -> BECCurve:
"""
Add a curve to the plot widget by its configuration.
Args:
curve_config(CurveConfig|dict): Configuration of the curve to be added.
Returns:
BECCurve: The curve object.
"""
@@ -357,8 +153,10 @@ class BECWaveform(BECPlotBase):
def get_curve_config(self, curve_id: str, dict_output: bool = True) -> CurveConfig | dict:
"""
Get the configuration of a curve by its ID.
Args:
curve_id(str): ID of the curve.
Returns:
CurveConfig|dict: Configuration of the curve.
"""
@@ -385,8 +183,10 @@ class BECWaveform(BECPlotBase):
def get_curve(self, identifier) -> BECCurve:
"""
Get the curve by its index or ID.
Args:
identifier(int|str): Identifier of the curve. Can be either an integer (index) or a string (curve_id).
Returns:
BECCurve: The curve object.
"""
@@ -410,6 +210,7 @@ class BECWaveform(BECPlotBase):
) -> BECCurve:
"""
Add a custom data curve to the plot widget.
Args:
x(list|np.ndarray): X data of the curve.
y(list|np.ndarray): Y data of the curve.
@@ -460,15 +261,17 @@ class BECWaveform(BECPlotBase):
) -> BECCurve:
"""
Add a curve object to the plot widget.
Args:
name(str): ID of the curve.
source(str): Source of the curve.
config(CurveConfig): Configuration of the curve.
data(tuple[list|np.ndarray,list|np.ndarray], optional): Data (x,y) to be plotted. Defaults to None.
Returns:
BECCurve: The curve object.
"""
curve = BECCurve(config=config, name=name)
curve = BECCurve(config=config, name=name, parent_item=self.plot_item)
self._curves_data[source][name] = curve
self.plot_item.addItem(curve)
self.config.curves[name] = curve.config
@@ -492,6 +295,7 @@ class BECWaveform(BECPlotBase):
) -> BECCurve:
"""
Add a curve to the plot widget from the scan segment.
Args:
x_name(str): Name of the x signal.
x_entry(str): Entry of the x signal.
@@ -562,6 +366,7 @@ class BECWaveform(BECPlotBase):
) -> tuple[str, str, str | None]:
"""
Validate the signal name and entry.
Args:
x_name(str): Name of the x signal.
y_name(str): Name of the y signal.
@@ -570,6 +375,7 @@ class BECWaveform(BECPlotBase):
y_entry(str|None): Entry of the y signal.
z_entry(str|None): Entry of the z signal.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
Returns:
tuple[str,str,str|None]: Validated x, y, z entries.
"""
@@ -587,6 +393,7 @@ class BECWaveform(BECPlotBase):
def _check_curve_id(self, val: Any, dict_to_check: dict) -> bool:
"""
Check if val is in the values of the dict_to_check or in the values of the nested dictionaries.
Args:
val(Any): Value to check.
dict_to_check(dict): Dictionary to check.
@@ -605,6 +412,7 @@ class BECWaveform(BECPlotBase):
def remove_curve(self, *identifiers):
"""
Remove a curve from the plot widget.
Args:
*identifiers: Identifier of the curve to be removed. Can be either an integer (index) or a string (curve_id).
"""
@@ -621,6 +429,7 @@ class BECWaveform(BECPlotBase):
def _remove_curve_by_id(self, curve_id):
"""
Remove a curve by its ID from the plot widget.
Args:
curve_id(str): ID of the curve to be removed.
"""
@@ -637,6 +446,7 @@ class BECWaveform(BECPlotBase):
def _remove_curve_by_order(self, N):
"""
Remove a curve by its order from the plot widget.
Args:
N(int): Order of the curve to be removed.
"""
@@ -682,6 +492,7 @@ class BECWaveform(BECPlotBase):
def _update_scan_curves(self, data: ScanData):
"""
Update the scan curves with the data from the scan segment.
Args:
data(ScanData): Data from the scan segment.
"""
@@ -716,6 +527,7 @@ class BECWaveform(BECPlotBase):
def _make_z_gradient(self, data_z: list | np.ndarray, colormap: str) -> list | None:
"""
Make a gradient color for the z values.
Args:
data_z(list|np.ndarray): Z values.
colormap(str): Colormap for the gradient color.
@@ -738,6 +550,7 @@ class BECWaveform(BECPlotBase):
"""
Update the scan curves with the data from the scan storage.
Provide only one of scan_id or scan_index.
Args:
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
@@ -757,8 +570,10 @@ class BECWaveform(BECPlotBase):
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict | pd.DataFrame:
"""
Extract all curve data into a dictionary or a pandas DataFrame.
Args:
output (Literal["dict", "pandas"]): Format of the output data.
Returns:
dict | pd.DataFrame: Data of all curves in the specified format.
"""
@@ -796,3 +611,6 @@ class BECWaveform(BECPlotBase):
def cleanup(self):
"""Cleanup the widget connection from BECDispatcher."""
self.bec_dispatcher.disconnect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
for curve in self.curves:
curve.cleanup()
super().cleanup()

View File

@@ -0,0 +1,227 @@
from __future__ import annotations
from typing import Any, Literal, Optional
import pyqtgraph as pg
from pydantic import BaseModel, Field
from qtpy import QtCore
from bec_widgets.utils import BECConnector, ConnectionConfig
class SignalData(BaseModel):
"""The data configuration of a signal in the 1D waveform widget for x and y axis."""
name: str
entry: str
unit: Optional[str] = None # todo implement later
modifier: Optional[str] = None # todo implement later
limits: Optional[list[float]] = None # todo implement later
class Signal(BaseModel):
"""The configuration of a signal in the 1D waveform widget."""
source: str
x: SignalData # TODO maybe add metadata for config gui later
y: SignalData
z: Optional[SignalData] = None
class CurveConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the curve.")
label: Optional[str] = Field(None, description="The label of the curve.")
color: Optional[Any] = Field(None, description="The color of the curve.")
symbol: Optional[str] = Field("o", description="The symbol of the curve.")
symbol_color: Optional[str] = Field(None, description="The color of the symbol of the curve.")
symbol_size: Optional[int] = Field(5, description="The size of the symbol of the curve.")
pen_width: Optional[int] = Field(2, description="The width of the pen of the curve.")
pen_style: Optional[Literal["solid", "dash", "dot", "dashdot"]] = Field(
"solid", description="The style of the pen of the curve."
)
source: Optional[str] = Field(None, description="The source of the curve.")
signals: Optional[Signal] = Field(None, description="The signal of the curve.")
colormap: Optional[str] = Field("plasma", description="The colormap of the curves z gradient.")
class BECCurve(BECConnector, pg.PlotDataItem):
USER_ACCESS = [
"remove",
"rpc_id",
"config_dict",
"set",
"set_data",
"set_color",
"set_colormap",
"set_symbol",
"set_symbol_color",
"set_symbol_size",
"set_pen_width",
"set_pen_style",
"get_data",
]
def __init__(
self,
name: Optional[str] = None,
config: Optional[CurveConfig] = None,
gui_id: Optional[str] = None,
parent_item: Optional[pg.PlotItem] = None,
**kwargs,
):
if config is None:
config = CurveConfig(label=name, widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
# config.widget_class = self.__class__.__name__
super().__init__(config=config, gui_id=gui_id)
pg.PlotDataItem.__init__(self, name=name)
self.parent_item = parent_item
self.apply_config()
if kwargs:
self.set(**kwargs)
def apply_config(self):
pen_style_map = {
"solid": QtCore.Qt.SolidLine,
"dash": QtCore.Qt.DashLine,
"dot": QtCore.Qt.DotLine,
"dashdot": QtCore.Qt.DashDotLine,
}
pen_style = pen_style_map.get(self.config.pen_style, QtCore.Qt.SolidLine)
pen = pg.mkPen(color=self.config.color, width=self.config.pen_width, style=pen_style)
self.setPen(pen)
if self.config.symbol:
symbol_color = self.config.symbol_color or self.config.color
brush = pg.mkBrush(color=symbol_color)
self.setSymbolBrush(brush)
self.setSymbolSize(self.config.symbol_size)
self.setSymbol(self.config.symbol)
def set_data(self, x, y):
if self.config.source == "custom":
self.setData(x, y)
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
def set(self, **kwargs):
"""
Set the properties of the curve.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- color: str
- symbol: str
- symbol_color: str
- symbol_size: int
- pen_width: int
- pen_style: Literal["solid", "dash", "dot", "dashdot"]
"""
# Mapping of keywords to setter methods
method_map = {
"color": self.set_color,
"colormap": self.set_colormap,
"symbol": self.set_symbol,
"symbol_color": self.set_symbol_color,
"symbol_size": self.set_symbol_size,
"pen_width": self.set_pen_width,
"pen_style": self.set_pen_style,
}
for key, value in kwargs.items():
if key in method_map:
method_map[key](value)
else:
print(f"Warning: '{key}' is not a recognized property.")
def set_color(self, color: str, symbol_color: Optional[str] = None):
"""
Change the color of the curve.
Args:
color(str): Color of the curve.
symbol_color(str, optional): Color of the symbol. Defaults to None.
"""
self.config.color = color
self.config.symbol_color = symbol_color or color
self.apply_config()
def set_symbol(self, symbol: str):
"""
Change the symbol of the curve.
Args:
symbol(str): Symbol of the curve.
"""
self.config.symbol = symbol
self.apply_config()
def set_symbol_color(self, symbol_color: str):
"""
Change the symbol color of the curve.
Args:
symbol_color(str): Color of the symbol.
"""
self.config.symbol_color = symbol_color
self.apply_config()
def set_symbol_size(self, symbol_size: int):
"""
Change the symbol size of the curve.
Args:
symbol_size(int): Size of the symbol.
"""
self.config.symbol_size = symbol_size
self.apply_config()
def set_pen_width(self, pen_width: int):
"""
Change the pen width of the curve.
Args:
pen_width(int): Width of the pen.
"""
self.config.pen_width = pen_width
self.apply_config()
def set_pen_style(self, pen_style: Literal["solid", "dash", "dot", "dashdot"]):
"""
Change the pen style of the curve.
Args:
pen_style(Literal["solid", "dash", "dot", "dashdot"]): Style of the pen.
"""
self.config.pen_style = pen_style
self.apply_config()
def set_colormap(self, colormap: str):
"""
Set the colormap for the scatter plot z gradient.
Args:
colormap(str): Colormap for the scatter plot.
"""
self.config.colormap = colormap
def get_data(self) -> tuple[np.ndarray, np.ndarray]:
"""
Get the data of the curve.
Returns:
tuple[np.ndarray,np.ndarray]: X and Y data of the curve.
"""
x_data, y_data = self.getData()
return x_data, y_data
def remove(self):
"""Remove the curve from the plot."""
self.parent_item.removeItem(self)
self.cleanup()

View File

@@ -1 +0,0 @@
from .monitor import BECMonitor

View File

@@ -1,590 +0,0 @@
import os
from pydantic import ValidationError
from qtpy import uic
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import (
QApplication,
QLineEdit,
QMessageBox,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.yaml_dialog import load_yaml, save_yaml
from bec_widgets.validation import MonitorConfigValidator
current_path = os.path.dirname(__file__)
Ui_Form, BaseClass = uic.loadUiType(os.path.join(current_path, "config_dialog.ui"))
Tab_Ui_Form, Tab_BaseClass = uic.loadUiType(os.path.join(current_path, "tab_template.ui"))
# test configs for demonstration purpose
# Configuration for default mode when only devices are monitored
CONFIG_DEFAULT = {
"plot_settings": {
"background_color": "black",
"num_columns": 1,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor Y",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [
{"name": "gauss_bpm"},
{"name": "gauss_adc1"},
{"name": "gauss_adc2"},
],
},
}
],
},
],
}
# Configuration which is dynamically changing depending on the scan type
CONFIG_SCAN_MODE = {
"plot_settings": {
"background_color": "white",
"num_columns": 3,
"colormap": "plasma",
"scan_types": True,
},
"plot_data": {
"grid_scan": [
{
"plot_name": "Grid plot 1",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}],
},
}
],
},
{
"plot_name": "Grid plot 2",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_adc1"}],
},
}
],
},
{
"plot_name": "Grid plot 3",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "gauss_adc2"}],
},
}
],
},
{
"plot_name": "Grid plot 4",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "gauss_adc3"}],
},
}
],
},
],
"line_scan": [
{
"plot_name": "BPM plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
},
}
class ConfigDialog(QWidget, Ui_Form):
config_updated = pyqtSignal(dict)
def __init__(
self,
client=None,
default_config=None,
skip_validation: bool = False,
):
super(ConfigDialog, self).__init__()
self.setupUi(self)
# Client
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
# Init validator
self.skip_validation = skip_validation
if self.skip_validation is False:
self.validator = MonitorConfigValidator(self.dev)
# Connect the Ok/Apply/Cancel buttons
self.pushButton_ok.clicked.connect(self.apply_and_close)
self.pushButton_apply.clicked.connect(self.apply_config)
self.pushButton_cancel.clicked.connect(self.close)
# Hook signals top level
self.pushButton_new_scan_type.clicked.connect(
lambda: self.generate_empty_scan_tab(
self.tabWidget_scan_types, self.lineEdit_scan_type.text()
)
)
# Load/save yaml file buttons
self.pushButton_import.clicked.connect(self.load_config_from_yaml)
self.pushButton_export.clicked.connect(self.save_config_to_yaml)
# Scan Types changed
self.comboBox_scanTypes.currentIndexChanged.connect(self._init_default)
# Make scan tabs closable
self.tabWidget_scan_types.tabCloseRequested.connect(self.handle_tab_close_request)
# Init functions to make a default dialog
if default_config is None:
self._init_default()
else:
self.load_config(default_config)
def _init_default(self):
"""Init default dialog"""
if self.comboBox_scanTypes.currentText() == "Disabled": # Default mode
self.add_new_scan_tab(self.tabWidget_scan_types, "Default")
self.add_new_plot_tab(self.tabWidget_scan_types.widget(0))
self.pushButton_new_scan_type.setEnabled(False)
self.lineEdit_scan_type.setEnabled(False)
else: # Scan mode with clear tab
self.pushButton_new_scan_type.setEnabled(True)
self.lineEdit_scan_type.setEnabled(True)
self.tabWidget_scan_types.clear()
def add_new_scan_tab(
self, parent_tab: QTabWidget, scan_name: str, closable: bool = False
) -> QWidget:
"""
Add a new scan tab to the parent tab widget
Args:
parent_tab(QTabWidget): Parent tab widget, where to add scan tab
scan_name(str): Scan name
closable(bool): If True, the scan tab will be closable
Returns:
scan_tab(QWidget): Scan tab widget
"""
# Check for an existing tab with the same name
for index in range(parent_tab.count()):
if parent_tab.tabText(index) == scan_name:
print(f'Scan name "{scan_name}" already exists.')
return None # or return the existing tab: return parent_tab.widget(index)
# Create a new scan tab
scan_tab = QWidget()
scan_tab_layout = QVBoxLayout(scan_tab)
# Set a tab widget for plots
tabWidget_plots = QTabWidget()
tabWidget_plots.setObjectName("tabWidget_plots") # TODO decide if needed to give a name
tabWidget_plots.setTabsClosable(True)
tabWidget_plots.tabCloseRequested.connect(self.handle_tab_close_request)
scan_tab_layout.addWidget(tabWidget_plots)
# Add scan tab
parent_tab.addTab(scan_tab, scan_name)
# Make tabs closable
if closable:
parent_tab.setTabsClosable(closable)
return scan_tab
def add_new_plot_tab(self, scan_tab: QWidget) -> QWidget:
"""
Add a new plot tab to the scan tab
Args:
scan_tab (QWidget): Scan tab widget
Returns:
plot_tab (QWidget): Plot tab
"""
# Create a new plot tab from .ui template
plot_tab = QWidget()
plot_tab_ui = Tab_Ui_Form()
plot_tab_ui.setupUi(plot_tab)
plot_tab.ui = plot_tab_ui
# Add plot to current scan tab
tabWidget_plots = scan_tab.findChild(
QTabWidget, "tabWidget_plots"
) # TODO decide if putting name is needed
plot_name = f"Plot {tabWidget_plots.count() + 1}"
tabWidget_plots.addTab(plot_tab, plot_name)
# Hook signal
self.hook_plot_tab_signals(scan_tab=scan_tab, plot_tab=plot_tab.ui)
return plot_tab
def hook_plot_tab_signals(self, scan_tab: QTabWidget, plot_tab: Tab_Ui_Form) -> None:
"""
Hook signals of the plot tab
Args:
scan_tab(QTabWidget): Scan tab widget
plot_tab(Tab_Ui_Form): Plot tab widget
"""
plot_tab.pushButton_add_new_plot.clicked.connect(
lambda: self.add_new_plot_tab(scan_tab=scan_tab)
)
plot_tab.pushButton_y_new.clicked.connect(
lambda: self.add_new_signal(plot_tab.tableWidget_y_signals)
)
def add_new_signal(self, table: QTableWidget) -> None:
"""
Add a new signal to the table
Args:
table(QTableWidget): Table widget
"""
row_position = table.rowCount()
table.insertRow(row_position)
table.setItem(row_position, 0, QTableWidgetItem(""))
table.setItem(row_position, 1, QTableWidgetItem(""))
def handle_tab_close_request(self, index: int) -> None:
"""
Handle tab close request
Args:
index(int): Index of the tab to be closed
"""
parent_tab = self.sender()
if parent_tab.count() > 1: # ensure there is at least one tab
parent_tab.removeTab(index)
def generate_empty_scan_tab(self, parent_tab: QTabWidget, scan_name: str):
"""
Generate an empty scan tab
Args:
parent_tab (QTabWidget): Parent tab widget where to add the scan tab
scan_name(str): name of the scan tab
"""
scan_tab = self.add_new_scan_tab(parent_tab, scan_name, closable=True)
if scan_tab is not None:
self.add_new_plot_tab(scan_tab)
def get_plot_config(self, plot_tab: QWidget) -> dict:
"""
Get plot configuration from the plot tab adn send it as dict
Args:
plot_tab(QWidget): Plot tab widget
Returns:
dict: Plot configuration
"""
ui = plot_tab.ui
table = ui.tableWidget_y_signals
x_signals = [
{
"name": self.safe_text(ui.lineEdit_x_name),
"entry": self.safe_text(ui.lineEdit_x_entry),
}
]
y_signals = [
{
"name": self.safe_text(table.item(row, 0)),
"entry": self.safe_text(table.item(row, 1)),
}
for row in range(table.rowCount())
]
plot_data = {
"plot_name": self.safe_text(ui.lineEdit_plot_title),
"x_label": self.safe_text(ui.lineEdit_x_label),
"y_label": self.safe_text(ui.lineEdit_y_label),
"sources": [
{
"type": "scan_segment",
"signals": {
"x": x_signals,
"y": y_signals,
},
}
],
}
return plot_data
def apply_config(self) -> dict:
"""
Apply configuration from the whole configuration window
Returns:
dict: Current configuration
"""
# General settings
config = {
"plot_settings": {
"background_color": self.comboBox_appearance.currentText(),
"num_columns": self.spinBox_n_column.value(),
"colormap": self.comboBox_colormap.currentText(),
"scan_types": True if self.comboBox_scanTypes.currentText() == "Enabled" else False,
},
"plot_data": {} if self.comboBox_scanTypes.currentText() == "Enabled" else [],
}
# Iterate through the plot tabs - Device monitor mode
if config["plot_settings"]["scan_types"] == False:
plot_tab = self.tabWidget_scan_types.widget(0).findChild(QTabWidget)
for index in range(plot_tab.count()):
plot_data = self.get_plot_config(plot_tab.widget(index))
config["plot_data"].append(plot_data)
# Iterate through the scan tabs - Scan mode
elif config["plot_settings"]["scan_types"] == True:
# Iterate through the scan tabs
for index in range(self.tabWidget_scan_types.count()):
scan_tab = self.tabWidget_scan_types.widget(index)
scan_name = self.tabWidget_scan_types.tabText(index)
plot_tab = scan_tab.findChild(QTabWidget)
config["plot_data"][scan_name] = []
# Iterate through the plot tabs
for index in range(plot_tab.count()):
plot_data = self.get_plot_config(plot_tab.widget(index))
config["plot_data"][scan_name].append(plot_data)
return config
def load_config(self, config: dict) -> None:
"""
Load configuration to the configuration window
Args:
config(dict): Configuration to be loaded
"""
# Plot setting General box
plot_settings = config.get("plot_settings", {})
self.comboBox_appearance.setCurrentText(plot_settings.get("background_color", "black"))
self.spinBox_n_column.setValue(plot_settings.get("num_columns", 1))
self.comboBox_colormap.setCurrentText(
plot_settings.get("colormap", "magma")
) # TODO make logic to allow also different colormaps -> validation of incoming dict
self.comboBox_scanTypes.setCurrentText(
"Enabled" if plot_settings.get("scan_types", False) else "Disabled"
)
# Clear exiting scan tabs
self.tabWidget_scan_types.clear()
# Get what mode is active - scan vs default device monitor
scan_mode = plot_settings.get("scan_types", False)
if scan_mode is False: # default mode:
plot_data = config.get("plot_data", [])
self.add_new_scan_tab(self.tabWidget_scan_types, "Default")
for plot_config in plot_data: # Create plot tab for each plot and populate GUI
plot = self.add_new_plot_tab(self.tabWidget_scan_types.widget(0))
self.load_plot_setting(plot, plot_config)
elif scan_mode is True: # scan mode
plot_data = config.get("plot_data", {})
for scan_name, scan_config in plot_data.items():
scan_tab = self.add_new_scan_tab(self.tabWidget_scan_types, scan_name)
for plot_config in scan_config:
plot = self.add_new_plot_tab(scan_tab)
self.load_plot_setting(plot, plot_config)
def load_plot_setting(self, plot: QWidget, plot_config: dict) -> None:
"""
Load plot setting from config
Args:
plot (QWidget): plot tab widget
plot_config (dict): config for single plot tab
"""
sources = plot_config.get("sources", [{}])[0]
x_signals = sources.get("signals", {}).get("x", [{}])[0]
y_signals = sources.get("signals", {}).get("y", [])
# LabelBox
plot.ui.lineEdit_plot_title.setText(plot_config.get("plot_name", ""))
plot.ui.lineEdit_x_label.setText(plot_config.get("x_label", ""))
plot.ui.lineEdit_y_label.setText(plot_config.get("y_label", ""))
# X axis
plot.ui.lineEdit_x_name.setText(x_signals.get("name", ""))
plot.ui.lineEdit_x_entry.setText(x_signals.get("entry", ""))
# Y axis
for y_signal in y_signals:
row_position = plot.ui.tableWidget_y_signals.rowCount()
plot.ui.tableWidget_y_signals.insertRow(row_position)
plot.ui.tableWidget_y_signals.setItem(
row_position, 0, QTableWidgetItem(y_signal.get("name", ""))
)
plot.ui.tableWidget_y_signals.setItem(
row_position, 1, QTableWidgetItem(y_signal.get("entry", ""))
)
def load_config_from_yaml(self):
"""
Load configuration from yaml file
"""
config = load_yaml(self)
self.load_config(config)
def save_config_to_yaml(self):
"""
Save configuration to yaml file
"""
config = self.apply_config()
save_yaml(self, config)
@staticmethod
def safe_text(line_edit: QLineEdit) -> str:
"""
Get text from a line edit, if it is None, return empty string
Args:
line_edit(QLineEdit): Line edit widget
Returns:
str: Text from the line edit
"""
return "" if line_edit is None else line_edit.text()
def apply_and_close(self):
new_config = self.apply_config()
if self.skip_validation is True:
self.config_updated.emit(new_config)
self.close()
else:
try:
validated_config = self.validator.validate_monitor_config(new_config)
approved_config = validated_config.model_dump()
self.config_updated.emit(approved_config)
self.close()
except ValidationError as e:
error_str = str(e)
formatted_error_message = ConfigDialog.format_validation_error(error_str)
# Display the formatted error message in a popup
QMessageBox.critical(self, "Configuration Error", formatted_error_message)
@staticmethod
def format_validation_error(error_str: str) -> str:
"""
Format the validation error string to be displayed in a popup.
Args:
error_str(str): Error string from the validation error.
"""
error_lines = error_str.split("\n")
# The first line contains the number of errors.
error_header = f"<p><b>{error_lines[0]}</b></p><hr>"
formatted_error_message = error_header
# Skip the first line as it's the header.
error_details = error_lines[1:]
# Iterate through pairs of lines (each error's two lines).
for i in range(0, len(error_details), 2):
location = error_details[i]
message = error_details[i + 1] if i + 1 < len(error_details) else ""
formatted_error_message += f"<p><b>{location}</b><br>{message}</p><hr>"
return formatted_error_message
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
main_app = ConfigDialog()
main_app.show()
main_app.load_config(CONFIG_SCAN_MODE)
app.exec()

View File

@@ -1,210 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>597</width>
<height>769</height>
</rect>
</property>
<property name="windowTitle">
<string>Plot Configuration</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QGroupBox" name="groupBox_plot_setting">
<property name="title">
<string>Plot Layout Settings</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Number of Columns</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Scan Types</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="pushButton_new_scan_type">
<property name="text">
<string>New Scan Type</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="comboBox_scanTypes">
<item>
<property name="text">
<string>Disabled</string>
</property>
</item>
<item>
<property name="text">
<string>Enabled</string>
</property>
</item>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="spinBox_n_column">
<property name="minimum">
<number>1</number>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="lineEdit_scan_type"/>
</item>
</layout>
</item>
<item>
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLabel" name="label_4">
<property name="text">
<string>Appearance</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox_appearance">
<property name="enabled">
<bool>false</bool>
</property>
<item>
<property name="text">
<string>black</string>
</property>
</item>
<item>
<property name="text">
<string>white</string>
</property>
</item>
</widget>
</item>
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>Default Color Palette</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox_colormap">
<item>
<property name="text">
<string>magma</string>
</property>
</item>
<item>
<property name="text">
<string>plasma</string>
</property>
</item>
<item>
<property name="text">
<string>viridis</string>
</property>
</item>
<item>
<property name="text">
<string>reds</string>
</property>
</item>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_config">
<property name="title">
<string>Configuration</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="pushButton_import">
<property name="text">
<string>Import</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_export">
<property name="text">
<string>Export</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTabWidget" name="tabWidget_scan_types">
<property name="tabPosition">
<enum>QTabWidget::West</enum>
</property>
<property name="tabShape">
<enum>QTabWidget::Rounded</enum>
</property>
<property name="currentIndex">
<number>-1</number>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="confirm_layout">
<item>
<widget class="QPushButton" name="pushButton_cancel">
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_apply">
<property name="text">
<string>Apply</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_ok">
<property name="text">
<string>OK</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -1,60 +0,0 @@
plot_settings:
background_color: "black"
num_columns: 2
colormap: "viridis"
scan_types: False
plot_data:
- plot_name: "BPM4i plots vs samy"
x:
label: 'Motor Y'
signals:
- name: "samy"
entry: "samy"
y:
label: 'bpm4i'
signals:
- name: "bpm4i"
entry: "bpm4i"
- plot_name: "BPM4i plots vs samx"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'bpm6b'
signals:
- name: "bpm6b"
entry: "bpm6b"
- name: "samy"
entry: "samy"
- plot_name: "Multiple Gaussian"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'Gauss ADC'
signals:
- name: "gauss_adc1"
entry: "gauss_adc1"
- name: "gauss_adc2"
entry: "gauss_adc2"
- name: "gauss_adc3"
entry: "gauss_adc3"
- plot_name: "Linear Signals"
x:
label: 'Motor X'
signals:
- name: "samy"
entry: "samy"
y:
label: 'Motor Y'
signals:
- name: "samy"
entry: "samy"

View File

@@ -1,92 +0,0 @@
plot_settings:
background_color: "black"
num_columns: 2
colormap: "plasma"
scan_types: True
plot_data:
line_scan:
- plot_name: "BPM plot"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "gauss_adc1"
entry: "gauss_adc1"
- name: "gauss_adc2"
entry: "gauss_adc2"
- plot_name: "Multi"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'Multi'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "samx"
entry: "samx"
grid_scan:
- plot_name: "Grid plot 1"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "gauss_adc1"
entry: "gauss_adc1"
- plot_name: "Grid plot 2"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "gauss_adc1"
entry: "gauss_adc1"
- plot_name: "Grid plot 3"
x:
label: 'Motor Y'
signals:
- name: "samy"
entry: "samy"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- plot_name: "Grid plot 4"
x:
label: 'Motor Y'
signals:
- name: "samy"
entry: "samy"
y:
label: 'BPM'
signals:
- name: "gauss_adc3"
entry: "gauss_adc3"

View File

@@ -1,845 +0,0 @@
# pylint: disable = no-name-in-module,missing-module-docstring
import time
import pyqtgraph as pg
from bec_lib import MessageEndpoints
from pydantic import ValidationError
from pyqtgraph import mkBrush, mkPen
from qtpy import QtCore
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QApplication, QMessageBox
from bec_widgets.utils import Colors, Crosshair, yaml_dialog
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.validation import MonitorConfigValidator
from bec_widgets.widgets.monitor.config_dialog import ConfigDialog
# just for demonstration purposes if script run directly
CONFIG_SCAN_MODE = {
"plot_settings": {
"background_color": "white",
"num_columns": 3,
"colormap": "plasma",
"scan_types": True,
},
"plot_data": {
"grid_scan": [
{
"plot_name": "Grid plot 1",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Grid plot 2",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Grid plot 3",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {"x": [{"name": "samy"}], "y": [{"name": "bpm4i"}]},
}
],
},
{
"plot_name": "Grid plot 4",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
],
"line_scan": [
{
"plot_name": "BPM plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}, {"name": "bpm4i"}],
},
}
],
},
],
},
}
CONFIG_WRONG = {
"plot_settings": {
"background_color": "black",
"num_columns": 2,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor Y",
"y_label": "bpm4i",
"sources": [
{
"type": "non_existing_source",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
{
"type": "history",
"scan_id": "<scan_id>",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "non_sense_entry"}],
"y": [
{"name": "non_existing_name"},
{"name": "samy", "entry": "non_existing_entry"},
],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "samx"}, {"name": "samy", "entry": "samx"}],
}
}
],
},
],
}
CONFIG_SIMPLE = {
"plot_settings": {
"background_color": "black",
"num_columns": 2,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor X",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
# {
# "type": "history",
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
# {
# "type": "dap",
# 'worker':'some_worker',
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}, {"name": "bpm4i"}],
},
}
],
},
],
}
CONFIG_REDIS = {
"plot_settings": {
"background_color": "white",
"axis_width": 2,
"num_columns": 5,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor Y",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {"x": [{"name": "samx"}], "y": [{"name": "gauss_bpm"}]},
},
{
"type": "redis",
"endpoint": "public/gui/data/6cd5ea3f-a9a9-4736-b4ed-74ab9edfb996",
"update": "append",
"signals": {"x": [{"name": "x_default_tag"}], "y": [{"name": "y_default_tag"}]},
},
],
}
],
}
class BECMonitor(pg.GraphicsLayoutWidget):
update_signal = pyqtSignal()
def __init__(
self,
parent=None,
client=None,
config: dict = None,
enable_crosshair: bool = True,
gui_id=None,
skip_validation: bool = False,
):
super().__init__(parent=parent)
# Client and device manager from BEC
self.plot_data = None
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
self.queue = self.client.queue
self.validator = MonitorConfigValidator(self.dev)
self.gui_id = gui_id
if self.gui_id is None:
self.gui_id = self.__class__.__name__ + str(time.time())
# Connect slots dispatcher
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
bec_dispatcher.connect_slot(self.on_config_update, MessageEndpoints.gui_config(self.gui_id))
bec_dispatcher.connect_slot(
self.on_instruction, MessageEndpoints.gui_instructions(self.gui_id)
)
bec_dispatcher.connect_slot(self.on_data_from_redis, MessageEndpoints.gui_data(self.gui_id))
# Current configuration
self.config = config
self.skip_validation = skip_validation
# Enable crosshair
self.enable_crosshair = enable_crosshair
# Displayed Data
self.database = None
self.crosshairs = None
self.plots = None
self.curves_data = None
self.grid_coordinates = None
self.scan_id = None
# TODO make colors accessible to users
self.user_colors = {} # key: (plot_name, y_name, y_entry), value: color
# Connect the update signal to the update plot method
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self.update_scan_segment_plot
)
# Init UI
if self.config is None:
print("No initial config found for BECDeviceMonitor")
else:
self.on_config_update(self.config)
def _init_config(self):
"""
Initializes or update the configuration settings for the PlotApp.
"""
# Separate configs
self.plot_settings = self.config.get("plot_settings", {})
self.plot_data_config = self.config.get("plot_data", {})
self.scan_types = self.plot_settings.get("scan_types", False)
if self.scan_types is False: # Device tracking mode
self.plot_data = self.plot_data_config # TODO logic has to be improved
else: # without incoming data setup the first configuration to the first scan type sorted alphabetically by name
self.plot_data = self.plot_data_config[min(list(self.plot_data_config.keys()))]
# Initialize the database
self.database = self._init_database(self.plot_data)
# Initialize the UI
self._init_ui(self.plot_settings["num_columns"])
if self.scan_id is not None:
self.replot_last_scan()
def _init_database(self, plot_data_config: dict, source_type_to_init=None) -> dict:
"""
Initializes or updates the database for the PlotApp.
Args:
plot_data_config(dict): Configuration settings for plots.
source_type_to_init(str, optional): Specific source type to initialize. If None, initialize all.
Returns:
dict: Updated or new database dictionary.
"""
database = {} if source_type_to_init is None else self.database.copy()
for plot in plot_data_config:
for source in plot["sources"]:
source_type = source["type"]
if source_type_to_init and source_type != source_type_to_init:
continue # Skip if not the specified source type
if source_type not in database:
database[source_type] = {}
for axis, signals in source["signals"].items():
for signal in signals:
name = signal["name"]
entry = signal.get("entry", name)
if name not in database[source_type]:
database[source_type][name] = {}
if entry not in database[source_type][name]:
database[source_type][name][entry] = []
return database
def _init_ui(self, num_columns: int = 3) -> None:
"""
Initialize the UI components, create plots and store their grid positions.
Args:
num_columns (int): Number of columns to wrap the layout.
This method initializes a dictionary `self.plots` to store the plot objects
along with their corresponding x and y signal names. It dynamically arranges
the plots in a grid layout based on the given number of columns and dynamically
stretches the last plots to fit the remaining space.
"""
self.clear()
self.plots = {}
self.grid_coordinates = []
num_plots = len(self.plot_data)
# Check if num_columns exceeds the number of plots
if num_columns >= num_plots:
num_columns = num_plots
self.plot_settings["num_columns"] = num_columns # Update the settings
print(
"Warning: num_columns in the YAML file was greater than the number of plots."
f" Resetting num_columns to number of plots:{num_columns}."
)
else:
self.plot_settings["num_columns"] = num_columns # Update the settings
num_rows = num_plots // num_columns
last_row_cols = num_plots % num_columns
remaining_space = num_columns - last_row_cols
for i, plot_config in enumerate(self.plot_data):
row, col = i // num_columns, i % num_columns
colspan = 1
if row == num_rows and remaining_space > 0:
if last_row_cols == 1:
colspan = num_columns
else:
colspan = remaining_space // last_row_cols + 1
remaining_space -= colspan - 1
last_row_cols -= 1
plot_name = plot_config.get("plot_name", "")
x_label = plot_config.get("x_label", "")
y_label = plot_config.get("y_label", "")
plot = self.addPlot(row=row, col=col, colspan=colspan, title=plot_name)
plot.setLabel("bottom", x_label)
plot.setLabel("left", y_label)
plot.addLegend()
self._set_plot_colors(plot, self.plot_settings)
self.plots[plot_name] = plot
self.grid_coordinates.append((row, col))
# Initialize curves
self.init_curves()
def _set_plot_colors(self, plot: pg.PlotItem, plot_settings: dict) -> None:
"""
Set the plot colors based on the plot config.
Args:
plot (pg.PlotItem): Plot object to set the colors.
plot_settings (dict): Plot settings dictionary.
"""
if plot_settings.get("show_grid", False):
plot.showGrid(x=True, y=True, alpha=0.5)
pen_width = plot_settings.get("axis_width")
color = plot_settings.get("axis_color")
if color is None:
if plot_settings["background_color"].lower() == "black":
color = "w"
self.setBackground("k")
elif plot_settings["background_color"].lower() == "white":
color = "k"
self.setBackground("w")
else:
raise ValueError(
f"Invalid background color {plot_settings['background_color']}. Allowed values"
" are 'white' or 'black'."
)
pen = pg.mkPen(color=color, width=pen_width)
x_axis = plot.getAxis("bottom") # 'bottom' corresponds to the x-axis
x_axis.setPen(pen)
x_axis.setTextPen(pen)
x_axis.setTickPen(pen)
y_axis = plot.getAxis("left") # 'left' corresponds to the y-axis
y_axis.setPen(pen)
y_axis.setTextPen(pen)
y_axis.setTickPen(pen)
def init_curves(self) -> None:
"""
Initialize curve data and properties for each plot and data source.
"""
self.curves_data = {}
for idx, plot_config in enumerate(self.plot_data):
plot_name = plot_config.get("plot_name", "")
plot = self.plots[plot_name]
plot.clear()
for source in plot_config["sources"]:
source_type = source["type"]
y_signals = source["signals"].get("y", [])
colors_ys = Colors.golden_angle_color(
colormap=self.plot_settings["colormap"], num=len(y_signals)
)
if source_type not in self.curves_data:
self.curves_data[source_type] = {}
if plot_name not in self.curves_data[source_type]:
self.curves_data[source_type][plot_name] = []
for i, (y_signal, color) in enumerate(zip(y_signals, colors_ys)):
y_name = y_signal["name"]
y_entry = y_signal.get("entry", y_name)
curve_name = f"{y_name} ({y_entry})-{source_type[0].upper()}"
curve_data = self.create_curve(curve_name, color)
plot.addItem(curve_data)
self.curves_data[source_type][plot_name].append((y_name, y_entry, curve_data))
# Render static plot elements
self.update_plot()
# # Hook Crosshair #TODO enable later, currently not working
if self.enable_crosshair is True:
self.hook_crosshair()
def create_curve(self, curve_name: str, color: str) -> pg.PlotDataItem:
"""
Create
Args:
curve_name: Name of the curve
color(str): Color of the curve
Returns:
pg.PlotDataItem: Assigned curve object
"""
user_color = self.user_colors.get(curve_name, None)
color_to_use = user_color if user_color else color
pen_curve = mkPen(color=color_to_use, width=2, style=QtCore.Qt.DashLine)
brush_curve = mkBrush(color=color_to_use)
return pg.PlotDataItem(
symbolSize=5,
symbolBrush=brush_curve,
pen=pen_curve,
skipFiniteCheck=True,
name=curve_name,
)
def hook_crosshair(self) -> None:
"""Hook the crosshair to all plots."""
# TODO can be extended to hook crosshair signal for mouse move/clicked
self.crosshairs = {}
for plot_name, plot in self.plots.items():
crosshair = Crosshair(plot, precision=3)
self.crosshairs[plot_name] = crosshair
def update_scan_segment_plot(self):
"""
Update the plot with the latest scan segment data.
"""
self.update_plot(source_type="scan_segment")
def update_plot(self, source_type=None) -> None:
"""
Update the plot data based on the stored data dictionary.
Only updates data for the specified source_type if provided.
"""
for src_type, plots in self.curves_data.items():
if source_type and src_type != source_type:
continue
for plot_name, curve_list in plots.items():
plot_config = next(
(pc for pc in self.plot_data if pc.get("plot_name") == plot_name), None
)
if not plot_config:
continue
x_name, x_entry = self.extract_x_config(plot_config, src_type)
for y_name, y_entry, curve in curve_list:
data_x = self.database.get(src_type, {}).get(x_name, {}).get(x_entry, [])
data_y = self.database.get(src_type, {}).get(y_name, {}).get(y_entry, [])
curve.setData(data_x, data_y)
def extract_x_config(self, plot_config: dict, source_type: str) -> tuple:
"""Extract the signal configurations for x and y axes from plot_config.
Args:
plot_config (dict): Plot configuration.
Returns:
tuple: Tuple containing the x name and x entry.
"""
x_name, x_entry = None, None
for source in plot_config["sources"]:
if source["type"] == source_type and "x" in source["signals"]:
x_signal = source["signals"]["x"][0]
x_name = x_signal.get("name")
x_entry = x_signal.get("entry", x_name)
return x_name, x_entry
def get_config(self):
"""Return the current configuration settings."""
return self.config
def show_config_dialog(self):
"""Show the configuration dialog."""
dialog = ConfigDialog(
client=self.client, default_config=self.config, skip_validation=self.skip_validation
)
dialog.config_updated.connect(self.on_config_update)
dialog.show()
def update_client(self, client) -> None:
"""Update the client and device manager from BEC.
Args:
client: BEC client
"""
self.client = client
self.dev = self.client.device_manager.devices
def _close_all_plots(self):
"""Close all plots."""
for plot in self.plots.values():
plot.clear()
@pyqtSlot(dict)
def on_instruction(self, msg_content: dict) -> None:
"""
Handle instructions sent to the GUI.
Possible actions are:
- clear: Clear the plots
- close: Close the GUI
- config_dialog: Open the configuration dialog
Args:
msg_content (dict): Message content with the instruction and parameters.
"""
action = msg_content.get("action", None)
parameters = msg_content.get("parameters", None)
if action == "clear":
self.flush()
self._close_all_plots()
elif action == "close":
self.close()
elif action == "config_dialog":
self.show_config_dialog()
else:
print(f"Unknown instruction received: {msg_content}")
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""
Validate and update the configuration settings for the PlotApp.
Args:
config(dict): Configuration settings
"""
# convert config from BEC CLI to correct formatting
config_tag = config.get("config", None)
if config_tag is not None:
config = config["config"]
if self.skip_validation is True:
self.config = config
self._init_config()
else:
try:
validated_config = self.validator.validate_monitor_config(config)
self.config = validated_config.model_dump()
self._init_config()
except ValidationError as e:
error_str = str(e)
formatted_error_message = BECMonitor.format_validation_error(error_str)
# Display the formatted error message in a popup
QMessageBox.critical(self, "Configuration Error", formatted_error_message)
@staticmethod
def format_validation_error(error_str: str) -> str:
"""
Format the validation error string to be displayed in a popup.
Args:
error_str(str): Error string from the validation error.
"""
error_lines = error_str.split("\n")
# The first line contains the number of errors.
error_header = f"<p><b>{error_lines[0]}</b></p><hr>"
formatted_error_message = error_header
# Skip the first line as it's the header.
error_details = error_lines[1:]
# Iterate through pairs of lines (each error's two lines).
for i in range(0, len(error_details), 2):
location = error_details[i]
message = error_details[i + 1] if i + 1 < len(error_details) else ""
formatted_error_message += f"<p><b>{location}</b><br>{message}</p><hr>"
return formatted_error_message
def flush(self, flush_all=False, source_type_to_flush=None) -> None:
"""Update or reset the database to match the current configuration.
Args:
flush_all (bool): If True, reset the entire database.
source_type_to_flush (str): Specific source type to reset. Ignored if flush_all is True.
"""
if flush_all:
self.database = self._init_database(self.plot_data)
self.init_curves()
else:
if source_type_to_flush in self.database:
# TODO maybe reinit the database from config again instead of cycle through names/entries
# Reset only the specified source type
for name in self.database[source_type_to_flush]:
for entry in self.database[source_type_to_flush][name]:
self.database[source_type_to_flush][name][entry] = []
# Reset curves for the specified source type
if source_type_to_flush in self.curves_data:
self.init_curves()
@pyqtSlot(dict, dict)
def on_scan_segment(self, msg: dict, metadata: dict):
"""
Handle new scan segments and saves data to a dictionary. Linked through bec_dispatcher.
Args:
msg (dict): Message received with scan data.
metadata (dict): Metadata of the scan.
"""
current_scan_id = msg.get("scan_id", None)
if current_scan_id is None:
return
if current_scan_id != self.scan_id:
if self.scan_types is False:
self.plot_data = self.plot_data_config
elif self.scan_types is True:
current_name = metadata.get("scan_name")
if current_name is None:
raise ValueError(
"Scan name not found in metadata. Please check the scan_name in the YAML"
" config or in bec configuration."
)
self.plot_data = self.plot_data_config.get(current_name, None)
if not self.plot_data:
raise ValueError(
f"Scan name {current_name} not found in the YAML config. Please check the scan_name in the "
"YAML config or in bec configuration."
)
# Init UI
self._init_ui(self.plot_settings["num_columns"])
self.scan_id = current_scan_id
self.scan_data = self.queue.scan_storage.find_scan_by_ID(self.scan_id)
if not self.scan_data:
print(f"No data found for scan_id: {self.scan_id}") # TODO better error
return
self.flush(source_type_to_flush="scan_segment")
self.scan_segment_update()
self.update_signal.emit()
def scan_segment_update(self):
"""
Update the database with data from scan storage based on the provided scan_id.
"""
scan_data = self.scan_data.data
for device_name, device_entries in self.database.get("scan_segment", {}).items():
for entry in device_entries.keys():
dataset = scan_data[device_name][entry].val
if dataset:
self.database["scan_segment"][device_name][entry] = dataset
else:
print(f"No data found for {device_name} {entry}")
def replot_last_scan(self):
"""
Replot the last scan.
"""
self.scan_segment_update()
self.update_plot(source_type="scan_segment")
@pyqtSlot(dict)
def on_data_from_redis(self, msg) -> None:
"""
Handle new data sent from redis.
Args:
msg (dict): Message received with data.
"""
# wait until new config is loaded
while "redis" not in self.database:
time.sleep(0.1)
self._init_database(
self.plot_data, source_type_to_init="redis"
) # add database entry for redis dataset
data = msg.get("data", {})
x_data = data.get("x", {})
y_data = data.get("y", {})
# Update x data
if x_data:
x_tag = x_data.get("tag")
self.database["redis"][x_tag][x_tag] = x_data["data"]
# Update y data
for y_tag, y_info in y_data.items():
self.database["redis"][y_tag][y_tag] = y_info["data"]
# Trigger plot update
self.update_plot(source_type="redis")
print(f"database after: {self.database}")
if __name__ == "__main__": # pragma: no cover
import argparse
import json
import sys
parser = argparse.ArgumentParser()
parser.add_argument("--config_file", help="Path to the config file.")
parser.add_argument("--config", help="Path to the config file.")
parser.add_argument("--id", help="GUI ID.")
args = parser.parse_args()
if args.config is not None:
# Load config from file
config = json.loads(args.config)
elif args.config_file is not None:
# Load config from file
config = yaml_dialog.load_yaml(args.config_file)
else:
config = CONFIG_SIMPLE
client = BECDispatcher().client
client.start()
app = QApplication(sys.argv)
monitor = BECMonitor(config=config, gui_id=args.id, skip_validation=False)
monitor.show()
# just to test redis data
# redis_data = {
# "x": {"data": [1, 2, 3], "tag": "x_default_tag"},
# "y": {"y_default_tag": {"data": [1, 2, 3]}},
# }
# monitor.on_data_from_redis({"data": redis_data})
sys.exit(app.exec())

View File

@@ -1,180 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>506</width>
<height>592</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QGroupBox" name="groupBox_general">
<property name="title">
<string>General</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="2" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>X Label</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="5">
<widget class="Line" name="line_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item row="2" column="4">
<widget class="QLineEdit" name="lineEdit_y_label"/>
</item>
<item row="2" column="3">
<widget class="QLabel" name="label_11">
<property name="text">
<string>Y Label</string>
</property>
</widget>
</item>
<item row="0" column="1" colspan="4">
<widget class="QLineEdit" name="lineEdit_plot_title"/>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="lineEdit_x_label"/>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="Line" name="line_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="groupBox_x_axis">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="label_6">
<property name="text">
<string>Name:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="lineEdit_x_name"/>
</item>
<item>
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_7">
<property name="text">
<string>Entry:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="lineEdit_x_entry"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_y_signals">
<property name="title">
<string>Y Signals</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="BECTable" name="tableWidget_y_signals">
<column>
<property name="text">
<string>Name</string>
</property>
</column>
<column>
<property name="text">
<string>Entries</string>
</property>
</column>
<column>
<property name="text">
<string>Color</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="pushButton_add_new_plot">
<property name="text">
<string>Add New Plot</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="pushButton_y_new">
<property name="text">
<string>Add New Signal</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECTable</class>
<extends>QTableWidget</extends>
<header>bec_widgets.utils.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,7 +0,0 @@
from .motor_control import (
MotorControlAbsolute,
MotorControlRelative,
MotorControlSelection,
MotorCoordinateTable,
MotorThread,
)

View File

@@ -1,26 +1,12 @@
# pylint: disable = no-name-in-module,missing-module-docstring
import os
from enum import Enum
from bec_lib.alarm_handler import AlarmBase
from bec_lib.device import Positioner
from qtpy import uic
from qtpy.QtCore import Qt, QThread
from qtpy.QtCore import QThread
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtGui import QDoubleValidator, QKeySequence
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QLineEdit,
QMessageBox,
QPushButton,
QShortcut,
QTableWidget,
QTableWidgetItem,
QWidget,
)
from qtpy.QtWidgets import QMessageBox, QWidget
from bec_widgets.utils.bec_dispatcher import BECDispatcher
@@ -77,938 +63,6 @@ class MotorControlWidget(QWidget):
self._init_ui()
class MotorControlSelection(MotorControlWidget):
"""
Widget for selecting the motors to control.
Signals:
selected_motors_signal (pyqtSignal(str,str)): Signal to emit the selected motors.
Slots:
get_available_motors (pyqtSlot): Slot to populate the available motors in the combo boxes and set the index based on the configuration.
enable_motor_controls (pyqtSlot(bool)): Slot to enable/disable the motor controls GUI.
on_config_update (pyqtSlot(dict)): Slot to update the config dict.
"""
selected_motors_signal = pyqtSignal(str, str)
def _load_ui(self):
"""Load the UI from the .ui file."""
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "motor_control_selection.ui"), self)
def _init_ui(self):
"""Initialize the UI."""
# Lock GUI while motors are moving
self.motor_thread.lock_gui.connect(self.enable_motor_controls)
self.pushButton_connecMotors.clicked.connect(self.select_motor)
self.get_available_motors()
# Connect change signals to change color
self.comboBox_motor_x.currentIndexChanged.connect(
lambda: self.set_combobox_style(self.comboBox_motor_x, "#ffa700")
)
self.comboBox_motor_y.currentIndexChanged.connect(
lambda: self.set_combobox_style(self.comboBox_motor_y, "#ffa700")
)
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""
Update config dict
Args:
config(dict): New config dict
"""
self.config = config
# Get motor names
self.motor_x, self.motor_y = (
self.config["motor_control"]["motor_x"],
self.config["motor_control"]["motor_y"],
)
self._init_ui()
@pyqtSlot(bool)
def enable_motor_controls(self, enable: bool) -> None:
"""
Enable or disable the motor controls.
Args:
enable(bool): True to enable, False to disable.
"""
self.motorSelection.setEnabled(enable)
@pyqtSlot()
def get_available_motors(self) -> None:
"""
Slot to populate the available motors in the combo boxes and set the index based on the configuration.
"""
# Get all available motors
self.motor_list = self.motor_thread.get_all_motors_names()
# Populate the combo boxes
self.comboBox_motor_x.addItems(self.motor_list)
self.comboBox_motor_y.addItems(self.motor_list)
# Set the index based on the config if provided
if self.config:
index_x = self.comboBox_motor_x.findText(self.motor_x)
index_y = self.comboBox_motor_y.findText(self.motor_y)
self.comboBox_motor_x.setCurrentIndex(index_x if index_x != -1 else 0)
self.comboBox_motor_y.setCurrentIndex(index_y if index_y != -1 else 0)
def set_combobox_style(self, combobox: QComboBox, color: str) -> None:
"""
Set the combobox style to a specific color.
Args:
combobox(QComboBox): Combobox to change the color.
color(str): Color to set the combobox to.
"""
combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
def select_motor(self):
"""Emit the selected motors"""
motor_x = self.comboBox_motor_x.currentText()
motor_y = self.comboBox_motor_y.currentText()
# Reset the combobox color to normal after selection
self.set_combobox_style(self.comboBox_motor_x, "")
self.set_combobox_style(self.comboBox_motor_y, "")
self.selected_motors_signal.emit(motor_x, motor_y)
class MotorControlAbsolute(MotorControlWidget):
"""
Widget for controlling the motors to absolute coordinates.
Signals:
coordinates_signal (pyqtSignal(tuple)): Signal to emit the coordinates.
Slots:
change_motors (pyqtSlot): Slot to change the active motors.
enable_motor_controls (pyqtSlot(bool)): Slot to enable/disable the motor controls.
"""
coordinates_signal = pyqtSignal(tuple)
def _load_ui(self):
"""Load the UI from the .ui file."""
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "motor_control_absolute.ui"), self)
def _init_ui(self):
"""Initialize the UI."""
# Check if there are any motors connected
if self.motor_x is None or self.motor_y is None:
self.motorControl_absolute.setEnabled(False)
return
# Move to absolute coordinates
self.pushButton_go_absolute.clicked.connect(
lambda: self.move_motor_absolute(
self.spinBox_absolute_x.value(), self.spinBox_absolute_y.value()
)
)
self.pushButton_set.clicked.connect(self.save_absolute_coordinates)
self.pushButton_save.clicked.connect(self.save_current_coordinates)
self.pushButton_stop.clicked.connect(self.motor_thread.stop_movement)
# Enable/Disable GUI
self.motor_thread.lock_gui.connect(self.enable_motor_controls)
# Error messages
self.motor_thread.motor_error.connect(
lambda error: MotorControlErrors.display_error_message(error)
)
# Keyboard shortcuts
self._init_keyboard_shortcuts()
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""Update config dict"""
self.config = config
# Get motor names
self.motor_x, self.motor_y = (
self.config["motor_control"]["motor_x"],
self.config["motor_control"]["motor_y"],
)
# Update step precision
self.precision = self.config["motor_control"]["precision"]
self._init_ui()
@pyqtSlot(bool)
def enable_motor_controls(self, enable: bool) -> None:
"""
Enable or disable the motor controls.
Args:
enable(bool): True to enable, False to disable.
"""
# Disable or enable all controls within the motorControl_absolute group box
for widget in self.motorControl_absolute.findChildren(QWidget):
widget.setEnabled(enable)
# Enable the pushButton_stop if the motor is moving
self.pushButton_stop.setEnabled(True)
@pyqtSlot(str, str)
def change_motors(self, motor_x: str, motor_y: str):
"""
Change the active motors and update config.
Can be connected to the selected_motors_signal from MotorControlSelection.
Args:
motor_x(str): New motor X to be controlled.
motor_y(str): New motor Y to be controlled.
"""
self.motor_x = motor_x
self.motor_y = motor_y
self.config["motor_control"]["motor_x"] = motor_x
self.config["motor_control"]["motor_y"] = motor_y
@pyqtSlot(int)
def set_precision(self, precision: int) -> None:
"""
Set the precision of the coordinates.
Args:
precision(int): Precision of the coordinates.
"""
self.precision = precision
self.config["motor_control"]["precision"] = precision
self.spinBox_absolute_x.setDecimals(precision)
self.spinBox_absolute_y.setDecimals(precision)
def move_motor_absolute(self, x: float, y: float) -> None:
"""
Move the motor to the target coordinates.
Args:
x(float): Target x coordinate.
y(float): Target y coordinate.
"""
# self._enable_motor_controls(False)
target_coordinates = (x, y)
self.motor_thread.move_absolute(self.motor_x, self.motor_y, target_coordinates)
if self.checkBox_save_with_go.isChecked():
self.save_absolute_coordinates()
def _init_keyboard_shortcuts(self):
"""Initialize the keyboard shortcuts."""
# Go absolute button
self.pushButton_go_absolute.setShortcut("Ctrl+G")
self.pushButton_go_absolute.setToolTip("Ctrl+G")
# Set absolute coordinates
self.pushButton_set.setShortcut("Ctrl+D")
self.pushButton_set.setToolTip("Ctrl+D")
# Save Current coordinates
self.pushButton_save.setShortcut("Ctrl+S")
self.pushButton_save.setToolTip("Ctrl+S")
# Stop Button
self.pushButton_stop.setShortcut("Ctrl+X")
self.pushButton_stop.setToolTip("Ctrl+X")
def save_absolute_coordinates(self):
"""Emit the setup coordinates from the spinboxes"""
x, y = round(self.spinBox_absolute_x.value(), self.precision), round(
self.spinBox_absolute_y.value(), self.precision
)
self.coordinates_signal.emit((x, y))
def save_current_coordinates(self):
"""Emit the current coordinates from the motor thread"""
x, y = self.motor_thread.get_coordinates(self.motor_x, self.motor_y)
self.coordinates_signal.emit((round(x, self.precision), round(y, self.precision)))
class MotorControlRelative(MotorControlWidget):
"""
Widget for controlling the motors to relative coordinates.
Signals:
precision_signal (pyqtSignal): Signal to emit the precision of the coordinates.
Slots:
change_motors (pyqtSlot(str,str)): Slot to change the active motors.
enable_motor_controls (pyqtSlot): Slot to enable/disable the motor controls.
"""
precision_signal = pyqtSignal(int)
def _load_ui(self):
"""Load the UI from the .ui file."""
# Loading UI
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "motor_control_relative.ui"), self)
def _init_ui(self):
"""Initialize the UI."""
self._init_ui_motor_control()
self._init_keyboard_shortcuts()
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""
Update config dict
Args:
config(dict): New config dict
"""
self.config = config
# Get motor names
self.motor_x, self.motor_y = (
self.config["motor_control"]["motor_x"],
self.config["motor_control"]["motor_y"],
)
# Update step precision
self.precision = self.config["motor_control"]["precision"]
self.spinBox_precision.setValue(self.precision)
# Update step sizes
self.spinBox_step_x.setValue(self.config["motor_control"]["step_size_x"])
self.spinBox_step_y.setValue(self.config["motor_control"]["step_size_y"])
# Checkboxes for keyboard shortcuts and x/y step size link
self.checkBox_same_xy.setChecked(self.config["motor_control"]["step_x_y_same"])
self.checkBox_enableArrows.setChecked(self.config["motor_control"]["move_with_arrows"])
self._init_ui()
def _init_ui_motor_control(self) -> None:
"""Initialize the motor control elements"""
# Connect checkbox and spinBoxes
self.checkBox_same_xy.stateChanged.connect(self._sync_step_sizes)
self.spinBox_step_x.valueChanged.connect(self._update_step_size_x)
self.spinBox_step_y.valueChanged.connect(self._update_step_size_y)
self.toolButton_right.clicked.connect(
lambda: self.move_motor_relative(self.motor_x, "x", 1)
)
self.toolButton_left.clicked.connect(
lambda: self.move_motor_relative(self.motor_x, "x", -1)
)
self.toolButton_up.clicked.connect(lambda: self.move_motor_relative(self.motor_y, "y", 1))
self.toolButton_down.clicked.connect(
lambda: self.move_motor_relative(self.motor_y, "y", -1)
)
# Switch between key shortcuts active
self.checkBox_enableArrows.stateChanged.connect(self._update_arrow_key_shortcuts)
self._update_arrow_key_shortcuts()
# Enable/Disable GUI
self.motor_thread.lock_gui.connect(self.enable_motor_controls)
# Precision update
self.spinBox_precision.valueChanged.connect(lambda x: self._update_precision(x))
# Error messages
self.motor_thread.motor_error.connect(
lambda error: MotorControlErrors.display_error_message(error)
)
# Stop Button
self.pushButton_stop.clicked.connect(self.motor_thread.stop_movement)
def _init_keyboard_shortcuts(self) -> None:
"""Initialize the keyboard shortcuts"""
# Increase/decrease step size for X motor
increase_x_shortcut = QShortcut(QKeySequence("Ctrl+A"), self)
decrease_x_shortcut = QShortcut(QKeySequence("Ctrl+Z"), self)
increase_x_shortcut.activated.connect(
lambda: self._change_step_size(self.spinBox_step_x, 2)
)
decrease_x_shortcut.activated.connect(
lambda: self._change_step_size(self.spinBox_step_x, 0.5)
)
self.spinBox_step_x.setToolTip("Increase step size: Ctrl+A\nDecrease step size: Ctrl+Z")
# Increase/decrease step size for Y motor
increase_y_shortcut = QShortcut(QKeySequence("Alt+A"), self)
decrease_y_shortcut = QShortcut(QKeySequence("Alt+Z"), self)
increase_y_shortcut.activated.connect(
lambda: self._change_step_size(self.spinBox_step_y, 2)
)
decrease_y_shortcut.activated.connect(
lambda: self._change_step_size(self.spinBox_step_y, 0.5)
)
self.spinBox_step_y.setToolTip("Increase step size: Alt+A\nDecrease step size: Alt+Z")
# Stop Button
self.pushButton_stop.setShortcut("Ctrl+X")
self.pushButton_stop.setToolTip("Ctrl+X")
def _update_arrow_key_shortcuts(self) -> None:
"""Update the arrow key shortcuts based on the checkbox state."""
if self.checkBox_enableArrows.isChecked():
# Set the arrow key shortcuts for motor movement
self.toolButton_right.setShortcut(Qt.Key_Right)
self.toolButton_left.setShortcut(Qt.Key_Left)
self.toolButton_up.setShortcut(Qt.Key_Up)
self.toolButton_down.setShortcut(Qt.Key_Down)
else:
# Clear the shortcuts
self.toolButton_right.setShortcut("")
self.toolButton_left.setShortcut("")
self.toolButton_up.setShortcut("")
self.toolButton_down.setShortcut("")
def _update_precision(self, precision: int) -> None:
"""
Update the precision of the coordinates.
Args:
precision(int): Precision of the coordinates.
"""
self.spinBox_step_x.setDecimals(precision)
self.spinBox_step_y.setDecimals(precision)
self.precision_signal.emit(precision)
def _change_step_size(self, spinBox: QDoubleSpinBox, factor: float) -> None:
"""
Change the step size of the spinbox.
Args:
spinBox(QDoubleSpinBox): Spinbox to change the step size.
factor(float): Factor to change the step size.
"""
old_step = spinBox.value()
new_step = old_step * factor
spinBox.setValue(new_step)
def _sync_step_sizes(self):
"""Sync step sizes based on checkbox state."""
if self.checkBox_same_xy.isChecked():
value = self.spinBox_step_x.value()
self.spinBox_step_y.setValue(value)
def _update_step_size_x(self):
"""Update step size for x if checkbox is checked."""
if self.checkBox_same_xy.isChecked():
value = self.spinBox_step_x.value()
self.spinBox_step_y.setValue(value)
def _update_step_size_y(self):
"""Update step size for y if checkbox is checked."""
if self.checkBox_same_xy.isChecked():
value = self.spinBox_step_y.value()
self.spinBox_step_x.setValue(value)
@pyqtSlot(str, str)
def change_motors(self, motor_x: str, motor_y: str):
"""
Change the active motors and update config.
Can be connected to the selected_motors_signal from MotorControlSelection.
Args:
motor_x(str): New motor X to be controlled.
motor_y(str): New motor Y to be controlled.
"""
self.motor_x = motor_x
self.motor_y = motor_y
self.config["motor_control"]["motor_x"] = motor_x
self.config["motor_control"]["motor_y"] = motor_y
@pyqtSlot(bool)
def enable_motor_controls(self, disable: bool) -> None:
"""
Enable or disable the motor controls.
Args:
disable(bool): True to disable, False to enable.
"""
# Disable or enable all controls within the motorControl_absolute group box
for widget in self.motorControl.findChildren(QWidget):
widget.setEnabled(disable)
# Enable the pushButton_stop if the motor is moving
self.pushButton_stop.setEnabled(True)
def move_motor_relative(self, motor, axis: str, direction: int) -> None:
"""
Move the motor relative to the current position.
Args:
motor: Motor to move.
axis(str): Axis to move.
direction(int): Direction to move. 1 for positive, -1 for negative.
"""
if axis == "x":
step = direction * self.spinBox_step_x.value()
elif axis == "y":
step = direction * self.spinBox_step_y.value()
self.motor_thread.move_relative(motor, step)
class MotorCoordinateTable(MotorControlWidget):
"""
Widget to save coordinates from motor, display them in the table and move back to them.
There are two modes of operation:
- Individual: Each row is a single coordinate.
- Start/Stop: Each pair of rows is a start and end coordinate.
Signals:
plot_coordinates_signal (pyqtSignal(list, str, str)): Signal to plot the coordinates in the MotorMap.
Slots:
add_coordinate (pyqtSlot(tuple)): Slot to add a coordinate to the table.
mode_switch (pyqtSlot): Slot to switch between individual and start/stop mode.
"""
plot_coordinates_signal = pyqtSignal(list, str, str)
def _load_ui(self):
"""Load the UI for the coordinate table."""
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "motor_control_table.ui"), self)
def _init_ui(self):
"""Initialize the UI"""
# Setup table behaviour
self._setup_table()
self.table.setSelectionBehavior(QTableWidget.SelectRows)
# for tag columns default tag
self.tag_counter = 1
# Connect signals and slots
self.checkBox_resize_auto.stateChanged.connect(self.resize_table_auto)
self.comboBox_mode.currentIndexChanged.connect(self.mode_switch)
# Keyboard shortcuts for deleting a row
self.delete_shortcut = QShortcut(QKeySequence(Qt.Key_Delete), self.table)
self.delete_shortcut.activated.connect(self.delete_selected_row)
self.backspace_shortcut = QShortcut(QKeySequence(Qt.Key_Backspace), self.table)
self.backspace_shortcut.activated.connect(self.delete_selected_row)
# Warning message for mode switch enable/disable
self.warning_message = True
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""
Update config dict
Args:
config(dict): New config dict
"""
self.config = config
# Get motor names
self.motor_x, self.motor_y = (
self.config["motor_control"]["motor_x"],
self.config["motor_control"]["motor_y"],
)
# Decimal precision of the table coordinates
self.precision = self.config["motor_control"].get("precision", 3)
# Mode switch default option
self.mode = self.config["motor_control"].get("mode", "Individual")
# Set combobox to default mode
self.comboBox_mode.setCurrentText(self.mode)
self._init_ui()
def _setup_table(self):
"""Setup the table with appropriate headers and configurations."""
mode = self.comboBox_mode.currentText()
if mode == "Individual":
self._setup_individual_mode()
elif mode == "Start/Stop":
self._setup_start_stop_mode()
self.start_stop_counter = 0 # TODO: remove this??
self.wipe_motor_map_coordinates()
def _setup_individual_mode(self):
"""Setup the table for individual mode."""
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(["Show", "Move", "Tag", "X", "Y"])
self.table.verticalHeader().setVisible(False)
def _setup_start_stop_mode(self):
"""Setup the table for start/stop mode."""
self.table.setColumnCount(8)
self.table.setHorizontalHeaderLabels(
[
"Show",
"Move [start]",
"Move [end]",
"Tag",
"X [start]",
"Y [start]",
"X [end]",
"Y [end]",
]
)
self.table.verticalHeader().setVisible(False)
# Set flag to track if the coordinate is stat or the end of the entry
self.is_next_entry_end = False
def mode_switch(self):
"""Switch between individual and start/stop mode."""
last_selected_index = self.comboBox_mode.currentIndex()
if self.table.rowCount() > 0 and self.warning_message is True:
msgBox = QMessageBox()
msgBox.setIcon(QMessageBox.Critical)
msgBox.setText(
"Switching modes will delete all table entries. Do you want to continue?"
)
msgBox.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
returnValue = msgBox.exec()
if returnValue is QMessageBox.Cancel:
self.comboBox_mode.blockSignals(True) # Block signals
self.comboBox_mode.setCurrentIndex(last_selected_index)
self.comboBox_mode.blockSignals(False) # Unblock signals
return
# Wipe table
self.wipe_motor_map_coordinates()
# Initiate new table with new mode
self._setup_table()
@pyqtSlot(tuple)
def add_coordinate(self, coordinates: tuple):
"""
Add a coordinate to the table.
Args:
coordinates(tuple): Coordinates (x,y) to add to the table.
"""
tag = f"Pos {self.tag_counter}"
self.tag_counter += 1
x, y = coordinates
self._add_row(tag, x, y)
def _add_row(self, tag: str, x: float, y: float) -> None:
"""
Add a row to the table.
Args:
tag(str): Tag of the coordinate.
x(float): X coordinate.
y(float): Y coordinate.
"""
mode = self.comboBox_mode.currentText()
if mode == "Individual":
checkbox_pos = 0
button_pos = 1
tag_pos = 2
x_pos = 3
y_pos = 4
coordinate_reference = "Individual"
color = "green"
# Add new row -> new entry
row_count = self.table.rowCount()
self.table.insertRow(row_count)
# Add Widgets
self._add_widgets(
tag,
x,
y,
row_count,
checkbox_pos,
tag_pos,
button_pos,
x_pos,
y_pos,
coordinate_reference,
color,
)
if mode == "Start/Stop":
# These positions are always fixed
checkbox_pos = 0
tag_pos = 3
if self.is_next_entry_end is False: # It is the start position of the entry
print("Start position")
button_pos = 1
x_pos = 4
y_pos = 5
coordinate_reference = "Start"
color = "blue"
# Add new row -> new entry
row_count = self.table.rowCount()
self.table.insertRow(row_count)
# Add Widgets
self._add_widgets(
tag,
x,
y,
row_count,
checkbox_pos,
tag_pos,
button_pos,
x_pos,
y_pos,
coordinate_reference,
color,
)
# Next entry will be the end of the current entry
self.is_next_entry_end = True
elif self.is_next_entry_end is True: # It is the end position of the entry
print("End position")
row_count = self.table.rowCount() - 1 # Current row
button_pos = 2
x_pos = 6
y_pos = 7
coordinate_reference = "Stop"
color = "red"
# Add Widgets
self._add_widgets(
tag,
x,
y,
row_count,
checkbox_pos,
tag_pos,
button_pos,
x_pos,
y_pos,
coordinate_reference,
color,
)
self.is_next_entry_end = False # Next entry will be the start of the new entry
# Auto table resize
self.resize_table_auto()
def _add_widgets(
self,
tag: str,
x: float,
y: float,
row: int,
checkBox_pos: int,
tag_pos: int,
button_pos: int,
x_pos: int,
y_pos: int,
coordinate_reference: str,
color: str,
) -> None:
"""
Add widgets to the table.
Args:
tag(str): Tag of the coordinate.
x(float): X coordinate.
y(float): Y coordinate.
row(int): Row of the QTableWidget where to add the widgets.
checkBox_pos(int): Column where to put CheckBox.
tag_pos(int): Column where to put Tag.
button_pos(int): Column where to put Move button.
x_pos(int): Column where to link x coordinate.
y_pos(int): Column where to link y coordinate.
coordinate_reference(str): Reference to the coordinate for MotorMap.
color(str): Color of the coordinate for MotorMap.
"""
# Add widgets
self._add_checkbox(row, checkBox_pos, x_pos, y_pos)
self._add_move_button(row, button_pos, x_pos, y_pos)
self.table.setItem(row, tag_pos, QTableWidgetItem(tag))
self._add_line_edit(x, row, x_pos, x_pos, y_pos, coordinate_reference, color)
self._add_line_edit(y, row, y_pos, x_pos, y_pos, coordinate_reference, color)
# # Emit the coordinates to be plotted
self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color)
# Connect item edit to emit coordinates
self.table.itemChanged.connect(
lambda: print(f"item changed from {coordinate_reference} slot \n {x}-{y}-{color}")
)
self.table.itemChanged.connect(
lambda: self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color)
)
def _add_checkbox(self, row: int, checkBox_pos: int, x_pos: int, y_pos: int):
"""
Add a checkbox to the table.
Args:
row(int): Row of QTableWidget where to add the checkbox.
checkBox_pos(int): Column where to put CheckBox.
x_pos(int): Column where to link x coordinate.
y_pos(int): Column where to link y coordinate.
"""
show_checkbox = QCheckBox()
show_checkbox.setChecked(True)
show_checkbox.stateChanged.connect(lambda: self.emit_plot_coordinates(x_pos, y_pos))
self.table.setCellWidget(row, checkBox_pos, show_checkbox)
def _add_move_button(self, row: int, button_pos: int, x_pos: int, y_pos: int) -> None:
"""
Add a move button to the table.
Args:
row(int): Row of QTableWidget where to add the move button.
button_pos(int): Column where to put move button.
x_pos(int): Column where to link x coordinate.
y_pos(int): Column where to link y coordinate.
"""
move_button = QPushButton("Move")
move_button.clicked.connect(lambda: self.handle_move_button_click(x_pos, y_pos))
self.table.setCellWidget(row, button_pos, move_button)
def _add_line_edit(
self,
value: float,
row: int,
line_pos: int,
x_pos: int,
y_pos: int,
coordinate_reference: str,
color: str,
) -> None:
"""
Add a QLineEdit to the table.
Args:
value(float): Initial value of the QLineEdit.
row(int): Row of QTableWidget where to add the QLineEdit.
line_pos(int): Column where to put QLineEdit.
x_pos(int): Column where to link x coordinate.
y_pos(int): Column where to link y coordinate.
coordinate_reference(str): Reference to the coordinate for MotorMap.
color(str): Color of the coordinate for MotorMap.
"""
# Adding validator
validator = QDoubleValidator()
validator.setDecimals(self.precision)
# Create line edit
edit = QLineEdit(str(f"{value:.{self.precision}f}"))
edit.setValidator(validator)
edit.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Add line edit to the table
self.table.setCellWidget(row, line_pos, edit)
edit.textChanged.connect(
lambda: self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color)
)
def wipe_motor_map_coordinates(self):
"""Wipe the motor map coordinates."""
try:
self.table.itemChanged.disconnect() # Disconnect all previous connections
except TypeError:
print("No previous connections to disconnect")
self.table.setRowCount(0)
reference_tags = ["Individual", "Start", "Stop"]
for reference_tag in reference_tags:
self.plot_coordinates_signal.emit([], reference_tag, "green")
def handle_move_button_click(self, x_pos: int, y_pos: int) -> None:
"""
Handle the move button click.
Args:
x_pos(int): X position of the coordinate.
y_pos(int): Y position of the coordinate.
"""
button = self.sender()
row = self.table.indexAt(button.pos()).row()
x = self.get_coordinate(row, x_pos)
y = self.get_coordinate(row, y_pos)
self.move_motor(x, y)
def emit_plot_coordinates(self, x_pos: float, y_pos: float, reference_tag: str, color: str):
"""
Emit the coordinates to be plotted.
Args:
x_pos(float): X position of the coordinate.
y_pos(float): Y position of the coordinate.
reference_tag(str): Reference tag of the coordinate.
color(str): Color of the coordinate.
"""
print(
f"Emitting plot coordinates: x_pos={x_pos}, y_pos={y_pos}, reference_tag={reference_tag}, color={color}"
)
coordinates = []
for row in range(self.table.rowCount()):
show = self.table.cellWidget(row, 0).isChecked()
x = self.get_coordinate(row, x_pos)
y = self.get_coordinate(row, y_pos)
coordinates.append((x, y, show)) # (x, y, show_flag)
self.plot_coordinates_signal.emit(coordinates, reference_tag, color)
def get_coordinate(self, row: int, column: int) -> float:
"""
Helper function to get the coordinate from the table QLineEdit cells.
Args:
row(int): Row of the table.
column(int): Column of the table.
Returns:
float: Value of the coordinate.
"""
edit = self.table.cellWidget(row, column)
value = float(edit.text()) if edit and edit.text() != "" else None
if value:
return value
def delete_selected_row(self):
"""Delete the selected row from the table."""
selected_rows = self.table.selectionModel().selectedRows()
for row in selected_rows:
self.table.removeRow(row.row())
if self.comboBox_mode.currentText() == "Start/Stop":
self.emit_plot_coordinates(x_pos=4, y_pos=5, reference_tag="Start", color="blue")
self.emit_plot_coordinates(x_pos=6, y_pos=7, reference_tag="Stop", color="red")
self.is_next_entry_end = False
elif self.comboBox_mode.currentText() == "Individual":
self.emit_plot_coordinates(x_pos=3, y_pos=4, reference_tag="Individual", color="green")
def resize_table_auto(self):
"""Resize the table to fit the contents."""
if self.checkBox_resize_auto.isChecked():
self.table.resizeColumnsToContents()
def move_motor(self, x: float, y: float) -> None:
"""
Move the motor to the target coordinates.
Args:
x(float): Target x coordinate.
y(float): Target y coordinate.
"""
self.motor_thread.move_absolute(self.motor_x, self.motor_y, (x, y))
@pyqtSlot(str, str)
def change_motors(self, motor_x: str, motor_y: str) -> None:
"""
Change the active motors and update config.
Can be connected to the selected_motors_signal from MotorControlSelection.
Args:
motor_x(str): New motor X to be controlled.
motor_y(str): New motor Y to be controlled.
"""
self.motor_x = motor_x
self.motor_y = motor_y
self.config["motor_control"]["motor_x"] = motor_x
self.config["motor_control"]["motor_y"] = motor_y
@pyqtSlot(int)
def set_precision(self, precision: int) -> None:
"""
Set the precision of the coordinates.
Args:
precision(int): Precision of the coordinates.
"""
self.precision = precision
self.config["motor_control"]["precision"] = precision
class MotorControlErrors:
"""Class for displaying formatted error messages."""

View File

@@ -0,0 +1,484 @@
# pylint: disable = no-name-in-module,missing-module-docstring
import os
from qtpy import uic
from qtpy.QtCore import Qt
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtGui import QDoubleValidator, QKeySequence
from qtpy.QtWidgets import (
QCheckBox,
QLineEdit,
QMessageBox,
QPushButton,
QShortcut,
QTableWidget,
QTableWidgetItem,
)
from bec_widgets.utils import UILoader
from bec_widgets.widgets.motor_control.motor_control import MotorControlWidget
class MotorCoordinateTable(MotorControlWidget):
"""
Widget to save coordinates from motor, display them in the table and move back to them.
There are two modes of operation:
- Individual: Each row is a single coordinate.
- Start/Stop: Each pair of rows is a start and end coordinate.
Signals:
plot_coordinates_signal (pyqtSignal(list, str, str)): Signal to plot the coordinates in the MotorMap.
Slots:
add_coordinate (pyqtSlot(tuple)): Slot to add a coordinate to the table.
mode_switch (pyqtSlot): Slot to switch between individual and start/stop mode.
"""
plot_coordinates_signal = pyqtSignal(list, str, str)
def _load_ui(self):
"""Load the UI for the coordinate table."""
current_path = os.path.dirname(__file__)
self.ui = UILoader().load_ui(os.path.join(current_path, "motor_table.ui"), self)
def _init_ui(self):
"""Initialize the UI"""
# Setup table behaviour
self._setup_table()
self.ui.table.setSelectionBehavior(QTableWidget.SelectRows)
# for tag columns default tag
self.tag_counter = 1
# Connect signals and slots
self.ui.checkBox_resize_auto.stateChanged.connect(self.resize_table_auto)
self.ui.comboBox_mode.currentIndexChanged.connect(self.mode_switch)
# Keyboard shortcuts for deleting a row
self.delete_shortcut = QShortcut(QKeySequence(Qt.Key_Delete), self.ui.table)
self.delete_shortcut.activated.connect(self.delete_selected_row)
self.backspace_shortcut = QShortcut(QKeySequence(Qt.Key_Backspace), self.ui.table)
self.backspace_shortcut.activated.connect(self.delete_selected_row)
# Warning message for mode switch enable/disable
self.warning_message = True
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""
Update config dict
Args:
config(dict): New config dict
"""
self.config = config
# Get motor names
self.motor_x, self.motor_y = (
self.config["motor_control"]["motor_x"],
self.config["motor_control"]["motor_y"],
)
# Decimal precision of the table coordinates
self.precision = self.config["motor_control"].get("precision", 3)
# Mode switch default option
self.mode = self.config["motor_control"].get("mode", "Individual")
# Set combobox to default mode
self.ui.comboBox_mode.setCurrentText(self.mode)
self._init_ui()
def _setup_table(self):
"""Setup the table with appropriate headers and configurations."""
mode = self.ui.comboBox_mode.currentText()
if mode == "Individual":
self._setup_individual_mode()
elif mode == "Start/Stop":
self._setup_start_stop_mode()
self.start_stop_counter = 0 # TODO: remove this??
self.wipe_motor_map_coordinates()
def _setup_individual_mode(self):
"""Setup the table for individual mode."""
self.ui.table.setColumnCount(5)
self.ui.table.setHorizontalHeaderLabels(["Show", "Move", "Tag", "X", "Y"])
self.ui.table.verticalHeader().setVisible(False)
def _setup_start_stop_mode(self):
"""Setup the table for start/stop mode."""
self.ui.table.setColumnCount(8)
self.ui.table.setHorizontalHeaderLabels(
[
"Show",
"Move [start]",
"Move [end]",
"Tag",
"X [start]",
"Y [start]",
"X [end]",
"Y [end]",
]
)
self.ui.table.verticalHeader().setVisible(False)
# Set flag to track if the coordinate is stat or the end of the entry
self.is_next_entry_end = False
def mode_switch(self):
"""Switch between individual and start/stop mode."""
last_selected_index = self.ui.comboBox_mode.currentIndex()
if self.ui.table.rowCount() > 0 and self.warning_message is True:
msgBox = QMessageBox()
msgBox.setIcon(QMessageBox.Critical)
msgBox.setText(
"Switching modes will delete all table entries. Do you want to continue?"
)
msgBox.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
returnValue = msgBox.exec()
if returnValue is QMessageBox.Cancel:
self.ui.comboBox_mode.blockSignals(True) # Block signals
self.ui.comboBox_mode.setCurrentIndex(last_selected_index)
self.ui.comboBox_mode.blockSignals(False) # Unblock signals
return
# Wipe table
self.wipe_motor_map_coordinates()
# Initiate new table with new mode
self._setup_table()
@pyqtSlot(tuple)
def add_coordinate(self, coordinates: tuple):
"""
Add a coordinate to the table.
Args:
coordinates(tuple): Coordinates (x,y) to add to the table.
"""
tag = f"Pos {self.tag_counter}"
self.tag_counter += 1
x, y = coordinates
self._add_row(tag, x, y)
def _add_row(self, tag: str, x: float, y: float) -> None:
"""
Add a row to the table.
Args:
tag(str): Tag of the coordinate.
x(float): X coordinate.
y(float): Y coordinate.
"""
mode = self.ui.comboBox_mode.currentText()
if mode == "Individual":
checkbox_pos = 0
button_pos = 1
tag_pos = 2
x_pos = 3
y_pos = 4
coordinate_reference = "Individual"
color = "green"
# Add new row -> new entry
row_count = self.ui.table.rowCount()
self.ui.table.insertRow(row_count)
# Add Widgets
self._add_widgets(
tag,
x,
y,
row_count,
checkbox_pos,
tag_pos,
button_pos,
x_pos,
y_pos,
coordinate_reference,
color,
)
if mode == "Start/Stop":
# These positions are always fixed
checkbox_pos = 0
tag_pos = 3
if self.is_next_entry_end is False: # It is the start position of the entry
print("Start position")
button_pos = 1
x_pos = 4
y_pos = 5
coordinate_reference = "Start"
color = "blue"
# Add new row -> new entry
row_count = self.ui.table.rowCount()
self.ui.table.insertRow(row_count)
# Add Widgets
self._add_widgets(
tag,
x,
y,
row_count,
checkbox_pos,
tag_pos,
button_pos,
x_pos,
y_pos,
coordinate_reference,
color,
)
# Next entry will be the end of the current entry
self.is_next_entry_end = True
elif self.is_next_entry_end is True: # It is the end position of the entry
print("End position")
row_count = self.ui.table.rowCount() - 1 # Current row
button_pos = 2
x_pos = 6
y_pos = 7
coordinate_reference = "Stop"
color = "red"
# Add Widgets
self._add_widgets(
tag,
x,
y,
row_count,
checkbox_pos,
tag_pos,
button_pos,
x_pos,
y_pos,
coordinate_reference,
color,
)
self.is_next_entry_end = False # Next entry will be the start of the new entry
# Auto table resize
self.resize_table_auto()
def _add_widgets(
self,
tag: str,
x: float,
y: float,
row: int,
checkBox_pos: int,
tag_pos: int,
button_pos: int,
x_pos: int,
y_pos: int,
coordinate_reference: str,
color: str,
) -> None:
"""
Add widgets to the table.
Args:
tag(str): Tag of the coordinate.
x(float): X coordinate.
y(float): Y coordinate.
row(int): Row of the QTableWidget where to add the widgets.
checkBox_pos(int): Column where to put CheckBox.
tag_pos(int): Column where to put Tag.
button_pos(int): Column where to put Move button.
x_pos(int): Column where to link x coordinate.
y_pos(int): Column where to link y coordinate.
coordinate_reference(str): Reference to the coordinate for MotorMap.
color(str): Color of the coordinate for MotorMap.
"""
# Add widgets
self._add_checkbox(row, checkBox_pos, x_pos, y_pos)
self._add_move_button(row, button_pos, x_pos, y_pos)
self.ui.table.setItem(row, tag_pos, QTableWidgetItem(tag))
self._add_line_edit(x, row, x_pos, x_pos, y_pos, coordinate_reference, color)
self._add_line_edit(y, row, y_pos, x_pos, y_pos, coordinate_reference, color)
# # Emit the coordinates to be plotted
self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color)
# Connect item edit to emit coordinates
self.ui.table.itemChanged.connect(
lambda: print(f"item changed from {coordinate_reference} slot \n {x}-{y}-{color}")
)
self.ui.table.itemChanged.connect(
lambda: self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color)
)
def _add_checkbox(self, row: int, checkBox_pos: int, x_pos: int, y_pos: int):
"""
Add a checkbox to the table.
Args:
row(int): Row of QTableWidget where to add the checkbox.
checkBox_pos(int): Column where to put CheckBox.
x_pos(int): Column where to link x coordinate.
y_pos(int): Column where to link y coordinate.
"""
show_checkbox = QCheckBox()
show_checkbox.setChecked(True)
show_checkbox.stateChanged.connect(lambda: self.emit_plot_coordinates(x_pos, y_pos))
self.ui.table.setCellWidget(row, checkBox_pos, show_checkbox)
def _add_move_button(self, row: int, button_pos: int, x_pos: int, y_pos: int) -> None:
"""
Add a move button to the table.
Args:
row(int): Row of QTableWidget where to add the move button.
button_pos(int): Column where to put move button.
x_pos(int): Column where to link x coordinate.
y_pos(int): Column where to link y coordinate.
"""
move_button = QPushButton("Move")
move_button.clicked.connect(lambda: self.handle_move_button_click(x_pos, y_pos))
self.ui.table.setCellWidget(row, button_pos, move_button)
def _add_line_edit(
self,
value: float,
row: int,
line_pos: int,
x_pos: int,
y_pos: int,
coordinate_reference: str,
color: str,
) -> None:
"""
Add a QLineEdit to the table.
Args:
value(float): Initial value of the QLineEdit.
row(int): Row of QTableWidget where to add the QLineEdit.
line_pos(int): Column where to put QLineEdit.
x_pos(int): Column where to link x coordinate.
y_pos(int): Column where to link y coordinate.
coordinate_reference(str): Reference to the coordinate for MotorMap.
color(str): Color of the coordinate for MotorMap.
"""
# Adding validator
validator = QDoubleValidator()
validator.setDecimals(self.precision)
# Create line edit
edit = QLineEdit(str(f"{value:.{self.precision}f}"))
edit.setValidator(validator)
edit.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Add line edit to the table
self.ui.table.setCellWidget(row, line_pos, edit)
edit.textChanged.connect(
lambda: self.emit_plot_coordinates(x_pos, y_pos, coordinate_reference, color)
)
def wipe_motor_map_coordinates(self):
"""Wipe the motor map coordinates."""
try:
self.ui.table.itemChanged.disconnect() # Disconnect all previous connections
except TypeError:
print("No previous connections to disconnect")
self.ui.table.setRowCount(0)
reference_tags = ["Individual", "Start", "Stop"]
for reference_tag in reference_tags:
self.plot_coordinates_signal.emit([], reference_tag, "green")
def handle_move_button_click(self, x_pos: int, y_pos: int) -> None:
"""
Handle the move button click.
Args:
x_pos(int): X position of the coordinate.
y_pos(int): Y position of the coordinate.
"""
button = self.sender()
row = self.ui.table.indexAt(button.pos()).row()
x = self.get_coordinate(row, x_pos)
y = self.get_coordinate(row, y_pos)
self.move_motor(x, y)
def emit_plot_coordinates(self, x_pos: float, y_pos: float, reference_tag: str, color: str):
"""
Emit the coordinates to be plotted.
Args:
x_pos(float): X position of the coordinate.
y_pos(float): Y position of the coordinate.
reference_tag(str): Reference tag of the coordinate.
color(str): Color of the coordinate.
"""
print(
f"Emitting plot coordinates: x_pos={x_pos}, y_pos={y_pos}, reference_tag={reference_tag}, color={color}"
)
coordinates = []
for row in range(self.ui.table.rowCount()):
show = self.ui.table.cellWidget(row, 0).isChecked()
x = self.get_coordinate(row, x_pos)
y = self.get_coordinate(row, y_pos)
coordinates.append((x, y, show)) # (x, y, show_flag)
self.plot_coordinates_signal.emit(coordinates, reference_tag, color)
def get_coordinate(self, row: int, column: int) -> float:
"""
Helper function to get the coordinate from the table QLineEdit cells.
Args:
row(int): Row of the table.
column(int): Column of the table.
Returns:
float: Value of the coordinate.
"""
edit = self.ui.table.cellWidget(row, column)
value = float(edit.text()) if edit and edit.text() != "" else None
if value:
return value
def delete_selected_row(self):
"""Delete the selected row from the table."""
selected_rows = self.ui.table.selectionModel().selectedRows()
for row in selected_rows:
self.ui.table.removeRow(row.row())
if self.ui.comboBox_mode.currentText() == "Start/Stop":
self.emit_plot_coordinates(x_pos=4, y_pos=5, reference_tag="Start", color="blue")
self.emit_plot_coordinates(x_pos=6, y_pos=7, reference_tag="Stop", color="red")
self.is_next_entry_end = False
elif self.ui.comboBox_mode.currentText() == "Individual":
self.emit_plot_coordinates(x_pos=3, y_pos=4, reference_tag="Individual", color="green")
def resize_table_auto(self):
"""Resize the table to fit the contents."""
if self.ui.checkBox_resize_auto.isChecked():
self.ui.table.resizeColumnsToContents()
def move_motor(self, x: float, y: float) -> None:
"""
Move the motor to the target coordinates.
Args:
x(float): Target x coordinate.
y(float): Target y coordinate.
"""
self.motor_thread.move_absolute(self.motor_x, self.motor_y, (x, y))
@pyqtSlot(str, str)
def change_motors(self, motor_x: str, motor_y: str) -> None:
"""
Change the active motors and update config.
Can be connected to the selected_motors_signal from MotorControlSelection.
Args:
motor_x(str): New motor X to be controlled.
motor_y(str): New motor Y to be controlled.
"""
self.motor_x = motor_x
self.motor_y = motor_y
self.config["motor_control"]["motor_x"] = motor_x
self.config["motor_control"]["motor_y"] = motor_y
@pyqtSlot(int)
def set_precision(self, precision: int) -> None:
"""
Set the precision of the coordinates.
Args:
precision(int): Precision of the coordinates.
"""
self.precision = precision
self.config["motor_control"]["precision"] = precision

View File

@@ -0,0 +1,159 @@
import os
from qtpy.QtWidgets import QWidget
from qtpy import uic
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from bec_widgets.utils import UILoader
from bec_widgets.widgets.motor_control.motor_control import MotorControlWidget, MotorControlErrors
class MotorControlAbsolute(MotorControlWidget):
"""
Widget for controlling the motors to absolute coordinates.
Signals:
coordinates_signal (pyqtSignal(tuple)): Signal to emit the coordinates.
Slots:
change_motors (pyqtSlot): Slot to change the active motors.
enable_motor_controls (pyqtSlot(bool)): Slot to enable/disable the motor controls.
"""
coordinates_signal = pyqtSignal(tuple)
def _load_ui(self):
"""Load the UI from the .ui file."""
current_path = os.path.dirname(__file__)
self.ui = UILoader().load_ui(os.path.join(current_path, "movement_absolute.ui"), self)
def _init_ui(self):
"""Initialize the UI."""
# Check if there are any motors connected
if self.motor_x is None or self.motor_y is None:
self.ui.motorControl_absolute.setEnabled(False)
return
# Move to absolute coordinates
self.ui.pushButton_go_absolute.clicked.connect(
lambda: self.move_motor_absolute(
self.ui.spinBox_absolute_x.value(), self.ui.spinBox_absolute_y.value()
)
)
self.ui.pushButton_set.clicked.connect(self.save_absolute_coordinates)
self.ui.pushButton_save.clicked.connect(self.save_current_coordinates)
self.ui.pushButton_stop.clicked.connect(self.motor_thread.stop_movement)
# Enable/Disable GUI
self.motor_thread.lock_gui.connect(self.enable_motor_controls)
# Error messages
self.motor_thread.motor_error.connect(
lambda error: MotorControlErrors.display_error_message(error)
)
# Keyboard shortcuts
self._init_keyboard_shortcuts()
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""Update config dict"""
self.config = config
# Get motor names
self.motor_x, self.motor_y = (
self.config["motor_control"]["motor_x"],
self.config["motor_control"]["motor_y"],
)
# Update step precision
self.precision = self.config["motor_control"]["precision"]
self._init_ui()
@pyqtSlot(bool)
def enable_motor_controls(self, enable: bool) -> None:
"""
Enable or disable the motor controls.
Args:
enable(bool): True to enable, False to disable.
"""
# Disable or enable all controls within the motorControl_absolute group box
for widget in self.ui.motorControl_absolute.findChildren(QWidget):
widget.setEnabled(enable)
# Enable the pushButton_stop if the motor is moving
self.ui.pushButton_stop.setEnabled(True)
@pyqtSlot(str, str)
def change_motors(self, motor_x: str, motor_y: str):
"""
Change the active motors and update config.
Can be connected to the selected_motors_signal from MotorControlSelection.
Args:
motor_x(str): New motor X to be controlled.
motor_y(str): New motor Y to be controlled.
"""
self.motor_x = motor_x
self.motor_y = motor_y
self.config["motor_control"]["motor_x"] = motor_x
self.config["motor_control"]["motor_y"] = motor_y
@pyqtSlot(int)
def set_precision(self, precision: int) -> None:
"""
Set the precision of the coordinates.
Args:
precision(int): Precision of the coordinates.
"""
self.precision = precision
self.config["motor_control"]["precision"] = precision
self.ui.spinBox_absolute_x.setDecimals(precision)
self.ui.spinBox_absolute_y.setDecimals(precision)
def move_motor_absolute(self, x: float, y: float) -> None:
"""
Move the motor to the target coordinates.
Args:
x(float): Target x coordinate.
y(float): Target y coordinate.
"""
# self._enable_motor_controls(False)
target_coordinates = (x, y)
self.motor_thread.move_absolute(self.motor_x, self.motor_y, target_coordinates)
if self.ui.checkBox_save_with_go.isChecked():
self.save_absolute_coordinates()
def _init_keyboard_shortcuts(self):
"""Initialize the keyboard shortcuts."""
# Go absolute button
self.ui.pushButton_go_absolute.setShortcut("Ctrl+G")
self.ui.pushButton_go_absolute.setToolTip("Ctrl+G")
# Set absolute coordinates
self.ui.pushButton_set.setShortcut("Ctrl+D")
self.ui.pushButton_set.setToolTip("Ctrl+D")
# Save Current coordinates
self.ui.pushButton_save.setShortcut("Ctrl+S")
self.ui.pushButton_save.setToolTip("Ctrl+S")
# Stop Button
self.ui.pushButton_stop.setShortcut("Ctrl+X")
self.ui.pushButton_stop.setToolTip("Ctrl+X")
def save_absolute_coordinates(self):
"""Emit the setup coordinates from the spinboxes"""
x, y = round(self.ui.spinBox_absolute_x.value(), self.precision), round(
self.ui.spinBox_absolute_y.value(), self.precision
)
self.coordinates_signal.emit((x, y))
def save_current_coordinates(self):
"""Emit the current coordinates from the motor thread"""
x, y = self.motor_thread.get_coordinates(self.motor_x, self.motor_y)
self.coordinates_signal.emit((round(x, self.precision), round(y, self.precision)))

View File

@@ -0,0 +1,227 @@
import os
from qtpy import uic
from qtpy.QtCore import Qt
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import QDoubleSpinBox, QShortcut, QWidget
from bec_widgets.widgets.motor_control.motor_control import MotorControlWidget
class MotorControlRelative(MotorControlWidget):
"""
Widget for controlling the motors to relative coordinates.
Signals:
precision_signal (pyqtSignal): Signal to emit the precision of the coordinates.
Slots:
change_motors (pyqtSlot(str,str)): Slot to change the active motors.
enable_motor_controls (pyqtSlot): Slot to enable/disable the motor controls.
"""
precision_signal = pyqtSignal(int)
def _load_ui(self):
"""Load the UI from the .ui file."""
# Loading UI
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "movement_relative.ui"), self)
def _init_ui(self):
"""Initialize the UI."""
self._init_ui_motor_control()
self._init_keyboard_shortcuts()
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""
Update config dict
Args:
config(dict): New config dict
"""
self.config = config
# Get motor names
self.motor_x, self.motor_y = (
self.config["motor_control"]["motor_x"],
self.config["motor_control"]["motor_y"],
)
# Update step precision
self.precision = self.config["motor_control"]["precision"]
self.spinBox_precision.setValue(self.precision)
# Update step sizes
self.spinBox_step_x.setValue(self.config["motor_control"]["step_size_x"])
self.spinBox_step_y.setValue(self.config["motor_control"]["step_size_y"])
# Checkboxes for keyboard shortcuts and x/y step size link
self.checkBox_same_xy.setChecked(self.config["motor_control"]["step_x_y_same"])
self.checkBox_enableArrows.setChecked(self.config["motor_control"]["move_with_arrows"])
self._init_ui()
def _init_ui_motor_control(self) -> None:
"""Initialize the motor control elements"""
# Connect checkbox and spinBoxes
self.checkBox_same_xy.stateChanged.connect(self._sync_step_sizes)
self.spinBox_step_x.valueChanged.connect(self._update_step_size_x)
self.spinBox_step_y.valueChanged.connect(self._update_step_size_y)
self.toolButton_right.clicked.connect(
lambda: self.move_motor_relative(self.motor_x, "x", 1)
)
self.toolButton_left.clicked.connect(
lambda: self.move_motor_relative(self.motor_x, "x", -1)
)
self.toolButton_up.clicked.connect(lambda: self.move_motor_relative(self.motor_y, "y", 1))
self.toolButton_down.clicked.connect(
lambda: self.move_motor_relative(self.motor_y, "y", -1)
)
# Switch between key shortcuts active
self.checkBox_enableArrows.stateChanged.connect(self._update_arrow_key_shortcuts)
self._update_arrow_key_shortcuts()
# Enable/Disable GUI
self.motor_thread.lock_gui.connect(self.enable_motor_controls)
# Precision update
self.spinBox_precision.valueChanged.connect(lambda x: self._update_precision(x))
# Error messages
self.motor_thread.motor_error.connect(
lambda error: MotorControlErrors.display_error_message(error)
)
# Stop Button
self.pushButton_stop.clicked.connect(self.motor_thread.stop_movement)
def _init_keyboard_shortcuts(self) -> None:
"""Initialize the keyboard shortcuts"""
# Increase/decrease step size for X motor
increase_x_shortcut = QShortcut(QKeySequence("Ctrl+A"), self)
decrease_x_shortcut = QShortcut(QKeySequence("Ctrl+Z"), self)
increase_x_shortcut.activated.connect(
lambda: self._change_step_size(self.spinBox_step_x, 2)
)
decrease_x_shortcut.activated.connect(
lambda: self._change_step_size(self.spinBox_step_x, 0.5)
)
self.spinBox_step_x.setToolTip("Increase step size: Ctrl+A\nDecrease step size: Ctrl+Z")
# Increase/decrease step size for Y motor
increase_y_shortcut = QShortcut(QKeySequence("Alt+A"), self)
decrease_y_shortcut = QShortcut(QKeySequence("Alt+Z"), self)
increase_y_shortcut.activated.connect(
lambda: self._change_step_size(self.spinBox_step_y, 2)
)
decrease_y_shortcut.activated.connect(
lambda: self._change_step_size(self.spinBox_step_y, 0.5)
)
self.spinBox_step_y.setToolTip("Increase step size: Alt+A\nDecrease step size: Alt+Z")
# Stop Button
self.pushButton_stop.setShortcut("Ctrl+X")
self.pushButton_stop.setToolTip("Ctrl+X")
def _update_arrow_key_shortcuts(self) -> None:
"""Update the arrow key shortcuts based on the checkbox state."""
if self.checkBox_enableArrows.isChecked():
# Set the arrow key shortcuts for motor movement
self.toolButton_right.setShortcut(Qt.Key_Right)
self.toolButton_left.setShortcut(Qt.Key_Left)
self.toolButton_up.setShortcut(Qt.Key_Up)
self.toolButton_down.setShortcut(Qt.Key_Down)
else:
# Clear the shortcuts
self.toolButton_right.setShortcut("")
self.toolButton_left.setShortcut("")
self.toolButton_up.setShortcut("")
self.toolButton_down.setShortcut("")
def _update_precision(self, precision: int) -> None:
"""
Update the precision of the coordinates.
Args:
precision(int): Precision of the coordinates.
"""
self.spinBox_step_x.setDecimals(precision)
self.spinBox_step_y.setDecimals(precision)
self.precision_signal.emit(precision)
def _change_step_size(self, spinBox: QDoubleSpinBox, factor: float) -> None:
"""
Change the step size of the spinbox.
Args:
spinBox(QDoubleSpinBox): Spinbox to change the step size.
factor(float): Factor to change the step size.
"""
old_step = spinBox.value()
new_step = old_step * factor
spinBox.setValue(new_step)
def _sync_step_sizes(self):
"""Sync step sizes based on checkbox state."""
if self.checkBox_same_xy.isChecked():
value = self.spinBox_step_x.value()
self.spinBox_step_y.setValue(value)
def _update_step_size_x(self):
"""Update step size for x if checkbox is checked."""
if self.checkBox_same_xy.isChecked():
value = self.spinBox_step_x.value()
self.spinBox_step_y.setValue(value)
def _update_step_size_y(self):
"""Update step size for y if checkbox is checked."""
if self.checkBox_same_xy.isChecked():
value = self.spinBox_step_y.value()
self.spinBox_step_x.setValue(value)
@pyqtSlot(str, str)
def change_motors(self, motor_x: str, motor_y: str):
"""
Change the active motors and update config.
Can be connected to the selected_motors_signal from MotorControlSelection.
Args:
motor_x(str): New motor X to be controlled.
motor_y(str): New motor Y to be controlled.
"""
self.motor_x = motor_x
self.motor_y = motor_y
self.config["motor_control"]["motor_x"] = motor_x
self.config["motor_control"]["motor_y"] = motor_y
@pyqtSlot(bool)
def enable_motor_controls(self, disable: bool) -> None:
"""
Enable or disable the motor controls.
Args:
disable(bool): True to disable, False to enable.
"""
# Disable or enable all controls within the motorControl_absolute group box
for widget in self.motorControl.findChildren(QWidget):
widget.setEnabled(disable)
# Enable the pushButton_stop if the motor is moving
self.pushButton_stop.setEnabled(True)
def move_motor_relative(self, motor, axis: str, direction: int) -> None:
"""
Move the motor relative to the current position.
Args:
motor: Motor to move.
axis(str): Axis to move.
direction(int): Direction to move. 1 for positive, -1 for negative.
"""
if axis == "x":
step = direction * self.spinBox_step_x.value()
elif axis == "y":
step = direction * self.spinBox_step_y.value()
self.motor_thread.move_relative(motor, step)

View File

@@ -0,0 +1,30 @@
import qdarktheme
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets.motor_control.selection.selection import MotorControlSelection
CONFIG_DEFAULT = {
"motor_control": {
"motor_x": "samx",
"motor_y": "samy",
"step_size_x": 3,
"step_size_y": 5,
"precision": 4,
"step_x_y_same": False,
"move_with_arrows": False,
}
}
if __name__ == "__main__":
bec_dispatcher = BECDispatcher()
# BECclient global variables
client = bec_dispatcher.client
client.start()
app = QApplication([])
qdarktheme.setup_theme("auto")
motor_control = MotorControlSelection(client=client, config=CONFIG_DEFAULT)
window = motor_control
window.show()
app.exec()

View File

@@ -0,0 +1,13 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from selection import MotorControlSelection
from selectionplugin import MotorControlSelectionPlugin
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin
if __name__ == "__main__":
QPyDesignerCustomWidgetCollection.addCustomWidget(MotorControlSelectionPlugin())

View File

@@ -0,0 +1,110 @@
# pylint: disable = no-name-in-module,missing-module-docstring
import os
from qtpy import uic
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QComboBox
from bec_widgets.widgets.motor_control.motor_control import MotorControlWidget
class MotorControlSelection(MotorControlWidget):
"""
Widget for selecting the motors to control.
Signals:
selected_motors_signal (pyqtSignal(str,str)): Signal to emit the selected motors.
Slots:
get_available_motors (pyqtSlot): Slot to populate the available motors in the combo boxes and set the index based on the configuration.
enable_motor_controls (pyqtSlot(bool)): Slot to enable/disable the motor controls GUI.
on_config_update (pyqtSlot(dict)): Slot to update the config dict.
"""
selected_motors_signal = pyqtSignal(str, str)
def _load_ui(self):
"""Load the UI from the .ui file."""
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "selection.ui"), self)
def _init_ui(self):
"""Initialize the UI."""
# Lock GUI while motors are moving
self.motor_thread.lock_gui.connect(self.enable_motor_controls)
self.pushButton_connecMotors.clicked.connect(self.select_motor)
self.get_available_motors()
# Connect change signals to change color
self.comboBox_motor_x.currentIndexChanged.connect(
lambda: self.set_combobox_style(self.comboBox_motor_x, "#ffa700")
)
self.comboBox_motor_y.currentIndexChanged.connect(
lambda: self.set_combobox_style(self.comboBox_motor_y, "#ffa700")
)
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""
Update config dict
Args:
config(dict): New config dict
"""
self.config = config
# Get motor names
self.motor_x, self.motor_y = (
self.config["motor_control"]["motor_x"],
self.config["motor_control"]["motor_y"],
)
self._init_ui()
@pyqtSlot(bool)
def enable_motor_controls(self, enable: bool) -> None:
"""
Enable or disable the motor controls.
Args:
enable(bool): True to enable, False to disable.
"""
self.motorSelection.setEnabled(enable)
@pyqtSlot()
def get_available_motors(self) -> None:
"""
Slot to populate the available motors in the combo boxes and set the index based on the configuration.
"""
# Get all available motors
self.motor_list = self.motor_thread.get_all_motors_names()
# Populate the combo boxes
self.comboBox_motor_x.addItems(self.motor_list)
self.comboBox_motor_y.addItems(self.motor_list)
# Set the index based on the config if provided
if self.config:
index_x = self.comboBox_motor_x.findText(self.motor_x)
index_y = self.comboBox_motor_y.findText(self.motor_y)
self.comboBox_motor_x.setCurrentIndex(index_x if index_x != -1 else 0)
self.comboBox_motor_y.setCurrentIndex(index_y if index_y != -1 else 0)
def set_combobox_style(self, combobox, color: str) -> None:
"""
Set the combobox style to a specific color.
Args:
combobox(QComboBox): Combobox to change the color.
color(str): Color to set the combobox to.
"""
combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
def select_motor(self):
"""Emit the selected motors"""
motor_x = self.comboBox_motor_x.currentText()
motor_y = self.comboBox_motor_y.currentText()
# Reset the combobox color to normal after selection
self.set_combobox_style(self.comboBox_motor_x, "")
self.set_combobox_style(self.comboBox_motor_y, "")
self.selected_motors_signal.emit(motor_x, motor_y)

View File

@@ -0,0 +1,4 @@
{
"files": ["selection.py", "motor_selection_launch.py", "registertictactoe.py", "tictactoeplugin.py",
"tictactoetaskmenu.py"]
}

View File

@@ -0,0 +1,58 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from selection import MotorControlSelection
from PySide6.QtGui import QIcon
from PySide6.QtDesigner import QDesignerCustomWidgetInterface
DOM_XML = """
<ui language='c++'>
<widget class='MotorControlSelection' name='selection'>
</widget>
</ui>
"""
class MotorControlSelectionPlugin(QDesignerCustomWidgetInterface):
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = MotorControlSelection(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return QIcon()
def includeFile(self):
return "selection"
def initialize(self, form_editor):
self._form_editor = form_editor
# manager = form_editor.extensionManager()
# iid = TicTacToeTaskMenuFactory.task_menu_iid()
# manager.registerExtensions(TicTacToeTaskMenuFactory(manager), iid)
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "MotorControlSelection"
def toolTip(self):
return "MotorControl Selection Example for BEC Widgets"
def whatsThis(self):
return self.toolTip()

View File

@@ -1 +0,0 @@
from .motor_map import MotorMap

View File

@@ -1,605 +0,0 @@
# pylint: disable = no-name-in-module,missing-module-docstring
from __future__ import annotations
import time
from typing import Any, Union
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints
from qtpy import QtCore, QtGui
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.yaml_dialog import load_yaml
CONFIG_DEFAULT = {
"plot_settings": {
"colormap": "Greys",
"scatter_size": 5,
"max_points": 1000,
"num_dim_points": 100,
"precision": 2,
"num_columns": 1,
"background_value": 25,
},
"motors": [
{
"plot_name": "Motor Map",
"x_label": "Motor X",
"y_label": "Motor Y",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "samy", "entry": "samy"}],
},
},
{
"plot_name": "Motor Map 2 ",
"x_label": "Motor X",
"y_label": "Motor Y",
"signals": {
"x": [{"name": "aptrx", "entry": "aptrx"}],
"y": [{"name": "aptry", "entry": "aptry"}],
},
},
],
}
class MotorMap(pg.GraphicsLayoutWidget):
update_signal = pyqtSignal()
def __init__(
self,
parent=None,
client=None,
config: dict = None,
gui_id=None,
skip_validation: bool = True,
):
super().__init__(parent=parent)
# Import BEC related stuff
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
# TODO import validator when prepared
self.gui_id = gui_id
if self.gui_id is None:
self.gui_id = self.__class__.__name__ + str(time.time())
# Current configuration
self.config = config
self.skip_validation = skip_validation # TODO implement validation when validator is ready
# Connect the update signal to the update plot method
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self._update_plots
)
# Config related variables
self.plot_data = None
self.plot_settings = None
self.max_points = None
self.num_dim_points = None
self.scatter_size = None
self.precision = None
self.background_value = None
self.database = {}
self.device_mapping = {}
self.plots = {}
self.grid_coordinates = []
self.curves_data = {}
# Init UI with config
if self.config is None:
print("No initial config found for MotorMap. Using default config.")
else:
self.on_config_update(self.config)
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""
Validate and update the configuration settings for the PlotApp.
Args:
config(dict): Configuration settings
"""
# TODO implement BEC CLI commands similar to BECPlotter
# convert config from BEC CLI to correct formatting
config_tag = config.get("config", None)
if config_tag is not None:
config = config["config"]
if self.skip_validation is True:
self.config = config
self._init_config()
else: # TODO implement validator
print("Do validation")
@pyqtSlot(str, str, int)
def change_motors(self, motor_x: str, motor_y: str, subplot: int = 0) -> None:
"""
Change the active motors for the plot.
Args:
motor_x(str): Motor name for the X axis.
motor_y(str): Motor name for the Y axis.
subplot(int): Subplot number.
"""
if subplot >= len(self.plot_data):
print(f"Invalid subplot index: {subplot}. Available subplots: {len(self.plot_data)}")
return
# Update the motor names in the plot configuration
self.config["motors"][subplot]["signals"]["x"][0]["name"] = motor_x
self.config["motors"][subplot]["signals"]["x"][0]["entry"] = motor_x
self.config["motors"][subplot]["signals"]["y"][0]["name"] = motor_y
self.config["motors"][subplot]["signals"]["y"][0]["entry"] = motor_y
# reinitialise the config and UI
self._init_config()
def _init_config(self):
"""Initiate the configuration."""
# Global widget settings
self._get_global_settings()
# Motor settings
self.plot_data = self.config.get("motors", {})
# Include motor limits into the config
self._add_limits_to_plot_data()
# Initialize the database
self.database = self._init_database()
# Create device mapping for x/y motor pairs
self.device_mapping = self._create_device_mapping()
# Initialize the plot UI
self._init_ui()
# Connect motors to slots
self._connect_motors_to_slots()
# Render init position of selected motors
self._update_plots()
def _get_global_settings(self):
"""Get global settings from the config."""
self.plot_settings = self.config.get("plot_settings", {})
self.max_points = self.plot_settings.get("max_points", 5000)
self.num_dim_points = self.plot_settings.get("num_dim_points", 100)
self.scatter_size = self.plot_settings.get("scatter_size", 5)
self.precision = self.plot_settings.get("precision", 2)
self.background_value = self.plot_settings.get("background_value", 25)
def _create_device_mapping(self):
"""
Create a mapping of device names to their corresponding x/y devices.
"""
mapping = {}
for motor in self.config.get("motors", []):
for axis in ["x", "y"]:
for signal in motor["signals"][axis]:
other_axis = "y" if axis == "x" else "x"
corresponding_device = motor["signals"][other_axis][0]["name"]
mapping[signal["name"]] = corresponding_device
return mapping
def _connect_motors_to_slots(self):
"""Connect motors to slots."""
# Disconnect all slots before connecting a new ones
bec_dispatcher = BECDispatcher()
bec_dispatcher.disconnect_all()
# Get list of all unique motors
unique_motors = []
for motor_config in self.plot_data:
for axis in ["x", "y"]:
for signal in motor_config["signals"][axis]:
unique_motors.append(signal["name"])
unique_motors = list(set(unique_motors))
# Create list of endpoint
endpoints = []
for motor in unique_motors:
endpoints.append(MessageEndpoints.device_readback(motor))
# Connect all topics to a single slot
bec_dispatcher.connect_slot(
self.on_device_readback,
endpoints,
)
def _add_limits_to_plot_data(self):
"""
Add limits to each motor signal in the plot_data.
"""
for motor_config in self.plot_data:
for axis in ["x", "y"]:
for signal in motor_config["signals"][axis]:
motor_name = signal["name"]
motor_limits = self._get_motor_limit(motor_name)
signal["limits"] = motor_limits
def _get_motor_limit(self, motor: str) -> Union[list | None]:
"""
Get the motor limit from the config.
Args:
motor(str): Motor name.
Returns:
float: Motor limit.
"""
try:
limits = self.dev[motor].limits
if limits == [0, 0]:
return None
return limits
except AttributeError: # TODO maybe not needed, if no limits it returns [0,0]
# If the motor doesn't have a 'limits' attribute, return a default value or raise a custom exception
print(f"The device '{motor}' does not have defined limits.")
return None
def _init_database(self):
"""Initiate the database according the config."""
database = {}
for plot in self.plot_data:
for axis, signals in plot["signals"].items():
for signal in signals:
name = signal["name"]
entry = signal.get("entry", name)
if name not in database:
database[name] = {}
if entry not in database[name]:
database[name][entry] = [self.get_coordinate(name, entry)]
return database
def get_coordinate(self, name, entry):
"""Get the initial coordinate value for a motor."""
try:
return self.dev[name].read()[entry]["value"]
except Exception as e:
print(f"Error getting initial value for {name}: {e}")
return None
def _init_ui(self, num_columns: int = 3) -> None:
"""
Initialize the UI components, create plots and store their grid positions.
Args:
num_columns (int): Number of columns to wrap the layout.
This method initializes a dictionary `self.plots` to store the plot objects
along with their corresponding x and y signal names. It dynamically arranges
the plots in a grid layout based on the given number of columns and dynamically
stretches the last plots to fit the remaining space.
"""
self.clear()
self.plots = {}
self.grid_coordinates = []
self.curves_data = {} # TODO moved from init_curves
num_plots = len(self.plot_data)
# Check if num_columns exceeds the number of plots
if num_columns >= num_plots:
num_columns = num_plots
self.plot_settings["num_columns"] = num_columns # Update the settings
print(
"Warning: num_columns in the YAML file was greater than the number of plots."
f" Resetting num_columns to number of plots:{num_columns}."
)
else:
self.plot_settings["num_columns"] = num_columns # Update the settings
num_rows = num_plots // num_columns
last_row_cols = num_plots % num_columns
remaining_space = num_columns - last_row_cols
for i, plot_config in enumerate(self.plot_data):
row, col = i // num_columns, i % num_columns
colspan = 1
if row == num_rows and remaining_space > 0:
if last_row_cols == 1:
colspan = num_columns
else:
colspan = remaining_space // last_row_cols + 1
remaining_space -= colspan - 1
last_row_cols -= 1
if "plot_name" not in plot_config:
plot_name = f"Plot ({row}, {col})"
plot_config["plot_name"] = plot_name
else:
plot_name = plot_config["plot_name"]
x_label = plot_config.get("x_label", "")
y_label = plot_config.get("y_label", "")
plot = self.addPlot(row=row, col=col, colspan=colspan, title="Motor position: (X, Y)")
plot.setLabel("bottom", f"{x_label} ({plot_config['signals']['x'][0]['name']})")
plot.setLabel("left", f"{y_label} ({plot_config['signals']['y'][0]['name']})")
plot.addLegend()
# self._set_plot_colors(plot, self.plot_settings) #TODO implement colors
self.plots[plot_name] = plot
self.grid_coordinates.append((row, col))
self._init_motor_map(plot_config)
def _init_motor_map(self, plot_config: dict) -> None:
"""
Initialize the motor map.
Args:
plot_config(dict): Plot configuration.
"""
# Get plot name to find appropriate plot
plot_name = plot_config.get("plot_name", "")
# Reset the curves data
plot = self.plots[plot_name]
plot.clear()
limits_x, limits_y = plot_config["signals"]["x"][0].get("limits", None), plot_config[
"signals"
]["y"][0].get("limits", None)
if limits_x is not None and limits_y is not None:
self._make_limit_map(plot, [limits_x, limits_y])
# Initiate ScatterPlotItem for motor coordinates
self.curves_data[plot_name] = {
"pos": pg.ScatterPlotItem(
size=self.scatter_size, pen=pg.mkPen(None), brush=pg.mkBrush(255, 255, 255, 255)
)
}
# Add the scatter plot to the plot
plot.addItem(self.curves_data[plot_name]["pos"])
# Set the point map to be always on the top
self.curves_data[plot_name]["pos"].setZValue(0)
# Add all layers to the plot
plot.showGrid(x=True, y=True)
# Add the crosshair for motor coordinates
init_position_x = self._get_motor_init_position(
plot_config["signals"]["x"][0]["name"], plot_config["signals"]["x"][0]["entry"]
)
init_position_y = self._get_motor_init_position(
plot_config["signals"]["y"][0]["name"], plot_config["signals"]["y"][0]["entry"]
)
self._add_coordinantes_crosshair(plot_name, init_position_x, init_position_y)
def _add_coordinantes_crosshair(self, plot_name: str, x: float, y: float) -> None:
"""
Add crosshair to the plot to highlight the current position.
Args:
plot_name(str): Name of the plot.
x(float): X coordinate.
y(float): Y coordinate.
"""
# find the current plot
plot = self.plots[plot_name]
# Crosshair to highlight the current position
highlight_H = pg.InfiniteLine(
angle=0, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
)
highlight_V = pg.InfiniteLine(
angle=90, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
)
# Add crosshair to the curve list for future referencing
self.curves_data[plot_name]["highlight_H"] = highlight_H
self.curves_data[plot_name]["highlight_V"] = highlight_V
# Add crosshair to the plot
plot.addItem(highlight_H)
plot.addItem(highlight_V)
highlight_H.setPos(x)
highlight_V.setPos(y)
def _make_limit_map(self, plot: pg.PlotItem, limits: list):
"""
Make a limit map from the limits list.
Args:
plot(pg.PlotItem): Plot to add the limit map to.
limits(list): List of limits.
"""
# Define the size of the image map based on the motor's limits
limit_x_min, limit_x_max = limits[0]
limit_y_min, limit_y_max = limits[1]
map_width = int(limit_x_max - limit_x_min + 1)
map_height = int(limit_y_max - limit_y_min + 1)
limit_map_data = np.full((map_width, map_height), self.background_value, dtype=np.float32)
# Create the image map
limit_map = pg.ImageItem()
limit_map.setImage(limit_map_data)
plot.addItem(limit_map)
# Translate and scale the image item to match the motor coordinates
tr = QtGui.QTransform()
tr.translate(limit_x_min, limit_y_min)
limit_map.setTransform(tr)
def _get_motor_init_position(self, name: str, entry: str) -> float:
"""
Get the motor initial position from the config.
Args:
name(str): Motor name.
entry(str): Motor entry.
Returns:
float: Motor initial position.
"""
init_position = round(self.dev[name].read()[entry]["value"], self.precision)
return init_position
def _update_plots(self):
"""Update the motor position on plots."""
for plot_name, curve_list in self.curves_data.items():
plot_config = next(
(pc for pc in self.plot_data if pc.get("plot_name") == plot_name), None
)
if not plot_config:
continue
# Get the motor coordinates
x_motor_name = plot_config["signals"]["x"][0]["name"]
x_motor_entry = plot_config["signals"]["x"][0]["entry"]
y_motor_name = plot_config["signals"]["y"][0]["name"]
y_motor_entry = plot_config["signals"]["y"][0]["entry"]
# update motor position only if there is data
if (
len(self.database[x_motor_name][x_motor_entry]) >= 1
and len(self.database[y_motor_name][y_motor_entry]) >= 1
):
# Relevant data for the plot
motor_x_data = self.database[x_motor_name][x_motor_entry]
motor_y_data = self.database[y_motor_name][y_motor_entry]
# Setup gradient brush for history
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(motor_x_data)
# Calculate the decrement step based on self.num_dim_points
decrement_step = (255 - 50) / self.num_dim_points
for i in range(1, min(self.num_dim_points + 1, len(motor_x_data) + 1)):
brightness = max(60, 255 - decrement_step * (i - 1))
brushes[-i] = pg.mkBrush(brightness, brightness, brightness, 255)
brushes[-1] = pg.mkBrush(
255, 255, 255, 255
) # Newest point is always full brightness
# Update the scatter plot
self.curves_data[plot_name]["pos"].setData(
x=motor_x_data,
y=motor_y_data,
brush=brushes,
pen=None,
size=self.scatter_size,
)
# Get last know position for crosshair
current_x = motor_x_data[-1]
current_y = motor_y_data[-1]
# Update plot title
self.plots[plot_name].setTitle(
f"Motor position: ({round(current_x,self.precision)}, {round(current_y,self.precision)})"
)
# Update the crosshair
self.curves_data[plot_name]["highlight_V"].setPos(current_x)
self.curves_data[plot_name]["highlight_H"].setPos(current_y)
@pyqtSlot(list, str, str)
def plot_saved_coordinates(self, coordinates: list, tag: str, color: str):
"""
Plot saved coordinates on the map.
Args:
coordinates(list): List of coordinates to be plotted.
tag(str): Tag for the coordinates for future reference.
color(str): Color to plot coordinates in.
"""
for plot_name in self.plots:
plot = self.plots[plot_name]
# Clear previous saved points
if tag in self.curves_data[plot_name]:
plot.removeItem(self.curves_data[plot_name][tag])
# Filter coordinates to be shown
visible_coords = [coord[:2] for coord in coordinates if coord[2]]
if visible_coords:
saved_points = pg.ScatterPlotItem(
pos=np.array(visible_coords), brush=pg.mkBrush(color)
)
plot.addItem(saved_points)
self.curves_data[plot_name][tag] = saved_points
@pyqtSlot(dict)
def on_device_readback(self, msg: dict):
"""
Update the motor coordinates on the plots.
Args:
msg (dict): Message received with device readback data.
"""
for device_name, device_info in msg["signals"].items():
# Check if the device is relevant to our current context
if device_name in self.device_mapping:
self._update_device_data(device_name, device_info["value"])
self.update_signal.emit()
def _update_device_data(self, device_name: str, value: float):
"""
Update the device data.
Args:
device_name (str): Device name.
value (float): Device value.
"""
if device_name in self.database:
self.database[device_name][device_name].append(value)
corresponding_device = self.device_mapping.get(device_name)
if corresponding_device and corresponding_device in self.database:
last_value = (
self.database[corresponding_device][corresponding_device][-1]
if self.database[corresponding_device][corresponding_device]
else None
)
self.database[corresponding_device][corresponding_device].append(last_value)
if __name__ == "__main__": # pragma: no cover
import argparse
import json
import sys
parser = argparse.ArgumentParser()
parser.add_argument("--config_file", help="Path to the config file.")
parser.add_argument("--config", help="Path to the config file.")
parser.add_argument("--id", help="GUI ID.")
args = parser.parse_args()
if args.config is not None:
# Load config from file
config = json.loads(args.config)
elif args.config_file is not None:
# Load config from file
config = load_yaml(args.config_file)
else:
config = CONFIG_DEFAULT
client = BECDispatcher().client
client.start()
app = QApplication(sys.argv)
motor_map = MotorMap(
config=config,
gui_id=args.id,
skip_validation=True,
)
motor_map.show()
sys.exit(app.exec())

View File

@@ -1,4 +0,0 @@
from .image import BECImageItem, BECImageShow, ImageItemConfig
from .motor_map import BECMotorMap, MotorMapConfig
from .plot_base import AxisConfig, BECPlotBase, WidgetConfig
from .waveform import BECCurve, BECWaveform, Waveform1DConfig

View File

@@ -1,5 +1,4 @@
import msgpack
from bec_lib import MessageEndpoints
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -109,6 +108,7 @@ class ScanControl(QWidget):
def add_horizontal_separator(self, layout) -> None:
"""
Adds a horizontal separator to the given layout
Args:
layout: Layout to add the separator to
"""
@@ -142,6 +142,7 @@ class ScanControl(QWidget):
def add_labels_to_layout(self, labels: list, grid_layout: QGridLayout) -> None:
"""
Adds labels to the given grid layout as a separate row.
Args:
labels (list): List of label names to add.
grid_layout (QGridLayout): The grid layout to which labels will be added.
@@ -157,6 +158,7 @@ class ScanControl(QWidget):
) -> None: # TODO could be moved to BECTable
"""
Adds labels to the given table widget as a header row.
Args:
labels(list): List of label names to add.
table(QTableWidget): The table widget to which labels will be added.
@@ -166,7 +168,8 @@ class ScanControl(QWidget):
def generate_args_input_fields(self, scan_info: dict) -> None:
"""
Generates input fields for args
Generates input fields for args.
Args:
scan_info(dict): Scan signature dictionary from BEC.
"""
@@ -188,6 +191,7 @@ class ScanControl(QWidget):
def generate_kwargs_input_fields(self, scan_info: dict) -> None:
"""
Generates input fields for kwargs
Args:
scan_info(dict): Scan signature dictionary from BEC.
"""
@@ -213,12 +217,13 @@ class ScanControl(QWidget):
def generate_widgets_from_signature(self, items: list, signature: dict = None) -> list:
"""
Generates widgets from the given list of items.
Args:
items(list): List of items to create widgets for.
signature(dict, optional): Scan signature dictionary from BEC.
Returns:
list: List of widgets created from the given items.
"""
widgets = [] # Initialize an empty list to hold the widgets
@@ -333,6 +338,7 @@ class ScanControl(QWidget):
def clear_and_delete_layout(self, layout: QLayout):
"""
Clears and deletes the given layout and all its child widgets.
Args:
layout(QLayout): Layout to clear and delete
"""
@@ -383,6 +389,7 @@ class ScanControl(QWidget):
def extract_args_from_table(self, table: QTableWidget) -> list:
"""
Extracts the arguments from the given table widget.
Args:
table(QTableWidget): Table widget from which to extract the arguments
"""
@@ -430,9 +437,7 @@ if __name__ == "__main__": # pragma: no cover
client.start()
app = QApplication([])
scan_control = ScanControl(
client=client,
) # allowed_scans=["line_scan", "grid_scan"])
scan_control = ScanControl(client=client) # allowed_scans=["line_scan", "grid_scan"])
window = scan_control
window.show()

Some files were not shown because too many files have changed in this diff Show More