Compare commits
125 Commits
v0.53.0
...
docs/add_t
| Author | SHA1 | Date | |
|---|---|---|---|
| bdbc2b903d | |||
| 2a36d9364f | |||
| 27426ce7a5 | |||
|
|
69adadd6d7 | ||
| 6f96498de6 | |||
| 836b6e64f6 | |||
|
|
fab7dd7eec | ||
| 9263f8ef5c | |||
|
|
658728efef | ||
| 6b8432f5b2 | |||
| bc709c4184 | |||
| b49462abeb | |||
| d9d4e3c9bf | |||
| fe04dd80e5 | |||
|
|
718950cf0d | ||
| 17a0068757 | |||
| abc6caa2d0 | |||
|
|
99fb82561b | ||
| 61ba08d0b8 | |||
| 40b5688158 | |||
|
|
0a4e253cbd | ||
| 6428e38ab9 | |||
| fc4f4f81ad | |||
| f6629852eb | |||
| 3adf6cfd58 | |||
| b15816ca9f | |||
| 6b1d5827d6 | |||
| f0391f59c9 | |||
| 006a0894b8 | |||
| 9c5a471234 | |||
| 1c7f4912ce | |||
| df1be10057 | |||
| 954c576131 | |||
| 867720a897 | |||
| 2b40602bdc | |||
| 11173b9c0a | |||
|
|
52d46e77db | ||
| e7838b0f2f | |||
|
|
2ae3810cf6 | ||
| 178fe4d2da | |||
| 2d79ef8fe5 | |||
| d56c5493cd | |||
| cf6e5a40fc | |||
| 64abd67b9b | |||
|
|
c19e856800 | ||
| 02a26086c4 | |||
|
|
35f880bc2f | ||
| c0ddeceeea | |||
| 67fd5e8581 | |||
| bf699ec1fb | |||
| 3094632134 | |||
| 6985ff0fce | |||
| 33f7be42c5 | |||
|
|
36fac70361 | ||
| ca5e8d2fbb | |||
| 828067f486 | |||
|
|
e5f4c0b952 | ||
| edb1775967 | |||
|
|
d0d6908a74 | ||
| c037b87675 | |||
| 52bc322b2b | |||
| 8479caf53a | |||
| 82e2c898d2 | |||
|
|
4852076e4a | ||
| 15cbc21e5b | |||
| ffae5ee54e | |||
|
|
10d77f20d1 | ||
| 4be0d14b74 | |||
| e883dbad81 | |||
| a2abad344f | |||
| d44b1cf8b1 | |||
| c5b6499e41 | |||
| a951ebf1be | |||
| 32da803df9 | |||
| 07d60cf735 | |||
|
|
fceb851c32 | ||
| e1af5ca60f | |||
| 7fb31fc4d7 | |||
| 5c6ba65469 | |||
| cd9fc46ff8 | |||
| 2a88e17b23 | |||
| 69f4371007 | |||
|
|
af2c816d35 | ||
| f51b25f0af | |||
| c3f4845b4f | |||
| 8ae323f5c3 | |||
|
|
a3805a765b | ||
| 8c03034acf | |||
| 4160f3d6d7 | |||
|
|
4742e1ff6a | ||
| 4af1abe4e1 | |||
| 131f49da8e | |||
|
|
f9bf496bd3 | ||
| 9648e3ea96 | |||
| 4be756a867 | |||
| 46face0ee5 | |||
| 6f3b1ea985 | |||
| 3c9181d93d | |||
|
|
a05f24785e | ||
| 9d615c915c | |||
| d2539918b2 | |||
|
|
ed264cb528 | ||
| ad208a5ef8 | |||
| ddc9510c2b | |||
| 855be3551a | |||
| db301b1be2 | |||
| 07b99d91a5 | |||
| 0fea8d6065 | |||
|
|
2a67f1667a | ||
| 76bd0d339a | |||
|
|
43759082dd | ||
| fc4d0f3bb2 | |||
| a47a8ec413 | |||
| 3455c60236 | |||
| edc25fbf9d | |||
|
|
dc38f2308b | ||
| 7d64cac661 | |||
|
|
ab4f1acd75 | ||
| 9f8fbdd5fc | |||
| d1e6cd388c | |||
| 5d09a13d88 | |||
| 0490e80c48 | |||
|
|
48553ba9b1 | ||
| 0f6a5e5fa9 | |||
| 8ff36105d1 |
106
.gitlab-ci.yml
@@ -1,13 +1,13 @@
|
||||
# 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: "main"
|
||||
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
@@ -22,7 +22,13 @@ workflow:
|
||||
|
||||
include:
|
||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
||||
|
||||
- project: "bec/awi_utils"
|
||||
file: "/templates/check-packages-job.yml"
|
||||
inputs:
|
||||
stage: test
|
||||
path: "."
|
||||
pytest_args: "-v --random-order tests/"
|
||||
exclude_packages: ""
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
@@ -32,8 +38,23 @@ stages:
|
||||
- End2End
|
||||
- Deploy
|
||||
|
||||
.install-qt-webengine-deps: &install-qt-webengine-deps
|
||||
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
|
||||
- export QTWEBENGINE_DISABLE_SANDBOX=1
|
||||
|
||||
.clone-repos: &clone-repos
|
||||
- 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
|
||||
|
||||
.install-os-packages: &install-os-packages
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- *install-qt-webengine-deps
|
||||
|
||||
before_script:
|
||||
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
|
||||
- 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
|
||||
|
||||
@@ -64,7 +85,7 @@ pylint:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint-check:
|
||||
stage: Formatter
|
||||
@@ -75,18 +96,21 @@ pylint-check:
|
||||
- apt-get update
|
||||
- apt-get install -y bc
|
||||
script:
|
||||
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
|
||||
# Identify changed Python files
|
||||
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
|
||||
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse $CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
|
||||
CHANGED_FILES=$(git diff --name-only $SOURCE_BRANCH_COMMIT_SHA $TARGET_BRANCH_COMMIT_SHA | grep '\.py$' || true);
|
||||
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
|
||||
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
|
||||
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
|
||||
fi
|
||||
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
|
||||
|
||||
- echo "Changed Python files:"
|
||||
- $CHANGED_FILES
|
||||
# Run pylint only on changed files
|
||||
- mkdir ./pylint
|
||||
- pylint $CHANGED_FILES --output-format=text . | tee ./pylint/pylint_changed_files.log || pylint-exit $?
|
||||
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
|
||||
@@ -97,20 +121,18 @@ pylint-check:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
tests:
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
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
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
- 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
|
||||
@@ -123,17 +145,32 @@ tests:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
|
||||
test-matrix:
|
||||
parallel:
|
||||
matrix:
|
||||
- PYTHON_VERSION:
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
QT_PCKG:
|
||||
- "pyside6"
|
||||
- "pyqt5"
|
||||
- "pyqt6"
|
||||
|
||||
tests-3.11:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
|
||||
allow_failure: true
|
||||
|
||||
tests-3.12:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
PYTHON_VERSION: ""
|
||||
QT_PCKG: ""
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
- pip install -e .[dev,$QT_PCKG]
|
||||
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
allow_failure: true
|
||||
|
||||
end-2-end-conda:
|
||||
@@ -144,20 +181,16 @@ end-2-end-conda:
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- conda config --prepend channels conda-forge
|
||||
- conda config --set channel_priority strict
|
||||
- conda config --set always_yes yes --set changeps1 no
|
||||
- conda create -q -n test-environment python=3.10
|
||||
- conda create -q -n test-environment python=3.11
|
||||
- conda init bash
|
||||
- source ~/.bashrc
|
||||
- conda activate test-environment
|
||||
|
||||
- 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
|
||||
|
||||
- cd ./bec
|
||||
- source ./bin/install_bec_dev.sh -t
|
||||
|
||||
@@ -166,7 +199,7 @@ end-2-end-conda:
|
||||
- cd ../
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- cd ./tests/end-2-end
|
||||
- pytest --start-servers --flush-redis --random-order
|
||||
- pytest -v --start-servers --flush-redis --random-order
|
||||
|
||||
artifacts:
|
||||
when: on_failure
|
||||
@@ -182,7 +215,6 @@ end-2-end-conda:
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
|
||||
|
||||
|
||||
semver:
|
||||
stage: Deploy
|
||||
needs: ["tests"]
|
||||
@@ -201,7 +233,7 @@ semver:
|
||||
- pip install python-semantic-release==9.* wheel build twine
|
||||
- export GL_TOKEN=$CI_UPDATES
|
||||
- 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
|
||||
@@ -217,9 +249,9 @@ pages:
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_REF_NAME
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG != null'
|
||||
- if: "$CI_COMMIT_TAG != null"
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_TAG
|
||||
- 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/
|
||||
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/
|
||||
|
||||
258
CHANGELOG.md
@@ -2,175 +2,175 @@
|
||||
|
||||
|
||||
|
||||
## v0.53.0 (2024-05-09)
|
||||
## v0.63.2 (2024-06-14)
|
||||
|
||||
### Ci
|
||||
### Fix
|
||||
|
||||
* ci: use formatter config of toml file ([`5cc816d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5cc816d0af73e20c648e044a027c589704ab1625))
|
||||
* fix: do not import "server" in client, prevents from having trouble with QApplication creation order
|
||||
|
||||
Like with QtWebEngine ([`6f96498`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6f96498de66358b89f3a2035627eed2e02dde5a1))
|
||||
|
||||
### Unknown
|
||||
|
||||
* Reapply "feat: implement non-polling, interruptible waiting of gui instruction response with timeout"
|
||||
|
||||
This reverts commit fe04dd80e59a0e74f7fdea603e0642707ecc7c2a. ([`836b6e6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/836b6e64f694916d6b6f909dedf11a4a6d2c86a4))
|
||||
|
||||
|
||||
## v0.63.1 (2024-06-13)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: just terminate the remote process in close() instead of communicating
|
||||
|
||||
The proper finalization sequence will be executed by the remote process
|
||||
on SIGTERM ([`9263f8e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9263f8ef5c17ae7a007a1a564baf787b39061756))
|
||||
|
||||
|
||||
## v0.63.0 (2024-06-13)
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs: update install instructions ([`57ee735`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/57ee735e5c2436d45a285507cdc939daa20e8e8f))
|
||||
* docs: add documentation ([`bc709c4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bc709c4184c985d4e721f9ea7d1b3dad5e9153a7))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat: moved to pyproject.toml; closes #162 ([`c86ce30`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c86ce302a964d71ee631f0817609ab5aa0e3ab0f))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: fixed semver job and upgraded to v9 ([`32e1a9d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/32e1a9d8472eb1c25d30697d407a8ffecd04e75d))
|
||||
* feat: add textbox widget ([`d9d4e3c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d9d4e3c9bf73ab2a5629c2867b50fc91e69489ec))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor: applied formatter ([`4117fd7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4117fd7b5b2090ff4fb7ad9e0d92cc87cd13ed5f))
|
||||
|
||||
|
||||
## v0.52.1 (2024-05-08)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(docstrings): docstrings formating fixed for sphinx to properly format readdocs ([`7f2f7cd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7f2f7cd07a14876617cd83cedde8c281fdc52c3a))
|
||||
|
||||
|
||||
## v0.52.0 (2024-05-07)
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: fixed support for child pipelines ([`e65c7f3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e65c7f3be895ada407bd358edf67d569d2cab08e))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(utils/layout_manager): added GridLayoutManager to extend functionalities of native QGridLayout ([`fcd6ef0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fcd6ef0975dc872f69c9d6fb2b8a1ad04a423aae))
|
||||
|
||||
* feat(widget/dock): BECDock and BECDock area for dockable windows ([`d8ff8af`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d8ff8afcd474660a6069bbdab05f10a65f221727))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(widgets/dock): BECDockArea close overwrites the default pyqtgraph Container close + minor improvements ([`ceae979`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ceae979f375ecc33c5c97148f197655c1ca57b6c))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor(widget/plots): WidgetConfig changed to SubplotConfig ([`03fa1f2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/03fa1f26d0fa6b58ed05556fb2438d1e62f6c107))
|
||||
|
||||
|
||||
## v0.51.0 (2024-05-07)
|
||||
|
||||
### Build
|
||||
|
||||
* build(cli): changed repo name to bec_widgets ([`799ea55`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/799ea554de9a7f3720d100be4886a63f02c6a390))
|
||||
|
||||
* build(setup): fakeredis added to dev env ([`df32350`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/df323504fea024a97304d96c2e39e61450714069))
|
||||
|
||||
* build(setup): PyQt6 version is set to 6.7 ([`0ab8aa3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0ab8aa3a2fe51b5c38b25fca44c1c422bb42478d))
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: added rule for parent-child pipelines ([`e085125`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e0851250eecb85503db929d37f75d2ba366308a6))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(utils): added plugin helper to find and load ([`5ece269`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5ece269adb0e9b0c2a468f1dfbaa6212e86d3561))
|
||||
|
||||
|
||||
## v0.50.2 (2024-04-30)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: 'disconnect_slot' has to be symmetric with 'connect_slot' regarding QtThreadSafeCallback ([`0dfcaa4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0dfcaa4b708948af0a40ec7cf34d03ff1e96ffac))
|
||||
|
||||
|
||||
## v0.50.1 (2024-04-29)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(cli): BECFigure takes the port to connect to redis from the current BECClient, supporting plugins ([`57cb136`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/57cb136a098e87a452414bf44e627edb562f6799))
|
||||
|
||||
|
||||
## v0.50.0 (2024-04-29)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(plots): universal cleanup and remove also for children items ([`381d713`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/381d713837bb9217c58ba1d8b89691aa35c9f5ec))
|
||||
|
||||
* feat(rpc/rpc_register): singleton rpc register for all rpc connections for session ([`a898e7e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a898e7e4f14e9ae854703dddbd1eb8c50cb640ff))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(widgets/figure): access pattern changed for getting widgets by coordinates for rpc ([`13c018a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/13c018a79704a7497c140df57179d294e43ecffa))
|
||||
|
||||
* fix(plots): cleanup policy reviewed for children items ([`8f20a0b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8f20a0b3b1b5dd117b36b45645717190b9ee9cbf))
|
||||
|
||||
* fix(rpc/client_utils): getoutput more transparent + error handling ([`6b6a6b2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b6a6b2249f24d3d02bd5fcd7ef1c63ed794c304))
|
||||
|
||||
* fix(rpc_register): thread lock for listign all connections ([`2ca3267`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2ca32675ec3f00137e2140259db51f6e5aa7bb71))
|
||||
* refactor: add pydantic config, add change_theme ([`6b8432f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b8432f5b20a71175a3537b5f6832b76e3b67d73))
|
||||
|
||||
### Test
|
||||
|
||||
* test(cli/rpc_register): e2e RPCRegister ([`4f261be`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f261be4c7cfe54501443d031f9266f4f838f6e2))
|
||||
* test: add test for text box ([`b49462a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b49462abeb186e56bac79d2ef0b0add1ef28a1a5))
|
||||
|
||||
* test(cli/rpc_register): rpc_register tests added ([`40eb75f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/40eb75f85a4d99d498b086a37e799276a9d2ac3f))
|
||||
### Unknown
|
||||
|
||||
* Revert "feat: implement non-polling, interruptible waiting of gui instruction response with timeout"
|
||||
|
||||
This reverts commit abc6caa2d0b6141dfbe1f3d025f78ae14deddcb3 ([`fe04dd8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fe04dd80e59a0e74f7fdea603e0642707ecc7c2a))
|
||||
|
||||
|
||||
## v0.49.1 (2024-04-26)
|
||||
|
||||
### Build
|
||||
|
||||
* build(pyqt6): fixing PyQt6-Qt6 package to 6.6.3 ([`a222298`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a22229849cbb57c15e4c1bae02d7e52e672f8c4c))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(widgets/editor): qscintilla editor removed ([`ab85374`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ab8537483da6c87cb9a0b0f01706208c964f292d))
|
||||
|
||||
|
||||
## v0.49.0 (2024-04-24)
|
||||
## v0.62.0 (2024-06-12)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(rpc/client_utils): timeout for rpc response ([`6500a00`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6500a00682a2a7ca535a138bd9496ed8470856a8))
|
||||
* feat: implement non-polling, interruptible waiting of gui instruction response with timeout ([`abc6caa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/abc6caa2d0b6141dfbe1f3d025f78ae14deddcb3))
|
||||
|
||||
### Fix
|
||||
### Unknown
|
||||
|
||||
* fix(rpc/client_utils): close clean up policy for BECFigure ([`9602085`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9602085f82cbc983f89b5bfe48bf35f08438fa87))
|
||||
* doc: add documentation about creating custom GUI applications embedding BEC Widgets ([`17a0068`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/17a00687579f5efab1990cd83862ec0e78198633))
|
||||
|
||||
|
||||
## v0.48.0 (2024-04-24)
|
||||
## v0.61.0 (2024-06-12)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(cli): added auto updates plugin support ([`6238693`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6238693ffb44b47a56b969bc4129f2af7a2c04fe))
|
||||
|
||||
|
||||
## v0.47.0 (2024-04-23)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(utils/thread_checker): util class to check the thread leakage for closeEvent in qt ([`71cb80d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/71cb80d544c5f4ef499379a431ce0c17907c7ce8))
|
||||
* feat(widgets/stop_button): General stop button added ([`61ba08d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/61ba08d0b8df9f48f5c54c7c2b4e6d395206e7e6))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor(utils/container_utils): part of the logic regarding locating widgets moved from BECFigure to utility class ([`77ff796`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/77ff7962cc91bce15d1c91b67b75b66fbea612c3))
|
||||
* refactor: improve labe of auto_update script ([`40b5688`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/40b568815893cd41af3531bb2e647ca1e2e315f4))
|
||||
|
||||
|
||||
## v0.46.7 (2024-04-21)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(plot/image): monitors are now validated with current bec session ([`67a99a1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/67a99a1a19c261f9a1f09635f274cd9fbfe53639))
|
||||
|
||||
|
||||
## v0.46.6 (2024-04-19)
|
||||
## v0.60.0 (2024-06-08)
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: changed ophyd default branch to main ([`81484e8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/81484e8160a4a09907313ed747c27ab3b6cbfdc4))
|
||||
* ci: added git fetch for target branch ([`fc4f4f8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc4f4f81ad1be99cf5112f2188a46c5bed2679ee))
|
||||
|
||||
* ci: fixed pylint-check ([`6b1d582`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b1d5827d6599f06a3acd316060a8d25f0686d54))
|
||||
|
||||
* ci: cleanup ([`11173b9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11173b9c0a7dc4b36e35962042e5b86407da49f1))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat: added isort to bw-generate-cli ([`f0391f5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0391f59c9eb0a51b693fccfe2e399e869d35dda))
|
||||
|
||||
* feat: added entry point for bw-generate-cli ([`1c7f491`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1c7f4912ce5998e666276969bf4af8656d619a91))
|
||||
|
||||
* feat(cli): auto-discover rpc-enabled widgets ([`df1be10`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/df1be10057a5e85a3f35bef1c1b27366b6727276))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(cli): fixed support for devices as cli input ([`1111610`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1111610f3206c5c46db6b4bd1e8827f1a4cd9e3f))
|
||||
* fix: removed BECConnector from rpc client interface ([`6428e38`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6428e38ab94c15a2c904e75cc6404bb6d0394e04))
|
||||
|
||||
* fix: added bec_ipython_client as dependency; needed for jupyter widget ([`006a089`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/006a0894b85cba3b2773737ed6fe3e92c81cdee0))
|
||||
|
||||
## v0.46.5 (2024-04-19)
|
||||
* fix(BECFigure): removed duplicated user access for plot ([`954c576`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/954c576131f7deac669ddf9f51eeb1d41b6f92b7))
|
||||
|
||||
* fix(bec_connector): field validator should be a classmethod ([`867720a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/867720a897b6713bd0df9af71ffdd11a6a380f7d))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor: minor cleanup ([`3adf6cf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3adf6cfd586355c8b8ce7fdc9722f868e22287c5))
|
||||
|
||||
* refactor: disabled pylint for auto-gen client ([`b15816c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b15816ca9fd3e4ae87cca5fcfe029b4dfca570ca))
|
||||
|
||||
* refactor(isort): added bec_widgets as known first party package ([`9c5a471`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9c5a471234ed2928e4527b079436db2a807c5f6f))
|
||||
|
||||
* refactor(dock): parent_dock_area changed to orig_area (native for pyqtgraph) ([`2b40602`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2b40602bdc593ece0447ec926c2100414bd5cf67))
|
||||
|
||||
### Test
|
||||
|
||||
* test(rpc/bec_figure): test_rpc_plotting_shortcuts_init_configs extended by testing scatter z gradient for BECWaveform through RPC ([`a156803`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a1568033899bbcdbd457e697d6c8d478df26ba54))
|
||||
* test: added missing pylint statement to header ([`f662985`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f6629852ebc2b4ee239fa560cc310a5ae2627cf7))
|
||||
|
||||
|
||||
## v0.59.1 (2024-06-07)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(curve): set_color_map_z typo fixed in user access ([`e7838b0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e7838b0f2fc23b0a232ed7d68fbd7f3493a91b9e))
|
||||
|
||||
|
||||
## v0.59.0 (2024-06-07)
|
||||
|
||||
### Build
|
||||
|
||||
* build: added webengine dependency ([`d56c549`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d56c5493cd28f379d04a79d90b01c73b0760da1b))
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: merged additional tests to parallel matrix job ([`178fe4d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/178fe4d2da3a959f7cd90e7ea0f47314dc1ef4ed))
|
||||
|
||||
* ci: added webengine dependencies ([`2d79ef8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2d79ef8fe5e52c61f4a78782770377cd6b41958b))
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs: added website docs ([`cf6e5a4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cf6e5a40fc8320e9898a446a5bf14b77e94ef013))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(widget): added simple website widget with rpc ([`64abd67`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/64abd67b9b416bff9c89880b248d6e8639aa1e70))
|
||||
|
||||
|
||||
## v0.58.1 (2024-06-07)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(dock): new dock can be detached upon creation ([`02a2608`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02a26086c4540127a11c235cba30afc4fd712007))
|
||||
|
||||
|
||||
## v0.58.0 (2024-06-07)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(utils.colors): general color validators ([`3094632`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/30946321348abc349fb4003dc39d0232dc19606c))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: bar colormap dynamic setting ([`67fd5e8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/67fd5e8581f60fe64027ac57f1f12cefa4d28343))
|
||||
|
||||
* fix: formatting isort ([`bf699ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bf699ec1fbe2aacd31854e84fb0438c336840fcf))
|
||||
|
||||
* fix(curve): 2D scatter updated if color_map_z is changed ([`6985ff0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6985ff0fcef9791b53198206ec8cbccd1d65ef99))
|
||||
|
||||
* fix(curve): color_map_z setting works ([`33f7be4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/33f7be42c512402dab3fdd9781a8234e3ec5f4ba))
|
||||
|
||||
### Test
|
||||
|
||||
* test(color): validation tests added ([`c0ddece`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c0ddeceeeabacbf33019a8f24b18821926dc17ac))
|
||||
|
||||
|
||||
## v0.57.7 (2024-06-07)
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -1,2 +1 @@
|
||||
from .auto_updates import AutoUpdates, ScanInfo
|
||||
from .client import BECDockArea, BECFigure
|
||||
from .client import *
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
|
||||
from pydantic import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import BECFigure
|
||||
from .client import BECDockArea, BECFigure
|
||||
|
||||
|
||||
class ScanInfo(BaseModel):
|
||||
@@ -15,12 +15,24 @@ class ScanInfo(BaseModel):
|
||||
scan_report_devices: list
|
||||
monitored_devices: list
|
||||
status: str
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class AutoUpdates:
|
||||
def __init__(self, figure: BECFigure, enabled: bool = True):
|
||||
self.enabled = enabled
|
||||
self.figure = figure
|
||||
create_default_dock: bool = False
|
||||
enabled: bool = False
|
||||
dock_name: str = None
|
||||
|
||||
def __init__(self, gui: BECDockArea):
|
||||
self.gui = gui
|
||||
|
||||
def start_default_dock(self):
|
||||
"""
|
||||
Create a default dock for the auto updates.
|
||||
"""
|
||||
dock = self.gui.add_dock("default_figure")
|
||||
dock.add_widget("BECFigure")
|
||||
self.dock_name = "default_figure"
|
||||
|
||||
@staticmethod
|
||||
def get_scan_info(msg) -> ScanInfo:
|
||||
@@ -44,6 +56,18 @@ class AutoUpdates:
|
||||
status=status,
|
||||
)
|
||||
|
||||
def get_default_figure(self) -> BECFigure | None:
|
||||
"""
|
||||
Get the default figure from the GUI.
|
||||
"""
|
||||
dock = self.gui.panels.get(self.dock_name, [])
|
||||
if not dock:
|
||||
return None
|
||||
widgets = dock.widget_list
|
||||
if not widgets:
|
||||
return None
|
||||
return widgets[0]
|
||||
|
||||
def run(self, msg):
|
||||
"""
|
||||
Run the update function if enabled.
|
||||
@@ -86,33 +110,44 @@ class AutoUpdates:
|
||||
"""
|
||||
Simple line scan.
|
||||
"""
|
||||
fig = self.get_default_figure()
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = self.get_selected_device(info.monitored_devices, self.figure.selected_device)
|
||||
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
if not dev_y:
|
||||
return
|
||||
self.figure.clear_all()
|
||||
plt = self.figure.plot(dev_x, dev_y)
|
||||
fig.clear_all()
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {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.
|
||||
"""
|
||||
fig = self.get_default_figure()
|
||||
if not fig:
|
||||
return
|
||||
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}")
|
||||
dev_z = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
fig.clear_all()
|
||||
plt = fig.plot(
|
||||
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
|
||||
)
|
||||
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.
|
||||
"""
|
||||
fig = self.get_default_figure()
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = self.get_selected_device(info.monitored_devices, self.figure.selected_device)
|
||||
dev_y = self.get_selected_device(info.monitored_devices, self.gui.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}")
|
||||
fig.clear_all()
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
|
||||
@@ -12,18 +12,25 @@ import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib import MessageEndpoints, ServiceConfig, messages
|
||||
from bec_lib.connector import MessageObject
|
||||
from bec_lib.device import DeviceBase
|
||||
from qtpy.QtCore import QCoreApplication
|
||||
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 QEventLoop, QSocketNotifier, QTimer
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.device import DeviceBase
|
||||
|
||||
from bec_widgets.cli.client import BECDockArea, BECFigure
|
||||
|
||||
from bec_lib.serialization import MsgpackSerialization
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -56,20 +63,66 @@ def rpc_call(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _get_output(process) -> None:
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
os.set_blocking(process.stderr.fileno(), False)
|
||||
while process.poll() is None:
|
||||
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
|
||||
if process.stdout in readylist:
|
||||
output = process.stdout.read(1024)
|
||||
if output:
|
||||
print(output, end="")
|
||||
if process.stderr in readylist:
|
||||
error_output = process.stderr.read(1024)
|
||||
if error_output:
|
||||
print(error_output, end="", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _start_plot_process(gui_id, gui_class, config) -> None:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
command = [
|
||||
"bec-gui-server",
|
||||
"--id",
|
||||
gui_id,
|
||||
"--config",
|
||||
config,
|
||||
"--gui_class",
|
||||
gui_class.__name__,
|
||||
]
|
||||
env_dict = os.environ.copy()
|
||||
env_dict["PYTHONUNBUFFERED"] = "1"
|
||||
process = subprocess.Popen(
|
||||
command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env_dict
|
||||
)
|
||||
process_output_processing_thread = threading.Thread(target=_get_output, args=(process,))
|
||||
process_output_processing_thread.start()
|
||||
return process, process_output_processing_thread
|
||||
|
||||
|
||||
class BECGuiClientMixin:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._process = None
|
||||
self.update_script = self._get_update_script()
|
||||
self._process_output_processing_thread = None
|
||||
self.auto_updates = self._get_update_script()
|
||||
self._target_endpoint = MessageEndpoints.scan_status()
|
||||
self._selected_device = None
|
||||
self.stderr_output = []
|
||||
|
||||
def _get_update_script(self) -> AutoUpdates:
|
||||
def _get_update_script(self) -> AutoUpdates | None:
|
||||
eps = imd.entry_points(group="bec.widgets.auto_updates")
|
||||
for ep in eps:
|
||||
if ep.name == "plugin_widgets_update":
|
||||
return ep.load()(figure=self)
|
||||
try:
|
||||
return ep.load()(gui=self)
|
||||
except Exception as e:
|
||||
print(f"Error loading auto update script from plugin: {str(e)}")
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -81,7 +134,7 @@ class BECGuiClientMixin:
|
||||
|
||||
@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
|
||||
@@ -95,7 +148,7 @@ class BECGuiClientMixin:
|
||||
|
||||
@staticmethod
|
||||
def _handle_msg_update(msg: MessageObject, parent: BECGuiClientMixin) -> None:
|
||||
if parent.update_script is not None:
|
||||
if parent.auto_updates is not None:
|
||||
# pylint: disable=protected-access
|
||||
parent._update_script_msg_parser(msg.value)
|
||||
|
||||
@@ -103,14 +156,17 @@ class BECGuiClientMixin:
|
||||
if isinstance(msg, messages.ScanStatusMessage):
|
||||
if not self.gui_is_alive():
|
||||
return
|
||||
self.update_script.run(msg)
|
||||
self.auto_updates.run(msg)
|
||||
|
||||
def show(self) -> None:
|
||||
"""
|
||||
Show the figure.
|
||||
"""
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
self._start_plot_process()
|
||||
self._start_update_script()
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id, self.__class__, self._client._service_config.redis
|
||||
)
|
||||
while not self.gui_is_alive():
|
||||
print("Waiting for GUI to start...")
|
||||
time.sleep(1)
|
||||
@@ -119,44 +175,11 @@ class BECGuiClientMixin:
|
||||
"""
|
||||
Close the figure.
|
||||
"""
|
||||
if self._process is None:
|
||||
return
|
||||
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
|
||||
self._client.shutdown()
|
||||
|
||||
def _start_plot_process(self) -> None:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
"""
|
||||
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,
|
||||
"--config",
|
||||
config,
|
||||
"--gui_class",
|
||||
gui_class,
|
||||
]
|
||||
self._process = subprocess.Popen(
|
||||
command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
self._process_output_processing_thread = threading.Thread(target=self._get_output)
|
||||
self._process_output_processing_thread.start()
|
||||
if self._process:
|
||||
self._process.terminate()
|
||||
self._process_output_processing_thread.join()
|
||||
self._process = None
|
||||
|
||||
def print_log(self) -> None:
|
||||
"""
|
||||
@@ -168,26 +191,6 @@ class BECGuiClientMixin:
|
||||
# Flush list
|
||||
self.stderr_output.clear()
|
||||
|
||||
def _get_output(self) -> str:
|
||||
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."""
|
||||
@@ -198,6 +201,48 @@ class RPCResponseTimeoutError(Exception):
|
||||
)
|
||||
|
||||
|
||||
class QtRedisMessageWaiter:
|
||||
def __init__(self, redis_connector, message_to_wait):
|
||||
self.ev_loop = QEventLoop()
|
||||
self.response = None
|
||||
self.connector = redis_connector
|
||||
self.message_to_wait = message_to_wait
|
||||
self.pubsub = redis_connector._redis_conn.pubsub()
|
||||
self.pubsub.subscribe(self.message_to_wait.endpoint)
|
||||
fd = self.pubsub.connection._sock.fileno()
|
||||
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
|
||||
self.notifier.activated.connect(self._pubsub_readable)
|
||||
|
||||
def _msg_received(self, msg_obj):
|
||||
self.response = msg_obj.value
|
||||
self.ev_loop.quit()
|
||||
|
||||
def wait(self, timeout=1):
|
||||
timer = QTimer()
|
||||
timer.singleShot(timeout * 1000, self.ev_loop.quit)
|
||||
self.ev_loop.exec_()
|
||||
timer.stop()
|
||||
self.notifier.setEnabled(False)
|
||||
self.pubsub.close()
|
||||
return self.response
|
||||
|
||||
def _pubsub_readable(self, fd):
|
||||
while True:
|
||||
msg = self.pubsub.get_message()
|
||||
if msg:
|
||||
if msg["type"] == "subscribe":
|
||||
# get_message buffers, so we may already have the answer
|
||||
# let's check...
|
||||
continue
|
||||
else:
|
||||
break
|
||||
else:
|
||||
return
|
||||
channel = msg["channel"].decode()
|
||||
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
|
||||
self.connector._execute_callback(self._msg_received, msg, {})
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
||||
self._client = BECDispatcher().client
|
||||
@@ -224,7 +269,7 @@ class RPCBase:
|
||||
parent = parent._parent
|
||||
return parent
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, **kwargs):
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
@@ -246,16 +291,24 @@ class RPCBase:
|
||||
|
||||
# pylint: disable=protected-access
|
||||
receiver = self._root._gui_id
|
||||
if wait_for_rpc_response:
|
||||
redis_msg = QtRedisMessageWaiter(
|
||||
self._client.connector, MessageEndpoints.gui_instruction_response(request_id)
|
||||
)
|
||||
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
if not wait_for_rpc_response:
|
||||
return None
|
||||
response = self._wait_for_response(request_id)
|
||||
# get class name
|
||||
if not response.content["accepted"]:
|
||||
raise ValueError(response.content["message"]["error"])
|
||||
msg_result = response.content["message"].get("result")
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
if wait_for_rpc_response:
|
||||
response = redis_msg.wait(timeout)
|
||||
|
||||
if response is None:
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
|
||||
# get class name
|
||||
if not response.accepted:
|
||||
raise ValueError(response.message["error"])
|
||||
msg_result = response.message.get("result")
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
@@ -278,31 +331,6 @@ class RPCBase:
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
|
||||
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() 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)
|
||||
time.sleep(0.1)
|
||||
if response is None and (time.time() - start_time) >= timeout:
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
|
||||
return response
|
||||
|
||||
def gui_is_alive(self):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
# pylint: disable=missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from typing import Literal
|
||||
|
||||
import black
|
||||
import isort
|
||||
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import get_overloads
|
||||
@@ -14,30 +22,52 @@ else:
|
||||
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
|
||||
)
|
||||
|
||||
def get_overloads(obj):
|
||||
# Dummy function for Python versions before 3.11
|
||||
def get_overloads(_obj):
|
||||
"""
|
||||
Dummy function for Python versions before 3.11.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
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, BECGuiClientMixin
|
||||
from typing import Literal, Optional, overload"""
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
|
||||
from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
|
||||
|
||||
# pylint: skip-file"""
|
||||
|
||||
self.content = ""
|
||||
|
||||
def generate_client(self, published_classes: list):
|
||||
def generate_client(
|
||||
self, published_classes: dict[Literal["connector_classes", "top_level_classes"], list[type]]
|
||||
):
|
||||
"""
|
||||
Generate the client for the published classes.
|
||||
|
||||
Args:
|
||||
published_classes(list): The list of published classes (e.g. [BECWaveform1D, BECFigure]).
|
||||
published_classes(dict): A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
|
||||
"""
|
||||
for cls in published_classes:
|
||||
self.write_client_enum(published_classes["top_level_classes"])
|
||||
for cls in published_classes["connector_classes"]:
|
||||
self.content += "\n\n"
|
||||
self.generate_content_for_class(cls)
|
||||
|
||||
def write_client_enum(self, published_classes: list[type]):
|
||||
"""
|
||||
Write the client enum to the content.
|
||||
"""
|
||||
self.content += """
|
||||
class Widgets(str, enum.Enum):
|
||||
\"\"\"
|
||||
Enum for the available widgets.
|
||||
\"\"\"
|
||||
"""
|
||||
for cls in published_classes:
|
||||
self.content += f'{cls.__name__} = "{cls.__name__}"\n '
|
||||
|
||||
def generate_content_for_class(self, cls):
|
||||
"""
|
||||
Generate the content for the class.
|
||||
@@ -47,11 +77,6 @@ from typing import Literal, Optional, overload"""
|
||||
"""
|
||||
|
||||
class_name = cls.__name__
|
||||
module = cls.__module__
|
||||
|
||||
# Generate the header
|
||||
# self.header += f"""
|
||||
# from {module} import {class_name}"""
|
||||
|
||||
# Generate the content
|
||||
if cls.__name__ == "BECDockArea":
|
||||
@@ -101,34 +126,85 @@ class {class_name}(RPCBase):"""
|
||||
except black.NothingChanged:
|
||||
formatted_content = full_content
|
||||
|
||||
isort.Config(
|
||||
profile="black",
|
||||
line_length=100,
|
||||
multi_line_output=3,
|
||||
include_trailing_comma=True,
|
||||
known_first_party=["bec_widgets"],
|
||||
)
|
||||
formatted_content = isort.code(formatted_content)
|
||||
|
||||
with open(file_name, "w", encoding="utf-8") as file:
|
||||
file.write(formatted_content)
|
||||
|
||||
@staticmethod
|
||||
def get_rpc_classes(
|
||||
repo_name: str,
|
||||
) -> dict[Literal["connector_classes", "top_level_classes"], list[type]]:
|
||||
"""
|
||||
Get all RPC-enabled classes in the specified repository.
|
||||
|
||||
Args:
|
||||
repo_name(str): The name of the repository.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
|
||||
"""
|
||||
connector_classes = []
|
||||
top_level_classes = []
|
||||
anchor_module = importlib.import_module(f"{repo_name}.widgets")
|
||||
directory = os.path.dirname(anchor_module.__file__)
|
||||
for root, _, files in sorted(os.walk(directory)):
|
||||
for file in files:
|
||||
if not file.endswith(".py") or file.startswith("__"):
|
||||
continue
|
||||
|
||||
path = os.path.join(root, file)
|
||||
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
|
||||
if len(subs) == 1 and not subs[0]:
|
||||
module_name = file.split(".")[0]
|
||||
else:
|
||||
module_name = ".".join(subs + [file.split(".")[0]])
|
||||
|
||||
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
|
||||
|
||||
for name in dir(module):
|
||||
obj = getattr(module, name)
|
||||
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
|
||||
continue
|
||||
if isinstance(obj, type) and issubclass(obj, BECConnector):
|
||||
connector_classes.append(obj)
|
||||
if len(subs) == 1 and (
|
||||
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
|
||||
):
|
||||
top_level_classes.append(obj)
|
||||
|
||||
return {"connector_classes": connector_classes, "top_level_classes": top_level_classes}
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for the script, controlled by command line arguments.
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="Auto-generate the client for RPC widgets")
|
||||
parser.add_argument("--core", action="store_true", help="Whether to generate the core client")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.core:
|
||||
current_path = os.path.dirname(__file__)
|
||||
client_path = os.path.join(current_path, "client.py")
|
||||
|
||||
rpc_classes = ClientGenerator.get_rpc_classes("bec_widgets")
|
||||
rpc_classes["connector_classes"].sort(key=lambda x: x.__name__)
|
||||
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(client_path)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import os
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.widgets.dock import BECDock, BECDockArea
|
||||
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
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
client_path = os.path.join(current_path, "client.py")
|
||||
clss = [
|
||||
BECPlotBase,
|
||||
BECWaveform,
|
||||
BECFigure,
|
||||
BECCurve,
|
||||
BECImageShow,
|
||||
BECConnector,
|
||||
BECImageItem,
|
||||
BECMotorMap,
|
||||
BECDock,
|
||||
BECDockArea,
|
||||
]
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(clss)
|
||||
generator.write(client_path)
|
||||
sys.argv = ["generate_cli.py", "--core"]
|
||||
main()
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
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
|
||||
from bec_widgets.widgets.text_box.text_box import TextBox
|
||||
from bec_widgets.widgets.website.website import WebsiteWidget
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
"""Handler class for creating widgets from RPC messages."""
|
||||
|
||||
widget_classes = {"BECFigure": BECFigure}
|
||||
widget_classes = {
|
||||
"BECFigure": BECFigure,
|
||||
"SpiralProgressBar": SpiralProgressBar,
|
||||
"Website": WebsiteWidget,
|
||||
"TextBox": TextBox,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_widget(widget_type, **kwargs) -> BECConnector:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import inspect
|
||||
import threading
|
||||
import time
|
||||
from typing import Literal, Union
|
||||
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
|
||||
@@ -11,11 +10,11 @@ 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,
|
||||
@@ -115,7 +114,7 @@ class BECWidgetsCLIServer:
|
||||
self.client.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
def main():
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
@@ -124,11 +123,13 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
import bec_widgets
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("BEC Figure")
|
||||
current_path = os.path.dirname(__file__)
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
icon = QIcon()
|
||||
icon.addFile(os.path.join(current_path, "bec_widgets_icon.png"), size=QSize(48, 48))
|
||||
icon.addFile(os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48))
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
win = QMainWindow()
|
||||
@@ -165,3 +166,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
app.aboutToQuit.connect(server.shutdown)
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
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 scipy.stats import multivariate_normal
|
||||
|
||||
|
||||
class EigerPlot(QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
# 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)
|
||||
|
||||
# Set widow name
|
||||
self.setWindowTitle("Eiger Plot")
|
||||
|
||||
self.hist_lims = None
|
||||
self.mask = None
|
||||
self.image = None
|
||||
|
||||
# UI
|
||||
self.init_ui()
|
||||
self.hook_signals()
|
||||
self.key_bindings()
|
||||
|
||||
# ZMQ Consumer
|
||||
self._zmq_consumer_exit_event = threading.Event()
|
||||
self._zmq_consumer_thread = self.start_zmq_consumer()
|
||||
|
||||
def close(self):
|
||||
super().close()
|
||||
self._zmq_consumer_exit_event.set()
|
||||
self._zmq_consumer_thread.join()
|
||||
|
||||
def init_ui(self):
|
||||
# Create Plot and add ImageItem
|
||||
self.plot_item = pg.PlotItem()
|
||||
self.plot_item.setAspectLocked(True)
|
||||
self.imageItem = pg.ImageItem()
|
||||
self.plot_item.addItem(self.imageItem)
|
||||
|
||||
# Setting up histogram
|
||||
self.hist = pg.HistogramLUTItem()
|
||||
self.hist.setImageItem(self.imageItem)
|
||||
self.hist.gradient.loadPreset("magma")
|
||||
self.update_hist()
|
||||
|
||||
# Adding Items to Graphical Layout
|
||||
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)
|
||||
|
||||
# SpinBoxes
|
||||
self.doubleSpinBox_hist_min.valueChanged.connect(self.update_hist)
|
||||
self.doubleSpinBox_hist_max.valueChanged.connect(self.update_hist)
|
||||
|
||||
# Signal/Slots
|
||||
self.update_signal.connect(self.on_image_update)
|
||||
|
||||
def key_bindings(self):
|
||||
# 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")
|
||||
|
||||
max_index = self.comboBox_rotation.count() - 1 # Maximum valid index
|
||||
|
||||
rotate_plus.activated.connect(
|
||||
lambda: self.comboBox_rotation.setCurrentIndex(
|
||||
min(self.comboBox_rotation.currentIndex() + 1, max_index)
|
||||
)
|
||||
)
|
||||
|
||||
rotate_minus.activated.connect(
|
||||
lambda: self.comboBox_rotation.setCurrentIndex(
|
||||
max(self.comboBox_rotation.currentIndex() - 1, 0)
|
||||
)
|
||||
)
|
||||
|
||||
# Key bindings for transpose
|
||||
transpose = QShortcut(QKeySequence("Ctrl+T"), self)
|
||||
transpose.activated.connect(self.checkBox_transpose.toggle)
|
||||
|
||||
FFT = QShortcut(QKeySequence("Ctrl+F"), self)
|
||||
FFT.activated.connect(self.checkBox_FFT.toggle)
|
||||
self.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")
|
||||
|
||||
mask = QShortcut(QKeySequence("Ctrl+M"), self)
|
||||
mask.activated.connect(self.pushButton_mask.click)
|
||||
self.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")
|
||||
|
||||
def update_hist(self):
|
||||
self.hist_levels = [
|
||||
self.doubleSpinBox_hist_min.value(),
|
||||
self.doubleSpinBox_hist_max.value(),
|
||||
]
|
||||
self.hist.setLevels(min=self.hist_levels[0], max=self.hist_levels[1])
|
||||
self.hist.setHistogramRange(
|
||||
self.hist_levels[0] - 0.1 * self.hist_levels[0],
|
||||
self.hist_levels[1] + 0.1 * self.hist_levels[1],
|
||||
)
|
||||
|
||||
def load_mask_dialog(self):
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.ReadOnly
|
||||
file_name, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Mask File", "", "H5 Files (*.h5);;All Files (*)", options=options
|
||||
)
|
||||
if file_name:
|
||||
self.load_mask(file_name)
|
||||
|
||||
def load_mask(self, path):
|
||||
try:
|
||||
with h5py.File(path, "r") as f:
|
||||
self.mask = f["data"][...]
|
||||
if self.mask is not None:
|
||||
# Set label to mask name without path
|
||||
self.label_mask.setText(os.path.basename(path))
|
||||
except KeyError as e:
|
||||
# Update GUI with the error message
|
||||
print(f"Error: {str(e)}")
|
||||
|
||||
def delete_mask(self):
|
||||
self.mask = None
|
||||
self.label_mask.setText("No Mask")
|
||||
|
||||
@pyqtSlot()
|
||||
def on_image_update(self):
|
||||
# TODO first rotate then transpose
|
||||
if self.mask is not None:
|
||||
# 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():
|
||||
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.checkBox_transpose.isChecked(): # transpose
|
||||
self.image = np.transpose(self.image)
|
||||
|
||||
if self.checkBox_log.isChecked():
|
||||
self.image = np.log10(self.image)
|
||||
|
||||
self.imageItem.setImage(self.image, autoLevels=False)
|
||||
|
||||
###############################
|
||||
# ZMQ Consumer
|
||||
###############################
|
||||
|
||||
def start_zmq_consumer(self):
|
||||
consumer_thread = threading.Thread(
|
||||
target=self.zmq_consumer, args=(self._zmq_consumer_exit_event,), daemon=True
|
||||
)
|
||||
consumer_thread.start()
|
||||
return consumer_thread
|
||||
|
||||
def zmq_consumer(self, exit_event):
|
||||
print("starting consumer")
|
||||
live_stream_url = "tcp://129.129.95.38:20000"
|
||||
receiver = zmq.Context().socket(zmq.SUB)
|
||||
receiver.connect(live_stream_url)
|
||||
receiver.setsockopt_string(zmq.SUBSCRIBE, "")
|
||||
|
||||
poller = zmq.Poller()
|
||||
poller.register(receiver, zmq.POLLIN)
|
||||
|
||||
# code could be a bit simpler here, testing exit_event in
|
||||
# 'while' condition, but like this it is easier for the
|
||||
# 'test_zmq_consumer' test
|
||||
while True:
|
||||
if poller.poll(1000): # 1s timeout
|
||||
raw_meta, raw_data = receiver.recv_multipart(zmq.NOBLOCK)
|
||||
|
||||
meta = json.loads(raw_meta.decode("utf-8"))
|
||||
self.image = np.frombuffer(raw_data, dtype=meta["type"]).reshape(meta["shape"])
|
||||
self.update_signal.emit()
|
||||
if exit_event.is_set():
|
||||
break
|
||||
|
||||
receiver.disconnect(live_stream_url)
|
||||
|
||||
###############################
|
||||
# just simulations from here
|
||||
###############################
|
||||
|
||||
def show_help_dialog(self):
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Help")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Key bindings section
|
||||
layout.addWidget(QLabel("Keyboard Shortcuts:"))
|
||||
|
||||
key_bindings = [
|
||||
("Ctrl+A", "Increase rotation"),
|
||||
("Ctrl+Z", "Decrease rotation"),
|
||||
("Ctrl+T", "Toggle transpose"),
|
||||
("Ctrl+F", "Toggle FFT"),
|
||||
("Ctrl+L", "Toggle log scale"),
|
||||
("Ctrl+M", "Load mask"),
|
||||
("Ctrl+D", "Delete mask"),
|
||||
]
|
||||
|
||||
for keys, action in key_bindings:
|
||||
layout.addWidget(QLabel(f"{keys} - {action}"))
|
||||
|
||||
# Separator
|
||||
separator = QFrame()
|
||||
separator.setFrameShape(QFrame.HLine)
|
||||
separator.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(separator)
|
||||
|
||||
# Histogram section
|
||||
layout.addWidget(QLabel("Histogram:"))
|
||||
layout.addWidget(
|
||||
QLabel(
|
||||
"Use the Double Spin Boxes to adjust the minimum and maximum values of the histogram."
|
||||
)
|
||||
)
|
||||
|
||||
# Another Separator
|
||||
another_separator = QFrame()
|
||||
another_separator.setFrameShape(QFrame.HLine)
|
||||
another_separator.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(another_separator)
|
||||
|
||||
# Mask section
|
||||
layout.addWidget(QLabel("Mask:"))
|
||||
layout.addWidget(
|
||||
QLabel(
|
||||
"Use 'Load Mask' to load a mask from an H5 file. 'Delete Mask' removes the current mask."
|
||||
)
|
||||
)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
dialog.exec()
|
||||
|
||||
###############################
|
||||
# just simulations from here
|
||||
###############################
|
||||
# def start_sim_stream(self):
|
||||
# sim_stream_thread = threading.Thread(target=self.sim_stream, daemon=True)
|
||||
# sim_stream_thread.start()
|
||||
#
|
||||
# def sim_stream(self):
|
||||
# for i in range(100):
|
||||
# # Generate 100x100 image of random noise
|
||||
# self.image = np.random.rand(100, 100) * 0.2
|
||||
#
|
||||
# # Define Gaussian parameters
|
||||
# x, y = np.mgrid[0:50, 0:50]
|
||||
# pos = np.dstack((x, y))
|
||||
#
|
||||
# # Center at (25, 25) longer along y-axis
|
||||
# rv = multivariate_normal(mean=[25, 25], cov=[[25, 0], [0, 80]])
|
||||
#
|
||||
# # Generate Gaussian in the first quadrant
|
||||
# gaussian_quadrant = rv.pdf(pos) * 40
|
||||
#
|
||||
# # Place Gaussian in the first quadrant
|
||||
# self.image[0:50, 0:50] += gaussian_quadrant * 10
|
||||
#
|
||||
# self.update_signal.emit()
|
||||
# time.sleep(0.1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
plot = EigerPlot()
|
||||
plot.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,207 +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>874</width>
|
||||
<height>762</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>Plot Control</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Histogram MIN</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox_hist_min">
|
||||
<property name="minimum">
|
||||
<double>-100000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>100000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Histogram MAX</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox_hist_max">
|
||||
<property name="minimum">
|
||||
<double>-100000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>100000.000000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>2.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Data Control</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_FFT">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>FFT</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_log">
|
||||
<property name="text">
|
||||
<string>log</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_mask">
|
||||
<property name="text">
|
||||
<string>Load Mask</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_delete_mask">
|
||||
<property name="text">
|
||||
<string>Delete Mask</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_3">
|
||||
<property name="title">
|
||||
<string>Orientation</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="2" column="1">
|
||||
<widget class="QComboBox" name="comboBox_rotation">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>0</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>90</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>180</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>270</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Rotation</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0" colspan="2">
|
||||
<widget class="QCheckBox" name="checkBox_transpose">
|
||||
<property name="text">
|
||||
<string>Transpose</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_4">
|
||||
<property name="title">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_mask">
|
||||
<property name="text">
|
||||
<string>No Mask</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_help">
|
||||
<property name="text">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="GraphicsLayoutWidget" name="glw"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -2,34 +2,32 @@ import os
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph.Qt import QtWidgets, uic
|
||||
import qdarktheme
|
||||
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.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
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.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
|
||||
|
||||
class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.kernel_manager = QtInProcessKernelManager()
|
||||
self.kernel_manager.start_kernel(show_banner=False)
|
||||
self.kernel_client = self.kernel_manager.client()
|
||||
self.kernel_client.start_channels()
|
||||
|
||||
self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
|
||||
# self.set_console_font_size(70)
|
||||
|
||||
def shutdown_kernel(self):
|
||||
self.kernel_client.stop_channels()
|
||||
self.kernel_manager.shutdown_kernel()
|
||||
# class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
|
||||
# def __init__(self):
|
||||
# super().__init__()
|
||||
#
|
||||
# self.kernel_manager = QtInProcessKernelManager()
|
||||
# self.kernel_manager.start_kernel(show_banner=False)
|
||||
# self.kernel_client = self.kernel_manager.client()
|
||||
# self.kernel_client.start_channels()
|
||||
#
|
||||
# self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
|
||||
#
|
||||
# def shutdown_kernel(self):
|
||||
# self.kernel_client.stop_channels()
|
||||
# self.kernel_manager.shutdown_kernel()
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
@@ -39,45 +37,41 @@ 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,
|
||||
"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,
|
||||
}
|
||||
)
|
||||
if self.console.inprocess is True:
|
||||
self.console.kernel_manager.kernel.shell.push(
|
||||
{
|
||||
"np": np,
|
||||
"pg": pg,
|
||||
"fig": self.figure,
|
||||
"dock": self.dock,
|
||||
"w1": self.w1,
|
||||
"w2": self.w2,
|
||||
"w3": self.w3,
|
||||
"d0": self.d0,
|
||||
"d1": self.d1,
|
||||
"d2": self.d2,
|
||||
"fig0": self.fig0,
|
||||
"fig1": self.fig1,
|
||||
"fig2": self.fig2,
|
||||
"bar": self.bar,
|
||||
}
|
||||
)
|
||||
|
||||
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.dock_placeholder)
|
||||
self.dock_layout = QVBoxLayout(self.ui.dock_placeholder)
|
||||
self.dock = BECDockArea(gui_id="remote")
|
||||
self.dock_layout.addWidget(self.dock)
|
||||
|
||||
@@ -87,15 +81,15 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
# init dock for testing
|
||||
self._init_dock()
|
||||
|
||||
self.console_layout = QVBoxLayout(self.widget_console)
|
||||
self.console = JupyterConsoleWidget()
|
||||
self.console_layout = QVBoxLayout(self.ui.widget_console)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
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="samy", z_name="bpm4i", color_map_z="cividis")
|
||||
self.figure.motor_map("samx", "samy")
|
||||
self.figure.image("eiger", color_map="viridis", vrange=(0, 100))
|
||||
self.figure.add_plot(x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="magma")
|
||||
|
||||
self.figure.change_layout(2, 2)
|
||||
|
||||
@@ -104,30 +98,26 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
self.w3 = self.figure[1, 0]
|
||||
|
||||
# curves for w1
|
||||
self.w1.add_curve_scan("samx", "samy", "bpm4i", pen_style="dash")
|
||||
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.label_1 = QtWidgets.QLabel("some scan info label with useful information")
|
||||
|
||||
self.label_2 = QtWidgets.QLabel("label which is added separately")
|
||||
self.label_3 = QtWidgets.QLabel("Label above figure")
|
||||
self.d0 = self.dock.add_dock(name="dock_0")
|
||||
self.fig0 = self.d0.add_widget("BECFigure")
|
||||
data = np.random.rand(10, 2)
|
||||
self.fig0.plot(data, label="2d Data")
|
||||
self.fig0.image("eiger", vrange=(0, 100))
|
||||
|
||||
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.label_1, position="right")
|
||||
self.d3 = self.dock.add_dock(name="figure")
|
||||
self.fig_dock3 = BECFigure()
|
||||
self.fig_dock3.plot("samx", "bpm4d")
|
||||
self.d3.add_widget(self.label_3)
|
||||
self.d3.add_widget(self.button_3)
|
||||
self.d3.add_widget(self.fig_dock3)
|
||||
self.d1 = self.dock.add_dock(name="dock_1", position="right")
|
||||
self.fig1 = self.d1.add_widget("BECFigure")
|
||||
self.fig1.plot(x_name="samx", y_name="bpm4i")
|
||||
self.fig1.plot(x_name="samx", y_name="bpm3a")
|
||||
|
||||
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
|
||||
self.fig2 = self.d2.add_widget("BECFigure", row=0, col=0)
|
||||
self.fig2.plot(x_name="samx", y_name="bpm4i")
|
||||
self.bar = self.d2.add_widget("SpiralProgressBar", row=0, col=1)
|
||||
self.bar.set_diameter(200)
|
||||
|
||||
self.dock.save_state()
|
||||
|
||||
@@ -142,16 +132,22 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
import bec_widgets
|
||||
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
qdarktheme.setup_theme("auto")
|
||||
icon = QIcon()
|
||||
icon.addFile("terminal_icon.png", size=QSize(48, 48))
|
||||
icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48))
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
win = JupyterConsoleWindow()
|
||||
win.show()
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
# import simulation_progress as SP
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import MessageEndpoints, messages
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class StreamApp(QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
new_scan_id = pyqtSignal(str)
|
||||
|
||||
def __init__(self, device, sub_device):
|
||||
super().__init__()
|
||||
pg.setConfigOptions(background="w", foreground="k")
|
||||
self.init_ui()
|
||||
|
||||
self.setWindowTitle("MCA readout")
|
||||
|
||||
self.data = None
|
||||
self.scan_id = None
|
||||
self.stream_consumer = None
|
||||
|
||||
self.device = device
|
||||
self.sub_device = sub_device
|
||||
|
||||
self.start_device_consumer()
|
||||
|
||||
# self.start_device_consumer(self.device) # for simulation
|
||||
|
||||
self.new_scan_id.connect(self.create_new_stream_consumer)
|
||||
self.update_signal.connect(self.plot_new)
|
||||
|
||||
def init_ui(self):
|
||||
# Create layout and add widgets
|
||||
self.layout = QVBoxLayout()
|
||||
self.setLayout(self.layout)
|
||||
|
||||
# Create plot
|
||||
self.glw = pg.GraphicsLayoutWidget()
|
||||
self.layout.addWidget(self.glw)
|
||||
|
||||
# Create Plot and add ImageItem
|
||||
self.plot_item = pg.PlotItem()
|
||||
self.plot_item.setAspectLocked(False)
|
||||
self.imageItem = pg.ImageItem()
|
||||
# self.plot_item1D = pg.PlotItem()
|
||||
# self.plot_item.addItem(self.imageItem)
|
||||
# self.plot_item.addItem(self.plot_item1D)
|
||||
|
||||
# Setting up histogram
|
||||
# self.hist = pg.HistogramLUTItem()
|
||||
# self.hist.setImageItem(self.imageItem)
|
||||
# self.hist.gradient.loadPreset("magma")
|
||||
# self.update_hist()
|
||||
|
||||
# Adding Items to Graphical Layout
|
||||
self.glw.addItem(self.plot_item)
|
||||
# self.glw.addItem(self.hist)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def create_new_stream_consumer(self, scan_id: str):
|
||||
print(f"Creating new stream consumer for scan_id: {scan_id}")
|
||||
|
||||
self.connect_stream_consumer(scan_id, self.device)
|
||||
|
||||
def connect_stream_consumer(self, scan_id, device):
|
||||
if self.stream_consumer is not None:
|
||||
self.stream_consumer.shutdown()
|
||||
|
||||
self.stream_consumer = connector.stream_consumer(
|
||||
topics=MessageEndpoints.device_async_readback(scan_id=scan_id, device=device),
|
||||
cb=self._streamer_cb,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.stream_consumer.start()
|
||||
|
||||
def start_device_consumer(self):
|
||||
self.device_consumer = connector.consumer(
|
||||
topics=MessageEndpoints.scan_status(), cb=self._device_cv, parent=self
|
||||
)
|
||||
|
||||
self.device_consumer.start()
|
||||
|
||||
# def start_device_consumer(self, device): #for simulation
|
||||
# self.device_consumer = connector.consumer(
|
||||
# topics=MessageEndpoints.device_status(device), cb=self._device_cv, parent=self
|
||||
# )
|
||||
#
|
||||
# self.device_consumer.start()
|
||||
|
||||
def plot_new(self):
|
||||
print(f"Printing data from plot update: {self.data}")
|
||||
self.plot_item.plot(self.data[0])
|
||||
# self.imageItem.setImage(self.data, autoLevels=False)
|
||||
|
||||
@staticmethod
|
||||
def _streamer_cb(msg, *, parent, **_kwargs) -> None:
|
||||
msgMCS = msg.value
|
||||
print(msgMCS)
|
||||
row = msgMCS.content["signals"][parent.sub_device]
|
||||
metadata = msgMCS.metadata
|
||||
|
||||
# Check if the current number of rows is odd
|
||||
# if parent.data is not None and parent.data.shape[0] % 2 == 1:
|
||||
# row = np.flip(row) # Flip the row
|
||||
print(f"Printing data from callback update: {row}")
|
||||
parent.data = np.array([row])
|
||||
# if parent.data is None:
|
||||
# parent.data = np.array([row])
|
||||
# else:
|
||||
# parent.data = np.vstack((parent.data, row))
|
||||
|
||||
parent.update_signal.emit()
|
||||
|
||||
@staticmethod
|
||||
def _device_cv(msg, *, parent, **_kwargs) -> None:
|
||||
print("Getting ScanID")
|
||||
|
||||
msgDEV = msg.value
|
||||
|
||||
current_scan_id = msgDEV.content["scan_id"]
|
||||
|
||||
if parent.scan_id is None:
|
||||
parent.scan_id = current_scan_id
|
||||
parent.new_scan_id.emit(current_scan_id)
|
||||
print(f"New scan_id: {current_scan_id}")
|
||||
|
||||
if current_scan_id != parent.scan_id:
|
||||
parent.scan_id = current_scan_id
|
||||
# parent.data = None
|
||||
# parent.imageItem.clear()
|
||||
parent.new_scan_id.emit(current_scan_id)
|
||||
|
||||
print(f"New scan_id: {current_scan_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
from bec_lib import RedisConnector
|
||||
|
||||
parser = argparse.ArgumentParser(description="Stream App.")
|
||||
parser.add_argument("--port", type=str, default="pc15543:6379", help="Port for RedisConnector")
|
||||
parser.add_argument("--device", type=str, default="mcs", help="Device name")
|
||||
parser.add_argument("--sub_device", type=str, default="mca4", help="Sub-device name")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
connector = RedisConnector(args.port)
|
||||
|
||||
app = QApplication([])
|
||||
streamApp = StreamApp(device=args.device, sub_device=args.sub_device)
|
||||
|
||||
streamApp.show()
|
||||
app.exec()
|
||||
@@ -1,26 +0,0 @@
|
||||
import time
|
||||
|
||||
from bec_lib import MessageEndpoints, RedisConnector, messages
|
||||
|
||||
connector = RedisConnector("localhost:6379")
|
||||
metadata = {}
|
||||
|
||||
scan_id = "ScanID1"
|
||||
|
||||
metadata.update(
|
||||
{"scan_id": scan_id, "async_update": "append"} # this will be different for each scan
|
||||
)
|
||||
for ii in range(20):
|
||||
data = {"mca1": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "mca2": [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]}
|
||||
msg = messages.DeviceMessage(signals=data, metadata=metadata).dumps()
|
||||
|
||||
connector.xadd(
|
||||
topic=MessageEndpoints.device_async_readback(
|
||||
scan_id=scan_id, device="mca"
|
||||
), # scan_id will be different for each scan
|
||||
msg={"data": msg}, # TODO should be msg_dict
|
||||
expire=1800,
|
||||
)
|
||||
|
||||
print(f"Sent {ii}")
|
||||
time.sleep(0.5)
|
||||
@@ -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>
|
||||
@@ -1,197 +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()
|
||||
@@ -1,17 +0,0 @@
|
||||
selected_motors:
|
||||
motor_x: "samx"
|
||||
motor_y: "samy"
|
||||
|
||||
plot_motors:
|
||||
max_points: 1000
|
||||
num_dim_points: 100
|
||||
scatter_size: 5
|
||||
precision: 3
|
||||
mode_lock: False # "Individual" or "Start/Stop". False to unlock
|
||||
extra_columns:
|
||||
- sample name: "sample 1"
|
||||
- step_x [mu]: 25
|
||||
- step_y [mu]: 25
|
||||
- exp_time [s]: 1
|
||||
- start: 1
|
||||
- tilt [deg]: 0
|
||||
@@ -1,10 +0,0 @@
|
||||
redis:
|
||||
host: pc15543
|
||||
port: 6379
|
||||
mongodb:
|
||||
host: localhost
|
||||
port: 27017
|
||||
scibec:
|
||||
host: http://localhost
|
||||
port: 3030
|
||||
beamline: MyBeamline
|
||||
@@ -1,17 +0,0 @@
|
||||
selected_motors:
|
||||
motor_x: "samx"
|
||||
motor_y: "samy"
|
||||
|
||||
plot_motors:
|
||||
max_points: 1000
|
||||
num_dim_points: 100
|
||||
scatter_size: 5
|
||||
precision: 3
|
||||
mode_lock: Start/Stop # "Individual" or "Start/Stop"
|
||||
extra_columns:
|
||||
- sample name: "sample 1"
|
||||
- step_x [mu]: 25
|
||||
- step_y [mu]: 25
|
||||
- exp_time [s]: 1
|
||||
- start: 1
|
||||
- tilt [deg]: 0
|
||||
@@ -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": {
|
||||
@@ -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
|
||||
|
||||
@@ -1,155 +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>845</width>
|
||||
<height>635</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Line Plot</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="QSplitter" name="splitter_plot">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>1</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<widget class="GraphicsLayoutWidget" name="glw_plot"/>
|
||||
<widget class="GraphicsLayoutWidget" name="glw_image"/>
|
||||
</widget>
|
||||
<widget class="QWidget" name="">
|
||||
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,1,1,15">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_generate">
|
||||
<property name="text">
|
||||
<string>Generate 1D and 2D data without stream</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>1st angle of azimutal segment (deg)</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>360.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.250000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f1amp</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f2amp</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f2 phase</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Precision</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="spinBox_precision">
|
||||
<property name="value">
|
||||
<number>4</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="cursor_table">
|
||||
<property name="textElideMode">
|
||||
<enum>Qt::ElideMiddle</enum>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Display</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,336 +0,0 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import MessageEndpoints, messages
|
||||
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 bec_widgets.utils import Colors, Crosshair
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
class StreamPlot(QtWidgets.QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
roi_signal = pyqtSignal(tuple)
|
||||
|
||||
def __init__(self, name="", y_value_list=["gauss_bpm"], client=None, parent=None) -> None:
|
||||
"""
|
||||
Basic plot widget for displaying scan data.
|
||||
|
||||
Args:
|
||||
name (str, optional): Name of the plot. Defaults to "".
|
||||
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
|
||||
"""
|
||||
|
||||
# Client and device manager from BEC
|
||||
self.client = BECDispatcher().client if client is None else client
|
||||
|
||||
super(StreamPlot, self).__init__()
|
||||
# Set style for pyqtgraph plots
|
||||
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._idle_time = 100
|
||||
self.connector = RedisConnector(["localhost:6379"])
|
||||
|
||||
self.y_value_list = y_value_list
|
||||
self.previous_y_value_list = None
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
|
||||
self.plotter_scan_id = None
|
||||
|
||||
self._current_proj = None
|
||||
self._current_metadata_ep = "px_stream/projection_{}/metadata"
|
||||
|
||||
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
|
||||
|
||||
self._data_retriever_thread_exit_event = threading.Event()
|
||||
self.data_retriever = threading.Thread(
|
||||
target=self.on_projection, args=(self._data_retriever_thread_exit_event,), daemon=True
|
||||
)
|
||||
self.data_retriever.start()
|
||||
|
||||
##########################
|
||||
# UI
|
||||
##########################
|
||||
self.init_ui()
|
||||
self.init_curves()
|
||||
self.hook_crosshair()
|
||||
|
||||
def close(self):
|
||||
super().close()
|
||||
self._data_retriever_thread_exit_event.set()
|
||||
self.data_retriever.join()
|
||||
|
||||
def init_ui(self):
|
||||
"""Setup all ui elements"""
|
||||
##########################
|
||||
# 1D Plot
|
||||
##########################
|
||||
|
||||
# LabelItem for ROI
|
||||
self.label_plot = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot)
|
||||
self.label_plot.setText("ROI region")
|
||||
|
||||
# ROI selector - so far from [-1,1] #TODO update to scale with xrange
|
||||
self.roi_selector = pg.LinearRegionItem([-1, 1])
|
||||
|
||||
self.glw_plot.nextRow() # TODO update of cursor
|
||||
self.label_plot_moved = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot_moved)
|
||||
self.label_plot_moved.setText("Actual coordinates (X, Y)")
|
||||
|
||||
# Label for coordinates clicked
|
||||
self.glw_plot.nextRow()
|
||||
self.label_plot_clicked = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot_clicked)
|
||||
self.label_plot_clicked.setText("Clicked coordinates (X, Y)")
|
||||
|
||||
# 1D PlotItem
|
||||
self.glw_plot.nextRow()
|
||||
self.plot = pg.PlotItem()
|
||||
self.plot.setLogMode(True, True)
|
||||
self.glw_plot.addItem(self.plot)
|
||||
self.plot.addLegend()
|
||||
|
||||
##########################
|
||||
# 2D Plot
|
||||
##########################
|
||||
|
||||
# Label for coordinates moved
|
||||
self.label_image_moved = pg.LabelItem(justify="center")
|
||||
self.glw_image.addItem(self.label_image_moved)
|
||||
self.label_image_moved.setText("Actual coordinates (X, Y)")
|
||||
|
||||
# Label for coordinates clicked
|
||||
self.glw_image.nextRow()
|
||||
self.label_image_clicked = pg.LabelItem(justify="center")
|
||||
self.glw_image.addItem(self.label_image_clicked)
|
||||
self.label_image_clicked.setText("Clicked coordinates (X, Y)")
|
||||
|
||||
# TODO try to lock aspect ratio with view
|
||||
|
||||
# # Create a window
|
||||
# win = pg.GraphicsLayoutWidget()
|
||||
# win.show()
|
||||
#
|
||||
# # Create a ViewBox
|
||||
# view = win.addViewBox()
|
||||
#
|
||||
# # Lock the aspect ratio
|
||||
# view.setAspectLocked(True)
|
||||
|
||||
# # Create an ImageItem
|
||||
# image_item = pg.ImageItem(np.random.random((100, 100)))
|
||||
#
|
||||
# # Add the ImageItem to the ViewBox
|
||||
# view.addItem(image_item)
|
||||
|
||||
# 2D ImageItem
|
||||
self.glw_image.nextRow()
|
||||
self.plot_image = pg.PlotItem()
|
||||
self.glw_image.addItem(self.plot_image)
|
||||
|
||||
def init_curves(self):
|
||||
# init of 1D plot
|
||||
self.plot.clear()
|
||||
|
||||
self.curves = []
|
||||
self.pens = []
|
||||
self.brushs = []
|
||||
|
||||
self.color_list = Colors.golden_angle_color(colormap="CET-R2", num=len(self.y_value_list))
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
pen = mkPen(color=self.color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
brush = mkBrush(color=self.color_list[ii])
|
||||
curve = pg.PlotDataItem(symbolBrush=brush, pen=pen, skipFiniteCheck=True, name=y_value)
|
||||
self.plot.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
self.pens.append(pen)
|
||||
self.brushs.append(brush)
|
||||
|
||||
# check if roi selector is in the plot
|
||||
if self.roi_selector not in self.plot.items:
|
||||
self.plot.addItem(self.roi_selector)
|
||||
|
||||
# init of 2D plot
|
||||
self.plot_image.clear()
|
||||
|
||||
self.img = pg.ImageItem()
|
||||
self.plot_image.addItem(self.img)
|
||||
|
||||
# hooking signals
|
||||
self.hook_crosshair()
|
||||
self.init_table()
|
||||
|
||||
def splitter_sizes(self): ...
|
||||
|
||||
def hook_crosshair(self):
|
||||
self.crosshair_1d = Crosshair(self.plot, precision=4)
|
||||
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.label_plot_moved.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
self.crosshair_1d.coordinatesClicked1D.connect(
|
||||
lambda x, y: self.label_plot_clicked.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.update_table(table_widget=self.cursor_table, x=x, y_values=y)
|
||||
)
|
||||
|
||||
self.crosshair_2D = Crosshair(self.plot_image)
|
||||
|
||||
self.crosshair_2D.coordinatesChanged2D.connect(
|
||||
lambda x, y: self.label_image_moved.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
self.crosshair_2D.coordinatesClicked2D.connect(
|
||||
lambda x, y: self.label_image_clicked.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
|
||||
# ROI
|
||||
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
|
||||
|
||||
def get_roi_region(self):
|
||||
"""For testing purpose now, get roi region and print it to self.label as tuple"""
|
||||
region = self.roi_selector.getRegion()
|
||||
self.label_plot.setText(f"x = {(10 ** region[0]):.4f}, y ={(10 ** region[1]):.4f}")
|
||||
return_dict = {
|
||||
"horiz_roi": [
|
||||
np.where(self.plotter_data_x[0] > 10 ** region[0])[0][0],
|
||||
np.where(self.plotter_data_x[0] < 10 ** region[1])[0][-1],
|
||||
]
|
||||
}
|
||||
msg = messages.DeviceMessage(signals=return_dict).dumps()
|
||||
self.connector.set_and_publish("px_stream/gui_event", msg=msg)
|
||||
self.roi_signal.emit(region)
|
||||
|
||||
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.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"]) #TODO can be dynamic
|
||||
self.cursor_table.setVerticalHeaderLabels(self.y_value_list)
|
||||
self.cursor_table.resizeColumnsToContents()
|
||||
|
||||
def update_table(self, table_widget, x, y_values):
|
||||
for i, y in enumerate(y_values):
|
||||
table_widget.setItem(i, 1, QTableWidgetItem(str(x)))
|
||||
table_widget.setItem(i, 2, QTableWidgetItem(str(y)))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update(self):
|
||||
"""Update the plot with the new data."""
|
||||
|
||||
# check if QTable was initialised and if list of devices was changed
|
||||
# if self.y_value_list != self.previous_y_value_list:
|
||||
# self.setup_cursor_table()
|
||||
# self.previous_y_value_list = self.y_value_list.copy() if self.y_value_list else None
|
||||
|
||||
self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
|
||||
|
||||
@staticmethod
|
||||
def flip_even_rows(arr):
|
||||
arr_copy = np.copy(arr) # Create a writable copy
|
||||
arr_copy[1::2, :] = arr_copy[1::2, ::-1]
|
||||
return arr_copy
|
||||
|
||||
@staticmethod
|
||||
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
|
||||
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
|
||||
"""Removes a curve from the given plot by the specified name.
|
||||
|
||||
Args:
|
||||
plot (pyqtgraph.PlotItem): The plot from which to remove the curve.
|
||||
name (str): The name of the curve to remove.
|
||||
"""
|
||||
# if checkbox.isChecked():
|
||||
for item in plot.items:
|
||||
if isinstance(item, pg.PlotDataItem) and getattr(item, "opts", {}).get("name") == name:
|
||||
plot.removeItem(item)
|
||||
return
|
||||
|
||||
# else:
|
||||
# return
|
||||
|
||||
def on_projection(self, exit_event):
|
||||
while not exit_event.is_set():
|
||||
if self._current_proj is None:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
endpoint = f"px_stream/projection_{self._current_proj}/data"
|
||||
msgs = self.client.connector.lrange(topic=endpoint, start=-1, end=-1)
|
||||
data = msgs
|
||||
if not data:
|
||||
continue
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
self.plotter_data_y = [
|
||||
np.sum(
|
||||
np.sum(data[-1].content["signals"]["data"] * self._current_norm, axis=1)
|
||||
/ np.sum(self._current_norm, axis=0),
|
||||
axis=0,
|
||||
).squeeze()
|
||||
]
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
@pyqtSlot(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)
|
||||
def new_proj(self, content: dict, _metadata: dict):
|
||||
proj_nr = content["signals"]["proj_nr"]
|
||||
endpoint = f"px_stream/projection_{proj_nr}/metadata"
|
||||
msg_raw = self.client.connector.get(topic=endpoint)
|
||||
msg = messages.DeviceMessage.loads(msg_raw)
|
||||
self._current_q = msg.content["signals"]["q"]
|
||||
self._current_norm = msg.content["signals"]["norm_sum"]
|
||||
self._current_metadata = msg.content["signals"]["metadata"]
|
||||
|
||||
self.plotter_data_x = [self._current_q]
|
||||
self._current_proj = proj_nr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
# from bec_widgets import ctrl_c # TODO uncomment when ctrl_c is ready to be compatible with qtpy
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--signals", help="specify recorded signals", nargs="+", default=["gauss_bpm"]
|
||||
)
|
||||
# default = ["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
|
||||
value = parser.parse_args()
|
||||
print(f"Plotting signals for: {', '.join(value.signals)}")
|
||||
|
||||
# Client from dispatcher
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
|
||||
app = QtWidgets.QApplication([])
|
||||
# ctrl_c.setup(app) # TODO uncomment when ctrl_c is ready to be compatible with qtpy
|
||||
plot = StreamPlot(y_value_list=value.signals, client=client)
|
||||
|
||||
bec_dispatcher.connect_slot(plot.new_proj, "px_stream/proj_nr")
|
||||
bec_dispatcher.connect_slot(
|
||||
plot.on_dap_update, MessageEndpoints.processed_data("px_dap_worker")
|
||||
)
|
||||
plot.show()
|
||||
# client.callbacks.register("scan_segment", plot, sync=False)
|
||||
app.exec()
|
||||
@@ -7,4 +7,5 @@ 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
|
||||
|
||||
@@ -4,11 +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.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
|
||||
class ConnectionConfig(BaseModel):
|
||||
@@ -18,8 +20,10 @@ class ConnectionConfig(BaseModel):
|
||||
gui_id: Optional[str] = Field(
|
||||
default=None, validate_default=True, description="The GUI ID of the widget."
|
||||
)
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@field_validator("gui_id")
|
||||
@classmethod
|
||||
def generate_gui_id(cls, v, values):
|
||||
"""Generate a GUI ID if none is provided."""
|
||||
if v is None:
|
||||
|
||||
@@ -6,9 +6,10 @@ from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
import redis
|
||||
from bec_lib import BECClient, ServiceConfig
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.redis_connector import MessageObject, RedisConnector
|
||||
from qtpy.QtCore import QObject
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QCoreApplication, QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -70,6 +71,7 @@ class BECDispatcher:
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
qapp = None
|
||||
|
||||
def __new__(cls, client=None, config: str = None, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
@@ -81,6 +83,9 @@ class BECDispatcher:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
if not QCoreApplication.instance():
|
||||
BECDispatcher.qapp = QCoreApplication([])
|
||||
|
||||
self._slots = collections.defaultdict(set)
|
||||
self.client = client
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy.QtGui import QColor
|
||||
|
||||
|
||||
class Colors:
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
@@ -63,3 +66,211 @@ class Colors:
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return colors
|
||||
|
||||
@staticmethod
|
||||
def validate_color(color: tuple | str) -> tuple | str:
|
||||
"""
|
||||
Validate the color input if it is HEX or RGBA compatible. Can be used in any pydantic model as a field validator.
|
||||
|
||||
Args:
|
||||
color(tuple|str): The color to be validated. Can be a tuple of RGBA values or a HEX string.
|
||||
|
||||
Returns:
|
||||
tuple|str: The validated color.
|
||||
"""
|
||||
CSS_COLOR_NAMES = {
|
||||
"aliceblue",
|
||||
"antiquewhite",
|
||||
"aqua",
|
||||
"aquamarine",
|
||||
"azure",
|
||||
"beige",
|
||||
"bisque",
|
||||
"black",
|
||||
"blanchedalmond",
|
||||
"blue",
|
||||
"blueviolet",
|
||||
"brown",
|
||||
"burlywood",
|
||||
"cadetblue",
|
||||
"chartreuse",
|
||||
"chocolate",
|
||||
"coral",
|
||||
"cornflowerblue",
|
||||
"cornsilk",
|
||||
"crimson",
|
||||
"cyan",
|
||||
"darkblue",
|
||||
"darkcyan",
|
||||
"darkgoldenrod",
|
||||
"darkgray",
|
||||
"darkgreen",
|
||||
"darkgrey",
|
||||
"darkkhaki",
|
||||
"darkmagenta",
|
||||
"darkolivegreen",
|
||||
"darkorange",
|
||||
"darkorchid",
|
||||
"darkred",
|
||||
"darksalmon",
|
||||
"darkseagreen",
|
||||
"darkslateblue",
|
||||
"darkslategray",
|
||||
"darkslategrey",
|
||||
"darkturquoise",
|
||||
"darkviolet",
|
||||
"deeppink",
|
||||
"deepskyblue",
|
||||
"dimgray",
|
||||
"dimgrey",
|
||||
"dodgerblue",
|
||||
"firebrick",
|
||||
"floralwhite",
|
||||
"forestgreen",
|
||||
"fuchsia",
|
||||
"gainsboro",
|
||||
"ghostwhite",
|
||||
"gold",
|
||||
"goldenrod",
|
||||
"gray",
|
||||
"green",
|
||||
"greenyellow",
|
||||
"grey",
|
||||
"honeydew",
|
||||
"hotpink",
|
||||
"indianred",
|
||||
"indigo",
|
||||
"ivory",
|
||||
"khaki",
|
||||
"lavender",
|
||||
"lavenderblush",
|
||||
"lawngreen",
|
||||
"lemonchiffon",
|
||||
"lightblue",
|
||||
"lightcoral",
|
||||
"lightcyan",
|
||||
"lightgoldenrodyellow",
|
||||
"lightgray",
|
||||
"lightgreen",
|
||||
"lightgrey",
|
||||
"lightpink",
|
||||
"lightsalmon",
|
||||
"lightseagreen",
|
||||
"lightskyblue",
|
||||
"lightslategray",
|
||||
"lightslategrey",
|
||||
"lightsteelblue",
|
||||
"lightyellow",
|
||||
"lime",
|
||||
"limegreen",
|
||||
"linen",
|
||||
"magenta",
|
||||
"maroon",
|
||||
"mediumaquamarine",
|
||||
"mediumblue",
|
||||
"mediumorchid",
|
||||
"mediumpurple",
|
||||
"mediumseagreen",
|
||||
"mediumslateblue",
|
||||
"mediumspringgreen",
|
||||
"mediumturquoise",
|
||||
"mediumvioletred",
|
||||
"midnightblue",
|
||||
"mintcream",
|
||||
"mistyrose",
|
||||
"moccasin",
|
||||
"navajowhite",
|
||||
"navy",
|
||||
"oldlace",
|
||||
"olive",
|
||||
"olivedrab",
|
||||
"orange",
|
||||
"orangered",
|
||||
"orchid",
|
||||
"palegoldenrod",
|
||||
"palegreen",
|
||||
"paleturquoise",
|
||||
"palevioletred",
|
||||
"papayawhip",
|
||||
"peachpuff",
|
||||
"peru",
|
||||
"pink",
|
||||
"plum",
|
||||
"powderblue",
|
||||
"purple",
|
||||
"red",
|
||||
"rosybrown",
|
||||
"royalblue",
|
||||
"saddlebrown",
|
||||
"salmon",
|
||||
"sandybrown",
|
||||
"seagreen",
|
||||
"seashell",
|
||||
"sienna",
|
||||
"silver",
|
||||
"skyblue",
|
||||
"slateblue",
|
||||
"slategray",
|
||||
"slategrey",
|
||||
"snow",
|
||||
"springgreen",
|
||||
"steelblue",
|
||||
"tan",
|
||||
"teal",
|
||||
"thistle",
|
||||
"tomato",
|
||||
"turquoise",
|
||||
"violet",
|
||||
"wheat",
|
||||
"white",
|
||||
"whitesmoke",
|
||||
"yellow",
|
||||
"yellowgreen",
|
||||
}
|
||||
if isinstance(color, str):
|
||||
hex_pattern = re.compile(r"^#(?:[0-9a-fA-F]{3}){1,2}$")
|
||||
if hex_pattern.match(color):
|
||||
return color
|
||||
elif color.lower() in CSS_COLOR_NAMES:
|
||||
return color
|
||||
else:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
"The color must be a valid HEX string or CSS Color.",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
elif isinstance(color, tuple):
|
||||
if len(color) != 4:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
"The color must be a tuple of 4 elements (R, G, B, A).",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
for value in color:
|
||||
if not 0 <= value <= 255:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
f"The color values must be between 0 and 255 in RGBA format (R,G,B,A)",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
return color
|
||||
|
||||
@staticmethod
|
||||
def validate_color_map(color_map: str) -> str:
|
||||
"""
|
||||
Validate the colormap input if it is supported by pyqtgraph. Can be used in any pydantic model as a field validator. If validation fails it prints all available colormaps from pyqtgraph instance.
|
||||
|
||||
Args:
|
||||
color_map(str): The colormap to be validated.
|
||||
|
||||
Returns:
|
||||
str: The validated colormap.
|
||||
"""
|
||||
available_colormaps = pg.colormap.listMaps()
|
||||
if color_map not in available_colormaps:
|
||||
raise PydanticCustomError(
|
||||
"unsupported colormap",
|
||||
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
|
||||
{"wrong_value": color_map},
|
||||
)
|
||||
return color_map
|
||||
|
||||
@@ -8,13 +8,13 @@ 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.
|
||||
|
||||
@@ -174,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(
|
||||
coordinate_to_emit = (
|
||||
round(x, self.precision),
|
||||
[round(y_val, self.precision) for y_val in y_values],
|
||||
)
|
||||
self.coordinatesChanged1D.emit(coordinate_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)],
|
||||
@@ -186,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)
|
||||
coordinate_to_emit = (x, y_values)
|
||||
self.coordinatesChanged2D.emit(coordinate_to_emit)
|
||||
|
||||
def mouse_clicked(self, event):
|
||||
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
|
||||
@@ -209,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(
|
||||
@@ -222,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):
|
||||
|
||||
58
bec_widgets/utils/ui_loader.py
Normal 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)
|
||||
@@ -1,2 +0,0 @@
|
||||
# from .monitor_config import validate_monitor_config, ValidationError
|
||||
from .monitor_config_validator import MonitorConfigValidator
|
||||
@@ -1,258 +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.
|
||||
|
||||
Args:
|
||||
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
|
||||
@@ -1,13 +1,5 @@
|
||||
from .buttons import StopButton
|
||||
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
|
||||
|
||||
1
bec_widgets/widgets/buttons/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .stop_button.stop_button import StopButton
|
||||
32
bec_widgets/widgets/buttons/stop_button/stop_button.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
|
||||
class StopButton(BECConnector, QPushButton):
|
||||
"""A button that stops the current scan."""
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QPushButton.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
self.setText("Stop")
|
||||
self.setStyleSheet("background-color: #cc181e; color: white")
|
||||
self.clicked.connect(self.stop_scan)
|
||||
|
||||
def stop_scan(self):
|
||||
"""Stop the scan."""
|
||||
self.queue.request_scan_abortion()
|
||||
self.queue.request_queue_reset()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = StopButton()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, Optional
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea import Dock
|
||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class DockConfig(ConnectionConfig):
|
||||
widgets: dict[str, ConnectionConfig] = Field({}, description="The widgets in the dock.")
|
||||
widgets: dict[str, Any] = Field({}, description="The widgets in the dock.")
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = Field(
|
||||
"bottom", description="The position of the dock."
|
||||
)
|
||||
@@ -26,13 +26,14 @@ class DockConfig(ConnectionConfig):
|
||||
|
||||
class BECDock(BECConnector, Dock):
|
||||
USER_ACCESS = [
|
||||
"config_dict",
|
||||
"rpc_id",
|
||||
"widget_list",
|
||||
"show_title_bar",
|
||||
"hide_title_bar",
|
||||
"get_widgets_positions",
|
||||
"set_title",
|
||||
"add_widget_bec",
|
||||
"add_widget",
|
||||
"list_eligible_widgets",
|
||||
"move_widget",
|
||||
"remove_widget",
|
||||
@@ -62,8 +63,6 @@ class BECDock(BECConnector, Dock):
|
||||
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)
|
||||
|
||||
@@ -72,8 +71,8 @@ class BECDock(BECConnector, Dock):
|
||||
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)
|
||||
if old_area in self.orig_area.tempAreas and old_area != self.orig_area:
|
||||
self.orig_area.removeTempArea(old_area)
|
||||
|
||||
def float(self):
|
||||
"""
|
||||
@@ -92,7 +91,7 @@ class BECDock(BECConnector, Dock):
|
||||
super().float()
|
||||
|
||||
@property
|
||||
def widget_list(self) -> list:
|
||||
def widget_list(self) -> list[BECConnector]:
|
||||
"""
|
||||
Get the widgets in the dock.
|
||||
|
||||
@@ -102,7 +101,7 @@ class BECDock(BECConnector, Dock):
|
||||
return self.widgets
|
||||
|
||||
@widget_list.setter
|
||||
def widget_list(self, value: list):
|
||||
def widget_list(self, value: list[BECConnector]):
|
||||
self.widgets = value
|
||||
|
||||
def hide_title_bar(self):
|
||||
@@ -128,8 +127,9 @@ class BECDock(BECConnector, Dock):
|
||||
Args:
|
||||
title(str): The title of the dock.
|
||||
"""
|
||||
self.parent_dock_area.docks[title] = self.parent_dock_area.docks.pop(self.name())
|
||||
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
|
||||
self.setTitle(title)
|
||||
self._name = title
|
||||
|
||||
def get_widgets_positions(self) -> dict:
|
||||
"""
|
||||
@@ -151,46 +151,15 @@ class BECDock(BECConnector, Dock):
|
||||
"""
|
||||
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,
|
||||
widget: BECConnector | str,
|
||||
row=None,
|
||||
col=0,
|
||||
rowspan=1,
|
||||
colspan=1,
|
||||
shift: Literal["down", "up", "left", "right"] = "down",
|
||||
):
|
||||
) -> BECConnector:
|
||||
"""
|
||||
Add a widget to the dock.
|
||||
|
||||
@@ -208,8 +177,18 @@ class BECDock(BECConnector, Dock):
|
||||
if self.layout_manager.is_position_occupied(row, col):
|
||||
self.layout_manager.shift_widgets(shift, start_row=row)
|
||||
|
||||
if isinstance(widget, str):
|
||||
widget = RPCWidgetHandler.create_widget(widget)
|
||||
else:
|
||||
widget = widget
|
||||
|
||||
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
|
||||
if hasattr(widget, "config"):
|
||||
self.config.widgets[widget.gui_id] = widget.config
|
||||
|
||||
return widget
|
||||
|
||||
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
|
||||
"""
|
||||
Move a widget to a new position in the layout.
|
||||
@@ -225,7 +204,7 @@ class BECDock(BECConnector, Dock):
|
||||
"""
|
||||
Attach the dock to the parent dock area.
|
||||
"""
|
||||
self.parent_dock_area.removeTempArea(self.area)
|
||||
self.orig_area.removeTempArea(self.area)
|
||||
|
||||
def detach(self):
|
||||
"""
|
||||
@@ -242,6 +221,7 @@ class BECDock(BECConnector, Dock):
|
||||
"""
|
||||
widget = self.rpc_register.get_rpc_by_id(widget_rpc_id)
|
||||
self.layout.removeWidget(widget)
|
||||
self.config.widgets.pop(widget_rpc_id, None)
|
||||
widget.close()
|
||||
|
||||
def remove(self):
|
||||
@@ -249,7 +229,7 @@ class BECDock(BECConnector, Dock):
|
||||
Remove the dock from the parent dock area.
|
||||
"""
|
||||
# self.cleanup()
|
||||
self.parent_dock_area.remove_dock(self.name())
|
||||
self.orig_area.remove_dock(self.name())
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
|
||||
@@ -16,10 +16,14 @@ from .dock import BECDock, DockConfig
|
||||
|
||||
class DockAreaConfig(ConnectionConfig):
|
||||
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
|
||||
docks_state: Optional[dict] = Field(
|
||||
None, description="The state of the docks in the dock area."
|
||||
)
|
||||
|
||||
|
||||
class BECDockArea(BECConnector, DockArea):
|
||||
USER_ACCESS = [
|
||||
"config_dict",
|
||||
"panels",
|
||||
"save_state",
|
||||
"remove_dock",
|
||||
@@ -29,6 +33,7 @@ class BECDockArea(BECConnector, DockArea):
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"get_all_rpc",
|
||||
"temp_areas",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -56,7 +61,7 @@ class BECDockArea(BECConnector, DockArea):
|
||||
painter.drawText(self.rect(), Qt.AlignCenter, "Add docks using 'add_dock' method")
|
||||
|
||||
@property
|
||||
def panels(self) -> dict:
|
||||
def panels(self) -> dict[str, BECDock]:
|
||||
"""
|
||||
Get the docks in the dock area.
|
||||
Returns:
|
||||
@@ -65,10 +70,23 @@ class BECDockArea(BECConnector, DockArea):
|
||||
return dict(self.docks)
|
||||
|
||||
@panels.setter
|
||||
def panels(self, value: dict):
|
||||
|
||||
def panels(self, value: dict[str, BECDock]):
|
||||
self.docks = WeakValueDictionary(value)
|
||||
|
||||
@property
|
||||
def temp_areas(self) -> list:
|
||||
"""
|
||||
Get the temporary areas in the dock area.
|
||||
|
||||
Returns:
|
||||
list: The temporary areas in the dock area.
|
||||
"""
|
||||
return list(map(str, self.tempAreas))
|
||||
|
||||
@temp_areas.setter
|
||||
def temp_areas(self, value: list):
|
||||
self.tempAreas = list(map(str, value))
|
||||
|
||||
def restore_state(
|
||||
self, state: dict = None, missing: Literal["ignore", "error"] = "ignore", extra="bottom"
|
||||
):
|
||||
@@ -81,7 +99,7 @@ class BECDockArea(BECConnector, DockArea):
|
||||
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
|
||||
state = self.config.docks_state
|
||||
self.restoreState(state, missing=missing, extra=extra)
|
||||
|
||||
def save_state(self) -> dict:
|
||||
@@ -91,8 +109,9 @@ class BECDockArea(BECConnector, DockArea):
|
||||
Returns:
|
||||
dict: The state of the dock area.
|
||||
"""
|
||||
self._last_state = self.saveState()
|
||||
return self._last_state
|
||||
last_state = self.saveState()
|
||||
self.config.docks_state = last_state
|
||||
return last_state
|
||||
|
||||
def remove_dock(self, name: str):
|
||||
"""
|
||||
@@ -102,6 +121,7 @@ class BECDockArea(BECConnector, DockArea):
|
||||
name(str): The name of the dock to remove.
|
||||
"""
|
||||
dock = self.docks.pop(name, None)
|
||||
self.config.docks.pop(name, None)
|
||||
if dock:
|
||||
dock.close()
|
||||
if len(self.docks) <= 1:
|
||||
@@ -117,6 +137,7 @@ class BECDockArea(BECConnector, DockArea):
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
|
||||
relative_to: BECDock | None = None,
|
||||
closable: bool = False,
|
||||
floating: bool = False,
|
||||
prefix: str = "dock",
|
||||
widget: str | QWidget | None = None,
|
||||
row: int = None,
|
||||
@@ -132,6 +153,7 @@ class BECDockArea(BECConnector, DockArea):
|
||||
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.
|
||||
floating(bool): Whether the dock is detached after creating.
|
||||
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.
|
||||
@@ -166,14 +188,14 @@ class BECDockArea(BECConnector, DockArea):
|
||||
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
|
||||
)
|
||||
dock.add_widget(widget=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()
|
||||
if floating:
|
||||
dock.detach()
|
||||
return dock
|
||||
|
||||
def detach_dock(self, dock_name: str) -> BECDock:
|
||||
@@ -187,7 +209,7 @@ class BECDockArea(BECConnector, DockArea):
|
||||
BECDock: The undocked dock.
|
||||
"""
|
||||
dock = self.docks[dock_name]
|
||||
self.floatDock(dock)
|
||||
dock.detach()
|
||||
return dock
|
||||
|
||||
def attach_all(self):
|
||||
|
||||
@@ -11,18 +11,13 @@ import qdarktheme
|
||||
from pydantic import Field
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from typeguard import typechecked
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.widgets.plots import (
|
||||
BECImageShow,
|
||||
BECMotorMap,
|
||||
BECPlotBase,
|
||||
BECWaveform,
|
||||
SubplotConfig,
|
||||
Waveform1DConfig,
|
||||
)
|
||||
from bec_widgets.widgets.plots.image import ImageConfig
|
||||
from bec_widgets.widgets.plots.motor_map import MotorMapConfig
|
||||
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):
|
||||
@@ -31,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, SubplotConfig] = Field(
|
||||
widgets: dict[str, Waveform1DConfig | ImageConfig | MotorMapConfig | SubplotConfig] = Field(
|
||||
{}, description="The list of widgets to be added to the figure widget."
|
||||
)
|
||||
|
||||
@@ -189,8 +184,9 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
"""
|
||||
self._widgets = value
|
||||
|
||||
def add_plot(
|
||||
def _init_waveform(
|
||||
self,
|
||||
waveform,
|
||||
x_name: str = None,
|
||||
y_name: str = None,
|
||||
z_name: str = None,
|
||||
@@ -203,33 +199,45 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
color_map_z: Optional[str] = "plasma",
|
||||
label: Optional[str] = None,
|
||||
validate: bool = True,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> BECWaveform:
|
||||
):
|
||||
"""
|
||||
Add a Waveform1D plot to the figure at the specified position.
|
||||
Configure the waveform based on the provided parameters.
|
||||
|
||||
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.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
|
||||
waveform (BECWaveform): The waveform to configure.
|
||||
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.
|
||||
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.
|
||||
validate (bool): If True, validate the device names and entries.
|
||||
"""
|
||||
widget_id = str(uuid.uuid4())
|
||||
waveform = self.add_widget(
|
||||
widget_type="Waveform1D",
|
||||
widget_id=widget_id,
|
||||
row=row,
|
||||
col=col,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
|
||||
# TODO remove repetition from .plot method
|
||||
|
||||
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(
|
||||
@@ -267,34 +275,102 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
|
||||
return waveform
|
||||
|
||||
def plot(
|
||||
def add_plot(
|
||||
self,
|
||||
x: list | np.ndarray = None,
|
||||
y: list | np.ndarray = None,
|
||||
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,
|
||||
validate: bool = True,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> BECWaveform:
|
||||
"""
|
||||
Add a Waveform1D plot to the figure at the specified position.
|
||||
|
||||
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.
|
||||
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.
|
||||
validate(bool): If True, validate the device names and entries.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
|
||||
"""
|
||||
widget_id = str(uuid.uuid4())
|
||||
waveform = self.add_widget(
|
||||
widget_type="Waveform1D",
|
||||
widget_id=widget_id,
|
||||
row=row,
|
||||
col=col,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
|
||||
waveform = self._init_waveform(
|
||||
waveform=waveform,
|
||||
x=x,
|
||||
y=y,
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color=color,
|
||||
color_map_z=color_map_z,
|
||||
label=label,
|
||||
validate=validate,
|
||||
)
|
||||
return waveform
|
||||
|
||||
@typechecked
|
||||
def plot(
|
||||
self,
|
||||
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.
|
||||
@@ -313,49 +389,61 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
else:
|
||||
waveform = self.add_plot(**axis_kwargs)
|
||||
|
||||
# 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(
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
color=color,
|
||||
color_map_z="plasma",
|
||||
label=label,
|
||||
validate=validate,
|
||||
)
|
||||
# User wants to add scan curve -> 2D Waveform Scatter
|
||||
elif (
|
||||
x_name is not None
|
||||
and y_name is not None
|
||||
and z_name is not None
|
||||
and x is None
|
||||
and y is None
|
||||
):
|
||||
waveform.add_curve_scan(
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color=color,
|
||||
color_map_z=color_map_z,
|
||||
label=label,
|
||||
validate=validate,
|
||||
)
|
||||
# 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 and z_name is None
|
||||
):
|
||||
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."
|
||||
)
|
||||
waveform = self._init_waveform(
|
||||
waveform=waveform,
|
||||
x=x,
|
||||
y=y,
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color=color,
|
||||
color_map_z=color_map_z,
|
||||
label=label,
|
||||
validate=validate,
|
||||
)
|
||||
# TODO remove repetition from .plot method
|
||||
return waveform
|
||||
|
||||
def _init_image(
|
||||
self,
|
||||
image,
|
||||
monitor: str = None,
|
||||
color_bar: Literal["simple", "full"] = "full",
|
||||
color_map: str = "magma",
|
||||
data: np.ndarray = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
) -> BECImageShow:
|
||||
"""
|
||||
Configure the image based on the provided parameters.
|
||||
|
||||
Args:
|
||||
image (BECImageShow): The image to configure.
|
||||
monitor (str): The name of the monitor to display.
|
||||
color_bar (Literal["simple","full"]): The type of color bar to display.
|
||||
color_map (str): The color map to use for the image.
|
||||
data (np.ndarray): Custom data to display.
|
||||
"""
|
||||
if monitor is not None and data is None:
|
||||
image.add_monitor_image(
|
||||
monitor=monitor, color_map=color_map, vrange=vrange, color_bar=color_bar
|
||||
)
|
||||
elif data is not None and monitor is None:
|
||||
image.add_custom_image(
|
||||
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
|
||||
)
|
||||
elif data is None and monitor is None:
|
||||
# Setting appearance
|
||||
if vrange is not None:
|
||||
image.set_vrange(vmin=vrange[0], vmax=vrange[1])
|
||||
if color_map is not None:
|
||||
image.set_color_map(color_map)
|
||||
else:
|
||||
raise ValueError("Invalid input. Provide either monitor name or custom data.")
|
||||
return image
|
||||
|
||||
def image(
|
||||
self,
|
||||
monitor: str = None,
|
||||
@@ -388,23 +476,14 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
else:
|
||||
image = self.add_image(color_bar=color_bar, **axis_kwargs)
|
||||
|
||||
# Setting data #TODO check logic if monitor or data are already created
|
||||
if monitor is not None and data is None:
|
||||
image.add_monitor_image(
|
||||
monitor=monitor, color_map=color_map, vrange=vrange, color_bar=color_bar
|
||||
)
|
||||
elif data is not None and monitor is None:
|
||||
image.add_custom_image(
|
||||
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
|
||||
)
|
||||
elif data is None and monitor is None:
|
||||
# Setting appearance
|
||||
if vrange is not None:
|
||||
image.set_vrange(vmin=vrange[0], vmax=vrange[1])
|
||||
if color_map is not None:
|
||||
image.set_color_map(color_map)
|
||||
else:
|
||||
raise ValueError("Invalid input. Provide either monitor name or custom data.")
|
||||
image = self._init_image(
|
||||
image=image,
|
||||
monitor=monitor,
|
||||
color_bar=color_bar,
|
||||
color_map=color_map,
|
||||
data=data,
|
||||
vrange=vrange,
|
||||
)
|
||||
return image
|
||||
|
||||
def add_image(
|
||||
@@ -455,22 +534,14 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
# TODO remove repetition from .image method
|
||||
if monitor is not None and data is None:
|
||||
image.add_monitor_image(
|
||||
monitor=monitor, color_map=color_map, vrange=vrange, color_bar=color_bar
|
||||
)
|
||||
elif data is not None and monitor is None:
|
||||
image.add_custom_image(
|
||||
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
|
||||
)
|
||||
elif data is None and monitor is None:
|
||||
# Setting appearance
|
||||
if vrange is not None:
|
||||
image.set_vrange(vmin=vrange[0], vmax=vrange[1])
|
||||
if color_map is not None:
|
||||
image.set_color_map(color_map)
|
||||
|
||||
image = self._init_image(
|
||||
image=image,
|
||||
monitor=monitor,
|
||||
color_bar=color_bar,
|
||||
color_map=color_map,
|
||||
data=data,
|
||||
vrange=vrange,
|
||||
)
|
||||
return image
|
||||
|
||||
def motor_map(self, motor_x: str = None, motor_y: str = None, **axis_kwargs) -> BECMotorMap:
|
||||
|
||||
@@ -4,50 +4,16 @@ 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, 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."
|
||||
)
|
||||
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 ImageConfig(SubplotConfig):
|
||||
@@ -57,251 +23,6 @@ class ImageConfig(SubplotConfig):
|
||||
)
|
||||
|
||||
|
||||
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[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",
|
||||
@@ -332,7 +53,6 @@ class BECImageShow(BECPlotBase):
|
||||
"set_y_lim",
|
||||
"set_grid",
|
||||
"lock_aspect_ratio",
|
||||
"plot",
|
||||
"remove",
|
||||
"images",
|
||||
]
|
||||
@@ -837,134 +557,3 @@ class BECImageShow(BECPlotBase):
|
||||
image.cleanup()
|
||||
|
||||
super().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
|
||||
277
bec_widgets/widgets/figure/plots/image/image_item.py
Normal 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'")
|
||||
153
bec_widgets/widgets/figure/plots/image/image_processor.py
Normal file
@@ -0,0 +1,153 @@
|
||||
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."
|
||||
)
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
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
|
||||
@@ -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,8 +13,8 @@ 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, SubplotConfig
|
||||
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(SubplotConfig):
|
||||
@@ -317,7 +317,7 @@ class BECMotorMap(BECPlotBase):
|
||||
Returns:
|
||||
float: Motor initial position.
|
||||
"""
|
||||
init_position = round(self.dev[name].read()[entry]["value"], precision)
|
||||
init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
|
||||
return init_position
|
||||
|
||||
def _validate_signal_entries(
|
||||
@@ -403,7 +403,7 @@ class BECMotorMap(BECPlotBase):
|
||||
# Update plot title
|
||||
precision = self.config.precision
|
||||
self.set_title(
|
||||
f"Motor position: ({round(current_x,precision)}, {round(current_y,precision)})"
|
||||
f"Motor position: ({round(float(current_x),precision)}, {round(float(current_y),precision)})"
|
||||
)
|
||||
|
||||
@pyqtSlot(dict)
|
||||
@@ -20,6 +20,7 @@ class AxisConfig(BaseModel):
|
||||
y_lim: Optional[tuple] = Field(None, description="The limits of the y-axis.")
|
||||
x_grid: bool = Field(False, description="Show grid on the x-axis.")
|
||||
y_grid: bool = Field(False, description="Show grid on the y-axis.")
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class SubplotConfig(ConnectionConfig):
|
||||
@@ -48,7 +49,6 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
|
||||
"set_y_lim",
|
||||
"set_grid",
|
||||
"lock_aspect_ratio",
|
||||
"plot",
|
||||
"remove",
|
||||
]
|
||||
|
||||
@@ -237,19 +237,6 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
|
||||
"""
|
||||
self.plot_item.setAspectLocked(lock)
|
||||
|
||||
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
|
||||
**kwargs: Keyword arguments for the plot.
|
||||
"""
|
||||
# TODO very basic so far, add more options
|
||||
# TODO decide name of the method
|
||||
self.plot_item.plot(data_x, data_y, **kwargs)
|
||||
|
||||
def remove(self):
|
||||
"""Remove the plot widget from the figure."""
|
||||
if self.figure is not None:
|
||||
@@ -5,52 +5,21 @@ 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, SubplotConfig
|
||||
|
||||
|
||||
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.")
|
||||
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 Waveform1DConfig(SubplotConfig):
|
||||
@@ -62,194 +31,11 @@ class Waveform1DConfig(SubplotConfig):
|
||||
)
|
||||
|
||||
|
||||
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 = 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()
|
||||
|
||||
|
||||
class BECWaveform(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"add_curve_scan",
|
||||
"add_curve_custom",
|
||||
"plot",
|
||||
"remove_curve",
|
||||
"scan_history",
|
||||
"curves",
|
||||
@@ -267,7 +53,6 @@ class BECWaveform(BECPlotBase):
|
||||
"set_y_lim",
|
||||
"set_grid",
|
||||
"lock_aspect_ratio",
|
||||
"plot",
|
||||
"remove",
|
||||
]
|
||||
scan_signal_update = pyqtSignal()
|
||||
@@ -413,6 +198,57 @@ class BECWaveform(BECPlotBase):
|
||||
else:
|
||||
raise ValueError("Identifier must be either an integer (index) or a string (curve_id).")
|
||||
|
||||
def plot(
|
||||
self,
|
||||
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,
|
||||
) -> BECCurve:
|
||||
"""
|
||||
Plot a curve to the plot widget.
|
||||
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.
|
||||
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.
|
||||
validate(bool): If True, validate the device names and entries.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
|
||||
if x is not None and y is not None:
|
||||
return self.add_curve_custom(x=x, y=y, label=label, color=color)
|
||||
else:
|
||||
return self.add_curve_scan(
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color=color,
|
||||
color_map_z=color_map_z,
|
||||
label=label,
|
||||
validate_bec=validate,
|
||||
)
|
||||
|
||||
def add_curve_custom(
|
||||
self,
|
||||
x: list | np.ndarray,
|
||||
@@ -465,33 +301,6 @@ class BECWaveform(BECPlotBase):
|
||||
)
|
||||
return curve
|
||||
|
||||
def _add_curve_object(
|
||||
self,
|
||||
name: str,
|
||||
source: str,
|
||||
config: CurveConfig,
|
||||
data: tuple[list | np.ndarray, list | np.ndarray] = None,
|
||||
) -> 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, parent_item=self.plot_item)
|
||||
self._curves_data[source][name] = curve
|
||||
self.plot_item.addItem(curve)
|
||||
self.config.curves[name] = curve.config
|
||||
if data is not None:
|
||||
curve.setData(data[0], data[1])
|
||||
return curve
|
||||
|
||||
def add_curve_scan(
|
||||
self,
|
||||
x_name: str,
|
||||
@@ -554,7 +363,7 @@ class BECWaveform(BECPlotBase):
|
||||
parent_id=self.gui_id,
|
||||
label=label,
|
||||
color=color,
|
||||
color_map=color_map_z,
|
||||
color_map_z=color_map_z,
|
||||
source=curve_source,
|
||||
signals=Signal(
|
||||
source=curve_source,
|
||||
@@ -567,6 +376,33 @@ class BECWaveform(BECPlotBase):
|
||||
curve = self._add_curve_object(name=label, source=curve_source, config=curve_config)
|
||||
return curve
|
||||
|
||||
def _add_curve_object(
|
||||
self,
|
||||
name: str,
|
||||
source: str,
|
||||
config: CurveConfig,
|
||||
data: tuple[list | np.ndarray, list | np.ndarray] = None,
|
||||
) -> 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, parent_item=self)
|
||||
self._curves_data[source][name] = curve
|
||||
self.plot_item.addItem(curve)
|
||||
self.config.curves[name] = curve.config
|
||||
if data is not None:
|
||||
curve.setData(data[0], data[1])
|
||||
return curve
|
||||
|
||||
def _validate_signal_entries(
|
||||
self,
|
||||
x_name: str,
|
||||
@@ -727,7 +563,7 @@ class BECWaveform(BECPlotBase):
|
||||
if curve.config.signals.z:
|
||||
data_z = data[z_name][z_entry].val
|
||||
color_z = self._make_z_gradient(
|
||||
data_z, curve.config.colormap
|
||||
data_z, curve.config.color_map_z
|
||||
) # TODO decide how to implement custom gradient
|
||||
except TypeError:
|
||||
continue
|
||||
@@ -790,7 +626,6 @@ class BECWaveform(BECPlotBase):
|
||||
Returns:
|
||||
dict | pd.DataFrame: Data of all curves in the specified format.
|
||||
"""
|
||||
|
||||
data = {}
|
||||
try:
|
||||
import pandas as pd
|
||||
245
bec_widgets/widgets/figure/plots/waveform/waveform_curve.py
Normal file
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
import pyqtgraph as pg
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy import QtCore
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.figure.plots.waveform import BECWaveform1D
|
||||
|
||||
|
||||
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
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
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
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
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[str | tuple] = Field(None, description="The color of the curve.")
|
||||
symbol: Optional[str] = Field("o", description="The symbol of the curve.")
|
||||
symbol_color: Optional[str | tuple] = 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.")
|
||||
color_map_z: Optional[str] = Field(
|
||||
"plasma", description="The colormap of the curves z gradient.", validate_default=True
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
_validate_color_map_z = field_validator("color_map_z")(Colors.validate_color_map)
|
||||
_validate_color = field_validator("color")(Colors.validate_color)
|
||||
_validate_symbol_color = field_validator("symbol_color")(Colors.validate_color)
|
||||
|
||||
|
||||
class BECCurve(BECConnector, pg.PlotDataItem):
|
||||
USER_ACCESS = [
|
||||
"remove",
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"set",
|
||||
"set_data",
|
||||
"set_color",
|
||||
"set_color_map_z",
|
||||
"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[BECWaveform1D] = 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,
|
||||
"color_map_z": self.set_color_map_z,
|
||||
"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_color_map_z(self, colormap: str):
|
||||
"""
|
||||
Set the colormap for the scatter plot z gradient.
|
||||
|
||||
Args:
|
||||
colormap(str): Colormap for the scatter plot.
|
||||
"""
|
||||
self.config.color_map_z = colormap
|
||||
self.apply_config()
|
||||
self.parent_item.scan_history(-1)
|
||||
|
||||
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()
|
||||
0
bec_widgets/widgets/jupyter_console/__init__.py
Normal file
72
bec_widgets/widgets/jupyter_console/jupyter_console.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from bec_ipython_client.main import BECIPythonClient
|
||||
from qtconsole.inprocess import QtInProcessKernelManager
|
||||
from qtconsole.manager import QtKernelManager
|
||||
from qtconsole.rich_jupyter_widget import RichJupyterWidget
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
|
||||
class BECJupyterConsole(RichJupyterWidget): # pragma: no cover:
|
||||
def __init__(self, inprocess: bool = False):
|
||||
super().__init__()
|
||||
|
||||
self.inprocess = None
|
||||
|
||||
self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=inprocess)
|
||||
self.set_default_style("linux")
|
||||
self._init_bec()
|
||||
|
||||
def _init_kernel(self, inprocess: bool = False, kernel_name: str = "python3"):
|
||||
self.inprocess = inprocess
|
||||
if inprocess is True:
|
||||
print("starting inprocess kernel")
|
||||
kernel_manager = QtInProcessKernelManager()
|
||||
else:
|
||||
kernel_manager = QtKernelManager(kernel_name=kernel_name)
|
||||
kernel_manager.start_kernel()
|
||||
kernel_client = kernel_manager.client()
|
||||
kernel_client.start_channels()
|
||||
return kernel_manager, kernel_client
|
||||
|
||||
def _init_bec(self):
|
||||
if self.inprocess is True:
|
||||
self._init_bec_inprocess()
|
||||
else:
|
||||
self._init_bec_kernel()
|
||||
|
||||
def _init_bec_inprocess(self):
|
||||
self.client = BECIPythonClient()
|
||||
self.client.start()
|
||||
|
||||
self.kernel_manager.kernel.shell.push(
|
||||
{
|
||||
"bec": self.client,
|
||||
"dev": self.client.device_manager.devices,
|
||||
"scans": self.client.scans,
|
||||
}
|
||||
)
|
||||
|
||||
def _init_bec_kernel(self):
|
||||
self.execute(
|
||||
"""
|
||||
from bec_ipython_client.main import BECIPythonClient
|
||||
bec = BECIPythonClient()
|
||||
bec.start()
|
||||
dev = bec.device_manager.devices if bec else None
|
||||
scans = bec.scans if bec else None
|
||||
"""
|
||||
)
|
||||
|
||||
def shutdown_kernel(self):
|
||||
self.kernel_client.stop_channels()
|
||||
self.kernel_manager.shutdown_kernel()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
win = QMainWindow()
|
||||
win.setCentralWidget(BECJupyterConsole(True))
|
||||
win.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
@@ -1 +0,0 @@
|
||||
from .monitor import BECMonitor
|
||||
@@ -1,574 +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()
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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>
|
||||
@@ -1,7 +0,0 @@
|
||||
from .motor_control import (
|
||||
MotorControlAbsolute,
|
||||
MotorControlRelative,
|
||||
MotorControlSelection,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
484
bec_widgets/widgets/motor_control/motor_table/motor_table.py
Normal 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
|
||||
@@ -0,0 +1,159 @@
|
||||
import os
|
||||
|
||||
from qtpy import uic
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.widgets.motor_control.motor_control import MotorControlErrors, MotorControlWidget
|
||||
|
||||
|
||||
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)))
|
||||
@@ -0,0 +1,230 @@
|
||||
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.utils import UILoader
|
||||
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__)
|
||||
self.ui = UILoader().load_ui(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.ui.spinBox_precision.setValue(self.precision)
|
||||
|
||||
# Update step sizes
|
||||
self.ui.spinBox_step_x.setValue(self.config["motor_control"]["step_size_x"])
|
||||
self.ui.spinBox_step_y.setValue(self.config["motor_control"]["step_size_y"])
|
||||
|
||||
# Checkboxes for keyboard shortcuts and x/y step size link
|
||||
self.ui.checkBox_same_xy.setChecked(self.config["motor_control"]["step_x_y_same"])
|
||||
self.ui.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.ui.checkBox_same_xy.stateChanged.connect(self._sync_step_sizes)
|
||||
self.ui.spinBox_step_x.valueChanged.connect(self._update_step_size_x)
|
||||
self.ui.spinBox_step_y.valueChanged.connect(self._update_step_size_y)
|
||||
|
||||
self.ui.toolButton_right.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_x, "x", 1)
|
||||
)
|
||||
self.ui.toolButton_left.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_x, "x", -1)
|
||||
)
|
||||
self.ui.toolButton_up.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_y, "y", 1)
|
||||
)
|
||||
self.ui.toolButton_down.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_y, "y", -1)
|
||||
)
|
||||
|
||||
# Switch between key shortcuts active
|
||||
self.ui.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.ui.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.ui.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.ui.spinBox_step_x, 2)
|
||||
)
|
||||
decrease_x_shortcut.activated.connect(
|
||||
lambda: self._change_step_size(self.ui.spinBox_step_x, 0.5)
|
||||
)
|
||||
self.ui.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.ui.spinBox_step_y, 2)
|
||||
)
|
||||
decrease_y_shortcut.activated.connect(
|
||||
lambda: self._change_step_size(self.ui.spinBox_step_y, 0.5)
|
||||
)
|
||||
self.ui.spinBox_step_y.setToolTip("Increase step size: Alt+A\nDecrease step size: Alt+Z")
|
||||
|
||||
# Stop Button
|
||||
self.ui.pushButton_stop.setShortcut("Ctrl+X")
|
||||
self.ui.pushButton_stop.setToolTip("Ctrl+X")
|
||||
|
||||
def _update_arrow_key_shortcuts(self) -> None:
|
||||
"""Update the arrow key shortcuts based on the checkbox state."""
|
||||
if self.ui.checkBox_enableArrows.isChecked():
|
||||
# Set the arrow key shortcuts for motor movement
|
||||
self.ui.toolButton_right.setShortcut(Qt.Key_Right)
|
||||
self.ui.toolButton_left.setShortcut(Qt.Key_Left)
|
||||
self.ui.toolButton_up.setShortcut(Qt.Key_Up)
|
||||
self.ui.toolButton_down.setShortcut(Qt.Key_Down)
|
||||
else:
|
||||
# Clear the shortcuts
|
||||
self.ui.toolButton_right.setShortcut("")
|
||||
self.ui.toolButton_left.setShortcut("")
|
||||
self.ui.toolButton_up.setShortcut("")
|
||||
self.ui.toolButton_down.setShortcut("")
|
||||
|
||||
def _update_precision(self, precision: int) -> None:
|
||||
"""
|
||||
Update the precision of the coordinates.
|
||||
Args:
|
||||
precision(int): Precision of the coordinates.
|
||||
"""
|
||||
self.ui.spinBox_step_x.setDecimals(precision)
|
||||
self.ui.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.ui.checkBox_same_xy.isChecked():
|
||||
value = self.ui.spinBox_step_x.value()
|
||||
self.ui.spinBox_step_y.setValue(value)
|
||||
|
||||
def _update_step_size_x(self):
|
||||
"""Update step size for x if checkbox is checked."""
|
||||
if self.ui.checkBox_same_xy.isChecked():
|
||||
value = self.ui.spinBox_step_x.value()
|
||||
self.ui.spinBox_step_y.setValue(value)
|
||||
|
||||
def _update_step_size_y(self):
|
||||
"""Update step size for y if checkbox is checked."""
|
||||
if self.ui.checkBox_same_xy.isChecked():
|
||||
value = self.ui.spinBox_step_y.value()
|
||||
self.ui.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.ui.motorControl.findChildren(QWidget):
|
||||
widget.setEnabled(disable)
|
||||
|
||||
# Enable the pushButton_stop if the motor is moving
|
||||
self.ui.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.ui.spinBox_step_x.value()
|
||||
elif axis == "y":
|
||||
step = direction * self.ui.spinBox_step_y.value()
|
||||
self.motor_thread.move_relative(motor, step)
|
||||
110
bec_widgets/widgets/motor_control/selection/selection.py
Normal 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)
|
||||
@@ -1 +0,0 @@
|
||||
from .motor_map import MotorMap
|
||||
@@ -1,594 +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())
|
||||
@@ -1,4 +0,0 @@
|
||||
from .image import BECImageItem, BECImageShow, ImageItemConfig
|
||||
from .motor_map import BECMotorMap, MotorMapConfig
|
||||
from .plot_base import AxisConfig, BECPlotBase, SubplotConfig
|
||||
from .waveform import BECCurve, BECWaveform, Waveform1DConfig
|
||||
@@ -1,5 +1,4 @@
|
||||
import msgpack
|
||||
from bec_lib import MessageEndpoints
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
|
||||
1
bec_widgets/widgets/spiral_progress_bar/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .spiral_progress_bar import SpiralProgressBar
|
||||
278
bec_widgets/widgets/spiral_progress_bar/ring.py
Normal file
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from bec_lib.endpoints import EndpointInfo
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy import QtGui
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
|
||||
|
||||
class RingConnections(BaseModel):
|
||||
slot: Literal["on_scan_progress", "on_device_readback"] = None
|
||||
endpoint: EndpointInfo | str = None
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@field_validator("endpoint")
|
||||
def validate_endpoint(cls, v, values):
|
||||
slot = values.data["slot"]
|
||||
v = v.endpoint if isinstance(v, EndpointInfo) else v
|
||||
if slot == "on_scan_progress":
|
||||
if v != "scans/scan_progress":
|
||||
raise PydanticCustomError(
|
||||
"unsupported endpoint",
|
||||
"For slot 'on_scan_progress', endpoint must be MessageEndpoint.scan_progress or 'scans/scan_progress'.",
|
||||
{"wrong_value": v},
|
||||
)
|
||||
elif slot == "on_device_readback":
|
||||
if not v.startswith("internal/devices/readback/"):
|
||||
raise PydanticCustomError(
|
||||
"unsupported endpoint",
|
||||
"For slot 'on_device_readback', endpoint must be MessageEndpoint.device_readback(device) or 'internal/devices/readback/{device}'.",
|
||||
{"wrong_value": v},
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class RingConfig(ConnectionConfig):
|
||||
value: int | float | None = Field(0, description="Value for the progress bars.")
|
||||
direction: int | None = Field(
|
||||
-1, description="Direction of the progress bars. -1 for clockwise, 1 for counter-clockwise."
|
||||
)
|
||||
color: str | tuple | None = Field(
|
||||
(0, 159, 227, 255),
|
||||
description="Color for the progress bars. Can be tuple (R, G, B, A) or string HEX Code.",
|
||||
)
|
||||
background_color: str | tuple | None = Field(
|
||||
(200, 200, 200, 50),
|
||||
description="Background color for the progress bars. Can be tuple (R, G, B, A) or string HEX Code.",
|
||||
)
|
||||
index: int | None = Field(0, description="Index of the progress bar. 0 is outer ring.")
|
||||
line_width: int | None = Field(10, description="Line widths for the progress bars.")
|
||||
start_position: int | None = Field(
|
||||
90,
|
||||
description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to "
|
||||
"the top of the ring.",
|
||||
)
|
||||
min_value: int | float | None = Field(0, description="Minimum value for the progress bars.")
|
||||
max_value: int | float | None = Field(100, description="Maximum value for the progress bars.")
|
||||
precision: int | None = Field(3, description="Precision for the progress bars.")
|
||||
update_behaviour: Literal["manual", "auto"] | None = Field(
|
||||
"auto", description="Update behaviour for the progress bars."
|
||||
)
|
||||
connections: RingConnections | None = Field(
|
||||
default_factory=RingConnections, description="Connections for the progress bars."
|
||||
)
|
||||
|
||||
|
||||
class Ring(BECConnector):
|
||||
USER_ACCESS = [
|
||||
"get_all_rpc",
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"set_value",
|
||||
"set_color",
|
||||
"set_background",
|
||||
"set_line_width",
|
||||
"set_min_max_values",
|
||||
"set_start_angle",
|
||||
"set_update",
|
||||
"reset_connection",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
parent_progress_widget=None,
|
||||
config: RingConfig | dict | None = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
):
|
||||
if config is None:
|
||||
config = RingConfig(widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = RingConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
|
||||
self.parent_progress_widget = parent_progress_widget
|
||||
self.color = None
|
||||
self.background_color = None
|
||||
self.start_position = None
|
||||
self.config = config
|
||||
self.RID = None
|
||||
self._init_config_params()
|
||||
|
||||
def _init_config_params(self):
|
||||
self.color = self.convert_color(self.config.color)
|
||||
self.background_color = self.convert_color(self.config.background_color)
|
||||
self.set_start_angle(self.config.start_position)
|
||||
if self.config.connections:
|
||||
self.set_connections(self.config.connections.slot, self.config.connections.endpoint)
|
||||
|
||||
def set_value(self, value: int | float):
|
||||
"""
|
||||
Set the value for the ring widget
|
||||
|
||||
Args:
|
||||
value(int | float): Value for the ring widget
|
||||
"""
|
||||
self.config.value = round(
|
||||
float(max(self.config.min_value, min(self.config.max_value, value))),
|
||||
self.config.precision,
|
||||
)
|
||||
|
||||
def set_color(self, color: str | tuple):
|
||||
"""
|
||||
Set the color for the ring widget
|
||||
|
||||
Args:
|
||||
color(str | tuple): Color for the ring widget. Can be HEX code or tuple (R, G, B, A).
|
||||
"""
|
||||
self.config.color = color
|
||||
self.color = self.convert_color(color)
|
||||
|
||||
def set_background(self, color: str | tuple):
|
||||
"""
|
||||
Set the background color for the ring widget
|
||||
|
||||
Args:
|
||||
color(str | tuple): Background color for the ring widget. Can be HEX code or tuple (R, G, B, A).
|
||||
"""
|
||||
self.config.background_color = color
|
||||
self.color = self.convert_color(color)
|
||||
|
||||
def set_line_width(self, width: int):
|
||||
"""
|
||||
Set the line width for the ring widget
|
||||
|
||||
Args:
|
||||
width(int): Line width for the ring widget
|
||||
"""
|
||||
self.config.line_width = width
|
||||
|
||||
def set_min_max_values(self, min_value: int | float, max_value: int | float):
|
||||
"""
|
||||
Set the min and max values for the ring widget.
|
||||
|
||||
Args:
|
||||
min_value(int | float): Minimum value for the ring widget
|
||||
max_value(int | float): Maximum value for the ring widget
|
||||
"""
|
||||
self.config.min_value = min_value
|
||||
self.config.max_value = max_value
|
||||
|
||||
def set_start_angle(self, start_angle: int):
|
||||
"""
|
||||
Set the start angle for the ring widget
|
||||
|
||||
Args:
|
||||
start_angle(int): Start angle for the ring widget in degrees
|
||||
"""
|
||||
self.config.start_position = start_angle
|
||||
self.start_position = start_angle * 16
|
||||
|
||||
@staticmethod
|
||||
def convert_color(color):
|
||||
"""
|
||||
Convert the color to QColor
|
||||
|
||||
Args:
|
||||
color(str | tuple): Color for the ring widget. Can be HEX code or tuple (R, G, B, A).
|
||||
"""
|
||||
converted_color = None
|
||||
if isinstance(color, str):
|
||||
converted_color = QtGui.QColor(color)
|
||||
elif isinstance(color, tuple):
|
||||
converted_color = QtGui.QColor(*color)
|
||||
return converted_color
|
||||
|
||||
def set_update(self, mode: Literal["manual", "scan", "device"], device: str = None):
|
||||
"""
|
||||
Set the update mode for the ring widget.
|
||||
Modes:
|
||||
- "manual": Manual update mode, the value is set by the user.
|
||||
- "scan": Update mode for the scan progress. The value is updated by the current scan progress.
|
||||
- "device": Update mode for the device readback. The value is updated by the device readback. Take into account that user has to set the device name and limits.
|
||||
|
||||
Args:
|
||||
mode(str): Update mode for the ring widget. Can be "manual", "scan" or "device"
|
||||
device(str): Device name for the device readback mode, only used when mode is "device"
|
||||
"""
|
||||
if mode == "manual":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
getattr(self, self.config.connections.slot), self.config.connections.endpoint
|
||||
)
|
||||
self.config.connections.slot = None
|
||||
self.config.connections.endpoint = None
|
||||
elif mode == "scan":
|
||||
self.set_connections("on_scan_progress", "scans/scan_progress")
|
||||
elif mode == "device":
|
||||
self.set_connections("on_device_readback", f"internal/devices/readback/{device}")
|
||||
|
||||
self.parent_progress_widget.enable_auto_updates(False)
|
||||
|
||||
def set_connections(self, slot: str, endpoint: str | EndpointInfo):
|
||||
"""
|
||||
Set the connections for the ring widget
|
||||
|
||||
Args:
|
||||
slot(str): Slot for the ring widget update. Can be "on_scan_progress" or "on_device_readback".
|
||||
endpoint(str | EndpointInfo): Endpoint for the ring widget update. Endpoint has to match the slot type.
|
||||
"""
|
||||
if self.config.connections.endpoint == endpoint and self.config.connections.slot == slot:
|
||||
return
|
||||
else:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.config.connections.slot, self.config.connections.endpoint
|
||||
)
|
||||
self.config.connections = RingConnections(slot=slot, endpoint=endpoint)
|
||||
self.bec_dispatcher.connect_slot(getattr(self, slot), endpoint)
|
||||
|
||||
def reset_connection(self):
|
||||
"""
|
||||
Reset the connections for the ring widget. Disconnect the current slot and endpoint.
|
||||
"""
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.config.connections.slot, self.config.connections.endpoint
|
||||
)
|
||||
self.config.connections = RingConnections()
|
||||
|
||||
def on_scan_progress(self, msg, meta):
|
||||
"""
|
||||
Update the ring widget with the scan progress.
|
||||
|
||||
Args:
|
||||
msg(dict): Message with the scan progress
|
||||
meta(dict): Metadata for the message
|
||||
"""
|
||||
current_RID = meta.get("RID", None)
|
||||
if current_RID != self.RID:
|
||||
self.set_min_max_values(0, msg.get("max_value", 100))
|
||||
self.set_value(msg.get("value", 0))
|
||||
self.parent_progress_widget.update()
|
||||
|
||||
def on_device_readback(self, msg, meta):
|
||||
"""
|
||||
Update the ring widget with the device readback.
|
||||
|
||||
Args:
|
||||
msg(dict): Message with the device readback
|
||||
meta(dict): Metadata for the message
|
||||
"""
|
||||
if isinstance(self.config.connections.endpoint, EndpointInfo):
|
||||
endpoint = self.config.connections.endpoint.endpoint
|
||||
else:
|
||||
endpoint = self.config.connections.endpoint
|
||||
device = endpoint.split("/")[-1]
|
||||
value = msg.get("signals").get(device).get("value")
|
||||
self.set_value(value)
|
||||
self.parent_progress_widget.update()
|
||||
|
||||
def cleanup(self):
|
||||
self.reset_connection()
|
||||
super().cleanup()
|
||||
636
bec_widgets/widgets/spiral_progress_bar/spiral_progress_bar.py
Normal file
@@ -0,0 +1,636 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy import QtCore, QtGui
|
||||
from qtpy.QtCore import QSize, Slot
|
||||
from qtpy.QtWidgets import QSizePolicy, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig, EntryValidator
|
||||
from bec_widgets.widgets.spiral_progress_bar.ring import Ring, RingConfig
|
||||
|
||||
|
||||
class SpiralProgressBarConfig(ConnectionConfig):
|
||||
color_map: Optional[str] = Field(
|
||||
"magma", description="Color scheme for the progress bars.", validate_default=True
|
||||
)
|
||||
min_number_of_bars: int | None = Field(
|
||||
1, description="Minimum number of progress bars to display."
|
||||
)
|
||||
max_number_of_bars: int | None = Field(
|
||||
10, description="Maximum number of progress bars to display."
|
||||
)
|
||||
num_bars: int | None = Field(1, description="Number of progress bars to display.")
|
||||
gap: int | None = Field(20, description="Gap between progress bars.")
|
||||
auto_updates: bool | None = Field(
|
||||
True, description="Enable or disable updates based on scan queue status."
|
||||
)
|
||||
rings: list[RingConfig] | None = Field([], description="List of ring configurations.")
|
||||
|
||||
@field_validator("num_bars")
|
||||
def validate_num_bars(cls, v, values):
|
||||
min_number_of_bars = values.data.get("min_number_of_bars", None)
|
||||
max_number_of_bars = values.data.get("max_number_of_bars", None)
|
||||
if min_number_of_bars is not None and max_number_of_bars is not None:
|
||||
print(
|
||||
f"Number of bars adjusted to be between defined min:{min_number_of_bars} and max:{max_number_of_bars} number of bars."
|
||||
)
|
||||
v = max(min_number_of_bars, min(v, max_number_of_bars))
|
||||
return v
|
||||
|
||||
@field_validator("rings")
|
||||
def validate_rings(cls, v, values):
|
||||
if v is not None and v is not []:
|
||||
num_bars = values.data.get("num_bars", None)
|
||||
if len(v) != num_bars:
|
||||
raise PydanticCustomError(
|
||||
"different number of configs",
|
||||
f"Length of rings configuration ({len(v)}) does not match the number of bars ({num_bars}).",
|
||||
{"wrong_value": len(v)},
|
||||
)
|
||||
indices = [ring.index for ring in v]
|
||||
if sorted(indices) != list(range(len(indices))):
|
||||
raise PydanticCustomError(
|
||||
"wrong indices",
|
||||
f"Indices of ring configurations must be unique and in order from 0 to num_bars {num_bars}.",
|
||||
{"wrong_value": indices},
|
||||
)
|
||||
return v
|
||||
|
||||
_validate_colormap = field_validator("color_map")(Colors.validate_color_map)
|
||||
|
||||
|
||||
class SpiralProgressBar(BECConnector, QWidget):
|
||||
USER_ACCESS = [
|
||||
"get_all_rpc",
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"rings",
|
||||
"update_config",
|
||||
"add_ring",
|
||||
"remove_ring",
|
||||
"set_precision",
|
||||
"set_min_max_values",
|
||||
"set_number_of_bars",
|
||||
"set_value",
|
||||
"set_colors_from_map",
|
||||
"set_colors_directly",
|
||||
"set_line_widths",
|
||||
"set_gap",
|
||||
"set_diameter",
|
||||
"reset_diameter",
|
||||
"enable_auto_updates",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
config: SpiralProgressBarConfig | dict | None = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
num_bars: int | None = None,
|
||||
):
|
||||
if config is None:
|
||||
config = SpiralProgressBarConfig(widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = SpiralProgressBarConfig(**config, widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=None)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
|
||||
self.RID = None
|
||||
|
||||
# For updating bar behaviour
|
||||
self._auto_updates = True
|
||||
self._rings = None
|
||||
|
||||
if num_bars is not None:
|
||||
self.config.num_bars = max(
|
||||
self.config.min_number_of_bars, min(num_bars, self.config.max_number_of_bars)
|
||||
)
|
||||
self.initialize_bars()
|
||||
|
||||
self.enable_auto_updates(self.config.auto_updates)
|
||||
|
||||
@property
|
||||
def rings(self):
|
||||
return self._rings
|
||||
|
||||
@rings.setter
|
||||
def rings(self, value):
|
||||
self._rings = value
|
||||
|
||||
def update_config(self, config: SpiralProgressBarConfig | dict):
|
||||
"""
|
||||
Update the configuration of the widget.
|
||||
|
||||
Args:
|
||||
config(SpiralProgressBarConfig|dict): Configuration to update.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
config = SpiralProgressBarConfig(**config, widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
self.clear_all()
|
||||
|
||||
def initialize_bars(self):
|
||||
"""
|
||||
Initialize the progress bars.
|
||||
"""
|
||||
start_positions = [90 * 16] * self.config.num_bars
|
||||
directions = [-1] * self.config.num_bars
|
||||
|
||||
self.config.rings = [
|
||||
RingConfig(
|
||||
widget_class="Ring",
|
||||
index=i,
|
||||
start_positions=start_positions[i],
|
||||
directions=directions[i],
|
||||
)
|
||||
for i in range(self.config.num_bars)
|
||||
]
|
||||
self._rings = [
|
||||
Ring(parent_progress_widget=self, config=config) for config in self.config.rings
|
||||
]
|
||||
|
||||
if self.config.color_map:
|
||||
self.set_colors_from_map(self.config.color_map)
|
||||
|
||||
min_size = self._calculate_minimum_size()
|
||||
self.setMinimumSize(min_size)
|
||||
self.update()
|
||||
|
||||
def add_ring(self, **kwargs) -> Ring:
|
||||
"""
|
||||
Add a new progress bar.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the new progress bar.
|
||||
|
||||
Returns:
|
||||
Ring: Ring object.
|
||||
"""
|
||||
if self.config.num_bars < self.config.max_number_of_bars:
|
||||
ring_index = self.config.num_bars
|
||||
ring_config = RingConfig(
|
||||
widget_class="Ring",
|
||||
index=ring_index,
|
||||
start_positions=90 * 16,
|
||||
directions=-1,
|
||||
**kwargs,
|
||||
)
|
||||
ring = Ring(parent_progress_widget=self, config=ring_config)
|
||||
self.config.num_bars += 1
|
||||
self._rings.append(ring)
|
||||
self.config.rings.append(ring.config)
|
||||
if self.config.color_map:
|
||||
self.set_colors_from_map(self.config.color_map)
|
||||
base_line_width = self._rings[ring.config.index].config.line_width
|
||||
self.set_line_widths(base_line_width, ring.config.index)
|
||||
self.update()
|
||||
return ring
|
||||
|
||||
def remove_ring(self, index: int):
|
||||
"""
|
||||
Remove a progress bar by index.
|
||||
|
||||
Args:
|
||||
index(int): Index of the progress bar to remove.
|
||||
"""
|
||||
ring = self._find_ring_by_index(index)
|
||||
ring.cleanup()
|
||||
self._rings.remove(ring)
|
||||
self.config.rings.remove(ring.config)
|
||||
self.config.num_bars -= 1
|
||||
self._reindex_rings()
|
||||
if self.config.color_map:
|
||||
self.set_colors_from_map(self.config.color_map)
|
||||
del ring
|
||||
self.update()
|
||||
|
||||
def _reindex_rings(self):
|
||||
"""
|
||||
Reindex the progress bars.
|
||||
"""
|
||||
for i, ring in enumerate(self._rings):
|
||||
ring.config.index = i
|
||||
|
||||
def set_precision(self, precision: int, bar_index: int = None):
|
||||
"""
|
||||
Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars.
|
||||
|
||||
Args:
|
||||
precision(int): Precision for the progress bars.
|
||||
bar_index(int): Index of the progress bar to set the precision for. If provided, only a single precision can be set.
|
||||
"""
|
||||
if bar_index is not None:
|
||||
bar_index = self._bar_index_check(bar_index)
|
||||
ring = self._find_ring_by_index(bar_index)
|
||||
ring.config.precision = precision
|
||||
else:
|
||||
for ring in self._rings:
|
||||
ring.config.precision = precision
|
||||
self.update()
|
||||
|
||||
def set_min_max_values(
|
||||
self,
|
||||
min_values: int | float | list[int | float],
|
||||
max_values: int | float | list[int | float],
|
||||
):
|
||||
"""
|
||||
Set the minimum and maximum values for the progress bars.
|
||||
|
||||
Args:
|
||||
min_values(int|float | list[float]): Minimum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of minimum values for each progress bar.
|
||||
max_values(int|float | list[float]): Maximum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of maximum values for each progress bar.
|
||||
"""
|
||||
if isinstance(min_values, int) or isinstance(min_values, float):
|
||||
min_values = [min_values]
|
||||
if isinstance(max_values, int) or isinstance(max_values, float):
|
||||
max_values = [max_values]
|
||||
min_values = self._adjust_list_to_bars(min_values)
|
||||
max_values = self._adjust_list_to_bars(max_values)
|
||||
for ring, min_value, max_value in zip(self._rings, min_values, max_values):
|
||||
ring.set_min_max_values(min_value, max_value)
|
||||
self.update()
|
||||
|
||||
def set_number_of_bars(self, num_bars: int):
|
||||
"""
|
||||
Set the number of progress bars to display.
|
||||
|
||||
Args:
|
||||
num_bars(int): Number of progress bars to display.
|
||||
"""
|
||||
num_bars = max(
|
||||
self.config.min_number_of_bars, min(num_bars, self.config.max_number_of_bars)
|
||||
)
|
||||
current_num_bars = self.config.num_bars
|
||||
|
||||
if num_bars > current_num_bars:
|
||||
for i in range(current_num_bars, num_bars):
|
||||
new_ring_config = RingConfig(
|
||||
widget_class="Ring", index=i, start_positions=90 * 16, directions=-1
|
||||
)
|
||||
self.config.rings.append(new_ring_config)
|
||||
new_ring = Ring(parent_progress_widget=self, config=new_ring_config)
|
||||
self._rings.append(new_ring)
|
||||
|
||||
elif num_bars < current_num_bars:
|
||||
for i in range(current_num_bars - 1, num_bars - 1, -1):
|
||||
self.remove_ring(i)
|
||||
|
||||
self.config.num_bars = num_bars
|
||||
|
||||
if self.config.color_map:
|
||||
self.set_colors_from_map(self.config.color_map)
|
||||
|
||||
base_line_width = self._rings[0].config.line_width
|
||||
self.set_line_widths(base_line_width)
|
||||
|
||||
self.update()
|
||||
|
||||
def set_value(self, values: int | list, ring_index: int = None):
|
||||
"""
|
||||
Set the values for the progress bars.
|
||||
|
||||
Args:
|
||||
values(int | tuple): Value(s) for the progress bars. If multiple progress bars are displayed, provide a tuple of values for each progress bar.
|
||||
ring_index(int): Index of the progress bar to set the value for. If provided, only a single value can be set.
|
||||
|
||||
Examples:
|
||||
>>> SpiralProgressBar.set_value(50)
|
||||
>>> SpiralProgressBar.set_value([30, 40, 50]) # (outer, middle, inner)
|
||||
>>> SpiralProgressBar.set_value(60, bar_index=1) # Set the value for the middle progress bar.
|
||||
"""
|
||||
if ring_index is not None:
|
||||
ring = self._find_ring_by_index(ring_index)
|
||||
if isinstance(values, list):
|
||||
values = values[0]
|
||||
print(
|
||||
f"Warning: Only a single value can be set for a single progress bar. Using the first value in the list {values}"
|
||||
)
|
||||
ring.set_value(values)
|
||||
else:
|
||||
if isinstance(values, int):
|
||||
values = [values]
|
||||
values = self._adjust_list_to_bars(values)
|
||||
for ring, value in zip(self._rings, values):
|
||||
ring.set_value(value)
|
||||
self.update()
|
||||
|
||||
def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"):
|
||||
"""
|
||||
Set the colors for the progress bars from a colormap.
|
||||
|
||||
Args:
|
||||
colormap(str): Name of the colormap.
|
||||
color_format(Literal["RGB","HEX"]): Format of the returned colors ('RGB', 'HEX').
|
||||
"""
|
||||
if colormap not in pg.colormap.listMaps():
|
||||
raise ValueError(
|
||||
f"Colormap '{colormap}' not found in the current installation of pyqtgraph"
|
||||
)
|
||||
colors = Colors.golden_angle_color(colormap, self.config.num_bars, color_format)
|
||||
self.set_colors_directly(colors)
|
||||
self.config.color_map = colormap
|
||||
self.update()
|
||||
|
||||
def set_colors_directly(self, colors: list[str | tuple] | str | tuple, bar_index: int = None):
|
||||
"""
|
||||
Set the colors for the progress bars directly.
|
||||
|
||||
Args:
|
||||
colors(list[str | tuple] | str | tuple): Color(s) for the progress bars. If multiple progress bars are displayed, provide a list of colors for each progress bar.
|
||||
bar_index(int): Index of the progress bar to set the color for. If provided, only a single color can be set.
|
||||
"""
|
||||
if bar_index is not None and isinstance(colors, (str, tuple)):
|
||||
bar_index = self._bar_index_check(bar_index)
|
||||
ring = self._find_ring_by_index(bar_index)
|
||||
ring.set_color(colors)
|
||||
else:
|
||||
if isinstance(colors, (str, tuple)):
|
||||
colors = [colors]
|
||||
colors = self._adjust_list_to_bars(colors)
|
||||
for ring, color in zip(self._rings, colors):
|
||||
ring.set_color(color)
|
||||
self.update()
|
||||
|
||||
def set_line_widths(self, widths: int | list[int], bar_index: int = None):
|
||||
"""
|
||||
Set the line widths for the progress bars.
|
||||
|
||||
Args:
|
||||
widths(int | list[int]): Line width(s) for the progress bars. If multiple progress bars are displayed, provide a list of line widths for each progress bar.
|
||||
bar_index(int): Index of the progress bar to set the line width for. If provided, only a single line width can be set.
|
||||
"""
|
||||
if bar_index is not None:
|
||||
bar_index = self._bar_index_check(bar_index)
|
||||
ring = self._find_ring_by_index(bar_index)
|
||||
if isinstance(widths, list):
|
||||
widths = widths[0]
|
||||
print(
|
||||
f"Warning: Only a single line width can be set for a single progress bar. Using the first value in the list {widths}"
|
||||
)
|
||||
ring.set_line_width(widths)
|
||||
else:
|
||||
if isinstance(widths, int):
|
||||
widths = [widths]
|
||||
widths = self._adjust_list_to_bars(widths)
|
||||
self.config.gap = max(widths) * 2
|
||||
for ring, width in zip(self._rings, widths):
|
||||
ring.set_line_width(width)
|
||||
min_size = self._calculate_minimum_size()
|
||||
self.setMinimumSize(min_size)
|
||||
self.update()
|
||||
|
||||
def set_gap(self, gap: int):
|
||||
"""
|
||||
Set the gap between the progress bars.
|
||||
|
||||
Args:
|
||||
gap(int): Gap between the progress bars.
|
||||
"""
|
||||
self.config.gap = gap
|
||||
self.update()
|
||||
|
||||
def set_diameter(self, diameter: int):
|
||||
"""
|
||||
Set the diameter of the widget.
|
||||
|
||||
Args:
|
||||
diameter(int): Diameter of the widget.
|
||||
"""
|
||||
size = QSize(diameter, diameter)
|
||||
self.resize(size)
|
||||
self.setFixedSize(size)
|
||||
|
||||
def _find_ring_by_index(self, index: int) -> Ring:
|
||||
"""
|
||||
Find the ring by index.
|
||||
|
||||
Args:
|
||||
index(int): Index of the ring.
|
||||
|
||||
Returns:
|
||||
Ring: Ring object.
|
||||
"""
|
||||
found_ring = None
|
||||
for ring in self._rings:
|
||||
if ring.config.index == index:
|
||||
found_ring = ring
|
||||
break
|
||||
if found_ring is None:
|
||||
raise ValueError(f"Ring with index {index} not found.")
|
||||
return found_ring
|
||||
|
||||
def enable_auto_updates(self, enable: bool = True):
|
||||
"""
|
||||
Enable or disable updates based on scan status. Overrides manual updates.
|
||||
The behaviour of the whole progress bar widget will be driven by the scan queue status.
|
||||
|
||||
Args:
|
||||
enable(bool): True or False.
|
||||
|
||||
Returns:
|
||||
bool: True if scan segment updates are enabled.
|
||||
"""
|
||||
|
||||
self._auto_updates = enable
|
||||
if enable is True:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_scan_queue_status, MessageEndpoints.scan_queue_status()
|
||||
)
|
||||
else:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_scan_queue_status, MessageEndpoints.scan_queue_status()
|
||||
)
|
||||
return self._auto_updates
|
||||
|
||||
@Slot(dict, dict)
|
||||
def on_scan_queue_status(self, msg, meta):
|
||||
"""
|
||||
Slot to handle scan queue status messages. Decides what update to perform based on the scan queue status.
|
||||
|
||||
Args:
|
||||
msg(dict): Message from the BEC.
|
||||
meta(dict): Metadata from the BEC.
|
||||
"""
|
||||
primary_queue = msg.get("queue").get("primary")
|
||||
info = primary_queue.get("info", None)
|
||||
|
||||
if info:
|
||||
active_request_block = info[0].get("active_request_block", None)
|
||||
if active_request_block:
|
||||
report_instructions = active_request_block.get("report_instructions", None)
|
||||
if report_instructions:
|
||||
instruction_type = list(report_instructions[0].keys())[0]
|
||||
if instruction_type == "scan_progress":
|
||||
self._hook_scan_progress(ring_index=0)
|
||||
elif instruction_type == "readback":
|
||||
devices = report_instructions[0].get("readback").get("devices")
|
||||
start = report_instructions[0].get("readback").get("start")
|
||||
end = report_instructions[0].get("readback").get("end")
|
||||
if self.config.num_bars != len(devices):
|
||||
self.set_number_of_bars(len(devices))
|
||||
for index, device in enumerate(devices):
|
||||
self._hook_readback(index, device, start[index], end[index])
|
||||
else:
|
||||
print(f"{instruction_type} not supported yet.")
|
||||
|
||||
# elif instruction_type == "device_progress":
|
||||
# print("hook device_progress")
|
||||
|
||||
def _hook_scan_progress(self, ring_index: int = None):
|
||||
"""
|
||||
Hook the scan progress to the progress bars.
|
||||
|
||||
Args:
|
||||
ring_index(int): Index of the progress bar to hook the scan progress to.
|
||||
"""
|
||||
if ring_index is not None:
|
||||
ring = self._find_ring_by_index(ring_index)
|
||||
else:
|
||||
ring = self._rings[0]
|
||||
|
||||
if ring.config.connections.slot == "on_scan_progress":
|
||||
return
|
||||
else:
|
||||
ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
|
||||
|
||||
def _hook_readback(self, bar_index: int, device: str, min: float | int, max: float | int):
|
||||
"""
|
||||
Hook the readback values to the progress bars.
|
||||
|
||||
Args:
|
||||
bar_index(int): Index of the progress bar to hook the readback values to.
|
||||
device(str): Device to readback values from.
|
||||
min(float|int): Minimum value for the progress bar.
|
||||
max(float|int): Maximum value for the progress bar.
|
||||
"""
|
||||
ring = self._find_ring_by_index(bar_index)
|
||||
ring.set_min_max_values(min, max)
|
||||
endpoint = MessageEndpoints.device_readback(device)
|
||||
ring.set_connections("on_device_readback", endpoint)
|
||||
|
||||
def _adjust_list_to_bars(self, items: list) -> list:
|
||||
"""
|
||||
Utility method to adjust the list of parameters to match the number of progress bars.
|
||||
|
||||
Args:
|
||||
items(list): List of parameters for the progress bars.
|
||||
|
||||
Returns:
|
||||
list: List of parameters for the progress bars.
|
||||
"""
|
||||
if items is None:
|
||||
raise ValueError(
|
||||
"Items cannot be None. Please provide a list for parameters for the progress bars."
|
||||
)
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
if len(items) < self.config.num_bars:
|
||||
last_item = items[-1]
|
||||
items.extend([last_item] * (self.config.num_bars - len(items)))
|
||||
elif len(items) > self.config.num_bars:
|
||||
items = items[: self.config.num_bars]
|
||||
return items
|
||||
|
||||
def _bar_index_check(self, bar_index: int):
|
||||
"""
|
||||
Utility method to check if the bar index is within the range of the number of progress bars.
|
||||
|
||||
Args:
|
||||
bar_index(int): Index of the progress bar to set the value for.
|
||||
"""
|
||||
if not (0 <= bar_index < self.config.num_bars):
|
||||
raise ValueError(
|
||||
f"bar_index {bar_index} out of range of number of bars {self.config.num_bars}."
|
||||
)
|
||||
return bar_index
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QtGui.QPainter(self)
|
||||
painter.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
size = min(self.width(), self.height())
|
||||
rect = QtCore.QRect(0, 0, size, size)
|
||||
rect.adjust(
|
||||
max(ring.config.line_width for ring in self._rings),
|
||||
max(ring.config.line_width for ring in self._rings),
|
||||
-max(ring.config.line_width for ring in self._rings),
|
||||
-max(ring.config.line_width for ring in self._rings),
|
||||
)
|
||||
|
||||
for i, ring in enumerate(self._rings):
|
||||
# Background arc
|
||||
painter.setPen(
|
||||
QtGui.QPen(ring.background_color, ring.config.line_width, QtCore.Qt.SolidLine)
|
||||
)
|
||||
offset = self.config.gap * i
|
||||
adjusted_rect = QtCore.QRect(
|
||||
rect.left() + offset,
|
||||
rect.top() + offset,
|
||||
rect.width() - 2 * offset,
|
||||
rect.height() - 2 * offset,
|
||||
)
|
||||
painter.drawArc(adjusted_rect, ring.config.start_position, 360 * 16)
|
||||
|
||||
# Foreground arc
|
||||
pen = QtGui.QPen(ring.color, ring.config.line_width, QtCore.Qt.SolidLine)
|
||||
pen.setCapStyle(QtCore.Qt.RoundCap)
|
||||
painter.setPen(pen)
|
||||
proportion = (ring.config.value - ring.config.min_value) / (
|
||||
(ring.config.max_value - ring.config.min_value) + 1e-3
|
||||
)
|
||||
angle = int(proportion * 360 * 16 * ring.config.direction)
|
||||
painter.drawArc(adjusted_rect, ring.start_position, angle)
|
||||
|
||||
def reset_diameter(self):
|
||||
"""
|
||||
Reset the fixed size of the widget.
|
||||
"""
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||
self.setMinimumSize(self._calculate_minimum_size())
|
||||
self.setMaximumSize(16777215, 16777215)
|
||||
|
||||
def _calculate_minimum_size(self):
|
||||
"""
|
||||
Calculate the minimum size of the widget.
|
||||
"""
|
||||
if not self.config.rings:
|
||||
print("no rings to get size from setting size to 10x10")
|
||||
return QSize(10, 10)
|
||||
ring_widths = [self.config.rings[i].line_width for i in range(self.config.num_bars)]
|
||||
total_width = sum(ring_widths) + self.config.gap * (self.config.num_bars - 1)
|
||||
diameter = total_width * 2
|
||||
if diameter < 50:
|
||||
diameter = 50
|
||||
return QSize(diameter, diameter)
|
||||
|
||||
def sizeHint(self):
|
||||
min_size = self._calculate_minimum_size()
|
||||
return min_size
|
||||
|
||||
def clear_all(self):
|
||||
for ring in self._rings:
|
||||
ring.cleanup()
|
||||
del ring
|
||||
self._rings = []
|
||||
self.update()
|
||||
self.initialize_bars()
|
||||
|
||||
def cleanup(self):
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_scan_queue_status, MessageEndpoints.scan_queue_status()
|
||||
)
|
||||
for ring in self._rings:
|
||||
ring.cleanup()
|
||||
del ring
|
||||
super().cleanup()
|
||||
0
bec_widgets/widgets/text_box/__init__.py
Normal file
127
bec_widgets/widgets/text_box/text_box.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import re
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from qtpy.QtWidgets import QTextEdit
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
|
||||
|
||||
class TextBoxConfig(ConnectionConfig):
|
||||
|
||||
theme: str = Field("dark", description="The theme of the figure widget.")
|
||||
font_color: str = Field("#FFF", description="The font color of the text")
|
||||
background_color: str = Field("#000", description="The background color of the widget.")
|
||||
font_size: int = Field(16, description="The font size of the text in the widget.")
|
||||
text: str = Field("", description="The text to display in the widget.")
|
||||
|
||||
@classmethod
|
||||
@field_validator("theme")
|
||||
def validate_theme(cls, v):
|
||||
"""Validate the theme of the figure widget."""
|
||||
if v not in ["dark", "light"]:
|
||||
raise ValueError("Theme must be either 'dark' or 'light'")
|
||||
return v
|
||||
|
||||
_validate_font_color = field_validator("font_color")(Colors.validate_color)
|
||||
_validate_background_color = field_validator("background_color")(Colors.validate_color)
|
||||
|
||||
|
||||
class TextBox(BECConnector, QTextEdit):
|
||||
|
||||
USER_ACCESS = ["set_color", "set_text", "set_font_size"]
|
||||
|
||||
def __init__(self, text: str = "", parent=None, client=None, config=None, gui_id=None):
|
||||
if config is None:
|
||||
config = TextBoxConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = TextBoxConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QTextEdit.__init__(self, parent=parent)
|
||||
|
||||
self.config = config
|
||||
self.setReadOnly(True)
|
||||
self.setGeometry(self.rect())
|
||||
self.set_color(self.config.background_color, self.config.font_color)
|
||||
if not text:
|
||||
text = "<h1>Welcome to the BEC Widget TextBox</h1><p>A widget that allows user to display text in plain and HTML format.</p><p>This is an example of displaying HTML text.</p>"
|
||||
self.set_text(text)
|
||||
|
||||
def change_theme(self) -> None:
|
||||
"""
|
||||
Change the theme of the figure widget.
|
||||
"""
|
||||
if self.config.theme == "dark":
|
||||
theme = "light"
|
||||
font_color = "#000"
|
||||
background_color = "#FFF"
|
||||
else:
|
||||
theme = "dark"
|
||||
font_color = "#FFF"
|
||||
background_color = "#000"
|
||||
self.config.theme = theme
|
||||
self.set_color(background_color, font_color)
|
||||
|
||||
def set_color(self, background_color: str, font_color: str) -> None:
|
||||
"""Set the background color of the widget.
|
||||
|
||||
Args:
|
||||
background_color (str): The color to set the background in HEX.
|
||||
font_color (str): The color to set the font in HEX.
|
||||
|
||||
"""
|
||||
self.config.background_color = background_color
|
||||
self.config.font_color = font_color
|
||||
self._update_stylesheet()
|
||||
|
||||
def set_font_size(self, size: int) -> None:
|
||||
"""Set the font size of the text in the widget.
|
||||
|
||||
Args:
|
||||
size (int): The font size to set.
|
||||
"""
|
||||
self.config.font_size = size
|
||||
self._update_stylesheet()
|
||||
|
||||
def _update_stylesheet(self):
|
||||
"""Update the stylesheet of the widget."""
|
||||
self.setStyleSheet(
|
||||
f"background-color: {self.config.background_color}; color: {self.config.font_color}; font-size: {self.config.font_size}px"
|
||||
)
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
"""Set the text of the widget.
|
||||
|
||||
Args:
|
||||
text (str): The text to set.
|
||||
"""
|
||||
if self.is_html(text):
|
||||
self.setHtml(text)
|
||||
else:
|
||||
self.setPlainText(text)
|
||||
self.config.text = text
|
||||
|
||||
def is_html(self, text: str) -> bool:
|
||||
"""Check if the text contains HTML tags.
|
||||
|
||||
Args:
|
||||
text (str): The text to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the text contains HTML tags, False otherwise.
|
||||
"""
|
||||
return bool(re.search(r"<[a-zA-Z/][^>]*>", text))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
widget = TextBox()
|
||||
widget.show()
|
||||
sys.exit(app.exec())
|
||||
0
bec_widgets/widgets/website/__init__.py
Normal file
65
bec_widgets/widgets/website/website.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from qtpy.QtCore import QUrl
|
||||
from qtpy.QtWebEngineWidgets import QWebEngineView
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
|
||||
class WebsiteWidget(BECConnector, QWebEngineView):
|
||||
"""
|
||||
A simple widget to display a website
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["set_url", "get_url", "reload", "back", "forward"]
|
||||
|
||||
def __init__(self, url: str = None, parent=None, config=None, client=None, gui_id=None):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QWebEngineView.__init__(self, parent=parent)
|
||||
self.set_url(url)
|
||||
|
||||
def set_url(self, url: str) -> None:
|
||||
"""
|
||||
Set the url of the website widget
|
||||
|
||||
Args:
|
||||
url (str): The url to set
|
||||
"""
|
||||
if not url:
|
||||
return
|
||||
self.setUrl(QUrl(url))
|
||||
|
||||
def get_url(self) -> str:
|
||||
"""
|
||||
Get the current url of the website widget
|
||||
|
||||
Returns:
|
||||
str: The current url
|
||||
"""
|
||||
return self.url().toString()
|
||||
|
||||
def reload(self):
|
||||
"""
|
||||
Reload the website
|
||||
"""
|
||||
QWebEngineView.reload(self)
|
||||
|
||||
def back(self):
|
||||
"""
|
||||
Go back in the history
|
||||
"""
|
||||
QWebEngineView.back(self)
|
||||
|
||||
def forward(self):
|
||||
"""
|
||||
Go forward in the history
|
||||
"""
|
||||
QWebEngineView.forward(self)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
mainWin = WebsiteWidget("https://scilog.psi.ch")
|
||||
mainWin.show()
|
||||
sys.exit(app.exec())
|
||||
170
docs/_static/custom.css
vendored
Normal file
@@ -1,4 +1,5 @@
|
||||
## API Reference
|
||||
(api_reference)=
|
||||
# API Reference
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
@@ -7,4 +8,5 @@
|
||||
:recursive:
|
||||
|
||||
bec_widgets
|
||||
|
||||
```
|
||||
1
docs/assets/apps_48dp.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z"/></svg>
|
||||
|
After Width: | Height: | Size: 292 B |
1
docs/assets/display_settings_48dp.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 20 20" height="48px" viewBox="0 0 20 20" width="48px" fill="#000000"><g><rect fill="none" height="20" width="20" x="0"/></g><g><g><path d="M16.5,3h-13C2.67,3,2,3.67,2,4.5v9C2,14.33,2.67,15,3.5,15H7v2h6v-2h3.5c0.83,0,1.5-0.67,1.5-1.5v-9 C18,3.67,17.33,3,16.5,3z M16.5,13.5h-13v-9h13V13.5z"/><rect height="1" width="7" x="5" y="7"/><polygon points="14,8 15,8 15,7 14,7 14,6 13,6 13,9 14,9"/><rect height="1" width="7" x="8" y="10"/><polygon points="6,12 7,12 7,9 6,9 6,10 5,10 5,11 6,11"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 568 B |
97
docs/assets/index_api.svg
Normal file
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="99.058548mm"
|
||||
height="89.967583mm"
|
||||
viewBox="0 0 99.058554 89.967582"
|
||||
version="1.1"
|
||||
id="svg1040"
|
||||
inkscape:version="0.92.4 (f8dce91, 2019-08-02)"
|
||||
sodipodi:docname="index_api.svg">
|
||||
<defs
|
||||
id="defs1034" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.35"
|
||||
inkscape:cx="533.74914"
|
||||
inkscape:cy="10.90433"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:window-width="930"
|
||||
inkscape:window-height="472"
|
||||
inkscape:window-x="2349"
|
||||
inkscape:window-y="267"
|
||||
inkscape:window-maximized="0" />
|
||||
<metadata
|
||||
id="metadata1037">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(195.19933,-1.0492759)">
|
||||
<g
|
||||
id="g1008"
|
||||
transform="matrix(1.094977,0,0,1.094977,-521.5523,-198.34055)">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path899"
|
||||
d="M 324.96812,187.09499 H 303.0455 v 72.1639 h 22.67969"
|
||||
style="fill:none;stroke:#5a5a5a;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path899-3"
|
||||
d="m 361.58921,187.09499 h 21.92262 v 72.1639 h -22.67969"
|
||||
style="fill:none;stroke:#5a5a5a;stroke-width:10;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<g
|
||||
transform="translate(415.87139,46.162126)"
|
||||
id="g944">
|
||||
<circle
|
||||
style="fill:#5a5a5a;fill-opacity:1;stroke:#5a5a5a;stroke-width:4.53704548;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path918"
|
||||
cx="-84.40152"
|
||||
cy="189.84375"
|
||||
r="2.2293637" />
|
||||
<circle
|
||||
style="fill:#5a5a5a;fill-opacity:1;stroke:#5a5a5a;stroke-width:4.53704548;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path918-5"
|
||||
cx="-72.949402"
|
||||
cy="189.84375"
|
||||
r="2.2293637" />
|
||||
<circle
|
||||
style="fill:#5a5a5a;fill-opacity:1;stroke:#5a5a5a;stroke-width:4.53704548;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path918-6"
|
||||
cx="-61.497284"
|
||||
cy="189.84375"
|
||||
r="2.2293637" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
76
docs/assets/index_contribute.svg
Normal file
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="89.624855mm"
|
||||
height="89.96759mm"
|
||||
viewBox="0 0 89.62486 89.96759"
|
||||
version="1.1"
|
||||
id="svg1040"
|
||||
inkscape:version="0.92.4 (f8dce91, 2019-08-02)"
|
||||
sodipodi:docname="index_contribute.svg">
|
||||
<defs
|
||||
id="defs1034" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.35"
|
||||
inkscape:cx="683.11893"
|
||||
inkscape:cy="-59.078181"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:window-width="930"
|
||||
inkscape:window-height="472"
|
||||
inkscape:window-x="2349"
|
||||
inkscape:window-y="267"
|
||||
inkscape:window-maximized="0" />
|
||||
<metadata
|
||||
id="metadata1037">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(234.72009,17.466935)">
|
||||
<g
|
||||
id="g875"
|
||||
transform="matrix(0.99300176,0,0,0.99300176,-133.24106,-172.58804)">
|
||||
<path
|
||||
sodipodi:nodetypes="ccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path869"
|
||||
d="m -97.139881,161.26069 47.247024,40.25446 -47.247024,40.25446"
|
||||
style="fill:none;stroke:#5a5a5a;stroke-width:10;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path871"
|
||||
d="m -49.514879,241.81547 h 32.505951"
|
||||
style="fill:none;stroke:#5a5a5a;stroke-width:10;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
66
docs/assets/index_getting_started.svg
Normal file
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="101.09389mm"
|
||||
height="89.96759mm"
|
||||
viewBox="0 0 101.09389 89.96759"
|
||||
version="1.1"
|
||||
id="svg1040"
|
||||
inkscape:version="0.92.4 (f8dce91, 2019-08-02)"
|
||||
sodipodi:docname="index_getting_started.svg">
|
||||
<defs
|
||||
id="defs1034" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.35"
|
||||
inkscape:cx="-93.242129"
|
||||
inkscape:cy="-189.9825"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:window-width="1875"
|
||||
inkscape:window-height="1056"
|
||||
inkscape:window-x="1965"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1" />
|
||||
<metadata
|
||||
id="metadata1037">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(2.9219487,-8.5995374)">
|
||||
<path
|
||||
style="fill:#5a5a5a;fill-opacity:1;stroke-width:0.20233451"
|
||||
d="M 37.270955,98.335591 C 33.358064,97.07991 31.237736,92.52319 32.964256,89.08022 c 0.18139,-0.361738 4.757999,-5.096629 10.17021,-10.521968 l 9.84041,-9.864254 -4.03738,-4.041175 -4.037391,-4.041172 -4.96415,4.916665 c -3.61569,3.581096 -5.238959,5.04997 -5.975818,5.407377 l -1.011682,0.490718 H 17.267525 1.5866055 L 0.65034544,70.96512 C -2.2506745,69.535833 -3.5952145,66.18561 -2.5925745,62.884631 c 0.53525,-1.762217 1.61699004,-3.050074 3.22528014,-3.839847 l 1.15623996,-0.56778 13.2591094,-0.05613 13.259111,-0.05613 11.5262,-11.527539 11.526199,-11.527528 H 40.622647 c -12.145542,0 -12.189222,-0.0046 -13.752801,-1.445851 -2.229871,-2.055423 -2.162799,-5.970551 0.135998,-7.938238 1.475193,-1.262712 1.111351,-1.238469 18.588522,-1.238469 12.899229,0 16.035311,0.05193 16.692589,0.276494 0.641832,0.219264 2.590731,2.051402 9.416301,8.852134 l 8.606941,8.575638 h 6.848168 c 4.837422,0 7.092281,0.07311 7.679571,0.249094 0.48064,0.144008 1.22985,0.634863 1.77578,1.163429 2.383085,2.307333 1.968685,6.539886 -0.804989,8.221882 -0.571871,0.346781 -1.38284,0.687226 -1.80217,0.756523 -0.41933,0.06928 -4.2741,0.127016 -8.56615,0.128238 -6.56998,0.0016 -7.977492,-0.04901 -8.902732,-0.321921 -0.975569,-0.287742 -1.400468,-0.622236 -3.783999,-2.978832 l -2.685021,-2.654679 -5.05411,5.051071 -5.0541,5.051081 3.926292,3.947202 c 2.365399,2.378001 4.114289,4.309171 4.399158,4.857713 0.39266,0.75606 0.47311,1.219412 0.474321,2.731516 0.003,3.083647 0.620779,2.331942 -13.598011,16.531349 -10.273768,10.259761 -12.679778,12.563171 -13.500979,12.92519 -1.267042,0.55857 -3.156169,0.681342 -4.390271,0.285321 z m 40.130741,-65.45839 c -2.212909,-0.579748 -3.782711,-1.498393 -5.51275,-3.226063 -2.522111,-2.518633 -3.633121,-5.181304 -3.633121,-8.707194 0,-3.530699 1.11238,-6.197124 3.631161,-8.704043 4.866751,-4.8438383 12.324781,-4.8550953 17.211791,-0.026 3.908758,3.862461 4.818578,9.377999 2.372188,14.380771 -0.846209,1.730481 -3.39493,4.326384 -5.143839,5.239072 -2.69708,1.407492 -6.042829,1.798628 -8.92543,1.043434 z"
|
||||
id="path1000"
|
||||
inkscape:connector-curvature="0" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
67
docs/assets/index_user_guide.svg
Normal file
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="123.72241mm"
|
||||
height="89.96759mm"
|
||||
viewBox="0 0 123.72242 89.96759"
|
||||
version="1.1"
|
||||
id="svg1040"
|
||||
inkscape:version="0.92.4 (f8dce91, 2019-08-02)"
|
||||
sodipodi:docname="index_userguide.svg">
|
||||
<defs
|
||||
id="defs1034" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.35"
|
||||
inkscape:cx="332.26618"
|
||||
inkscape:cy="83.744004"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:window-width="930"
|
||||
inkscape:window-height="472"
|
||||
inkscape:window-x="2349"
|
||||
inkscape:window-y="267"
|
||||
inkscape:window-maximized="0" />
|
||||
<metadata
|
||||
id="metadata1037">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(141.8903,-20.32143)">
|
||||
<path
|
||||
style="fill:#5a5a5a;fill-opacity:1;stroke-width:0.20483544"
|
||||
d="m -139.53374,110.1657 c -0.80428,-0.24884 -1.71513,-1.11296 -2.07107,-1.96486 -0.23905,-0.57214 -0.28453,-6.28104 -0.28453,-35.720988 0,-38.274546 -0.079,-35.840728 1.19849,-36.91568 0.58869,-0.495345 4.63766,-2.187548 8.47998,-3.544073 l 1.58749,-0.560453 v -3.309822 c 0,-3.025538 0.0396,-3.388179 0.46086,-4.222122 0.68808,-1.362003 1.38671,-1.714455 4.60319,-2.322195 4.12797,-0.779966 5.13304,-0.912766 8.81544,-1.16476 11.80964,-0.808168 22.80911,2.509277 30.965439,9.3392 1.750401,1.465747 3.840861,3.5635 5.0903,5.108065 l 0.659122,0.814805 0.659109,-0.814805 c 1.249431,-1.544565 3.33988,-3.642318 5.09029,-5.108065 8.156331,-6.829923 19.155791,-10.147368 30.965441,-9.3392 3.682389,0.251994 4.68748,0.384794 8.81544,1.16476 3.21647,0.60774 3.91511,0.960192 4.60318,2.322195 0.4213,0.833943 0.46087,1.196584 0.46087,4.222122 v 3.309822 l 1.58748,0.560453 c 4.10165,1.448077 7.98852,3.072753 8.5259,3.563743 1.22643,1.120567 1.15258,-1.245868 1.15258,36.927177 0,34.567591 -0.005,35.083151 -0.40663,35.903991 -0.22365,0.45804 -0.73729,1.05665 -1.14143,1.33024 -1.22281,0.82783 -2.17721,0.70485 -5.86813,-0.7561 -9.19595,-3.63998 -18.956011,-6.38443 -26.791332,-7.53353 -3.02827,-0.44412 -9.26189,-0.61543 -11.77821,-0.3237 -5.19357,0.60212 -8.736108,2.05527 -11.700039,4.79936 -0.684501,0.63371 -1.466141,1.23646 -1.736979,1.33942 -0.63859,0.2428 -4.236521,0.2428 -4.875112,0 -0.27083,-0.10296 -1.05247,-0.70571 -1.73696,-1.33942 -2.96395,-2.74409 -6.50648,-4.19724 -11.700058,-4.79936 -2.516312,-0.29173 -8.749941,-0.12042 -11.778201,0.3237 -7.78194,1.14127 -17.39965,3.83907 -26.73341,7.49883 -3.38325,1.32658 -4.15525,1.50926 -5.11851,1.21125 z m 4.2107,-5.34052 c 5.86759,-2.29858 14.40398,-4.922695 20.2018,-6.210065 6.31584,-1.402418 8.5236,-1.646248 14.91592,-1.647338 4.68699,-7.94e-4 6.013661,0.0632 7.257809,0.3497 0.837332,0.19286 1.561052,0.312028 1.60828,0.264819 0.147111,-0.147119 -1.803289,-1.307431 -4.154879,-2.471801 -8.12511,-4.023029 -18.27311,-4.986568 -29.0861,-2.761718 -1.09536,0.22538 -2.32708,0.40827 -2.73715,0.406418 -1.12787,-0.005 -2.3054,-0.76382 -2.84516,-1.8332 l -0.46086,-0.913098 V 62.99179 35.97471 l -0.56331,0.138329 c -0.30981,0.07608 -1.89985,0.665075 -3.5334,1.308881 -2.27551,0.896801 -2.96414,1.252878 -2.94452,1.522563 0.014,0.193604 0.0372,15.284513 0.0512,33.535345 0.014,18.250839 0.0538,33.183322 0.0884,33.183322 0.0346,0 1.02543,-0.3771 2.20198,-0.83801 z m 113.006991,-32.697216 -0.0518,-33.535203 -3.17495,-1.272156 c -1.74623,-0.699685 -3.33627,-1.278755 -3.53341,-1.286819 -0.33966,-0.01389 -0.35847,1.401778 -0.35847,26.980216 v 26.994863 l -0.46087,0.913112 c -0.53976,1.06939 -1.71729,1.828088 -2.84515,1.833189 -0.41008,0.0021 -1.6418,-0.181031 -2.73716,-0.406421 -11.888201,-2.446089 -22.84337,-1.046438 -31.491022,4.02332 -1.68175,0.985941 -2.216748,1.467501 -1.36534,1.228942 1.575181,-0.441362 4.990592,-0.73864 8.524862,-0.742011 5.954408,-0.005 11.43046,0.791951 19.10874,2.78333 3.9516,1.024874 12.1555,3.687454 15.6699,5.085704 1.23926,0.49306 2.36869,0.90517 2.50985,0.9158 0.20489,0.0155 0.2462,-6.745894 0.20483,-33.515866 z m -59.76135,-2.233777 V 40.065438 l -0.95972,-1.357442 c -1.380522,-1.952627 -5.376262,-5.847994 -7.64336,-7.45136 -3.778692,-2.672401 -9.063392,-4.943324 -13.672511,-5.875304 -3.19731,-0.646503 -5.23069,-0.833103 -9.05886,-0.831312 -4.37716,0.0021 -7.70223,0.349169 -11.83461,1.235469 l -1.07538,0.230645 v 31.242342 c 0,26.565778 0.0426,31.226011 0.28429,31.133261 0.15637,-0.06 1.42379,-0.297169 2.81648,-0.527026 12.37657,-2.042634 23.21658,-0.346861 32.521639,5.087596 2.10018,1.226558 5.20202,3.618878 6.880942,5.30692 0.788609,0.792909 1.502978,1.446609 1.587468,1.452679 0.0845,0.006 0.153622,-13.411893 0.153622,-29.817719 z m 5.80221,28.3766 c 6.21476,-6.141601 15.08488,-10.061509 25.025529,-11.05933 4.262419,-0.427849 11.579921,-0.0054 16.017661,0.924912 0.75932,0.15916 1.45259,0.244888 1.54058,0.190498 0.088,-0.05434 0.16003,-14.060382 0.16003,-31.124436 V 26.176883 l -0.52136,-0.198219 c -0.66893,-0.254325 -4.77649,-0.95482 -7.159981,-1.221048 -2.41372,-0.269605 -8.559851,-0.266589 -10.759229,0.0052 -6.458111,0.798299 -12.584091,3.083792 -17.405651,6.49374 -2.267091,1.603366 -6.262831,5.498733 -7.64336,7.45136 l -0.959721,1.357438 v 29.828747 c 0,16.405812 0.0532,29.828746 0.11802,29.828746 0.065,0 0.77928,-0.65347 1.587482,-1.452149 z"
|
||||
id="path845"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="csscccscsssscsssssscscsccsccsccscsscccccccscccccccccsccscscscccscccsccssccsscccscccccsccccsccscsccsscc" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
1
docs/assets/rocket_launch_48dp.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 20 20" height="48px" viewBox="0 0 20 20" width="48px" fill="#000000"><g><rect fill="none" height="20" width="20" x="0"/></g><g><g><path d="M14.45,10.86l0.26,2.1c0.06,0.46-0.1,0.92-0.43,1.25l-3.55,3.55l-1.41-4.24l-2.83-2.83L2.25,9.27l3.55-3.55 c0.33-0.33,0.79-0.49,1.25-0.43l2.1,0.26C13.92,0.78,17.8,2.2,17.8,2.2C17.8,2.2,19.22,6.08,14.45,10.86z M10.2,6.62 C9.18,7.64,8.37,9.06,7.9,9.97l2.12,2.12c0.91-0.46,2.34-1.27,3.36-2.3C16.18,7,16.54,4.67,16.5,3.5C15.33,3.46,13,3.82,10.2,6.62 z M14,7.5C14,6.68,13.32,6,12.5,6C11.67,6,11,6.68,11,7.5S11.67,9,12.5,9C13.32,9,14,8.33,14,7.5z M7.99,6.92L6.86,6.78L5.02,8.61 l1.62,0.54C6.97,8.52,7.43,7.71,7.99,6.92z M10.85,13.36l0.54,1.62l1.83-1.83l-0.14-1.14C12.29,12.57,11.48,13.03,10.85,13.36z M5.25,12.5c-0.62,0-1.18,0.25-1.59,0.66C2.44,14.38,2,18,2,18s3.62-0.44,4.84-1.66c0.41-0.41,0.66-0.97,0.66-1.59 C7.5,13.51,6.49,12.5,5.25,12.5z M5.78,15.28c-0.44,0.44-1.87,0.81-1.87,0.81s0.37-1.43,0.81-1.87c0.29-0.29,0.77-0.29,1.06,0 S6.07,14.99,5.78,15.28z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -18,7 +18,7 @@ author = "Paul Scherrer Institute"
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
current_path = pathlib.Path(__file__).parent.parent.parent.resolve()
|
||||
current_path = pathlib.Path(__file__).parent.parent.resolve()
|
||||
version_path = f"{current_path}/pyproject.toml"
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ add_module_names = False # Remove namespaces from class/method signatures
|
||||
autodoc_inherit_docstrings = True # If no docstring, inherit from base class
|
||||
set_type_checking_flag = True # Enable 'expensive' imports for sphinx_autodoc_typehints
|
||||
autoclass_content = "both" # Include both class docstring and __init__
|
||||
autodoc_mock_imports = ["pyqtgraph"]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ["_templates"]
|
||||
@@ -76,6 +77,5 @@ language = "Python"
|
||||
|
||||
html_theme = "pydata_sphinx_theme"
|
||||
html_static_path = ["_static"]
|
||||
html_css_files = ["css/custom.css"]
|
||||
html_logo = "_static/bec.png"
|
||||
html_theme_options = {"show_nav_level": 1, "navbar_align": "content"}
|
||||
html_css_files = ["custom.css"]
|
||||
html_logo = "../bec_widgets/assets/bec_widgets_icon.png"
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
(developer)=
|
||||
# Development
|
||||
# Developer
|
||||
|
||||
Welcome to the BEC Widgets developer guide! This section is intended for developers who want to contribute to the development of BEC Widgets.
|
||||
|
||||
```{toctree}
|
||||
---
|
||||
maxdepth: 1
|
||||
maxdepth: 2
|
||||
hidden: true
|
||||
---
|
||||
reference/
|
||||
```
|
||||
|
||||
To contribute to the development of BEC Widgets, start by setting up the development environment:
|
||||
|
||||
1. **Clone the Repository**:
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec_widgets
|
||||
cd bec_widgets
|
||||
```
|
||||
2. **Install in Editable Mode**:
|
||||
|
||||
Installing the package in editable mode allows you to make changes to the code and test them in real-time.
|
||||
```bash
|
||||
pip install -e .[dev,pyqt6]
|
||||
getting_started/getting_started.md
|
||||
widgets/widgets.md
|
||||
api_reference/api_reference.md
|
||||
```
|
||||
|
||||
|
||||
***
|
||||
|
||||
````{grid} 2
|
||||
:gutter: 5
|
||||
|
||||
```{grid-item-card}
|
||||
:link: user.getting_started
|
||||
:link-type: ref
|
||||
:img-top: /assets/rocket_launch_48dp.svg
|
||||
:text-align: center
|
||||
|
||||
## Getting Started
|
||||
|
||||
Learn how to install BEC Widgets and get started with the framework.
|
||||
```
|
||||
|
||||
```{grid-item-card}
|
||||
:link: user.widgets
|
||||
:link-type: ref
|
||||
:img-top: /assets/apps_48dp.svg
|
||||
:text-align: center
|
||||
|
||||
## Widgets
|
||||
|
||||
Learn about the building blocks of larger applications: widgets.
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
|
||||
27
docs/developer/getting_started/development.md
Normal file
@@ -0,0 +1,27 @@
|
||||
(developer.development)=
|
||||
# Development
|
||||
|
||||
If you like to contribute to the development of BEC Widgets, you can follow the steps below to set up your development environment.
|
||||
BEC Widgets works in conjunction with [BEC](https://bec.readthedocs.io/en/latest/).
|
||||
Therefore, we recommend that you install BEC first following the [developer instructions](https://bec.readthedocs.io/en/latest/developer/getting_started/install_developer_env.html) and include BEC Widgets.
|
||||
|
||||
If you already have a BEC environment set up, you can install BEC Widgets in editable mode into your BEC Python environment.
|
||||
|
||||
**Prerequisites**
|
||||
1. **Python Version:** BEC Widgets requires Python version 3.10 or higher. Verify your Python version to ensure compatibility.
|
||||
2. **BEC Installation:** BEC Widgets works in conjunction with BEC. While BEC is a dependency and will be installed automatically, you can find more information about BEC and its installation process in the [BEC documentation](https://beamline-experiment-control.readthedocs.io/en/latest/).
|
||||
|
||||
**Clone the Repository**:
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec_widgets
|
||||
cd bec_widgets
|
||||
```
|
||||
**Install in Editable Mode**:
|
||||
|
||||
Please install the package in editable mode into your BEC Python environemnt.
|
||||
```bash
|
||||
pip install -e '.[dev,pyqt6]'
|
||||
```
|
||||
This installs the package together with [PyQT6](https://www.riverbankcomputing.com/static/Docs/PyQt6/introduction.html).
|
||||
|
||||
|
||||
12
docs/developer/getting_started/getting_started.md
Normal file
@@ -0,0 +1,12 @@
|
||||
(developer.getting_started)=
|
||||
# Getting Started
|
||||
This section provides valuable information for developers who want to contribute to the development of BEC Widgets. The guide will help you set up the development environment, understand the modular development concept of BEC Widgets, and contribute to the project.
|
||||
|
||||
```{toctree}
|
||||
---
|
||||
maxdepth: 2
|
||||
hidden: false
|
||||
---
|
||||
|
||||
development/
|
||||
```
|
||||
353
docs/developer/widgets/how_to_develop_a_widget.md
Normal file
@@ -0,0 +1,353 @@
|
||||
(developer.widgets.how_to_develop_a_widget)=
|
||||
# How to Develop a Widget
|
||||
This section provides a step-by-step guide on how to develop a new widget for BEC Widgets. We will develop a simple widget that allows you to press a button and specify a user-defined action. The general widget will be based on a [QPushButton](https://doc.qt.io/qt-6/qpushbutton.html) which we will extend to be capable of communicating with BEC through the interface provided by BEC Widgets.
|
||||
|
||||
## Button to start a scan
|
||||
Developing a new widget in BEC Widgets is straightforward. Let's create a widget that allows a user to press a button and execute a `line_scan` in BEC. The proper location to create a new widget is either in the `bec_widgets/widgets` directory, or the beamline plugin widget direction, i.e. `csaxs_bec/bec_widgets`, depending on where your development takes place.
|
||||
|
||||
### Step 1: Create a new widget class
|
||||
|
||||
We first create a simple class that inherits from the `QPushButton` class.
|
||||
The following code snippet demonstrates how to create a new widget:
|
||||
|
||||
``` python
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
|
||||
class StartScanButton(QPushButton):
|
||||
def __init__(self, parent=None):
|
||||
QPushButton.__init__(self, parent=parent)
|
||||
# Connect the button to the on_click method
|
||||
self.clicked.connect(self.on_click)
|
||||
|
||||
def on_click(self):
|
||||
pass
|
||||
```
|
||||
So far we have created the button, but we have not yet put any logic to the `on_click` event of the button.
|
||||
Adding the functionality to be able to execute a scans will be tackled in the next step.
|
||||
|
||||
````{note}
|
||||
To make the button work as a standalone application, you can simply add the following lines at the end.
|
||||
``` python
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = StartScanButton()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
### Step 2: Connect with BEC, implement *on_click* functionality
|
||||
To be able to start a scan, we need to communicate with BEC. This can be facilitated easily by inheriting additionally from [`BECConnector`](../../api_reference/_autosummary/bec_widgets.utils.bec_connector.BECConnector).
|
||||
With the *BECConnector*, we will also have to pass the *client* ([BECClient](https://bec.readthedocs.io/en/latest/api_reference/_autosummary/bec_lib.client.BECClient.html)) and the *gui_id* (str) to init function of both, our *StartScanButton* widget and the `super().__init__(client=client, gui_id=gui_id)` call.
|
||||
In the init of *BECConnector*, the client will be initialised and stored in `self.client`, which gives us access to the available scan objects via `self.client.scans`.
|
||||
|
||||
``` python
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
class StartScanButton(BECConnector, QPushButton):
|
||||
def __init__(self, parent=None, client:=None, gui_id=None):
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
QPushButton.__init__(self, parent=parent)
|
||||
|
||||
# Set a default scan command, args and kwargs
|
||||
self.scan_name = "line_scan"
|
||||
self.scan_args = (dev.samx, -5, 5)
|
||||
self.scan_kwargs = {"steps": 50, "exp_time": 0.1, "relative": True}
|
||||
# Set the text of the button to display the current scan name
|
||||
self.set_button_text()
|
||||
# Connect the button to the on_click method
|
||||
self.clicked.connect(self.on_click)
|
||||
|
||||
def set_button_text(self):
|
||||
"""Set the text of the button"""
|
||||
self.setText(f"Start {self.scan_name}")
|
||||
|
||||
def run_command(self):
|
||||
"""Run the scan command."""
|
||||
# Get the scan command from the scans library
|
||||
scan_command = getattr(self.client.scans, self.scan_name)
|
||||
# Run the scan command
|
||||
scan_report = scan_command(*self.scan_args, **self.scan_kwargs)
|
||||
# Wait for the scan to finish
|
||||
scan_report.wait()
|
||||
|
||||
def on_click(self):
|
||||
"""Start a line scan"""
|
||||
self.run_command()
|
||||
```
|
||||
|
||||
```{note}
|
||||
For the args and kwargs of the scan command, we are using the same syntax as in the client: `dev.samx` is not a string but the same object as in the client.
|
||||
```
|
||||
In the *run_command* method, we retrieve the scan object from the client by its name, and execute the method with all *args* and *kwargs* that we have set.
|
||||
The current implementation of *run_command* is a blocking call due to `scan_report.wait()`, which is not ideal for a GUI application since it freezes the GUI. We will adress this in the next step.
|
||||
|
||||
### Step 3: Improving the widget interactivity
|
||||
To not freeze the GUI, we need to run the scan command in a separate thread. We can either use [QThreads](https://doc.qt.io/qtforpython-6/PySide6/QtCore/QThread.html) or the Python [threading module](https://docs.python.org/3/library/threading.html#thread-objects). In this example, we will use the Python threading module. In addition, we add a method `update_style` to change the style of the button to indicate to the user that the scan is running. We also extend the cleanup procedure of `BECConnector` to ensure that the thread is stopped when the widget is closed. This is good practice to avoid having threads running in the background when the widget is closed.
|
||||
|
||||
``` python
|
||||
|
||||
def update_style(self, mode: Literal["ready", "running"]):
|
||||
"""Update the style of the button based on the mode.
|
||||
|
||||
Args:
|
||||
mode (Literal["ready", "running"): The mode of the button.
|
||||
"""
|
||||
if mode == "ready":
|
||||
self.setStyleSheet(
|
||||
"background-color: #4CAF50; color: white; font-size: 16px; padding: 10px 24px;"
|
||||
)
|
||||
elif mode == "running":
|
||||
self.setStyleSheet(
|
||||
"background-color: #808080; color: white; font-size: 16px; padding: 10px 24px;"
|
||||
)
|
||||
|
||||
def run_command(self):
|
||||
"""Run the scan command."""
|
||||
# Switch the style of the button
|
||||
self.update_style("running")
|
||||
# Disable the buttom while the scan is running
|
||||
self.setEnabled(False)
|
||||
# Get the scan command from the scans library
|
||||
scan_command = getattr(self.scans, self.scan_name)
|
||||
# Run the scan command
|
||||
scan_report = scan_command(*self.scan_args, **self.scan_kwargs)
|
||||
# Wait for the scan to finish
|
||||
scan_report.wait()
|
||||
# Reactivate the button
|
||||
self.setEnabled(True)
|
||||
# Switch the style of the button back to ready
|
||||
self.update_style("ready")
|
||||
|
||||
def on_click(self):
|
||||
"""Start a line scan"""
|
||||
thread = threading.Thread(target=self.run_command)
|
||||
thread.start()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget"""
|
||||
# stop thread
|
||||
# stop the thread or if this is implemented via QThread, ensure stopping of QThread.
|
||||
# Ideally, the BECConnector should take care of this automatically.
|
||||
# Important to call super().cleanup() to ensure that the cleanup of the BECConnector is also called
|
||||
super().cleanup()
|
||||
```
|
||||
We now added started the scan in a separate thread, which allows the GUI to remain responsive. We also added a method to change the style of the button to indicate to the user that the scan is running. The cleanup method ensures that the thread is stopped when the widget is closed. In a last step, we know like to make the scan command configurable.
|
||||
|
||||
### Step 4: Make the scan command configurable
|
||||
In order to make the scan comman configurable, we implement a method `set_scan_command` which allows the user to set the scan command, arguments and keyword arguments.
|
||||
This method should also become available through the RPC interface of BEC Widgets, so we add the class attribute `USER_ACCESS` which is a list of strings with functions that should become available for the CLI.
|
||||
|
||||
``` python
|
||||
def set_scan_command(
|
||||
self, scan_name: str, args: tuple, kwargs: dict
|
||||
):
|
||||
"""Set the scan command to run.
|
||||
|
||||
Args:
|
||||
scan_name (str): The name of the scan command.
|
||||
args (tuple): The arguments for the scan command.
|
||||
kwargs (dict): The keyword arguments for the scan command.
|
||||
"""
|
||||
# check if scan_command starts with scans.
|
||||
if not getattr(self.client.scans, scan_name):
|
||||
raise ValueError(
|
||||
f"The scan type must be implemented in the scan library of BEC, received {scan_name}"
|
||||
)
|
||||
self.scan_name = scan_name
|
||||
self.scan_args = args
|
||||
self.scan_kwargs = kwargs
|
||||
self.set_button_text()
|
||||
```
|
||||
|
||||
### Step 5: Generate client interface for RPC
|
||||
We have now prepared the widget which is fully functional as a standalone widget. But we also want to make it available to the BEC command-line-interface (CLI), for which we prepared the **USER_ACCESS** class attribute.
|
||||
The communication between the BEC IPythonClient and the widget is done vie the RPC interface of BEC Widgets.
|
||||
For this, we need to run the `bec_widgets.cli.generate_cli` script to generate the CLI interface.
|
||||
|
||||
``` bash
|
||||
python bec_widgets.cli.generate_cli --core
|
||||
# alternatively use the entry point from BEC Widgets
|
||||
bw-generate-cli
|
||||
```
|
||||
|
||||
This will generate a new client with all relevant methods in [`bec_widgets.cli.client.py`](../../api_reference/_autosummary/bec_widgets.bec_widgets.cli.client.rst).
|
||||
The last step is to make the RPCWidgetHandler class aware of the widget, which means to add the name of the widget to the widgets list in the [`RPCWidgetHandler`](../../api_reference/_autosummary/bec_widgets.bec_widgets.cli.rpc_widget_handler.RPCWidgetHandler.rst) class.
|
||||
|
||||
````{dropdown} View code: RPCWidgetHandler class
|
||||
:icon: code-square
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
```{literalinclude} ../../../bec_widgets/cli/rpc_widget_handler.py
|
||||
:language: python
|
||||
:pyobject: RPCWidgetHandler
|
||||
```
|
||||
````
|
||||
|
||||
With this, we have a fully functional widget that allows the user to start a scan with a button. The scan command, arguments and keyword arguments can be set by the user.
|
||||
The full code is shown once again below:
|
||||
|
||||
````{dropdown} View code: Full code of the StartScanButton widget
|
||||
:icon: code-square
|
||||
:animate: fade-in-slide-down
|
||||
|
||||
```
|
||||
import threading
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
|
||||
class StartScanButton(BECConnector, QPushButton):
|
||||
"""A button to start a line scan.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
client (BECClient): The BEC client.
|
||||
gui_id (str): The unique ID of the widget.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["set_scan_command"]
|
||||
|
||||
def __init__(self, parent=None, client=None, gui_id=None):
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
QPushButton.__init__(self, parent=parent)
|
||||
|
||||
# Set the scan command to None
|
||||
self.scan_command = None
|
||||
# Set default scan command
|
||||
self.scan_name = "line_scan"
|
||||
self.scan_args = (dev.samx, -5, 5)
|
||||
self.scan_kwargs = {"steps": 50, "exp_time": 0.1, "relative": True}
|
||||
# Set the text of the button
|
||||
self.set_button_text()
|
||||
# Set the style of the button
|
||||
self.update_style("ready")
|
||||
# Connect the button to the on_click method
|
||||
self.clicked.connect(self.on_click)
|
||||
|
||||
def update_style(self, mode: Literal["ready", "running"]):
|
||||
"""Update the style of the button based on the mode.
|
||||
|
||||
Args:
|
||||
mode (Literal["ready", "running"): The mode of the button.
|
||||
"""
|
||||
if mode == "ready":
|
||||
self.setStyleSheet(
|
||||
"background-color: #4CAF50; color: white; font-size: 16px; padding: 10px 24px;"
|
||||
)
|
||||
elif mode == "running":
|
||||
self.setStyleSheet(
|
||||
"background-color: #808080; color: white; font-size: 16px; padding: 10px 24px;"
|
||||
)
|
||||
|
||||
def set_button_text(self):
|
||||
"""Set the text of the button."""
|
||||
self.setText(f"Start {self.scan_name}")
|
||||
|
||||
def set_scan_command(self, scan_name: str, args: tuple, kwargs: dict):
|
||||
"""Set the scan command to run.
|
||||
|
||||
Args:
|
||||
scan_name (str): The name of the scan command.
|
||||
args (tuple): The arguments for the scan command.
|
||||
kwargs (dict): The keyword arguments for the scan command.
|
||||
"""
|
||||
# check if scan_command starts with scans.
|
||||
if not getattr(self.client.scans, scan_name):
|
||||
raise ValueError(
|
||||
f"The scan type must be implemented in the scan library of BEC, received {scan_name}"
|
||||
)
|
||||
self.scan_name = scan_name
|
||||
self.scan_args = args
|
||||
self.scan_kwargs = kwargs
|
||||
self.set_button_text()
|
||||
|
||||
def run_command(self):
|
||||
"""Run the scan command."""
|
||||
# Switch the style of the button
|
||||
self.update_style("running")
|
||||
# Disable the buttom while the scan is running
|
||||
self.setEnabled(False)
|
||||
# Get the scan command from the scans library
|
||||
scan_command = getattr(self.scans, self.scan_name)
|
||||
# Run the scan command
|
||||
scan_report = scan_command(*self.scan_args, **self.scan_kwargs)
|
||||
# Wait for the scan to finish
|
||||
scan_report.wait()
|
||||
# Reactivate the button
|
||||
self.setEnabled(True)
|
||||
# Switch the style of the button back to ready
|
||||
self.update_style("ready")
|
||||
|
||||
def on_click(self):
|
||||
"""Start a line scan"""
|
||||
thread = threading.Thread(target=self.run_command)
|
||||
thread.start()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget"""
|
||||
# stop thread
|
||||
# stop the thread or if this is implemented via QThread, ensure stopping of QThread.
|
||||
# Ideally, the BECConnector should take care of this automatically.
|
||||
# Important to call super().cleanup() to ensure that the cleanup of the BECConnector is also called
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = StartScanButton()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
```
|
||||
````
|
||||
|
||||
### Step 6: Write a test for the widget
|
||||
We highly recommend writing tests for the widget to ensure that they work as expected. This allows to run the tests automatically in a CI/CD pipeline and to ensure that the widget works as expected not only now but als in the future.
|
||||
The following code snippet shows an example to test the set_scan_command from the `StartScanButton` widget.
|
||||
``` python
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.start_scan_button import StartScanButton
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_scan_button(qtbot, mocked_client):
|
||||
widget = StartScanButton(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
widget.close()
|
||||
|
||||
|
||||
def test_set_scan_command(test_scan_button):
|
||||
"""Test the set_scan_command function."""
|
||||
test_scan_button.set_scan_command(
|
||||
scan_name="grid_scan",
|
||||
args=(dev.samx, -5, 5, 10, dev.samy, -5, 5, 20),
|
||||
kwargs={"exp_time": 0.1, "relative": True},
|
||||
)
|
||||
# Check first if all parameter have been properly set
|
||||
assert test_scan_button.scan_name == "grid_scan"
|
||||
assert test_scan_button.scan_args == (dev.samx, -5, 5, 10, dev.samy, -5, 5, 20)
|
||||
assert test_scan_button.scan_kwargs == {"exp_time": 0.1, "relative": True}
|
||||
# Next, we check if the displayed text of the button has been updated
|
||||
# We use the .text() method from the QPushButton class to retrieve the text displayed
|
||||
assert test_scan_button.text() == "Start grid_scan"
|
||||
```
|
||||
12
docs/developer/widgets/widgets.md
Normal file
@@ -0,0 +1,12 @@
|
||||
(developer.widgets)=
|
||||
# Widgets
|
||||
This section provides an introduction to the building blocks of BEC Widgets: widgets. Widgets are the basic components of the graphical user interface (GUI) and are used to create larger applications. We will cover key topics such as how to develop new widgets or how to customise existing widgets. For details on the already available widgets and their usage, please refer to user section about [widgets](#user.widgets)
|
||||
|
||||
```{toctree}
|
||||
---
|
||||
maxdepth: 2
|
||||
hidden: false
|
||||
---
|
||||
|
||||
how_to_develop_a_widget/
|
||||
```
|
||||