Compare commits
111 Commits
v0.53.1
...
feat/bash-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e52550f67 | |||
| 74182bb142 | |||
| 2b5068903a | |||
|
|
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 |
@@ -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:
|
||||
@@ -23,7 +23,6 @@ workflow:
|
||||
include:
|
||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
||||
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
- Formatter
|
||||
@@ -32,8 +31,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
|
||||
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 +78,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 +89,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);
|
||||
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,7 +114,7 @@ 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
|
||||
@@ -105,12 +122,10 @@ tests:
|
||||
variables:
|
||||
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 +138,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 +174,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 +192,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 +208,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"]
|
||||
|
||||
211
CHANGELOG.md
@@ -2,175 +2,164 @@
|
||||
|
||||
|
||||
|
||||
## v0.53.1 (2024-05-09)
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: fixed rtd pages url ([`8ff3610`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8ff36105d1e637c429915b4bfc2852d54a3c6f19))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: docs config ([`0f6a5e5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0f6a5e5fa9530969c98a9266c9ca7b89a378ff70))
|
||||
|
||||
|
||||
## v0.53.0 (2024-05-09)
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: use formatter config of toml file ([`5cc816d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5cc816d0af73e20c648e044a027c589704ab1625))
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs: update install instructions ([`57ee735`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/57ee735e5c2436d45a285507cdc939daa20e8e8f))
|
||||
## v0.62.0 (2024-06-12)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat: moved to pyproject.toml; closes #162 ([`c86ce30`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c86ce302a964d71ee631f0817609ab5aa0e3ab0f))
|
||||
* 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: fixed semver job and upgraded to v9 ([`32e1a9d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/32e1a9d8472eb1c25d30697d407a8ffecd04e75d))
|
||||
* doc: add documentation about creating custom GUI applications embedding BEC Widgets ([`17a0068`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/17a00687579f5efab1990cd83862ec0e78198633))
|
||||
|
||||
|
||||
## v0.61.0 (2024-06-12)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(widgets/stop_button): General stop button added ([`61ba08d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/61ba08d0b8df9f48f5c54c7c2b4e6d395206e7e6))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor: applied formatter ([`4117fd7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4117fd7b5b2090ff4fb7ad9e0d92cc87cd13ed5f))
|
||||
* refactor: improve labe of auto_update script ([`40b5688`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/40b568815893cd41af3531bb2e647ca1e2e315f4))
|
||||
|
||||
|
||||
## 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)
|
||||
## v0.60.0 (2024-06-08)
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: fixed support for child pipelines ([`e65c7f3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e65c7f3be895ada407bd358edf67d569d2cab08e))
|
||||
* 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(utils/layout_manager): added GridLayoutManager to extend functionalities of native QGridLayout ([`fcd6ef0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fcd6ef0975dc872f69c9d6fb2b8a1ad04a423aae))
|
||||
* feat: added isort to bw-generate-cli ([`f0391f5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0391f59c9eb0a51b693fccfe2e399e869d35dda))
|
||||
|
||||
* feat(widget/dock): BECDock and BECDock area for dockable windows ([`d8ff8af`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d8ff8afcd474660a6069bbdab05f10a65f221727))
|
||||
* 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(widgets/dock): BECDockArea close overwrites the default pyqtgraph Container close + minor improvements ([`ceae979`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ceae979f375ecc33c5c97148f197655c1ca57b6c))
|
||||
* 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))
|
||||
|
||||
* 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(widget/plots): WidgetConfig changed to SubplotConfig ([`03fa1f2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/03fa1f26d0fa6b58ed05556fb2438d1e62f6c107))
|
||||
* 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))
|
||||
|
||||
## v0.51.0 (2024-05-07)
|
||||
* refactor(isort): added bec_widgets as known first party package ([`9c5a471`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9c5a471234ed2928e4527b079436db2a807c5f6f))
|
||||
|
||||
### 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(dock): parent_dock_area changed to orig_area (native for pyqtgraph) ([`2b40602`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2b40602bdc593ece0447ec926c2100414bd5cf67))
|
||||
|
||||
### Test
|
||||
|
||||
* test(cli/rpc_register): e2e RPCRegister ([`4f261be`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f261be4c7cfe54501443d031f9266f4f838f6e2))
|
||||
|
||||
* test(cli/rpc_register): rpc_register tests added ([`40eb75f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/40eb75f85a4d99d498b086a37e799276a9d2ac3f))
|
||||
* test: added missing pylint statement to header ([`f662985`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f6629852ebc2b4ee239fa560cc310a5ae2627cf7))
|
||||
|
||||
|
||||
## v0.49.1 (2024-04-26)
|
||||
## 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(pyqt6): fixing PyQt6-Qt6 package to 6.6.3 ([`a222298`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a22229849cbb57c15e4c1bae02d7e52e672f8c4c))
|
||||
* 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(widgets/editor): qscintilla editor removed ([`ab85374`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ab8537483da6c87cb9a0b0f01706208c964f292d))
|
||||
* fix(dock): new dock can be detached upon creation ([`02a2608`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02a26086c4540127a11c235cba30afc4fd712007))
|
||||
|
||||
|
||||
## v0.49.0 (2024-04-24)
|
||||
## v0.58.0 (2024-06-07)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(rpc/client_utils): timeout for rpc response ([`6500a00`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6500a00682a2a7ca535a138bd9496ed8470856a8))
|
||||
* feat(utils.colors): general color validators ([`3094632`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/30946321348abc349fb4003dc39d0232dc19606c))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(rpc/client_utils): close clean up policy for BECFigure ([`9602085`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9602085f82cbc983f89b5bfe48bf35f08438fa87))
|
||||
* 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.48.0 (2024-04-24)
|
||||
## v0.57.7 (2024-06-07)
|
||||
|
||||
### Feature
|
||||
### Documentation
|
||||
|
||||
* feat(cli): added auto updates plugin support ([`6238693`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6238693ffb44b47a56b969bc4129f2af7a2c04fe))
|
||||
* docs: added schema of BECDockArea and BECFigure ([`828067f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/828067f486a905eb4678538df58e2bdd6c770de1))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: add model_config to pydantic models to allow runtime checks after creation ([`ca5e8d2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ca5e8d2fbbffbf221cc5472710fef81a33ee29d6))
|
||||
|
||||
|
||||
## v0.47.0 (2024-04-23)
|
||||
## v0.57.6 (2024-06-06)
|
||||
|
||||
### Feature
|
||||
### Fix
|
||||
|
||||
* 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))
|
||||
* fix(bar): docstrings extended ([`edb1775`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/edb1775967c3ff0723d0edad2b764f1ffc832b7c))
|
||||
|
||||
|
||||
## v0.57.5 (2024-06-06)
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs(figure): docs adjusted to be compatible with new signature ([`c037b87`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c037b87675af91b26e8c7c60e76622d4ed4cf5d5))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(waveform): added .plot method with the same signature as BECFigure.plot ([`8479caf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8479caf53a7325788ca264e5bd9aee01f1d4c5a0))
|
||||
|
||||
* fix(plot_base): .plot removed from plot_base.py, because there is no use case for it ([`82e2c89`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/82e2c898d2e26f786b2d481f85c647472675e75b))
|
||||
|
||||
### 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(figure): logic for .add_image and .image consolidated; logic for .add_plot and .plot consolidated ([`52bc322`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/52bc322b2b8d3ef92ff3480e61bddaf32464f976))
|
||||
|
||||
|
||||
## v0.46.7 (2024-04-21)
|
||||
## v0.57.4 (2024-06-06)
|
||||
|
||||
### 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)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(cli): fixed support for devices as cli input ([`1111610`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1111610f3206c5c46db6b4bd1e8827f1a4cd9e3f))
|
||||
* fix(docks): set_title do update dock internal _name now ([`15cbc21`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/15cbc21e5bb3cf85f5822d44a2b3665b5aa2f346))
|
||||
|
||||
|
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,67 @@ 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
|
||||
monitor_module = importlib.import_module("bec_widgets.cli.server")
|
||||
monitor_path = monitor_module.__file__
|
||||
|
||||
command = [
|
||||
sys.executable,
|
||||
"-u",
|
||||
monitor_path,
|
||||
"--id",
|
||||
gui_id,
|
||||
"--config",
|
||||
config,
|
||||
"--gui_class",
|
||||
gui_class.__name__,
|
||||
]
|
||||
process = subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
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 +135,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 +149,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 +157,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)
|
||||
@@ -130,34 +187,6 @@ class BECGuiClientMixin:
|
||||
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()
|
||||
|
||||
def print_log(self) -> None:
|
||||
"""
|
||||
Print the log of the plot process.
|
||||
@@ -168,26 +197,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 +207,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 +275,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 +297,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 +337,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,17 @@
|
||||
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.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,
|
||||
}
|
||||
|
||||
@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,
|
||||
@@ -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()
|
||||
|
||||
@@ -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_())
|
||||
159
bec_widgets/widgets/console/console.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
import termqt
|
||||
from qtpy.QtCore import QSocketNotifier, Qt
|
||||
from qtpy.QtGui import QFont
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QWidget
|
||||
from termqt import Terminal
|
||||
|
||||
try:
|
||||
from qtpy.QtCore import pyqtRemoveInputHook
|
||||
|
||||
pyqtRemoveInputHook()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if platform.system() in ["Linux", "Darwin"]:
|
||||
terminal_cmd = os.environ["SHELL"]
|
||||
|
||||
from termqt import TerminalPOSIXExecIO
|
||||
|
||||
class TerminalExecIO(TerminalPOSIXExecIO):
|
||||
def _read_loop(self):
|
||||
pass
|
||||
|
||||
def find_utf8_split(self, data):
|
||||
"""UTF-8 characters can be 1-4 bytes long, this finds first index which is not mid character
|
||||
|
||||
Character lengths include:
|
||||
1 Bytes: 0xxxxxxx
|
||||
2 Bytes: 110xxxxx 10xxxxxx
|
||||
3 Bytes: 1110xxxx 10xxxxxx 10xxxxxx
|
||||
4 bytes: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
|
||||
Source: https://en.wikipedia.org/wiki/UTF-8#Encoding
|
||||
|
||||
Start at end of chunk moving backwards, find first UTF-8 start byte:
|
||||
1 Bytes: 0xxxxxxx - 0x80 == 0x00
|
||||
2 Bytes: 110xxxxx - 0xE0 == 0xC0
|
||||
3 Bytes: 1110xxxx - 0xF0 == 0xE0
|
||||
4 bytes: 11110xxx - 0xF8 == 0xF0
|
||||
|
||||
Parameters:
|
||||
data (bytes) - buffer to be evaluated
|
||||
|
||||
Returns:
|
||||
(int) - last position of complete UTF-8 character
|
||||
"""
|
||||
pos = 0
|
||||
for i, c in enumerate(reversed(data)):
|
||||
if c & 0x80 == 0x00 or c & 0xE0 == 0xC0 or c & 0xF0 == 0xE0 or c & 0xF8 == 0xF0:
|
||||
pos = i
|
||||
break
|
||||
return len(data) - pos
|
||||
|
||||
def _read(self, fd):
|
||||
try:
|
||||
data = os.read(fd, 2**16) # read as much as possible
|
||||
except OSError:
|
||||
data = b""
|
||||
|
||||
if data:
|
||||
self._read_buf += data
|
||||
i = self.find_utf8_split(self._read_buf)
|
||||
output = self._read_buf[:i]
|
||||
self._read_buf = self._read_buf[i:]
|
||||
self.stdout_callback(output)
|
||||
else:
|
||||
self.logger.info("Spawned process has been killed")
|
||||
if self.running:
|
||||
self.running = False
|
||||
self.terminated_callback()
|
||||
os.close(fd)
|
||||
|
||||
def spawn(self):
|
||||
super().spawn()
|
||||
self._read_notifier = QSocketNotifier(self.fd, QSocketNotifier.Read)
|
||||
self._read_notifier.activated.connect(self._read)
|
||||
|
||||
def write(self, buffer):
|
||||
# same as original method, but without logging and without assert (unneeded)
|
||||
if not self.running:
|
||||
return
|
||||
try:
|
||||
os.write(self.fd, buffer)
|
||||
except OSError:
|
||||
self.running = False
|
||||
self.terminated_callback()
|
||||
|
||||
else:
|
||||
terminal_cmd = "cmd.exe"
|
||||
from termqt import TerminalWinptyIO as TerminalExecIO
|
||||
|
||||
|
||||
class TerminalWidget(QWidget):
|
||||
def __init__(self, logger):
|
||||
super().__init__()
|
||||
self.logger = logger
|
||||
self.terminal = Terminal(800, 600, logger=self.logger)
|
||||
self.terminal.set_font()
|
||||
self.terminal.maximum_line_history = 2000
|
||||
self.scroll = QScrollBar(Qt.Vertical, self.terminal)
|
||||
self.terminal.connect_scroll_bar(self.scroll)
|
||||
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(self.terminal)
|
||||
layout.addWidget(self.scroll)
|
||||
layout.setSpacing(0)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class BECConsole(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle(f"termqt on {platform.system()}")
|
||||
self.logger = self.setup_logger()
|
||||
|
||||
self.terminal_widget = TerminalWidget(self.logger)
|
||||
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(self.terminal_widget)
|
||||
self.setLayout(layout)
|
||||
|
||||
self.auto_wrap_enabled = True
|
||||
self.platform = platform.system()
|
||||
|
||||
self.setup_terminal_io()
|
||||
|
||||
def setup_logger(self):
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter("[%(asctime)s] > [%(filename)s:%(lineno)d] %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
def setup_terminal_io(self):
|
||||
self.terminal_io = TerminalExecIO(
|
||||
self.terminal_widget.terminal.row_len,
|
||||
self.terminal_widget.terminal.col_len,
|
||||
terminal_cmd,
|
||||
logger=self.logger,
|
||||
)
|
||||
self.auto_wrap_enabled = False
|
||||
|
||||
self.terminal_widget.terminal.enable_auto_wrap(self.auto_wrap_enabled)
|
||||
self.terminal_io.stdout_callback = self.terminal_widget.terminal.stdout
|
||||
self.terminal_widget.terminal.stdin_callback = self.terminal_io.write
|
||||
self.terminal_widget.terminal.resize_callback = self.terminal_io.resize
|
||||
self.terminal_io.spawn()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication([])
|
||||
main_window = BECConsole()
|
||||
main_window.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/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 |
@@ -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,14 +1,6 @@
|
||||
(developer)=
|
||||
# Development
|
||||
|
||||
```{toctree}
|
||||
---
|
||||
maxdepth: 1
|
||||
hidden: true
|
||||
---
|
||||
reference/
|
||||
```
|
||||
|
||||
To contribute to the development of BEC Widgets, start by setting up the development environment:
|
||||
|
||||
1. **Clone the Repository**:
|
||||
|
||||
@@ -1,39 +1,70 @@
|
||||
# BEC Widgets documentation
|
||||
|
||||
A flexible and extensible framework for building graphical user interfaces in Python, optimized for use in the BEC environment.
|
||||
|
||||
<br><br>
|
||||
|
||||
````{grid} 3
|
||||
<br><br>
|
||||
|
||||
````{grid} 2
|
||||
:gutter: 5
|
||||
|
||||
```{grid-item-card} Introduction
|
||||
```{grid-item-card}
|
||||
:link: introduction
|
||||
:link-type: ref
|
||||
:img-top: /assets/index_getting_started.svg
|
||||
:text-align: center
|
||||
|
||||
General information.
|
||||
## Introduction
|
||||
|
||||
General information about BEC Widgets.
|
||||
```
|
||||
|
||||
```{grid-item-card} User
|
||||
```{grid-item-card}
|
||||
:link: user
|
||||
:link-type: ref
|
||||
:img-top: /assets/index_user_guide.svg
|
||||
:text-align: center
|
||||
|
||||
Information for users.
|
||||
## User guide
|
||||
|
||||
Information for users of BEC Widgets.
|
||||
```
|
||||
|
||||
```{grid-item-card} Developer
|
||||
```{grid-item-card}
|
||||
:link: developer
|
||||
:link-type: ref
|
||||
:img-top: /assets/index_contribute.svg
|
||||
:text-align: center
|
||||
|
||||
Information for developers.
|
||||
## Developer guide
|
||||
|
||||
Information for developers of BEC Widgets.
|
||||
```
|
||||
|
||||
```{grid-item-card}
|
||||
:link: api_reference
|
||||
:link-type: ref
|
||||
:img-top: /assets/index_api.svg
|
||||
:text-align: center
|
||||
|
||||
## API reference
|
||||
|
||||
Comprehensive reference of all BEC Widget classes, functions, and methods.
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
|
||||
```{toctree}
|
||||
---
|
||||
numbered: true
|
||||
maxdepth: 1
|
||||
maxdepth: 2
|
||||
hidden: true
|
||||
---
|
||||
|
||||
introduction/introduction
|
||||
user/user
|
||||
developer/developer
|
||||
api_reference/api_reference
|
||||
```
|
||||
|
||||
@@ -6,5 +6,6 @@ pydata-sphinx-theme
|
||||
sphinx-copybutton
|
||||
myst-parser
|
||||
sphinx-design
|
||||
PyQt6
|
||||
bec-widgets
|
||||
tomli
|
||||
11
docs/user/api_reference/api_reference.md
Normal file
@@ -0,0 +1,11 @@
|
||||
(user.api_reference)=
|
||||
# User API Reference
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:toctree: _autosummary
|
||||
:template: custom-module-template.rst
|
||||
|
||||
bec_widgets.cli.client
|
||||
|
||||
```
|
||||
10
docs/user/applications/applications.md
Normal file
@@ -0,0 +1,10 @@
|
||||
(user.applications)=
|
||||
# Applications
|
||||
|
||||
**Coming soon**
|
||||
|
||||
```{toctree}
|
||||
---
|
||||
maxdepth: 1
|
||||
hidden: true
|
||||
---
|
||||