mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 01:37:53 +02:00
Compare commits
3 Commits
v0.66.0
...
94-bec-fig
| Author | SHA1 | Date | |
|---|---|---|---|
| d996ff9284 | |||
| 8b43eba282 | |||
| 9c822ec480 |
@@ -1,3 +1,2 @@
|
||||
black --line-length=100 $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
isort --line-length=100 --profile=black --multi-line=3 --trailing-comma $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
git add $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
black --line-length=100 $(git diff --cached --name-only --diff-filter=ACM -- '***.py')
|
||||
git add $(git diff --cached --name-only --diff-filter=ACM -- '***.py')
|
||||
|
||||
188
.gitlab-ci.yml
188
.gitlab-ci.yml
@@ -1,79 +1,34 @@
|
||||
# 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.11
|
||||
image: $CI_DOCKER_REGISTRY/python:3.9
|
||||
#commands to run in the Docker container before starting each job.
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
BEC_CORE_BRANCH: "main"
|
||||
OPHYD_DEVICES_BRANCH: "main"
|
||||
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "schedule"
|
||||
- if: $CI_PIPELINE_SOURCE == "web"
|
||||
- if: $CI_PIPELINE_SOURCE == "pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
|
||||
when: never
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
include:
|
||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
||||
- project: "bec/awi_utils"
|
||||
file: "/templates/check-packages-job.yml"
|
||||
inputs:
|
||||
stage: test
|
||||
path: "."
|
||||
pytest_args: "-v --random-order tests/"
|
||||
exclude_packages: ""
|
||||
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
- Formatter
|
||||
- test
|
||||
- AdditionalTests
|
||||
- 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
|
||||
|
||||
formatter:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
- pip install black isort
|
||||
- isort --check --diff ./
|
||||
- black --check --diff --color ./
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
- pip install black
|
||||
- black --check --diff --color --line-length=100 ./
|
||||
pylint:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- pip install -e .[dev]
|
||||
script:
|
||||
- mkdir ./pylint
|
||||
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
|
||||
@@ -84,8 +39,6 @@ pylint:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint-check:
|
||||
stage: Formatter
|
||||
@@ -96,21 +49,18 @@ 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 origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
|
||||
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
|
||||
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);
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
|
||||
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
|
||||
fi
|
||||
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
|
||||
|
||||
- echo "Changed Python files:"
|
||||
- $CHANGED_FILES
|
||||
# Run pylint only on changed files
|
||||
- mkdir ./pylint
|
||||
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
|
||||
- pylint $CHANGED_FILES --output-format=text . | tee ./pylint/pylint_changed_files.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
|
||||
@@ -120,21 +70,17 @@ pylint-check:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
tests:
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *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
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install .[dev]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests
|
||||
- coverage report
|
||||
- coverage xml
|
||||
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
|
||||
@@ -145,75 +91,35 @@ 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.9-pyqt5: #todo enable when we decide what qt distributions we want to support
|
||||
# extends: "tests"
|
||||
# stage: AdditionalTests
|
||||
# image: $CI_DOCKER_REGISTRY/python:3.9
|
||||
# script:
|
||||
# - apt-get update
|
||||
# - apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
# - pip install .[dev,pyqt5]
|
||||
# - pytest -v --random-order ./tests
|
||||
|
||||
tests-3.10:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
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
|
||||
image: $CI_DOCKER_REGISTRY/python:3.10
|
||||
allow_failure: true
|
||||
|
||||
end-2-end-conda:
|
||||
stage: End2End
|
||||
needs: []
|
||||
image: continuumio/miniconda3
|
||||
allow_failure: false
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *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.11
|
||||
- conda init bash
|
||||
- source ~/.bashrc
|
||||
- conda activate test-environment
|
||||
|
||||
- cd ./bec
|
||||
- source ./bin/install_bec_dev.sh -t
|
||||
tests-3.11:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DOCKER_REGISTRY/python:3.11
|
||||
allow_failure: true
|
||||
|
||||
- pip install -e ./bec_lib[dev]
|
||||
- pip install -e ./bec_ipython_client[dev]
|
||||
- cd ../
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- cd ./tests/end-2-end
|
||||
- pytest -v --start-servers --flush-redis --random-order
|
||||
tests-3.12:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DOCKER_REGISTRY/python:3.12
|
||||
allow_failure: true
|
||||
|
||||
artifacts:
|
||||
when: on_failure
|
||||
paths:
|
||||
- ./logs/*.log
|
||||
expire_in: 1 week
|
||||
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "schedule"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "web"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
|
||||
|
||||
semver:
|
||||
stage: Deploy
|
||||
@@ -229,19 +135,19 @@ semver:
|
||||
- git fetch --tags
|
||||
- git tag
|
||||
|
||||
# build and publish package
|
||||
- pip install python-semantic-release==9.* wheel build twine
|
||||
# build
|
||||
- pip install python-semantic-release==7.* wheel
|
||||
- export GL_TOKEN=$CI_UPDATES
|
||||
- semantic-release -vv version
|
||||
|
||||
# check if any artifacts were created
|
||||
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
|
||||
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
|
||||
- semantic-release publish
|
||||
- export REPOSITORY_USERNAME=__token__
|
||||
- export REPOSITORY_PASSWORD=$CI_PYPI_TOKEN
|
||||
- >
|
||||
semantic-release publish -v DEBUG
|
||||
-D version_variable=./setup.py:__version__
|
||||
-D hvcs=gitlab
|
||||
|
||||
allow_failure: false
|
||||
rules:
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
- if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
|
||||
pages:
|
||||
stage: Deploy
|
||||
@@ -249,9 +155,9 @@ pages:
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_REF_NAME
|
||||
rules:
|
||||
- if: "$CI_COMMIT_TAG != null"
|
||||
- if: '$CI_COMMIT_TAG != null'
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_TAG
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
- if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
script:
|
||||
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/
|
||||
|
||||
@@ -52,7 +52,7 @@ persistent=yes
|
||||
|
||||
# Minimum Python version to use for version dependent checks. Will default to
|
||||
# the version used to run pylint.
|
||||
py-version=3.10
|
||||
py-version=3.9
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
|
||||
@@ -9,7 +9,7 @@ version: 2
|
||||
build:
|
||||
os: ubuntu-20.04
|
||||
tools:
|
||||
python: "3.10"
|
||||
python: "3.9"
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
|
||||
911
CHANGELOG.md
911
CHANGELOG.md
@@ -1,176 +1,859 @@
|
||||
# CHANGELOG
|
||||
# Changelog
|
||||
|
||||
## v0.66.0 (2024-06-20)
|
||||
<!--next-version-placeholder-->
|
||||
|
||||
## v0.41.1 (2024-02-26)
|
||||
|
||||
### Fix
|
||||
|
||||
* **bec_dispatcher:** Handle redis connection errors more gracefully ([`a2ed2eb`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a2ed2ebe00c623eb183b03f8182ffd672fbf9e1e))
|
||||
* **bec_dispatcher:** Adapt code to redis connector refactoring ([`8127fc2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8127fc2960bebd3e862dbe55ac9401af4a6dccb6))
|
||||
|
||||
## v0.41.0 (2024-02-26)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(rpc): discover widgets automatically ([`ef25f56`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ef25f5638032f931ceb292540ada618508bb2aed))
|
||||
|
||||
## v0.65.2 (2024-06-20)
|
||||
* **widgets/waveform1d:** Data can be exported from rendered curve ([`5fc8047`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5fc8047c8ff971cdc2807d02743eae56d288f4d7))
|
||||
* **widgets/figure:** Clear_all method for BECFigure ([`0363fd5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0363fd5194320a7ea868ef883f8022ea464d0298))
|
||||
* **widgets/Waveform1D:** Waveform1D can be fully constructed by config ([`9a5c86e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9a5c86ea35178b9cab270fc35e668dd22f3ec8da))
|
||||
* **widgets/figure.py:** Dark/light theme changer ([`08534a4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/08534a4739ec8e85d82a00ab639411dd0198e9d8))
|
||||
* **utils/entry_validator:** Possibility to validate add_scan_curve with current BEC session ([`1db77b9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1db77b969bcf9b38716ae3d38bf4695b2b8c1f37))
|
||||
* **cli:** Added cli interface, rebased ([`a61bf36`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a61bf36df5d54ad44f78479c2474c4e38e68ed26))
|
||||
* Curve can be modified after adding to the plot ([`684592a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/684592ae37e9dd5328a96018c78ca242e10395b2))
|
||||
* Waveform1d.py curves can be removed by identifier by order(int) or by curve_id(str) ([`f0ed243`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f0ed243c9197b7d1aab0d99a15e9ba175708ec90))
|
||||
* Waveform1d.py curves can be stylised; access scan history by index or scanID ([`cba3863`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cba3863e5a9ac1187ea643be67db6cfc36b44ee2))
|
||||
* Start method for BECFigure, jupyter console .ui added to git ([`1d26b23`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1d26b2322147d9ea5a6a245e1648c00986f80881))
|
||||
* Added @user_access from bec_lib.utils ([`b827e9e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b827e9eaa77f8b64433bb7a54e40ab5ccd86f4b6))
|
||||
* Plot can be removed from BECFigure ([`60d150a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/60d150a41193aa7659285cf3612965f1a3c57244))
|
||||
* Figure.py create widget factory ([`c781b1b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c781b1b4e4121c4ec6fc8871a4cdf6f494913138))
|
||||
* Waveform1d.py draft ([`565e475`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/565e475ace72ccc103d71ea98af1dcaf04f37861))
|
||||
* Rpc decorator to add methods to USER_ACCESS ([`b676877`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b6768772424a3ad5ee7e271de19131f8065eef09))
|
||||
* BECFigure and BECPlotBase created ([`9ef331c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9ef331c272b88f725de9b8497fdf906056c0738b))
|
||||
* BECConnector -> mixin class for all BEC Widget to hook them to BEC client ([`91447a2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/91447a2d6234de1e8f2bac792e822bfda556abba))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(pyqt): webengine must be imported before qcoreapplication ([`cbbd23a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cbbd23aa33095141e4c265719d176c4aa8c25996))
|
||||
* **cli/client_utils:** "__rpc__" pop from msg_results ([`ebb36f6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ebb36f62ddc1c5013435f9e7727648b977b6b732))
|
||||
* **tests:** BECDispatcher fixture putted back ([`644f103`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/644f1031f6ff27064111565b0882cb8b2544aa2f))
|
||||
* **cli/rpc:** Rpc client can return any type of object + config dict of the widgets ([`fd711b4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fd711b475f268fbdb59739da0a428f0355b25bac))
|
||||
* **cli/rpc:** Server access children widget.find_widget_by_id(gui_id) ([`57132a4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/57132a472165c55bf99e1994d09f5fe3586c24da))
|
||||
* **cli:** Fixed property access, rebased ([`f71dc5c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f71dc5c5abdd6b8b585cb9b502b11ef513d7813e))
|
||||
* **rpc_server:** Fixed gui_id lookup ([`4630d78`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4630d78fc28109da7daf53e49dd3cdb9b8084941))
|
||||
* **cli:** Fixed rpc construction of nested widgets ([`da640e8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/da640e888d575b536fdd5d7adbf1df3eda802219))
|
||||
* **plots/waveform1d:** Pandas import clean up, export curves with none skipped ([`35cd4fd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/35cd4fd6f176ba670fad5d9fec44b305094280d6))
|
||||
* **widgets/plots:** Added placeholder for cleanup method to BECPlotBase ([`24c7737`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/24c77376b232c3846a1d6be360ec46acc077b48d))
|
||||
* **widget/figure:** Add cleanup method to disconnect all slots before removing Waveform1D from layout ([`a28b9c8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a28b9c8981d1058e4dc4146463f16c53413e8db9))
|
||||
* **rpc:** Added annotations to pass py3.9 tests ([`c6bdf0b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c6bdf0b6a5b12c054863b101a3944efc366686cb))
|
||||
* **rpc:** Connection to on_rpc_update done through bec_dispatcher ([`1c2fb8b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1c2fb8b972d4cb28cead11989461aea010c4571d))
|
||||
* After removing plot from BECFigure, the coordinates are correctly resigned ([`d678a85`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d678a85957c13c1fda2b52692c0d3b9b7ff40834))
|
||||
* Removed DI references, fixed set when adding plot by fig ([`7c15d75`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7c15d750117aec9e75111853074630a44dca87ae))
|
||||
|
||||
## v0.65.1 (2024-06-20)
|
||||
## v0.40.1 (2024-02-23)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: prevent segfault by closing the QCoreApplication, if any ([`fa344a5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fa344a5799b07a2d8ace63cc7010b69bc4ed6f1d))
|
||||
* **utils/bec_dispatcher:** _do_disconnect_slot will shutdown consumer of slots/signals which were already disconnected ([`feca7a3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/feca7a3dcde6d0befa415db64fc8f9bbf0c06e52))
|
||||
|
||||
## v0.65.0 (2024-06-20)
|
||||
## v0.40.0 (2024-02-16)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(device_input): DeviceLineEdit with QCompleter added ([`50e41ff`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/50e41ff26160ec26d77feb6d519e4dad902a9b9b))
|
||||
* **utils.colors:** Golden_angle_color utility can return colors as a list of QColor, RGB or HEC ([`5125909`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/51259097fa23ff861eac3f7c63624ea591bf1bd3))
|
||||
|
||||
* feat(device_combobox): DeviceInputBase and DeviceComboBox added ([`430b282`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/430b282039806e3fbc6cf98e958861a065760620))
|
||||
## v0.39.0 (2024-02-12)
|
||||
|
||||
### Feature
|
||||
|
||||
* Added full app with all motor movement related widgets into motor_control_compilations.py ([`fa4ca93`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fa4ca935bb39fdba4c6500ce9569d47400190e65))
|
||||
* MotorCoordinateTable mode_switch added for "Individual" and "Start/Stop" modes ([`2f96e10`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2f96e10b9deb76eedd8f6b6e201ba3b0e526a6f0))
|
||||
* Motor_control.py MotorCoordinateTable added basic version to store coordinates and show them in motor_map.py ([`031cb09`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/031cb094e7f8a7be4a295bea99b7ca8e095db8d7))
|
||||
* Active motors from motor_map.py can be changed by slot without changing the whole config ([`17f1458`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/17f14581d7c4662a2f5814ea477dfae8ef6de555))
|
||||
* Control panels compilations ([`8361736`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/83617366796ce2926650e38a1a9cec296befd3c6))
|
||||
* Comboboxes of motor selection are changed to orange if the motors are not connected yet ([`0b9927f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0b9927fcf5f46410d05187b2e5a83f97a6ca9246))
|
||||
* Motor_control.py MotorControl widgets - Absolute + Relative movement, MotorSelection, ErrorMessage popups ([`6fe08e6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6fe08e6b8206bcaaa292b7ff0e6b0d32b883f24f))
|
||||
|
||||
## v0.38.2 (2024-02-07)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(device_input_base): bug with setting config and overwriting default device and filter ([`d79f7e9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d79f7e9ccde03dc77819ca556c79736d30f7821a))
|
||||
* Adapt code to BEC 1.0 ([`b36131e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b36131eed5c3a3ea58c0fa4d083e63a3717cdf22))
|
||||
|
||||
### Test
|
||||
|
||||
* test(device_input): tests added ([`1a0a98a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1a0a98a45367db414bed813bbd346b3e1ae8d550))
|
||||
|
||||
## v0.64.2 (2024-06-19)
|
||||
## v0.38.1 (2024-01-26)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(client_utils): added close rpc command to shutdown of gui from bec_ipython_client ([`e5a7d47`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e5a7d47b21cbf066f740f1d11d7c9ea7c70f3080))
|
||||
|
||||
## v0.64.1 (2024-06-19)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(widgets): removed widget module import of sub widgets ([`216511b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/216511b951ff0e15b6d7c70133095f3ac45c23f4))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor(utils): moved get_rpc_widgets to plugin_utils ([`6dabbf8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6dabbf874fbbdde89c34a7885bf95aa9c895a28b))
|
||||
|
||||
### Test
|
||||
|
||||
* test: moved rpc_classes test ([`b3575eb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b3575eb06852b456cde915dfda281a3e778e3aeb))
|
||||
|
||||
## v0.64.0 (2024-06-19)
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: add job optional dependency check ([`27426ce`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/27426ce7a52b4cbad7f3bef114d6efe6ad73bd7f))
|
||||
* Monitor.py replots last scan after changing config with new signals; config_dialog.py checks if the new config is valid with BEC ([`ab275b8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ab275b8e5f226d6c5d22a844c4c0fae0fdc66108))
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs: fix links in developer section ([`9e16f2f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9e16f2faf9c59a5d36ae878512c5a910cca31e69))
|
||||
* 2D waveform scatter plot changed to 2D scatter plot ([`812ffaf`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/812ffaf8eafc3f8c3a6973717149e4befba2c395))
|
||||
* Documentation for example apps and widgets updated ([`f7a4967`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f7a496723c3fd113867a712928e06636e3212e1a))
|
||||
|
||||
* docs: refactor developer section, add widget tutorial ([`2a36d93`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2a36d9364f242bf42e4cda4b50e6f46aa3833bbd))
|
||||
## v0.38.0 (2024-01-23)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat: add option to change size of the fonts ([`ea805d1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ea805d1362fc084d3b703b6f81b0180072f0825d))
|
||||
* BECMonitor2DScatter for plotting x/y/z signal as a mesh of scatter plot ([`75090b8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/75090b857526fa642218986806d0daeb1dec0914))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(plot_base): font size is set with setScale which is scaling the whole legend window ([`5d66720`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d6672069ea1cbceb62104f66c127e4e3c23e4a4))
|
||||
* Monitor_scatter_2D.py changed to new BECDispatcher definition ([`747e97e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/747e97e0c924cdedb85e9fe7d47512002b791b10))
|
||||
|
||||
### Test
|
||||
|
||||
* test: add tests ([`140ad83`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/140ad83380808928edf7953e23c762ab72a0a1e9))
|
||||
|
||||
## v0.63.2 (2024-06-14)
|
||||
## v0.37.1 (2024-01-23)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: do not import "server" in client, prevents from having trouble with QApplication creation order
|
||||
* **tests:** Ensure BEC service is shutdown after bec dispatcher test ([`4664568`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/46645686725a2acb7196dbd1a504c98dbf2e4b5d))
|
||||
* **tests:** Ensure threads started during plot tests are properly stopped ([`3fb6644`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3fb6644543b4065236216b70a583641956a09a60))
|
||||
|
||||
Like with QtWebEngine ([`6f96498`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6f96498de66358b89f3a2035627eed2e02dde5a1))
|
||||
## v0.37.0 (2024-01-17)
|
||||
|
||||
### Unknown
|
||||
### Feature
|
||||
|
||||
* Reapply "feat: implement non-polling, interruptible waiting of gui instruction response with timeout"
|
||||
* Independent motor_map widget ([`1a429b3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1a429b3024e76446ed530bee71ed797c20843fba))
|
||||
|
||||
This reverts commit fe04dd80e59a0e74f7fdea603e0642707ecc7c2a. ([`836b6e6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/836b6e64f694916d6b6f909dedf11a4a6d2c86a4))
|
||||
|
||||
## v0.63.1 (2024-06-13)
|
||||
## v0.36.2 (2024-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: just terminate the remote process in close() instead of communicating
|
||||
* Bec_dispatcher.py can partially disconnect topics from slot ([`7607d7a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7607d7a3b64b3861f4833c9b8f5afc360f31b38d))
|
||||
* Bec_dispatcher.py can connect multiple topics to one callback slot ([`e51be04`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e51be04b95f1a9549a4a3b00d76944aa58b0526a))
|
||||
|
||||
The proper finalization sequence will be executed by the remote process
|
||||
on SIGTERM ([`9263f8e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9263f8ef5c17ae7a007a1a564baf787b39061756))
|
||||
## v0.36.1 (2024-01-15)
|
||||
|
||||
## v0.63.0 (2024-06-13)
|
||||
### Fix
|
||||
|
||||
* Motor_example.py fix to the new .read() structure from bec_lib ([`f9c5c82`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f9c5c82381907a19582bf9132740fe27b48d48cc))
|
||||
|
||||
## v0.36.0 (2024-01-12)
|
||||
|
||||
### Feature
|
||||
|
||||
* Bec_dispatcher can link multiple endpoints topics for one qt slot ([`58721be`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/58721bea1a2b4b06220ef0e3b2dcec8c1656213d))
|
||||
|
||||
## v0.35.0 (2024-01-12)
|
||||
|
||||
### Feature
|
||||
|
||||
* Monitor.py can access custom data send through redis ([`6e4775a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6e4775a1248153f6027be754054f3f43c18514d1))
|
||||
* Monitor.py access data directly from scan storage ([`26c07c3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/26c07c3205debaf88a346410a8ebab0a3ab7a5d9))
|
||||
|
||||
### Fix
|
||||
|
||||
* Monitor.py clear command from BECPlotter CLI clear now flush database and clear the plots ([`ebd4fcc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ebd4fccda2321aa0dc108a5436fb4cc717911d4b))
|
||||
* Monitor.py crosshair enabled by default ([`97dcc5a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/97dcc5ac768cc4f0122382591238fd5a9d035270))
|
||||
* Monitor.py change import of ConfigDialog from relative to absolute in order to make BECPlotter be able to open it ([`6061b31`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6061b3150e990141eafb8d5b17c7e931c7bf8631))
|
||||
* Monitor_config_validator.py changed to check .describe() instead of signals ([`5ab82bc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5ab82bc13340adb992c921a7211e8e2265861f7a))
|
||||
* Monitor.py fixed not updating config changes after receiving refresh from BECPlotter ([`00ef3ae`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/00ef3ae9256a368f4842c1dc38a407131181ec1d))
|
||||
* Monitor_config_validator.py valid color is Literal['black','white'] ([`86c5f25`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/86c5f25205dbaa45b7b2efd255f3a3cb2d3eb0b1))
|
||||
* Monitor.py fixed scan mode ([`a706da2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a706da2490f4cce80e9515633e8437b3667b0db0))
|
||||
* Motor_config_validation changed to new monitor config structure ([`d67bdd2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d67bdd26167dca6c65627192dbd098af08355d06))
|
||||
|
||||
## v0.34.1 (2023-12-12)
|
||||
|
||||
### Fix
|
||||
|
||||
* Formatter and tests fixed ([`186c42d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/186c42d6676a495bc2f66d8b7ed37dbf7d0be747))
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs: add documentation ([`bc709c4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bc709c4184c985d4e721f9ea7d1b3dad5e9153a7))
|
||||
* Readdocs updated ([`af995a7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/af995a74f34d59eeaff5d9100117f103ec79765d))
|
||||
* Readme.md updated ([`cba8131`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cba81313671acfee0a40410753c1974008316d07))
|
||||
* Gitlab templates for issues and merge requests from main bec repo ([`831eddc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/831eddc13600cc06b67de92d39509af37bb05002))
|
||||
|
||||
## v0.34.0 (2023-12-08)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat: add textbox widget ([`d9d4e3c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d9d4e3c9bf73ab2a5629c2867b50fc91e69489ec))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor: add pydantic config, add change_theme ([`6b8432f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b8432f5b20a71175a3537b5f6832b76e3b67d73))
|
||||
|
||||
### Test
|
||||
|
||||
* test: add test for text box ([`b49462a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b49462abeb186e56bac79d2ef0b0add1ef28a1a5))
|
||||
|
||||
### Unknown
|
||||
|
||||
* Revert "feat: implement non-polling, interruptible waiting of gui instruction response with timeout"
|
||||
|
||||
This reverts commit abc6caa2d0b6141dfbe1f3d025f78ae14deddcb3 ([`fe04dd8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fe04dd80e59a0e74f7fdea603e0642707ecc7c2a))
|
||||
|
||||
## v0.62.0 (2024-06-12)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat: implement non-polling, interruptible waiting of gui instruction response with timeout ([`abc6caa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/abc6caa2d0b6141dfbe1f3d025f78ae14deddcb3))
|
||||
|
||||
### Unknown
|
||||
|
||||
* 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: improve labe of auto_update script ([`40b5688`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/40b568815893cd41af3531bb2e647ca1e2e315f4))
|
||||
|
||||
## v0.60.0 (2024-06-08)
|
||||
|
||||
### Ci
|
||||
|
||||
* 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))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat: added isort to bw-generate-cli ([`f0391f5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0391f59c9eb0a51b693fccfe2e399e869d35dda))
|
||||
* Monitor.py error message popup ([`a3b24f9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a3b24f92420420c8968ef4793342c3857c826e57))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: removed BECConnector from rpc client interface ([`6428e38`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6428e38ab94c15a2c904e75cc6404bb6d0394e04))
|
||||
* Monitor_config_validator.py - Signal validation changed from field_validator to model_validator to check first name and then entry ([`0868047`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/086804780d19956331d8385381d2f7f9c181e77c))
|
||||
* Monitor_config_validator.py fix entry validation executed only if name validator is successful ([`af71e35`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/af71e35e73733472228c4be0061faefaf655b769))
|
||||
|
||||
* fix: added bec_ipython_client as dependency; needed for jupyter widget ([`006a089`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/006a0894b85cba3b2773737ed6fe3e92c81cdee0))
|
||||
## v0.33.0 (2023-12-07)
|
||||
|
||||
### Refactor
|
||||
### Feature
|
||||
|
||||
* refactor: minor cleanup ([`3adf6cf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3adf6cfd586355c8b8ce7fdc9722f868e22287c5))
|
||||
* Added axis_width and axis_color as optional plot settings ([`504944f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/504944f696a7b2881adec06d29c271fec7e2c981))
|
||||
|
||||
* refactor: disabled pylint for auto-gen client ([`b15816c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b15816ca9fd3e4ae87cca5fcfe029b4dfca570ca))
|
||||
### Fix
|
||||
|
||||
* refactor(isort): added bec_widgets as known first party package ([`9c5a471`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9c5a471234ed2928e4527b079436db2a807c5f6f))
|
||||
* Fixed default config options ([`03bdf98`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/03bdf980bcfc37e217cde1beb258d11cee97e0eb))
|
||||
* Added hooks to react to incoming config messages and instructions ([`1084bc0`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1084bc0a803ff73cfa2ab53819bc9809588fa622))
|
||||
|
||||
### Test
|
||||
## v0.32.2 (2023-12-06)
|
||||
|
||||
* test: added missing pylint statement to header ([`f662985`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f6629852ebc2b4ee239fa560cc310a5ae2627cf7))
|
||||
### Fix
|
||||
|
||||
* Changed exec_ to exec for all apps ([`080c258`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/080c258d1542aaace093bca74225297b30453f77))
|
||||
* Yaml_dialog.py changed to use native solution of OS -> should prevent crashing on py3.11 ([`5adde23`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5adde23a457bbd3ae1488b77d4b927b5bded0473))
|
||||
|
||||
## v0.32.1 (2023-12-06)
|
||||
|
||||
### Fix
|
||||
|
||||
* Widget_io print_widget_hierarchy fix comboboxes ([`d1f9979`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d1f9979ab1372c2f650c8aff12ccb17d668b52eb))
|
||||
* WidgetIO combobox fixed for qt6 distributions ([`4f70097`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4f700976ddd78a6f06e358950786b731ef9051ce))
|
||||
|
||||
## v0.32.0 (2023-11-30)
|
||||
|
||||
### Feature
|
||||
|
||||
* Jupyter rich console added as alternative to default QTextEdit terminal output ([`016b26f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/016b26f5cf05e90da144487a9359ac2a54c8e549))
|
||||
* Editor.py basic signature calltip ([`045b1ba`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/045b1baa60a93d2266800821940d7aa29bd8bbe1))
|
||||
* Editor.py jedi autocomplete hooked ([`fb555b2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fb555b278a5139f180592280408742d34dc5fa84))
|
||||
* Editor.py added splitter between editor and terminal ([`c70ddb3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c70ddb3cb19fecf7ce14b551d7d265e2e0cff357))
|
||||
* Toolbar.py proof-of-concept ([`286e62d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/286e62df92927d2efe0b4ab07995f7b5e36a0435))
|
||||
* Basic text editor + running terminal output ([`9487844`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/94878448c8f39a69e9e65df2789da029a9acfc0e))
|
||||
|
||||
### Fix
|
||||
|
||||
* Added missing dependency jedi ([`d978740`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d978740f9879580d01e092ad1fead46786d3ed5c))
|
||||
* Editor.py switch to disable docstring ([`3cc05cd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3cc05cde147cd520b98f0896beb64781ea47d816))
|
||||
* Editor.py compact signature on tooltip ([`f96cacc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f96caccfcb43c904887ebfc0b34fd779ffff8bf1))
|
||||
* Editor.py removed automatic background behind edited text ([`d865e2f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d865e2f1af6eb3d5fb31f9c53088b629a232343f))
|
||||
* Toolbar.py automatic initialisation works ([`8ad3059`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8ad305959257a58b297896218baae06d09520ee1))
|
||||
* Terminal output as QThread ([`a0d172e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a0d172e3dc35bdc2d7e1b43185e31bb9a3629631))
|
||||
|
||||
## v0.31.0 (2023-11-13)
|
||||
|
||||
### Feature
|
||||
|
||||
* Pydantic validation module for monitor.py ([`7fec0c7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7fec0c7e4411c221413d69aeeb4d68ade10d502b))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Pydantic validation module docs ([`92a5325`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/92a5325aad02fe308caaf9088a3c4386ca055124))
|
||||
|
||||
## v0.30.0 (2023-11-10)
|
||||
|
||||
### Feature
|
||||
|
||||
* WidgetIO support for QLabel ([`aa4c7c3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/aa4c7c3385f52e4bbc805ee2aced181929943a89))
|
||||
* Scan_control.py added option to limit scan selection from list of strings as init parameter ([`0fe06ad`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0fe06ade5b44d13a9188aef474364b36baa480ef))
|
||||
* Scan_control.py a general widget which can generate GUI for scan input ([`088fa51`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/088fa516a8876d112a98cd60aa2a5701dff6b97c))
|
||||
|
||||
### Fix
|
||||
|
||||
* Added imports to __init__.py in widget for ScanControl class ([`b85cc89`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b85cc898d521df1c99a65e579b8fe853bb04cc32))
|
||||
* Scan_control.py args_size_max fixed ([`da9025e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/da9025e032c2bc9b34cf359a20745e3156d2f731))
|
||||
* Scan_control.py default spinBox limits increases ([`5c67026`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5c67026637472d9c77185a59e1bf9a24cfe01307))
|
||||
* Scan_control.py supports minimum and maximum number of args ([`ee2f36f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ee2f36fb402d626c300a018afacbd57eff14a665))
|
||||
* Scan_control.py wipe table and reinitialise devices when scan is changed ([`5ac3526`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5ac3526384b9ee0eb94568bac035b348eaa52abd))
|
||||
* Widget_IO.py added handler for QCheckBox ([`18a7025`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/18a702572f6bed41081d368e39c8fc69122c6203))
|
||||
* Scan_control.py scan can be executed from GUI ([`2e42ba1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2e42ba174f7abe1590b9afb099dc2d068eb848ae))
|
||||
* Scan_control.py all kwargs are rendered ([`4b7592c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4b7592c2795a26591b3e30870c73aa406316588d))
|
||||
* Scan_control.py kwargs and args are added to the correct layouts ([`b311069`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b311069722226b95a7902f42815d2c1e219e9584))
|
||||
|
||||
## v0.29.0 (2023-10-31)
|
||||
|
||||
### Feature
|
||||
|
||||
* Widget_hierarchy.py tool to inspect hierarchy of the widget ([`cda8dae`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cda8daeb35b36692316173a19fb29f1cc0dbdb7c))
|
||||
* Yaml_dialog.py interactive QFileDialog window to load/save .yaml files to/from dict ([`2b29b6c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2b29b6cfe2ea94a974da4a332c47473176ddddff))
|
||||
* Qt_utils custom class for class where one can delete the row with backspace or delete ([`a6616f5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a6616f5986d59ad8d065105234f5b704731cce71))
|
||||
* Modular_app.py, device_monitor.py, config_dialog.py linked together, plot configuration can be done through GUI ([`bf2a09e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bf2a09e6307da63ecf02a1286095a19e5f1dcab4))
|
||||
* Config_dialog.py interactive editor of plot settings ([`c9e5dd5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c9e5dd542c9eb7c9069d1c0f1256a634a166eb40))
|
||||
|
||||
### Fix
|
||||
|
||||
* Yaml_dialog.py added support for .yml files ([`10539f0`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/10539f0ba59be716102b2c0577ea62f5c4a3136a))
|
||||
* Yaml_dialog.py added return None if no file path is specified ([`ff1d918`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ff1d918d43f0f2e5fe8d78c6de9051c50e0e12c1))
|
||||
* Wrong __init__.py in modular_app ([`d52aa15`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d52aa15aac42f09487c828836e377c78596037bd))
|
||||
* Test_bec_monitor.py config loaded fresh in the test function to avoid parameter leak ([`3866d7c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3866d7ce4de3391fe57ef872808f7620562eeeb0))
|
||||
* Test_bec_monitor.py setup_monitor help function changed to pytest.fixture ([`989cd51`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/989cd51162147805db1229b50b330af29f275204))
|
||||
* Test_config_dialog.py - QApplication removed ([`1cdd760`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1cdd760e4062de1f19837737766ce05edc9ac2da))
|
||||
* Test_config_dialog.py - test_add_new_plot_and_modify qtbot action .click() changed -> function called directly ([`1333e6c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1333e6cbca18943b0a61206dc1ba63720b031b40))
|
||||
* Test_config_dialog.py disabled ([`4e710dd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4e710dda5e88b2e82ec87db350e8b1fe6aa09181))
|
||||
* Test_bec_monitor.py QApplication instance removed ([`77e1d09`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/77e1d0925db2dc6669159fbe3fb08daf330cb5c8))
|
||||
* Test_config_dialog.py QApplication instance added ([`60e864b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/60e864b2590d121e1b0ad645d39d1a028abe8d7b))
|
||||
* Device_monitor.py BECDeviceMonitor can be promoted in the QtDesigner and then setup in the modular app ([`afab283`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/afab283988a1acc008bc53ae5a56a8f67504da81))
|
||||
* Device_monitor.py crosshairs can be attached again ([`644a97a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/644a97aee848c973125375d5f28d3edf2ffc20cf))
|
||||
* Config_dialog.py prevents to add one scan twice ([`12469c8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/12469c8c1e45f83cc0c65708bd412103a8ec1838))
|
||||
* Config_dialog.py export to .yaml fixed ([`7e99920`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7e99920fc565acd59cb3a4286ac5ee40597d8af4))
|
||||
* Config_dialog.py scan_type structure implemented ([`e41d81c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e41d81cbd9371f8633c1e7de82c8f9b64fcb721b))
|
||||
* Config_dialog.py config from default mode can be exported to dict ([`55b5ca7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/55b5ca7381dc33119baac0f48c76fc9d9e8215ae))
|
||||
* Config_dialog.py tabs for scans and plots are closable now ([`ec88564`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ec88564e6577cd6579c30f36193c4a0e5fcbc483))
|
||||
* Modular_app.py configs are linked to the actual version of the state of the device monitor ([`d78940d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d78940da3f114062aa397b87c169f26cbc131a5f))
|
||||
* Config_dialog.py can load the current configuration of the plot ([`f94a29b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f94a29bf4be0a883abc200821746c3d81a0c00d4))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Config_dialog.py comments added to example cases ([`4a6e73f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4a6e73f4f791d2152ff29680a4e28529a8df0b47))
|
||||
* Device_monitor.py update docstrings ([`a785bca`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a785bca8806613f9e1e4b67380e72867b581fe6e))
|
||||
* Added sphinx base structure ([`9d36b96`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9d36b9686e31b2c4b4206ad47b385c8f2769c641))
|
||||
|
||||
## v0.28.1 (2023-10-19)
|
||||
|
||||
### Fix
|
||||
|
||||
* Stream_plot.py on_dap_update data dict opened correctly ([`28908dd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/28908dd07c1eef8a9d3213a581393e665b310d1b))
|
||||
|
||||
## v0.28.0 (2023-10-13)
|
||||
|
||||
### Feature
|
||||
|
||||
* BECDeviceMonitor modular class which can be used to replace placeholder in .ui file. ([`f3f55a7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f3f55a7ee0ad58aab74526a24f27436fd2bef61d))
|
||||
* Placeholders initialised ([`75af040`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/75af0404b3aa5f454528255e8971af07c4e8b39b))
|
||||
|
||||
### Fix
|
||||
|
||||
* Scan_mode for BECDeviceMonitor fixed init_ui ([`59bba14`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/59bba1429c1f8aeeb562b539583e71303506bd58))
|
||||
|
||||
## v0.27.2 (2023-10-12)
|
||||
|
||||
### Fix
|
||||
|
||||
* Scan_plot tests ([`f7cbdbc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f7cbdbc5ca318d60a9501df3fa03c7dea15b5b21))
|
||||
|
||||
## v0.27.1 (2023-10-10)
|
||||
|
||||
### Fix
|
||||
|
||||
* Extreme.py default config file changed to the config_example.yaml ([`5814113`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5814113f73fb1c4552bb715b27d3330decd9c878))
|
||||
* Extreme.py retry action fixed in ErrorHandler ([`5162270`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5162270d28ca8eab4eac9d9665e2fb4c5e8a33a3))
|
||||
* Extreme.py advanced error handling with possibility to reload different config ([`51c3a9e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/51c3a9e9ee3d75c8324300afac366dcdb9adb876))
|
||||
* Extreme.py error in configuration are displayed as messagebox ([`9750039`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9750039097c9e4b9a45603dcefe76e5b2e8920fd))
|
||||
* Extreme.py validation function to check config key component structure ([`824ce82`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/824ce821cd5f060f2c550b970afb1f3479a006ef))
|
||||
* Extreme.py improved error handling for scan types mode ([`fbd299c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fbd299c7e7cf548886e2b1787d8e188c708ee8cd))
|
||||
* Extreme.py init_ui changed > to >= for setting number of columns ([`6c773c7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6c773c7c94e5eee700b74a792657978be86dbbf4))
|
||||
* Extreme.py init_plot_background error handling ([`c525eba`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c525eba88576e0094063019d00fba6a43c52b42e))
|
||||
* Extreme.py ui is initialised for the first scan of config in scan mode ([`fc60984`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fc6098414e328e14ec9ab6006538f46e36f17723))
|
||||
* Extreme.py client and device manager initialisation ([`ae79faa`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ae79faa7ed8e9d8f680e1be1afefe43706305d9a))
|
||||
* Extreme.py default config file changed to the config_example.yaml ([`d356cf7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d356cf734b81fd7ed2c9b48ee85a1722af179d83))
|
||||
* Extreme.py retry action fixed in ErrorHandler ([`b76df1b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b76df1b583a5922229f97876f9e65e0cad64c88e))
|
||||
* Extreme.py advanced error handling with possibility to reload different config ([`d623cf9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d623cf95391adfc89837cd54ca1b2a1b6e491a3c))
|
||||
* Extreme.py error in configuration are displayed as messagebox ([`89a52a0`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/89a52a0948ee300e57bb7198eac339ee771bff06))
|
||||
* Extreme.py validation function to check config key component structure ([`5a7ac86`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5a7ac860a8cf5cef53ae699b2869e649c1721f9d))
|
||||
* Extreme.py improved error handling for scan types mode ([`ece1859`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ece1859a63b83b1d56b33cc610efea6876dd9e1f))
|
||||
* Extreme.py init_ui changed > to >= for setting number of columns ([`a0a89fe`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a0a89fe704db6c11a99a26a080051af1c677ba7a))
|
||||
* Extreme.py init_plot_background error handling ([`dafb6fa`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/dafb6fae7a526d5b311ded1d0424ac4dbb3c8b74))
|
||||
* Extreme.py ui is initialised for the first scan of config in scan mode ([`82bebe6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/82bebe6b41004befcb1b54db141e20ff844f76e5))
|
||||
* Extreme.py client and device manager initialisation ([`cf15163`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cf15163bd91291e9851662c147b2e799ae022b9e))
|
||||
* Formatter fixed ([`153c5f4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/153c5f4f9d168f433380bd2deddd2b17a45916a3))
|
||||
|
||||
## v0.27.0 (2023-09-25)
|
||||
|
||||
### Feature
|
||||
|
||||
* Motor_example.py in start/end mode new button allowing user to go to end position ([`65b045e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/65b045e1a26a0f799311e9dca25e2a9dfd7f7147))
|
||||
|
||||
### Fix
|
||||
|
||||
* Epics removed from requirements ([`44cc881`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/44cc881ac9e69c68f1f5296fea62a14daa55d4e3))
|
||||
* Motor_example.py load .csv logic fixed ([`b78152b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b78152b14999ba5c07d7cd2ef2e3309df1ba5ca6))
|
||||
* Motor_example.py export .csv logic fixed ([`85841cd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/85841cdf1fc44472cfcc7e3e6529a41018140896))
|
||||
* Motor_example.py precision in duplicate table fixed ([`05f48de`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/05f48de3f1f6793de3f6a8bc2c5e3ad3261dfcf0))
|
||||
* Motor_example.py duplicate table fixed ([`401fec8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/401fec85395886ea2816b6993bf8084b6e652967))
|
||||
* Motor_example.py manual changing coordinates in start/stop works again ([`b13509e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b13509e9eb88b55a59b141b0cec06f3c8a983151))
|
||||
* Motor_example.py replot points logic simplified ([`a15860a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a15860abac984328382868b0a953960c44792c41))
|
||||
* Motor_example.py new independent mapping relying on the table ([`673ed32`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/673ed325d1f56505035899549ea555497823a31f))
|
||||
* Extreme.py formatting fixed ([`63f52fc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/63f52fc8419cd53856a32af6be3f548f8e077cd1))
|
||||
* Line_plot.py ROI interactions fixed ([`e4f23f5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e4f23f51012b54cde5cd41bb9ab356a277ef4b2f))
|
||||
* Online changes e21543 ([`b41d63e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b41d63ea4d6e15c80a7baab7a70c607079152d0a))
|
||||
* Motor_example.py user is blocked to duplicate last entry in start/end mode if end coordinate was not defined ([`418480f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/418480f1fcdc72c05887e8b73c24f76e1e8475b2))
|
||||
* Motor_example.py - new more robust logic for getting coordinates for table go buttons ([`08f508f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/08f508f4c3c3e1f3c2d4c6dda0d8e6693e9331b5))
|
||||
|
||||
### Performance
|
||||
|
||||
* Motor_example.py replot logic optimizes ([`a4fb6bd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a4fb6bd1d2c819740077cdc7291daf28a9e4abdd))
|
||||
|
||||
## v0.26.7 (2023-09-19)
|
||||
|
||||
### Fix
|
||||
|
||||
* Eiger_plot_hist.py removed ([`abe35bf`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/abe35bf96757a38395733bddbd8702a29fd26f42))
|
||||
|
||||
## v0.26.6 (2023-09-19)
|
||||
|
||||
### Fix
|
||||
|
||||
* Extreme.py saved to .yaml works correctly for different scans configurations ([`cb144c7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cb144c7c2cba50fc49ba53b0a9e3293b549665be))
|
||||
* Extreme.py fixed logic of loading new config.yaml during app operation ([`4287ac8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4287ac888591abf27a4e4ce8c23f94d54bc6c2a9))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Extreme.py updated documentation ([`7ff72b4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7ff72b4086e5e340d591d130f011f83fc8370315))
|
||||
|
||||
## v0.26.5 (2023-09-13)
|
||||
|
||||
### Fix
|
||||
|
||||
* Motor_example.py help extended ([`a5c6ffa`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a5c6ffaa024a0dd6901976c81ea9146e5be016ec))
|
||||
|
||||
## v0.26.4 (2023-09-12)
|
||||
|
||||
### Fix
|
||||
|
||||
* Logic fixed ([`7cb56e9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7cb56e9e7f2cbeee5a141c4a52a3489c26963839))
|
||||
|
||||
## v0.26.3 (2023-09-12)
|
||||
|
||||
### Fix
|
||||
|
||||
* Import works for both modes ([`b867f25`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b867f25c780ba97393ca65fe76c1cb492f365ded))
|
||||
|
||||
## v0.26.2 (2023-09-12)
|
||||
|
||||
### Fix
|
||||
|
||||
* Import with start/stop mode works again ([`cacc076`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cacc076959cdd55218b74de2974d890e583c3d94))
|
||||
|
||||
## v0.26.1 (2023-09-12)
|
||||
|
||||
### Fix
|
||||
|
||||
* Removed scipy from eiger_plot.py ([`0e634ee`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0e634ee2ac58b8be43b7f4e64fbc08ef08675aa1))
|
||||
|
||||
## v0.26.0 (2023-09-12)
|
||||
|
||||
### Feature
|
||||
|
||||
* Plot different signals and plot configurations based on different scans ([`57e6990`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/57e69907d55f7693e97d48026f3bb426adfb4870))
|
||||
|
||||
## v0.25.1 (2023-09-12)
|
||||
|
||||
### Fix
|
||||
|
||||
* Specific config for csaxs ([`8ff983f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8ff983f16e78d881582d4aaaa0261e10d9d62bf2))
|
||||
* Mode lock in config to disable changing the mode for users ([`10ccf0c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/10ccf0cc977cae30c0c185a920e15b9cf2def58f))
|
||||
|
||||
## v0.25.0 (2023-09-12)
|
||||
|
||||
### Feature
|
||||
|
||||
* ComboBox to switch between entries mode ([`f2fde2c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f2fde2cf5c4b219520eb0257c1c8e02ce66cde87))
|
||||
|
||||
### Fix
|
||||
|
||||
* Extra columns works again ([`2123361`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2123361ada9767333792d34de56d6f1447f67cda))
|
||||
* Resize table is user controlled ([`63e3896`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/63e389672560e505159de2014846d1506b05633f))
|
||||
|
||||
## v0.24.2 (2023-09-12)
|
||||
|
||||
### Fix
|
||||
|
||||
* Changes e20643 ([`2657440`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/265744076cc53bd054b45c12de3bb24b23e1845c))
|
||||
|
||||
## v0.24.1 (2023-09-08)
|
||||
|
||||
### Fix
|
||||
|
||||
* Typo fixed in mca_plot.py ([`3b12f1b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3b12f1bc1d65772fc3613f62013809445dcead7a))
|
||||
|
||||
## v0.24.0 (2023-09-08)
|
||||
|
||||
### Feature
|
||||
|
||||
* HistogramLUT for mca_plot ([`ae04072`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ae040727fc60160de8b50ac1af51fba676106e52))
|
||||
|
||||
## v0.23.0 (2023-09-08)
|
||||
|
||||
### Feature
|
||||
|
||||
* Added key bindings and help dialog ([`ade893d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ade893d33d07f1190994de19b84d4021586bcbcb))
|
||||
|
||||
## v0.22.0 (2023-09-08)
|
||||
|
||||
### Feature
|
||||
|
||||
* Added FFT ([`b984f0f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b984f0f36e2178690eaaec091d4a7b9443f2378f))
|
||||
|
||||
## v0.21.2 (2023-09-08)
|
||||
|
||||
### Fix
|
||||
|
||||
* Moved mask as a last step of image processing ([`87d5467`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/87d546764318679cd80e56d17d590f0e31e51504))
|
||||
|
||||
## v0.21.1 (2023-09-08)
|
||||
|
||||
### Fix
|
||||
|
||||
* Update_signal typo fixed ([`43f03b5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/43f03b543083da9b743828139a92f87732187dd9))
|
||||
|
||||
## v0.21.0 (2023-09-08)
|
||||
|
||||
### Feature
|
||||
|
||||
* Added functionality to load mask ([`33d1193`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/33d1193c9623b157cc74883184677a727b8e33ce))
|
||||
|
||||
### Fix
|
||||
|
||||
* Path to mask fixed ([`ef42921`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ef42921c9a585bce8a97fc8bb251e27a9455a771))
|
||||
|
||||
## v0.20.0 (2023-09-08)
|
||||
|
||||
### Feature
|
||||
|
||||
* Added rotate and transpose logic ([`acd7a3b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/acd7a3bc92746c7e56dc8699c4378d2ab778267f))
|
||||
|
||||
### Fix
|
||||
|
||||
* Added missing .ui file to git ([`ae8fc94`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ae8fc9497954ca49c16d76eaeea7ecc7659c1269))
|
||||
|
||||
## v0.19.2 (2023-09-08)
|
||||
|
||||
### Fix
|
||||
|
||||
* Rotation logic fixed ([`6733371`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6733371c2ccb4e233d9aa9421e21d627978925d7))
|
||||
|
||||
## v0.19.1 (2023-09-08)
|
||||
|
||||
### Fix
|
||||
|
||||
* Rotation always counter-clockwise ([`00385ab`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/00385abbf98add7945af170b292774d377473a70))
|
||||
|
||||
## v0.19.0 (2023-09-08)
|
||||
|
||||
### Feature
|
||||
|
||||
* Rotation of the image to the left/right by 90, 180, 270 degree ([`327f6b3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/327f6b3df300d1f88b475973a86175379688aa9b))
|
||||
* Simulation stream with Gaussian peak in 1st quadrant ([`4fa8d46`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4fa8d46631ff822d5465564434d173dd766a6b1a))
|
||||
* Eiger_plot.py in example folder with new GUI ([`5cbedec`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5cbedec5d9f6a6ae763e2cb336ecb40c4d3e1ed1))
|
||||
|
||||
## v0.18.1 (2023-09-08)
|
||||
|
||||
### Fix
|
||||
|
||||
* Online changes ([`29c983f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/29c983fb268bb2dbcfe552453501ff42442f075f))
|
||||
|
||||
## v0.18.0 (2023-09-08)
|
||||
|
||||
### Feature
|
||||
|
||||
* Eigerplot added ([`70d74c7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/70d74c774d2b318d99c049f0f03743e77812df98))
|
||||
|
||||
## v0.17.1 (2023-09-08)
|
||||
|
||||
### Fix
|
||||
|
||||
* Start_device_consumer changed from EP device_status to scan_status ([`46a3981`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/46a3981e7dfd5ded7b7f325301d2a25c47abd16f))
|
||||
|
||||
## v0.17.0 (2023-09-07)
|
||||
|
||||
### Feature
|
||||
|
||||
* Console arguments added for Redis port, device, and sub_device tag ([`fb52b2a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fb52b2a8e59fca556764e0dc32bd4edc167e31d3))
|
||||
* Plot flips every second row ([`c368871`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c36887191914d23e85a1b480dac324be0eefb963))
|
||||
* Device_consumer is getting scanID and initialise stream_consumer ([`9271b91`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9271b91113a3bbd46f0bffdaef7b50b629e4f44f))
|
||||
* Simulation and simple 2D plot for mca card stream ([`bfef713`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bfef71382e6a1180d750d2c800650942c5da7a21))
|
||||
|
||||
## v0.16.4 (2023-09-06)
|
||||
|
||||
### Fix
|
||||
|
||||
* Self.limit_map_data fixed to be initialised only with integers from limits ([`b62509a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b62509a28e970358c3ffd4f7d55c2a6bbef35970))
|
||||
|
||||
## v0.16.3 (2023-09-06)
|
||||
|
||||
### Fix
|
||||
|
||||
* Limit spinBoxes morphed to doubleSpinBoxes ([`a1264fe`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a1264fe4e2e0c864c68786d6db16550f489b00fa))
|
||||
|
||||
### Documentation
|
||||
|
||||
* PyqtGraph controls in help ([`2397af1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2397af140f2f9ee23ed5e62ef9bdf4d0aba249a1))
|
||||
|
||||
## v0.16.2 (2023-09-06)
|
||||
|
||||
### Fix
|
||||
|
||||
* X and y motor can be linked again ([`f45512e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f45512e0ae9c189a1d26456333c5b348cd681ce7))
|
||||
|
||||
## v0.16.1 (2023-09-06)
|
||||
|
||||
### Fix
|
||||
|
||||
* Default values fixed from .yaml ([`8a6e2da`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8a6e2daaf95cb5417951cbe3cca0cb3e909b08b4))
|
||||
|
||||
## v0.16.0 (2023-09-06)
|
||||
|
||||
### Feature
|
||||
|
||||
* Added help button ([`2087d19`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2087d19d3c2349e160327880210a5cf129852f09))
|
||||
* Table can be loaded from .csv ([`15d995f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/15d995f66b892f55526bd8b0954b6886d8f861ea))
|
||||
* Table can be exported to csv ([`772f18f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/772f18fa09bef54c849d2fdd58e02e8dada84a4e))
|
||||
* Additional extra rows takes values from previous row ([`1235294`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1235294b034dae50ff9a2ea93bc1a318383cbbf5))
|
||||
* Additional columns can be added through .yaml ([`fa76acb`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fa76acbd6dda1695add1c1159c4a96c33741a4c7))
|
||||
|
||||
### Fix
|
||||
|
||||
* Help extended ([`9fba033`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9fba0334a0389f66344b84dd434d4d9a39b1565e))
|
||||
* Table loads number of columns correctly ([`bf12963`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bf129632471da2e6dc5d637a5b02c321d8d3dcac))
|
||||
* Content always aligned to centre ([`74884a3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/74884a37076cd047e2dc75e07246f73e5f93167e))
|
||||
|
||||
## v0.15.0 (2023-09-06)
|
||||
|
||||
### Feature
|
||||
|
||||
* Step for x and y can be linked or separated ([`16ab746`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/16ab746f54007ee0647b6602b7d74a4a59401705))
|
||||
* User can choose if to save coordinates when moving to absolute coordinates ([`6324199`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/632419929921fbe4e970149ce8d4e617566f71fc))
|
||||
|
||||
### Fix
|
||||
|
||||
* Table checkbox fixed ([`7e6244c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7e6244c5d3698e6fea944b9501064470b6c884c7))
|
||||
* Partial fix to table checkBox ([`75f5c8f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/75f5c8fcd6e80288e1f3bc1b9c0c0b3edd1335bc))
|
||||
* Coordinates markers are updated on the map, if X, Y in table manually is changed ([`0aa667b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0aa667b70d48356bdda59b879baa3862c5e2e756))
|
||||
* Added float validator to the table ([`be1bd81`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/be1bd81d60373a0d9e776dc3f0d879d1bf905f7a))
|
||||
* Table bug, when deleted multiple rows ([`9d83a45`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9d83a455e899e3018364123707064882076c4eb0))
|
||||
* Table bug, when user deleted row and wanted to go to the previous position ([`63e6d61`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/63e6d61c2e6f9cbc069c9d55c7006d18b6b34b4d))
|
||||
|
||||
## v0.14.2 (2023-09-05)
|
||||
|
||||
### Fix
|
||||
|
||||
* Bec_config initialisation by command line argument ([`b7a1b8b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b7a1b8bca1b89df859c9ed0ed17862bb6d533de7))
|
||||
|
||||
## v0.14.1 (2023-09-05)
|
||||
|
||||
### Fix
|
||||
|
||||
* Gui default tab changed to coordinates table ([`3c74fa5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3c74fa59b7b83976b13afc821c1333868e62a686))
|
||||
|
||||
## v0.14.0 (2023-09-05)
|
||||
|
||||
### Feature
|
||||
|
||||
* Enable gui button, in the case that motor movement is not finished ([`84155d2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/84155d22640e229820fa5104975d2675f63cef31))
|
||||
* Saved coordinates are shown on the map ([`0ca665a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0ca665a1e91d9c5dee9af0218c2e211de8304b26))
|
||||
|
||||
### Fix
|
||||
|
||||
* Motor position points can be switched on/off if points were deleted ([`5b30dfd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5b30dfd43fcbe4b9941e26cab76005ffeb21d95f))
|
||||
* Highlight disapear with new motor ([`3fb8651`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3fb8651dd5777861488928b414d5bdacb517d0e9))
|
||||
* New points do not make invisible points visible again ([`fb10551`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fb105513e52bcd9c62dfead16e91b45ecd817612))
|
||||
* Checkbox visibility toggle is working. ([`a178c43`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a178c434b1d9efc1795b6f5115e2a8b9685ccdf2))
|
||||
* Saved coordinates can be removed from table and from the map again ([`c32e95a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c32e95a57d3faec46652b413581d830698855367))
|
||||
|
||||
## v0.13.0 (2023-09-05)
|
||||
|
||||
### Feature
|
||||
|
||||
* Crosshair highlight at motor position ([`9228e5a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9228e5aea3d5e4192733539643654fd635c63559))
|
||||
* Increase step size double with key bindings ([`e9ef1e3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e9ef1e315bc7222c38c1f2f3f410f5cdff994f08))
|
||||
* Go, set, save current coordinates and keyboard shortcuts ([`5d6a328`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5d6a328728a017eb4f1d191c96d2659800d41941))
|
||||
|
||||
### Fix
|
||||
|
||||
* Spinbox limits in ui file ([`8de08cf`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8de08cf9ccb092b3cfa5cf751f69fbf5edd2b217))
|
||||
* Precision updated correctly ([`172ccc6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/172ccc69056380abcddf572f668a4ddbd5d34eec))
|
||||
|
||||
## v0.12.0 (2023-09-04)
|
||||
|
||||
### Feature
|
||||
|
||||
* Config from .yaml file ([`1a67758`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1a677584708e1c91491fe84db169103bdda488e5))
|
||||
* Removal of motor configurations from user ([`34212d4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/34212d4d45c88a7bba75f289a25e5488ff95fc73))
|
||||
|
||||
### Fix
|
||||
|
||||
* Error message if motor do not have limits attribute ([`bf93b02`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bf93b02cdc82086b32e2bd16f4b506c1bb76c65d))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Added documentation to all classes and methods ([`4afaa1b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4afaa1b0ce1f29e4193e6999ecc13b1f0f662213))
|
||||
|
||||
## v0.11.0 (2023-09-04)
|
||||
|
||||
### Feature
|
||||
|
||||
* Colorbutton next to each curve in the table to be able to set up colors ([`2c6719c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2c6719cf390e6638cadbc814eb0c085bb45c3c6c))
|
||||
|
||||
### Fix
|
||||
|
||||
* User selected colors are preserved with the new scan ([`8e7885f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8e7885f36dd2812e3285c4d2d101212055644c7b))
|
||||
* Colorbutton change now symbols as well ([`6d2e1c9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6d2e1c9d08595a45f502287c6490905e8df3db10))
|
||||
|
||||
## v0.10.0 (2023-09-01)
|
||||
|
||||
### Feature
|
||||
|
||||
* Load and export configuration into .yaml from GUI ([`e527353`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e5273539741a1261e69b1bf76af78c7c1ab0d901))
|
||||
* Error messages if name or entry is wrong ([`415c4ee`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/415c4ee3f232c02ee5a00a82352c7fbb0d324449))
|
||||
* Number of columns can be dynamically changed ([`65bfccc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/65bfccce8fce158150652fead769721de805d99e))
|
||||
* Multi window interface created for extreme BL ([`69c38d6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/69c38d67e4e9b8a30767f6f67defce6c5c2e5b16))
|
||||
|
||||
### Fix
|
||||
|
||||
* Check if num_columns is not higher that actual number of plots ([`aac6e17`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/aac6e172f6e4583e751bee00db6f381aaff8ac69))
|
||||
* Add max number of columns according to the number of plots ([`fbd71c1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fbd71c131386508a9ec7bb5963afefc13f8b1618))
|
||||
* More specific error messages ([`583e643`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/583e643dacac3d7aaa744751baef2da69f6f892e))
|
||||
* Bec_dispatcher.py can take multiple workers as a list ([`7bcf88d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7bcf88d5eb139aa3cf491185b9fb3f45aa5e39a2))
|
||||
* Config.yaml can be passed as a console argument to extreme.py ([`b8aa373`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b8aa37321d6ac0ebd9f2237c8d2ed6594b614b57))
|
||||
* Columns span generalised for any number of columns ([`2d851b6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2d851b6b4eb0002e32908c2effbfb79122f18c24))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Updated documentation and TODOs ([`0ebe35a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0ebe35ac7a144db84c323f9ecb85dfdf6de66c21))
|
||||
* Fixed documentation ([`2f7c1b9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2f7c1b92a9624741f6dea44fc8f3c19a8a506fd9))
|
||||
|
||||
## v0.9.0 (2023-08-29)
|
||||
|
||||
### Feature
|
||||
|
||||
* Migrate to .yaml config file instead of argparse ([`a9f1688`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a9f16884b0b274e36fdb531b56a26343692a78f5))
|
||||
* Better color coding of curves ([`0eff18f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0eff18f5a074ea806d43d52ae72bf87f0187a26d))
|
||||
|
||||
## v0.8.1 (2023-08-29)
|
||||
|
||||
### Fix
|
||||
|
||||
* Added missing local .ui file ([`f0589b7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f0589b79ec7f50ee9d040b911d1874b4232659d5))
|
||||
|
||||
## v0.8.0 (2023-08-29)
|
||||
|
||||
### Feature
|
||||
|
||||
* User can specify tuple of (x,y) devices which wants to plot ([`3344f1b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3344f1b92a7e4f4ecd2e63c66aa01d3a4c325070))
|
||||
* Fit table hardcode to "gaussian_fit_worker_3" ([`3af57ab`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3af57abc4888dfcd0224bf50708488dc8192be84))
|
||||
* Crosshair snapped to x, y data automatically, clicked coordinates glows ([`49ba6fe`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/49ba6feb3a8494336c5772a06e9569d611fc240a))
|
||||
* Crosshair snaps to data, but it is activated with button due to debug ([`223f102`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/223f102aa9f0e625fecef37c827c55f9062330d7))
|
||||
* Dap fit plotted as curve, data as scatter ([`118f6af`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/118f6af2b97188398a3dd0e2121f73328c53465b))
|
||||
* Oneplot can receive one motor and one monitor signal ([`ff545bf`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ff545bf5c9e707f2dd9b43f9d059aa8605f3916b))
|
||||
* Oneplot initialized as an example app for plotting motor vs monitor signals + dispatcher loop over msg ([`98c0c64`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/98c0c64e8577f7e40eb0324dfe97d0ae4670c3a2))
|
||||
|
||||
### Fix
|
||||
|
||||
* User can disable dap_worker and just choose signals to plot ([`cab5354`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cab53543e644921df69c57c70ad2b3a03bbafcc1))
|
||||
* Crosshair snaps correctly to x dataset ([`2ed5d72`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2ed5d7208c42f8a1175a49236d706ebf503875e4))
|
||||
|
||||
## v0.7.0 (2023-08-28)
|
||||
|
||||
### Feature
|
||||
|
||||
* Labels of current motors are shown in motors limits ([`413e435`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/413e4356cfde6e2432682332e470eb69427ad397))
|
||||
* Total number of points, scatter size and number of point to dim after last position can be changed from GUI ([`e0b52fc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e0b52fcedca46d913d1677b45f9815eccd92e8f7))
|
||||
* Speed and frequency can be updated from GUI ([`f391a2f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f391a2fd004f1dc8187cfe12d60f856427ae01ec))
|
||||
* Speed and frequency is retrieved from devices ([`ce98164`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ce9816480b82373895b602d1a1bca7d1d9725f01))
|
||||
* Delete coordinate table row by DELETE or BACKSPACE key ([`5dd0af6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5dd0af6894a5d97457d60ef18b098e40856e4875))
|
||||
* Motor selection ([`cab32be`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cab32be0092185870b5a12398103475342c8b1fd))
|
||||
* New GUI ([`0226188`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0226188079f1dac4eece6b1a6fa330620f1504bc))
|
||||
* Keyboard shortcut to go to coordinates ([`3c0e595`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3c0e5955d40a67935b8fb064d5c52fd3f29bd1a1))
|
||||
* Ability to choose how many points should be dimmed before reaching the threshold + total number of point which should be stored. ([`9eae697`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9eae697df8a2f3961454db9ed397353f110c0e67))
|
||||
* Stop movement function, one callback function for 2 motors, move_finished is emitted in move_motor function not in callback ([`187c748`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/187c748e87264448d5026d9fa2f15b5fc9a55949))
|
||||
* Controls are disabled while motor is moving and enabled when motor movement is finished ([`ed84293`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ed842931971fbf87ed2f3e366eb822531ef5aacc))
|
||||
* Motor coordinates are now scatter instead of image ([`3f6d5c6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3f6d5c66411459703c402f7449e8b1abae9a2b08))
|
||||
* Going to absolute coordinates saves coordinate in the table for later use with tag ([`8be98c9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8be98c9bb6af941a69c593c62d5c52339d2262bc))
|
||||
* Table with coordinates getting initial coordinates of motor ([`92388c3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/92388c3cab7e024978aaa2906afbd698015dec66))
|
||||
* Motor move to absolute (X,Y) coordinates ([`cbe27e4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cbe27e46cfb6282c71844641e1ed6059e8fa96bf))
|
||||
* Motor limits can be changed by spinBoxes ([`2d1665c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2d1665c76b8174d9fffa3442afa98fe1ea6ac207))
|
||||
* Switch for keyboard shortcuts for motor movement ([`cac4562`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cac45626fc9a315f9012b110760a92e27e5ed226))
|
||||
* Setting map according to motor limits ([`512e698`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/512e698e26d9eef05b4f430475ccc268b68ad632))
|
||||
* Map of motor position ([`e6952a6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e6952a6d13c84487fd6ab08056f1f5b46d594b8a))
|
||||
* Motor_example.py created, motor samx and samy can be moved by buttons ([`947ba9f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/947ba9f8b730e96082cb51ff6894734a0e119ca1))
|
||||
|
||||
### Fix
|
||||
|
||||
* Line_plot.py default changed back to "gauss_bpm" ([`64708bc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/64708bc1b2e6a4256da9123d0215fc87e0afa455))
|
||||
* Motor selection is disabled while motor is moving ([`c7e35d7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c7e35d7da69853343aa7eee53c8ad988eb490d93))
|
||||
* Init_motor_map receive motor position from motor_thread ([`95ead71`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/95ead7117e59e0979aec51b85b49537ab728cad4))
|
||||
* Motor movement absolute fixed - movement by thread ([`11aa15f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/11aa15fefda7433e885cc8586f93c97af83b0c48))
|
||||
|
||||
## v0.6.3 (2023-08-17)
|
||||
|
||||
### Fix
|
||||
|
||||
* Crosshair handles dynamic changes of number of curves in 1D plot ([`242737b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/242737b516af7c524a6c8a98db566815f0f4ab65))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Crosshair class documentation ([`8a60cad`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8a60cad9187df2b2bc93dc78dd01ceb42df9c9af))
|
||||
|
||||
## v0.6.2 (2023-08-17)
|
||||
|
||||
### Fix
|
||||
|
||||
* Correct coordinates for cursor table ([`ce54daf`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ce54daf754cb2410790216585467c0ffcc8e3587))
|
||||
|
||||
## v0.6.1 (2023-08-14)
|
||||
|
||||
### Fix
|
||||
|
||||
* Crosshair snaps to correct coordinates also with logx and logy ([`167a891`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/167a891c474b09ef7738e473c4a2e89dbbcbe881))
|
||||
|
||||
## v0.6.0 (2023-08-11)
|
||||
|
||||
### Feature
|
||||
|
||||
* New GUI for line_plot.py ([`b57b3bb`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b57b3bb1afc7c85acc7ed328ac8a219f392869f1))
|
||||
* Cursor universal signals ([`20e9516`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/20e951659558b7fc023e357bfe07d812c5fd020a))
|
||||
|
||||
## v0.5.0 (2023-08-11)
|
||||
|
||||
### Feature
|
||||
|
||||
* Add generic connect function for slots ([`6a3df34`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6a3df34cdfbec2434153362ded630305e5dc5e28))
|
||||
* Add possibility to provide service config ([`8c9a9c9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8c9a9c93535ee77c0622b483a3157af367ebce1f))
|
||||
|
||||
### Fix
|
||||
|
||||
* Dispatcher argparse and scan_plot tests ([`67f619e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/67f619ee897e0040c6310e67d69fbb2e0685293d))
|
||||
* Gui event removing bugs ([`a9dd191`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a9dd191629295ca476e2f9a1b9944ff355216583))
|
||||
|
||||
## v0.4.0 (2023-08-11)
|
||||
|
||||
### Feature
|
||||
|
||||
* Cursor universal for 1D and 2D ([`f75554b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f75554bd7b072207847956a8720b9a62c20ba2c8))
|
||||
* Added qt_utils package with general Crosshair function ([`5353fed`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5353fed7bfe1819819fa3348ec93d2d0ba540628))
|
||||
* 2D plot updating ([`d32088b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d32088b643a4d0613c32fb464a0a55a3b6b684d6))
|
||||
* Metadata available on_dap_update ([`18b5d46`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/18b5d46678619a972815532629ce96c121f5fcc9))
|
||||
* Plotting from streamer ([`bb806c1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bb806c149dee88023ecb647b523cbd5189ea9001))
|
||||
* Added Legend to plot ([`0feca4b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0feca4b1578820ec1f5f3ead3073e4d45c23798b))
|
||||
* Cursor coordinate as a QTable ([`a999f76`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a999f7669a12910ad66e10a6d2e75197b2dce1c2))
|
||||
* Changed from PlotItem to GraphicsLayoutWidget, added LabelItem ([`075cc79`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/075cc79d6fa011803cf4a06fbff8faa951c1b59f))
|
||||
* Add display_ui_file.py ([`91d8ffa`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/91d8ffacffcbeebdf7623caf62e07244c4dcee16))
|
||||
* Add disconnect_dap_slot ([`1325704`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1325704750ebab897e3dcae80c9d455bfbbf886f))
|
||||
* Inherit from GraphicsView for consistency with 2D plot ([`d8c101c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d8c101cdd7f960a152a1f318911cac6eecf6bad4))
|
||||
* Add BECScanPlot2D ([`67905e8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/67905e896c81383f57c268db544b3314104bda38))
|
||||
* Emit the full bec message to slots ([`1bb3020`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1bb30207038f3a54c0e96dbbbcd1ea7f6c70eca2))
|
||||
|
||||
### Fix
|
||||
|
||||
* Q selection for gui_event signal ([`0bf452a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0bf452ad1b7d9ad941e2ef4b8d61ec4ed5266415))
|
||||
* Fixed logic in data subscription ([`c2d469b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c2d469b4543fcf237b274399b83969cc2213b61b))
|
||||
* Scan_plot to accept metadata from dap signal ([`7bec0b5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7bec0b5e6c1663670f8fcc2fc6aa6c8b6df28b61))
|
||||
* Plotting latest 1d curves ([`378be81`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/378be81bf6dd5e9239f8f1fb908cafc97161c79d))
|
||||
* Testing the data structure of plotting ([`4fb0a3b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4fb0a3b058957f5b37227ff7c8e9bdf5259a1cde))
|
||||
* Fix examples when run directly as a script ([`cd11ee5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cd11ee51c1c725255e748a32b89a74487e84a631))
|
||||
* Module paths ([`e7f644c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e7f644c5079a8665d7d872eb0b27ed7da6cbd078))
|
||||
|
||||
## v0.3.0 (2023-07-19)
|
||||
|
||||
### Feature
|
||||
|
||||
* Add auto-computed color_list from colormaps ([`3e1708b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3e1708bf48bc15a25c0d01242fff28d6db868e02))
|
||||
* Add functionality for plotting multiple signals ([`10e2906`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/10e29064455f50bc3b66c55b4361575957db1489))
|
||||
* Added lineplot widget ([`989a3f0`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/989a3f080839b98f1e1c2118600cddf449120124))
|
||||
* Added ctrl_c from grum ([`8fee13a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8fee13a67bef3ed6ed6de9d47438f04687f548d8))
|
||||
|
||||
### Fix
|
||||
|
||||
* Add warning for non-existing signalz ([`48075e4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/48075e4fe3187f6ac8d0b61f94f8df73b8fd6daf))
|
||||
* Documentation and bugfix for mouse_moved ([`a460f3c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a460f3c0bd7b9e106a758bc330f361868407b1e3))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add notes about qt designer install via conda-forge ([`d8038a8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d8038a8cd0efa3a16df403390164603e4e8afdd8))
|
||||
* Added license ([`db2d33e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/db2d33e8912dc493cce9ee7f09d8336155110079))
|
||||
|
||||
## v0.2.1 (2023-07-13)
|
||||
|
||||
### Fix
|
||||
|
||||
* Fixed setup config (wrong name) ([`947db1e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/947db1e0f32b067e67f94a7c8321da5194b1547b))
|
||||
* Fixed bec_lib dependency ([`86f4def`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/86f4deffd899111e8997010487ec54c6c62c43ab))
|
||||
|
||||
## v0.2.0 (2023-07-13)
|
||||
|
||||
### Feature
|
||||
|
||||
* Move ivan's qtwidgets to bec-widgets ([`34e5ed2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/34e5ed2cf7e6128a51c110db8870d9560f2b2831))
|
||||
|
||||
## v0.1.0 (2023-07-11)
|
||||
|
||||
### Feature
|
||||
|
||||
* Added config plotter ([`db274c6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/db274c644f643f830c35b6a92edd328bf7e24f59))
|
||||
|
||||
15
README.md
15
README.md
@@ -6,33 +6,32 @@ BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Expe
|
||||
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets PyQt6
|
||||
pip install bec-widgets
|
||||
```
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
cd bec_widgets
|
||||
pip install -e .[dev,pyqt6]
|
||||
cd bec-widgets
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
BEC Widgets currently supports both PyQt5 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
|
||||
Python Qt distributions manually.
|
||||
BEC Widgets currently supports both PyQt5 and PyQt6. By default, PyQt6 is installed.
|
||||
|
||||
To select a specific Python Qt distribution, install the package with an additional tag:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyqt6]
|
||||
pip install bec-widgets[pyqt6]
|
||||
```
|
||||
or
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyqt5]
|
||||
pip install bec-widgets[pyqt5]
|
||||
```
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://beamline-experiment-control.readthedocs.io/en/latest/).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1 +0,0 @@
|
||||
from .client import *
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import BECDockArea, BECFigure
|
||||
|
||||
|
||||
class ScanInfo(BaseModel):
|
||||
scan_id: str
|
||||
scan_number: int
|
||||
scan_name: str
|
||||
scan_report_devices: list
|
||||
monitored_devices: list
|
||||
status: str
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class AutoUpdates:
|
||||
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:
|
||||
"""
|
||||
Update the script with the given data.
|
||||
"""
|
||||
info = msg.info
|
||||
status = msg.status
|
||||
scan_id = msg.scan_id
|
||||
scan_number = info.get("scan_number", 0)
|
||||
scan_name = info.get("scan_name", "Unknown")
|
||||
scan_report_devices = info.get("scan_report_devices", [])
|
||||
monitored_devices = info.get("readout_priority", {}).get("monitored", [])
|
||||
monitored_devices = [dev for dev in monitored_devices if dev not in scan_report_devices]
|
||||
return ScanInfo(
|
||||
scan_id=scan_id,
|
||||
scan_number=scan_number,
|
||||
scan_name=scan_name,
|
||||
scan_report_devices=scan_report_devices,
|
||||
monitored_devices=monitored_devices,
|
||||
status=status,
|
||||
)
|
||||
|
||||
def 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.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
if msg.status != "open":
|
||||
return
|
||||
info = self.get_scan_info(msg)
|
||||
self.handler(info)
|
||||
|
||||
@staticmethod
|
||||
def get_selected_device(monitored_devices, selected_device):
|
||||
"""
|
||||
Get the selected device for the plot. If no device is selected, the first
|
||||
device in the monitored devices list is selected.
|
||||
"""
|
||||
if selected_device:
|
||||
return selected_device
|
||||
if len(monitored_devices) > 0:
|
||||
sel_device = monitored_devices[0]
|
||||
return sel_device
|
||||
return None
|
||||
|
||||
def handler(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
Default update function.
|
||||
"""
|
||||
if info.scan_name == "line_scan" and info.scan_report_devices:
|
||||
self.simple_line_scan(info)
|
||||
return
|
||||
if info.scan_name == "grid_scan" and info.scan_report_devices:
|
||||
self.simple_grid_scan(info)
|
||||
return
|
||||
if info.scan_report_devices:
|
||||
self.best_effort(info)
|
||||
return
|
||||
|
||||
def simple_line_scan(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
Simple line scan.
|
||||
"""
|
||||
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.gui.selected_device)
|
||||
if not dev_y:
|
||||
return
|
||||
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.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.gui.selected_device)
|
||||
if not dev_y:
|
||||
return
|
||||
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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.metadata as imd
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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
|
||||
from functools import wraps
|
||||
|
||||
from qtpy.QtCore import QCoreApplication
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
|
||||
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",))
|
||||
from bec_lib import MessageEndpoints, messages
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
@@ -45,148 +25,45 @@ def rpc_call(func):
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# we could rely on a strict type check here, but this is more flexible
|
||||
# moreover, it would anyway crash for objects...
|
||||
out = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "name"):
|
||||
arg = arg.name
|
||||
out.append(arg)
|
||||
args = tuple(out)
|
||||
for key, val in kwargs.items():
|
||||
if hasattr(val, "name"):
|
||||
kwargs[key] = val.name
|
||||
if not self.gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _get_output(process) -> None:
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
os.set_blocking(process.stderr.fileno(), False)
|
||||
while process.poll() is None:
|
||||
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
|
||||
if process.stdout in readylist:
|
||||
output = process.stdout.read(1024)
|
||||
if output:
|
||||
print(output, end="")
|
||||
if process.stderr in readylist:
|
||||
error_output = process.stderr.read(1024)
|
||||
if error_output:
|
||||
print(error_output, end="", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _start_plot_process(gui_id, gui_class, config) -> None:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
command = [
|
||||
"bec-gui-server",
|
||||
"--id",
|
||||
gui_id,
|
||||
"--config",
|
||||
config,
|
||||
"--gui_class",
|
||||
gui_class.__name__,
|
||||
]
|
||||
env_dict = os.environ.copy()
|
||||
env_dict["PYTHONUNBUFFERED"] = "1"
|
||||
process = subprocess.Popen(
|
||||
command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env_dict
|
||||
)
|
||||
process_output_processing_thread = threading.Thread(target=_get_output, args=(process,))
|
||||
process_output_processing_thread.start()
|
||||
return process, process_output_processing_thread
|
||||
|
||||
|
||||
class BECGuiClientMixin:
|
||||
class BECFigureClientMixin:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._process = None
|
||||
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 | None:
|
||||
eps = imd.entry_points(group="bec.widgets.auto_updates")
|
||||
for ep in eps:
|
||||
if ep.name == "plugin_widgets_update":
|
||||
try:
|
||||
return ep.load()(gui=self)
|
||||
except Exception as e:
|
||||
print(f"Error loading auto update script from plugin: {str(e)}")
|
||||
return None
|
||||
|
||||
@property
|
||||
def selected_device(self):
|
||||
"""
|
||||
Selected device for the plot.
|
||||
"""
|
||||
return self._selected_device
|
||||
|
||||
@selected_device.setter
|
||||
def selected_device(self, device: str | 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
|
||||
else:
|
||||
raise ValueError("Device must be a string or a device object")
|
||||
|
||||
def _start_update_script(self) -> None:
|
||||
self._client.connector.register(
|
||||
self._target_endpoint, cb=self._handle_msg_update, parent=self
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _handle_msg_update(msg: MessageObject, parent: BECGuiClientMixin) -> None:
|
||||
if parent.auto_updates is not None:
|
||||
# pylint: disable=protected-access
|
||||
parent._update_script_msg_parser(msg.value)
|
||||
|
||||
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
|
||||
if isinstance(msg, messages.ScanStatusMessage):
|
||||
if not self.gui_is_alive():
|
||||
return
|
||||
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_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)
|
||||
self._start_plot_process()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the gui window.
|
||||
Close the figure.
|
||||
"""
|
||||
if self._process is None:
|
||||
return
|
||||
|
||||
self._run_rpc("close", (), wait_for_rpc_response=False)
|
||||
while self.gui_is_alive():
|
||||
time.sleep(0.2)
|
||||
self._process.kill()
|
||||
self._process = None
|
||||
|
||||
self._client.shutdown()
|
||||
if self._process:
|
||||
self._process.terminate()
|
||||
self._process_output_processing_thread.join()
|
||||
self._process = None
|
||||
def _start_plot_process(self) -> 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 = f"python {monitor_path} --id {self._gui_id}"
|
||||
self._process = subprocess.Popen(
|
||||
command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
def print_log(self) -> None:
|
||||
"""
|
||||
@@ -194,60 +71,22 @@ class BECGuiClientMixin:
|
||||
"""
|
||||
if self._process is None:
|
||||
return
|
||||
print("".join(self.stderr_output))
|
||||
# Flush list
|
||||
self.stderr_output.clear()
|
||||
print(self._get_stderr_output())
|
||||
|
||||
def _get_stderr_output(self) -> str:
|
||||
stderr_output = []
|
||||
while self._process.poll() is not None:
|
||||
readylist, _, _ = select.select([self._process.stderr], [], [], 0.1)
|
||||
if not readylist:
|
||||
break
|
||||
line = self._process.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
stderr_output.append(line.decode("utf-8"))
|
||||
return "".join(stderr_output)
|
||||
|
||||
class RPCResponseTimeoutError(Exception):
|
||||
"""Exception raised when an RPC response is not received within the expected time."""
|
||||
|
||||
def __init__(self, request_id, timeout):
|
||||
super().__init__(
|
||||
f"RPC response not received within {timeout} seconds for request ID {request_id}"
|
||||
)
|
||||
|
||||
|
||||
class 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, {})
|
||||
def __del__(self) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class RPCBase:
|
||||
@@ -257,12 +96,7 @@ class RPCBase:
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())
|
||||
self._parent = parent
|
||||
super().__init__()
|
||||
# print(f"RPCBase: {self._gui_id}")
|
||||
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} object at {hex(id(self))}>"
|
||||
print(f"RPCBase: {self._gui_id}")
|
||||
|
||||
@property
|
||||
def _root(self):
|
||||
@@ -276,7 +110,7 @@ class RPCBase:
|
||||
parent = parent._parent
|
||||
return parent
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, **kwargs):
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
@@ -295,27 +129,19 @@ class RPCBase:
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
|
||||
metadata={"request_id": request_id},
|
||||
)
|
||||
|
||||
print(f"RPCBase: {rpc_msg}")
|
||||
# pylint: disable=protected-access
|
||||
receiver = self._root._gui_id
|
||||
if wait_for_rpc_response:
|
||||
redis_msg = QtRedisMessageWaiter(
|
||||
self._client.connector, MessageEndpoints.gui_instruction_response(request_id)
|
||||
)
|
||||
self._client.connector.send(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
@@ -324,9 +150,7 @@ class RPCBase:
|
||||
return [self._create_widget_from_msg_result(res) for res in msg_result]
|
||||
if isinstance(msg_result, dict):
|
||||
if "__rpc__" not in msg_result:
|
||||
return {
|
||||
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
||||
}
|
||||
return msg_result
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
@@ -334,13 +158,18 @@ class RPCBase:
|
||||
return msg_result
|
||||
|
||||
cls = getattr(client, cls)
|
||||
# print(msg_result)
|
||||
print(msg_result)
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
|
||||
def gui_is_alive(self):
|
||||
def _wait_for_response(self, request_id):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
Wait for the response from the server.
|
||||
"""
|
||||
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
|
||||
return heart is not None
|
||||
response = None
|
||||
while response is None:
|
||||
response = self._client.connector.get(
|
||||
MessageEndpoints.gui_instruction_response(request_id)
|
||||
)
|
||||
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
|
||||
return response
|
||||
|
||||
@@ -1,85 +1,44 @@
|
||||
# pylint: disable=missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from typing import Literal
|
||||
|
||||
import black
|
||||
import isort
|
||||
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_classes
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import get_overloads
|
||||
else:
|
||||
print(
|
||||
"Python version is less than 3.11, using dummy function for get_overloads. "
|
||||
"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.
|
||||
"""
|
||||
return []
|
||||
import typing
|
||||
|
||||
|
||||
class ClientGenerator:
|
||||
def __init__(self):
|
||||
self.header = """# This file was automatically generated by generate_cli.py\n
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
|
||||
from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
|
||||
|
||||
# pylint: skip-file"""
|
||||
from bec_widgets.cli.client_utils import rpc_call, RPCBase, BECFigureClientMixin
|
||||
from typing import Literal, Optional, overload"""
|
||||
|
||||
self.content = ""
|
||||
|
||||
def generate_client(
|
||||
self, published_classes: dict[Literal["connector_classes", "top_level_classes"], list[type]]
|
||||
):
|
||||
def generate_client(self, published_classes: list):
|
||||
"""
|
||||
Generate the client for the published classes.
|
||||
|
||||
Args:
|
||||
published_classes(dict): A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
|
||||
published_classes(list): The list of published classes (e.g. [BECWaveform1D, BECFigure]).
|
||||
"""
|
||||
self.write_client_enum(published_classes["top_level_classes"])
|
||||
for cls in published_classes["connector_classes"]:
|
||||
for cls in published_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.
|
||||
|
||||
Args:
|
||||
cls: The class for which to generate the content.
|
||||
"""
|
||||
|
||||
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":
|
||||
if cls.__name__ == "BECFigure":
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase, BECGuiClientMixin):"""
|
||||
class {class_name}(RPCBase, BECFigureClientMixin):"""
|
||||
else:
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
@@ -94,7 +53,7 @@ class {class_name}(RPCBase):"""
|
||||
else:
|
||||
sig = str(inspect.signature(obj))
|
||||
doc = inspect.getdoc(obj)
|
||||
overloads = get_overloads(obj)
|
||||
overloads = typing.get_overloads(obj)
|
||||
for overload in overloads:
|
||||
sig_overload = str(inspect.signature(overload))
|
||||
self.content += f"""
|
||||
@@ -112,53 +71,26 @@ class {class_name}(RPCBase):"""
|
||||
|
||||
def write(self, file_name: str):
|
||||
"""
|
||||
Write the content to a file, automatically formatted with black.
|
||||
Write the content to a file.
|
||||
|
||||
Args:
|
||||
file_name(str): The name of the file to write to.
|
||||
"""
|
||||
# Combine header and content, then format with black
|
||||
full_content = self.header + "\n" + self.content
|
||||
try:
|
||||
formatted_content = black.format_str(full_content, mode=black.FileMode(line_length=100))
|
||||
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)
|
||||
file.write(self.header)
|
||||
file.write(self.content)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for the script, controlled by command line arguments.
|
||||
"""
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
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")
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
from bec_widgets.widgets.plots import BECPlotBase, BECWaveform1D # ,BECCurve
|
||||
from bec_widgets.widgets.plots.waveform1d import BECCurve
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.core:
|
||||
current_path = os.path.dirname(__file__)
|
||||
client_path = os.path.join(current_path, "client.py")
|
||||
|
||||
rpc_classes = 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
|
||||
sys.argv = ["generate_cli.py", "--core"]
|
||||
main()
|
||||
current_path = os.path.dirname(__file__)
|
||||
client_path = os.path.join(current_path, "client.py")
|
||||
clss = [BECPlotBase, BECWaveform1D, BECFigure, BECCurve]
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(clss)
|
||||
generator.write(client_path)
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
from threading import Lock
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
|
||||
class RPCRegister:
|
||||
"""
|
||||
A singleton class that keeps track of all the RPC objects registered in the system for CLI usage.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(RPCRegister, cls).__new__(cls)
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._rpc_register = WeakValueDictionary()
|
||||
self._initialized = True
|
||||
|
||||
def add_rpc(self, rpc: QObject):
|
||||
"""
|
||||
Add an RPC object to the register.
|
||||
|
||||
Args:
|
||||
rpc(QObject): The RPC object to be added to the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError("RPC object must have a 'gui_id' attribute.")
|
||||
self._rpc_register[rpc.gui_id] = rpc
|
||||
|
||||
def remove_rpc(self, rpc: str):
|
||||
"""
|
||||
Remove an RPC object from the register.
|
||||
|
||||
Args:
|
||||
rpc(str): The RPC object to be removed from the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
|
||||
self._rpc_register.pop(rpc.gui_id, None)
|
||||
|
||||
def get_rpc_by_id(self, gui_id: str) -> QObject:
|
||||
"""
|
||||
Get an RPC object by its ID.
|
||||
|
||||
Args:
|
||||
gui_id(str): The ID of the RPC object to be retrieved.
|
||||
|
||||
Returns:
|
||||
QObject: The RPC object with the given ID.
|
||||
"""
|
||||
rpc_object = self._rpc_register.get(gui_id, None)
|
||||
return rpc_object
|
||||
|
||||
def list_all_connections(self) -> dict:
|
||||
"""
|
||||
List all the registered RPC objects.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing all the registered RPC objects.
|
||||
"""
|
||||
with self._lock:
|
||||
connections = dict(self._rpc_register)
|
||||
return connections
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
"""
|
||||
Reset the singleton instance.
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
@@ -1,53 +0,0 @@
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
"""Handler class for creating widgets from RPC messages."""
|
||||
|
||||
def __init__(self):
|
||||
self._widget_classes = None
|
||||
|
||||
@property
|
||||
def widget_classes(self):
|
||||
"""
|
||||
Get the available widget classes.
|
||||
|
||||
Returns:
|
||||
dict: The available widget classes.
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
return self._widget_classes
|
||||
|
||||
def update_available_widgets(self):
|
||||
"""
|
||||
Update the available widgets.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_classes
|
||||
|
||||
clss = get_rpc_classes("bec_widgets")
|
||||
self._widget_classes = {cls.__name__: cls for cls in clss["top_level_classes"]}
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECConnector:
|
||||
"""
|
||||
Create a widget from an RPC message.
|
||||
|
||||
Args:
|
||||
widget_type(str): The type of the widget.
|
||||
**kwargs: The keyword arguments for the widget.
|
||||
|
||||
Returns:
|
||||
widget(BECConnector): The created widget.
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
widget_class = self._widget_classes.get(widget_type)
|
||||
if widget_class:
|
||||
return widget_class(**kwargs)
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
|
||||
widget_handler = RPCWidgetHandler()
|
||||
@@ -1,54 +1,39 @@
|
||||
import inspect
|
||||
from typing import Union
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import QTimer
|
||||
from bec_lib import MessageEndpoints, messages
|
||||
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.widgets.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
from bec_widgets.widgets.plots import BECCurve, BECWaveform1D
|
||||
|
||||
|
||||
class BECWidgetsCLIServer:
|
||||
WIDGETS = [BECWaveform1D, BECFigure, BECCurve]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str = None,
|
||||
dispatcher: BECDispatcher = None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_class: Union["BECFigure", "BECDockArea"] = BECFigure,
|
||||
) -> None:
|
||||
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
|
||||
self.client = self.dispatcher.client if client is None else client
|
||||
def __init__(self, gui_id: str = None, dispatcher: BECDispatcher = None) -> None:
|
||||
self.dispatcher = BECDispatcher() if dispatcher is None else dispatcher
|
||||
self.client = self.dispatcher.client
|
||||
self.client.start()
|
||||
self.gui_id = gui_id
|
||||
self.gui = gui_class(gui_id=self.gui_id)
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self.gui)
|
||||
self.fig = BECFigure(gui_id=self.gui_id)
|
||||
print(f"Server started with gui_id {self.gui_id}")
|
||||
|
||||
self.dispatcher.connect_slot(
|
||||
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
|
||||
)
|
||||
|
||||
# Setup QTimer for heartbeat
|
||||
self._shutdown_event = False
|
||||
self._heartbeat_timer = QTimer()
|
||||
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
|
||||
self._heartbeat_timer.start(200)
|
||||
def start(self):
|
||||
"""Start the figure window."""
|
||||
self.fig.start()
|
||||
|
||||
def on_rpc_update(self, msg: dict, metadata: dict):
|
||||
request_id = metadata.get("request_id")
|
||||
try:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
@@ -57,7 +42,7 @@ class BECWidgetsCLIServer:
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
self.client.connector.set_and_publish(
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
messages.RequestResponseMessage(accepted=accepted, message=msg),
|
||||
expire=60,
|
||||
@@ -65,10 +50,23 @@ class BECWidgetsCLIServer:
|
||||
|
||||
def get_object_from_config(self, config: dict):
|
||||
gui_id = config.get("gui_id")
|
||||
obj = self.rpc_register.get_rpc_by_id(gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Object with gui_id {gui_id} not found")
|
||||
return obj
|
||||
# check if the object is the figure
|
||||
if gui_id == self.fig.gui_id:
|
||||
return self.fig
|
||||
# check if the object is a widget
|
||||
if gui_id in self.fig.widgets:
|
||||
obj = self.fig.widgets[config["gui_id"]]
|
||||
return obj
|
||||
if self.fig.widgets:
|
||||
for widget in self.fig.widgets.values():
|
||||
item = widget.find_widget_by_id(gui_id)
|
||||
if item:
|
||||
return item
|
||||
raise NotImplementedError(
|
||||
f"gui_id lookup for widget of type {widget.__class__.__name__} not implemented"
|
||||
)
|
||||
|
||||
raise ValueError(f"Object with gui_id {gui_id} not found")
|
||||
|
||||
def run_rpc(self, obj, method, args, kwargs):
|
||||
method_obj = getattr(obj, method)
|
||||
@@ -84,8 +82,6 @@ class BECWidgetsCLIServer:
|
||||
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
elif isinstance(res, dict):
|
||||
res = {key: self.serialize_object(val) for key, val in res.items()}
|
||||
else:
|
||||
res = self.serialize_object(res)
|
||||
return res
|
||||
@@ -100,73 +96,15 @@ class BECWidgetsCLIServer:
|
||||
}
|
||||
return obj
|
||||
|
||||
def emit_heartbeat(self):
|
||||
if self._shutdown_event is False:
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_heartbeat(self.gui_id),
|
||||
messages.StatusMessage(name=self.gui_id, status=1, info={}),
|
||||
expire=1,
|
||||
)
|
||||
|
||||
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
|
||||
self._shutdown_event = True
|
||||
self._heartbeat_timer.stop()
|
||||
self.client.shutdown()
|
||||
|
||||
|
||||
def main():
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
import bec_widgets
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("BEC Figure")
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
icon = QIcon()
|
||||
icon.addFile(os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48))
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
win = QMainWindow()
|
||||
win.setWindowTitle("BEC Widgets")
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
|
||||
parser.add_argument("--id", type=str, help="The id of the server")
|
||||
parser.add_argument(
|
||||
"--gui_class",
|
||||
type=str,
|
||||
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
|
||||
)
|
||||
parser.add_argument("--config", type=str, help="Config to connect to redis.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.gui_class == "BECFigure":
|
||||
gui_class = BECFigure
|
||||
elif args.gui_class == "BECDockArea":
|
||||
gui_class = BECDockArea
|
||||
else:
|
||||
print(
|
||||
"Please specify a valid gui_class to run. Use -h for help."
|
||||
"\n Starting with default gui_class BECFigure."
|
||||
)
|
||||
gui_class = BECFigure
|
||||
|
||||
server = BECWidgetsCLIServer(gui_id=args.id, config=args.config, gui_class=gui_class)
|
||||
|
||||
gui = server.gui
|
||||
win.setCentralWidget(gui)
|
||||
win.resize(800, 600)
|
||||
win.show()
|
||||
|
||||
app.aboutToQuit.connect(server.shutdown)
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
server = BECWidgetsCLIServer(gui_id=args.id)
|
||||
# server = BECWidgetsCLIServer(gui_id="test")
|
||||
server.start()
|
||||
|
||||
@@ -2,8 +2,8 @@ from .motor_movement import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorControlPanelRelative,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
|
||||
168
bec_widgets/examples/crosshair_example/crosshair_example.py
Normal file
168
bec_widgets/examples/crosshair_example/crosshair_example.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QSpinBox,
|
||||
)
|
||||
from pyqtgraph import mkPen
|
||||
from pyqtgraph.Qt import QtCore
|
||||
from bec_widgets.utils import Crosshair
|
||||
|
||||
|
||||
class ExampleApp(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# Layout
|
||||
self.layout = QHBoxLayout()
|
||||
self.setLayout(self.layout)
|
||||
|
||||
##########################
|
||||
# 1D Plot
|
||||
##########################
|
||||
|
||||
# PlotWidget
|
||||
self.plot_widget_1d = pg.PlotWidget(title="1D PlotWidget with multiple curves")
|
||||
self.plot_item_1d = self.plot_widget_1d.getPlotItem()
|
||||
self.plot_item_1d.setLogMode(True, True)
|
||||
|
||||
# 1D Datasets
|
||||
self.x_data = np.linspace(0, 10, 1000)
|
||||
|
||||
def gauss(x, mu, sigma):
|
||||
return (1 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
|
||||
|
||||
# same convention as in line_plot.py
|
||||
self.y_value_list = [
|
||||
gauss(self.x_data, 1, 1),
|
||||
gauss(self.x_data, 1.5, 3),
|
||||
abs(np.sin(self.x_data)),
|
||||
abs(np.cos(self.x_data)),
|
||||
abs(np.sin(2 * self.x_data)),
|
||||
] # List of y-values for multiple curves
|
||||
|
||||
self.curve_names = ["Gauss(1,1)", "Gauss(1.5,3)", "Abs(Sine)", "Abs(Cosine)", "Abs(Sine2x)"]
|
||||
self.curves = []
|
||||
|
||||
##########################
|
||||
# 2D Plot
|
||||
##########################
|
||||
self.plot_widget_2d = pg.PlotWidget(title="2D plot with crosshair and ROI square")
|
||||
self.data_2D = np.random.random((100, 200))
|
||||
self.plot_item_2d = self.plot_widget_2d.getPlotItem()
|
||||
self.image_item = pg.ImageItem(self.data_2D)
|
||||
self.plot_item_2d.addItem(self.image_item)
|
||||
|
||||
##########################
|
||||
# Table
|
||||
##########################
|
||||
self.table = QTableWidget(len(self.curve_names), 2)
|
||||
self.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"])
|
||||
self.table.setVerticalHeaderLabels(self.curve_names)
|
||||
self.table.resizeColumnsToContents()
|
||||
|
||||
##########################
|
||||
# Spinbox for N curves
|
||||
##########################
|
||||
self.spin_box = QSpinBox()
|
||||
self.spin_box.setMinimum(0)
|
||||
self.spin_box.setMaximum(len(self.y_value_list))
|
||||
self.spin_box.setValue(2)
|
||||
self.spin_box.valueChanged.connect(lambda: self.update_curves(self.spin_box.value()))
|
||||
|
||||
##########################
|
||||
# Adding widgets to layout
|
||||
##########################
|
||||
|
||||
##### left side #####
|
||||
self.column1 = QVBoxLayout()
|
||||
self.layout.addLayout(self.column1)
|
||||
|
||||
# SpinBox
|
||||
self.spin_row = QHBoxLayout()
|
||||
self.column1.addLayout(self.spin_row)
|
||||
self.spin_row.addWidget(QLabel("Number of curves:"))
|
||||
self.spin_row.addWidget(self.spin_box)
|
||||
|
||||
# label
|
||||
self.clicked_label_1d = QLabel("Clicked Coordinates (1D):")
|
||||
self.column1.addWidget(self.clicked_label_1d)
|
||||
|
||||
# table
|
||||
self.column1.addWidget(self.table)
|
||||
|
||||
# 1D plot
|
||||
self.column1.addWidget(self.plot_widget_1d)
|
||||
|
||||
##### left side #####
|
||||
self.column2 = QVBoxLayout()
|
||||
self.layout.addLayout(self.column2)
|
||||
|
||||
# labels
|
||||
self.clicked_label_2d = QLabel("Clicked Coordinates (2D):")
|
||||
self.moved_label_2d = QLabel("Moved Coordinates (2D):")
|
||||
self.column2.addWidget(self.clicked_label_2d)
|
||||
self.column2.addWidget(self.moved_label_2d)
|
||||
|
||||
# 2D plot
|
||||
self.column2.addWidget(self.plot_widget_2d)
|
||||
|
||||
self.update_curves(2) # just Gaussian curves
|
||||
|
||||
def hook_crosshair(self):
|
||||
self.crosshair_1d = Crosshair(self.plot_item_1d, precision=10)
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.update_table(self.table, x, y, column=0)
|
||||
)
|
||||
self.crosshair_1d.coordinatesClicked1D.connect(
|
||||
lambda x, y: self.update_table(self.table, x, y, column=1)
|
||||
)
|
||||
# 2D
|
||||
self.crosshair_2d = Crosshair(self.plot_item_2d)
|
||||
self.crosshair_2d.coordinatesChanged2D.connect(
|
||||
lambda x, y: self.moved_label_2d.setText(f"Mouse Moved Coordinates (2D): x={x}, y={y}")
|
||||
)
|
||||
self.crosshair_2d.coordinatesClicked2D.connect(
|
||||
lambda x, y: self.clicked_label_2d.setText(f"Clicked Coordinates (2D): x={x}, y={y}")
|
||||
)
|
||||
|
||||
def update_table(self, table_widget, x, y_values, column):
|
||||
"""Update the table with the new coordinates"""
|
||||
for i, y in enumerate(y_values):
|
||||
table_widget.setItem(i, column, QTableWidgetItem(f"({x}, {y})"))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update_curves(self, num_curves):
|
||||
"""Update the number of curves"""
|
||||
|
||||
self.plot_item_1d.clear()
|
||||
|
||||
# Curves
|
||||
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
|
||||
self.plot_item_1d.addLegend()
|
||||
self.curves = []
|
||||
|
||||
y_value_list = self.y_value_list[:num_curves]
|
||||
|
||||
for ii, y_value in enumerate(y_value_list):
|
||||
pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
curve = pg.PlotDataItem(
|
||||
self.x_data, y_value, pen=pen, skipFiniteCheck=True, name=self.curve_names[ii]
|
||||
)
|
||||
self.plot_item_1d.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
|
||||
self.hook_crosshair()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication([])
|
||||
window = ExampleApp()
|
||||
window.show()
|
||||
app.exec()
|
||||
315
bec_widgets/examples/eiger_plot/eiger_plot.py
Normal file
315
bec_widgets/examples/eiger_plot/eiger_plot.py
Normal file
@@ -0,0 +1,315 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import zmq
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtGui import QKeySequence
|
||||
from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
QFileDialog,
|
||||
QShortcut,
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QFrame,
|
||||
)
|
||||
from pyqtgraph.Qt import uic
|
||||
|
||||
|
||||
# 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())
|
||||
207
bec_widgets/examples/eiger_plot/eiger_plot.ui
Normal file
207
bec_widgets/examples/eiger_plot/eiger_plot.ui
Normal file
@@ -0,0 +1,207 @@
|
||||
<?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>
|
||||
@@ -1,155 +0,0 @@
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
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.utils import BECDispatcher, UILoader
|
||||
from bec_widgets.widgets.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
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})
|
||||
#
|
||||
# def shutdown_kernel(self):
|
||||
# self.kernel_client.stop_channels()
|
||||
# self.kernel_manager.shutdown_kernel()
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API)."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader().load_ui(os.path.join(current_path, "jupyter_console_window.ui"), self)
|
||||
|
||||
self._init_ui()
|
||||
|
||||
self.ui.splitter.setSizes([200, 100])
|
||||
self.safe_close = False
|
||||
|
||||
# console push
|
||||
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.ui.glw) # Create a new QVBoxLayout
|
||||
self.figure = BECFigure(parent=self, gui_id="remote") # Create a new BECDeviceMonitor
|
||||
self.glw_1_layout.addWidget(self.figure) # Add BECDeviceMonitor to the layout
|
||||
|
||||
self.dock_layout = QVBoxLayout(self.ui.dock_placeholder)
|
||||
self.dock = BECDockArea(gui_id="remote")
|
||||
self.dock_layout.addWidget(self.dock)
|
||||
|
||||
# add stuff to figure
|
||||
self._init_figure()
|
||||
|
||||
# init dock for testing
|
||||
self._init_dock()
|
||||
|
||||
self.console_layout = QVBoxLayout(self.ui.widget_console)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
self.console_layout.addWidget(self.console)
|
||||
|
||||
def _init_figure(self):
|
||||
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)
|
||||
|
||||
self.w1 = self.figure[0, 0]
|
||||
self.w2 = self.figure[0, 1]
|
||||
self.w3 = self.figure[1, 0]
|
||||
|
||||
# curves for w1
|
||||
self.c1 = self.w1.get_config()
|
||||
|
||||
def _init_dock(self):
|
||||
|
||||
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(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()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Override to handle things when main window is closed."""
|
||||
self.dock.cleanup()
|
||||
self.figure.clear_all()
|
||||
self.figure.client.shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
import bec_widgets
|
||||
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
qdarktheme.setup_theme("auto")
|
||||
icon = QIcon()
|
||||
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()
|
||||
|
||||
app.aboutToQuit.connect(win.close)
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,54 +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>2104</width>
|
||||
<height>966</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Plotting Console</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tab_1">
|
||||
<attribute name="title">
|
||||
<string>BECDock</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QWidget" name="dock_placeholder" native="true"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_2">
|
||||
<attribute name="title">
|
||||
<string>BECFigure</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QWidget" name="glw" native="true"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QWidget" name="widget_console" native="true"/>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
161
bec_widgets/examples/mca_readout/mca_plot.py
Normal file
161
bec_widgets/examples/mca_readout/mca_plot.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# import simulation_progress as SP
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_lib import MessageEndpoints, messages
|
||||
|
||||
|
||||
class StreamApp(QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
new_scanID = 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.scanID = 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_scanID.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, scanID: str):
|
||||
print(f"Creating new stream consumer for scanID: {scanID}")
|
||||
|
||||
self.connect_stream_consumer(scanID, self.device)
|
||||
|
||||
def connect_stream_consumer(self, scanID, device):
|
||||
if self.stream_consumer is not None:
|
||||
self.stream_consumer.shutdown()
|
||||
|
||||
self.stream_consumer = connector.stream_consumer(
|
||||
topics=MessageEndpoints.device_async_readback(scanID=scanID, 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_scanID = msgDEV.content["scanID"]
|
||||
|
||||
if parent.scanID is None:
|
||||
parent.scanID = current_scanID
|
||||
parent.new_scanID.emit(current_scanID)
|
||||
print(f"New scanID: {current_scanID}")
|
||||
|
||||
if current_scanID != parent.scanID:
|
||||
parent.scanID = current_scanID
|
||||
# parent.data = None
|
||||
# parent.imageItem.clear()
|
||||
parent.new_scanID.emit(current_scanID)
|
||||
|
||||
print(f"New scanID: {current_scanID}")
|
||||
|
||||
|
||||
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()
|
||||
31
bec_widgets/examples/mca_readout/mca_sim.py
Normal file
31
bec_widgets/examples/mca_readout/mca_sim.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from bec_lib import messages, MessageEndpoints, RedisConnector
|
||||
import time
|
||||
|
||||
connector = RedisConnector("localhost:6379")
|
||||
metadata = {}
|
||||
|
||||
scanID = "ScanID1"
|
||||
|
||||
metadata.update(
|
||||
{
|
||||
"scanID": scanID, # this will be different for each scan
|
||||
"async_update": "append",
|
||||
}
|
||||
)
|
||||
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(
|
||||
scanID=scanID, device="mca"
|
||||
), # scanID will be different for each scan
|
||||
msg={"data": msg}, # TODO should be msg_dict
|
||||
expire=1800,
|
||||
)
|
||||
|
||||
print(f"Sent {ii}")
|
||||
time.sleep(0.5)
|
||||
92
bec_widgets/examples/modular_app/modular.ui
Normal file
92
bec_widgets/examples/modular_app/modular.ui
Normal file
@@ -0,0 +1,92 @@
|
||||
<?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>
|
||||
200
bec_widgets/examples/modular_app/modular_app.py
Normal file
200
bec_widgets/examples/modular_app/modular_app.py
Normal file
@@ -0,0 +1,200 @@
|
||||
import os
|
||||
|
||||
from qtpy import uic
|
||||
from qtpy.QtWidgets import QMainWindow, QApplication
|
||||
|
||||
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()
|
||||
@@ -2,8 +2,8 @@ from .motor_control_compilations import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorControlPanelRelative,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
|
||||
17
bec_widgets/examples/motor_movement/config_example.yaml
Normal file
17
bec_widgets/examples/motor_movement/config_example.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
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
|
||||
10
bec_widgets/examples/motor_movement/csax_bec_config.yaml
Normal file
10
bec_widgets/examples/motor_movement/csax_bec_config.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
redis:
|
||||
host: pc15543
|
||||
port: 6379
|
||||
mongodb:
|
||||
host: localhost
|
||||
port: 27017
|
||||
scibec:
|
||||
host: http://localhost
|
||||
port: 3030
|
||||
beamline: MyBeamline
|
||||
17
bec_widgets/examples/motor_movement/csaxs_config.yaml
Normal file
17
bec_widgets/examples/motor_movement/csaxs_config.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -1,19 +1,23 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
|
||||
import qdarktheme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QVBoxLayout
|
||||
from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
QSplitter,
|
||||
)
|
||||
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.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 (
|
||||
from bec_widgets.widgets import (
|
||||
MotorControlAbsolute,
|
||||
)
|
||||
from bec_widgets.widgets.motor_control.movement_relative.movement_relative import (
|
||||
MotorControlRelative,
|
||||
MotorControlSelection,
|
||||
MotorThread,
|
||||
MotorMap,
|
||||
MotorCoordinateTable,
|
||||
)
|
||||
from bec_widgets.widgets.motor_control.selection.selection import MotorControlSelection
|
||||
|
||||
CONFIG_DEFAULT = {
|
||||
"motor_control": {
|
||||
@@ -43,7 +47,7 @@ CONFIG_DEFAULT = {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "samy", "entry": "samy"}],
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -59,13 +63,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)
|
||||
|
||||
@@ -75,9 +79,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
|
||||
)
|
||||
@@ -88,7 +92,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):
|
||||
@@ -102,11 +106,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
|
||||
@@ -115,9 +119,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):
|
||||
@@ -151,7 +155,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):
|
||||
@@ -178,6 +182,9 @@ 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):
|
||||
@@ -203,6 +210,9 @@ 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
|
||||
@@ -232,19 +242,19 @@ if __name__ == "__main__": # pragma: no cover
|
||||
qdarktheme.setup_theme("auto")
|
||||
|
||||
if args.variant == "app":
|
||||
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
|
||||
window = MotorControlApp(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "map":
|
||||
window = MotorControlMap(client=client) # , config=CONFIG_DEFAULT)
|
||||
window = MotorControlMap(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel":
|
||||
window = MotorControlPanel(client=client) # , config=CONFIG_DEFAULT)
|
||||
window = MotorControlPanel(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_abs":
|
||||
window = MotorControlPanelAbsolute(client=client) # , config=CONFIG_DEFAULT)
|
||||
window = MotorControlPanelAbsolute(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_rel":
|
||||
window = MotorControlPanelRelative(client=client) # , config=CONFIG_DEFAULT)
|
||||
window = MotorControlPanelRelative(client=client, config=CONFIG_DEFAULT)
|
||||
else:
|
||||
print("Please specify a valid variant to run. Use -h for help.")
|
||||
print("Running the full application by default.")
|
||||
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
|
||||
window = MotorControlApp(client=client, config=CONFIG_DEFAULT)
|
||||
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -883,7 +883,7 @@
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>scan_id</string>
|
||||
<string>scanID</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
|
||||
1353
bec_widgets/examples/motor_movement/motor_example.py
Normal file
1353
bec_widgets/examples/motor_movement/motor_example.py
Normal file
File diff suppressed because it is too large
Load Diff
3
bec_widgets/examples/oneplot/config_gaussworker.yaml
Normal file
3
bec_widgets/examples/oneplot/config_gaussworker.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
x_value: "samx"
|
||||
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
|
||||
dap_worker: "gaussian_fit_worker_3"
|
||||
3
bec_widgets/examples/oneplot/config_noworker.yaml
Normal file
3
bec_widgets/examples/oneplot/config_noworker.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
x_value: "samx"
|
||||
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
|
||||
dap_worker: None
|
||||
271
bec_widgets/examples/oneplot/oneplot.py
Normal file
271
bec_widgets/examples/oneplot/oneplot.py
Normal file
@@ -0,0 +1,271 @@
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import qtpy.QtWidgets
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import MessageEndpoints
|
||||
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QApplication, QTableWidgetItem, QWidget
|
||||
from pyqtgraph import mkBrush, mkColor, mkPen
|
||||
from pyqtgraph.Qt import QtCore, uic
|
||||
|
||||
from bec_widgets.utils import Crosshair, ctrl_c
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
# TODO implement:
|
||||
# - implement scanID database for visualizing previous scans
|
||||
# - multiple signals for different monitors
|
||||
# - change how dap is handled in bec_dispatcher to handle more workers
|
||||
|
||||
|
||||
class PlotApp(QWidget):
|
||||
"""
|
||||
Main class for the PlotApp used to plot two signals from the BEC.
|
||||
|
||||
Attributes:
|
||||
update_signal (pyqtSignal): Signal to trigger plot updates.
|
||||
update_dap_signal (pyqtSignal): Signal to trigger DAP updates.
|
||||
|
||||
Args:
|
||||
x_value (str): The x device/signal for plotting.
|
||||
y_values (list of str): List of y device/signals for plotting.
|
||||
dap_worker (str, optional): DAP process to specify. Set to None to disable.
|
||||
parent (QWidget, optional): Parent widget.
|
||||
"""
|
||||
|
||||
update_signal = pyqtSignal()
|
||||
update_dap_signal = pyqtSignal()
|
||||
|
||||
def __init__(self, x_value, y_values, dap_worker=None, parent=None):
|
||||
super(PlotApp, self).__init__(parent)
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "oneplot.ui"), self)
|
||||
|
||||
self.x_value = x_value
|
||||
self.y_values = y_values
|
||||
self.dap_worker = dap_worker
|
||||
|
||||
self.scanID = None
|
||||
self.data_x = []
|
||||
self.data_y = []
|
||||
|
||||
self.dap_x = np.array([])
|
||||
self.dap_y = np.array([])
|
||||
|
||||
self.fit = None
|
||||
|
||||
self.init_ui()
|
||||
self.init_curves()
|
||||
self.hook_crosshair()
|
||||
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self.update_plot
|
||||
)
|
||||
self.proxy_update_fit = pg.SignalProxy(
|
||||
self.update_dap_signal, rateLimit=25, slot=self.update_fit_table
|
||||
)
|
||||
|
||||
def init_ui(self) -> None:
|
||||
"""Initialize the UI components."""
|
||||
self.plot = pg.PlotItem()
|
||||
self.glw.addItem(self.plot)
|
||||
self.plot.setLabel("bottom", self.x_value)
|
||||
self.plot.setLabel("left", ", ".join(self.y_values))
|
||||
self.plot.addLegend()
|
||||
|
||||
def init_curves(self) -> None:
|
||||
"""Initialize curve data and properties."""
|
||||
self.plot.clear()
|
||||
|
||||
self.curves_data = []
|
||||
self.curves_dap = []
|
||||
|
||||
colors_y_values = PlotApp.golden_angle_color(colormap="CET-R2", num=len(self.y_values))
|
||||
# colors_y_daps = PlotApp.golden_angle_color(
|
||||
# colormap="CET-I2", num=len(self.dap_worker)
|
||||
# ) # TODO adapt for multiple dap_workers
|
||||
|
||||
# Initialize curves for y_values
|
||||
for ii, (signal, color) in enumerate(zip(self.y_values, colors_y_values)):
|
||||
pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine)
|
||||
brush_curve = mkBrush(color=color)
|
||||
curve_data = pg.PlotDataItem(
|
||||
symbolSize=5,
|
||||
symbolBrush=brush_curve,
|
||||
pen=pen_curve,
|
||||
skipFiniteCheck=True,
|
||||
name=f"{signal}",
|
||||
)
|
||||
self.curves_data.append(curve_data)
|
||||
self.plot.addItem(curve_data)
|
||||
|
||||
# Initialize curves for DAP if dap_worker is not None
|
||||
if self.dap_worker is not None:
|
||||
# for ii, (monitor, color) in enumerate(zip(self.dap_worker, colors_y_daps)):#TODO adapt for multiple dap_workers
|
||||
pen_dap = mkPen(color="#3b5998", width=2, style=QtCore.Qt.DashLine)
|
||||
curve_dap = pg.PlotDataItem(
|
||||
pen=pen_dap, skipFiniteCheck=True, symbolSize=5, name=f"{self.dap_worker}"
|
||||
)
|
||||
self.curves_dap.append(curve_dap)
|
||||
self.plot.addItem(curve_dap)
|
||||
|
||||
self.tableWidget_crosshair.setRowCount(len(self.y_values))
|
||||
self.tableWidget_crosshair.setVerticalHeaderLabels(self.y_values)
|
||||
self.hook_crosshair()
|
||||
|
||||
def hook_crosshair(self) -> None:
|
||||
"""Attach the crosshair to the plot."""
|
||||
self.crosshair_1d = Crosshair(self.plot, precision=3)
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.update_table(self.tableWidget_crosshair, x, y, column=0)
|
||||
)
|
||||
self.crosshair_1d.coordinatesClicked1D.connect(
|
||||
lambda x, y: self.update_table(self.tableWidget_crosshair, x, y, column=1)
|
||||
)
|
||||
|
||||
def update_table(
|
||||
self, table_widget: qtpy.QtWidgets.QTableWidget, x: float, y_values: list, column: int
|
||||
) -> None:
|
||||
for i, y in enumerate(y_values):
|
||||
table_widget.setItem(i, column, QTableWidgetItem(f"({x}, {y})"))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update_plot(self) -> None:
|
||||
"""Update the plot data."""
|
||||
for ii, curve in enumerate(self.curves_data):
|
||||
curve.setData(self.data_x, self.data_y[ii])
|
||||
|
||||
if self.dap_worker is not None:
|
||||
# for ii, curve in enumerate(self.curves_dap): #TODO adapt for multiple dap_workers
|
||||
# curve.setData(self.dap_x, self.dap_y[ii])
|
||||
self.curves_dap[0].setData(self.dap_x, self.dap_y)
|
||||
|
||||
def update_fit_table(self):
|
||||
"""Update the table for fit data."""
|
||||
|
||||
self.tableWidget_fit.setData(self.fit)
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_dap_update(self, msg: dict, metadata: dict) -> None:
|
||||
"""
|
||||
Update DAP related data.
|
||||
|
||||
Args:
|
||||
msg (dict): Message received with data.
|
||||
metadata (dict): Metadata of the DAP.
|
||||
"""
|
||||
|
||||
# TODO adapt for multiple dap_workers
|
||||
self.dap_x = msg[self.dap_worker]["x"]
|
||||
self.dap_y = msg[self.dap_worker]["y"]
|
||||
|
||||
self.fit = metadata["fit_parameters"]
|
||||
|
||||
self.update_dap_signal.emit()
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, msg: dict, metadata: dict):
|
||||
"""
|
||||
Handle new scan segments.
|
||||
|
||||
Args:
|
||||
msg (dict): Message received with scan data.
|
||||
metadata (dict): Metadata of the scan.
|
||||
"""
|
||||
current_scanID = msg["scanID"]
|
||||
|
||||
if current_scanID != self.scanID:
|
||||
self.scanID = current_scanID
|
||||
self.data_x = []
|
||||
self.data_y = [[] for _ in self.y_values]
|
||||
self.init_curves()
|
||||
|
||||
dev_x = self.x_value
|
||||
data_x = msg["data"][dev_x][dev[dev_x]._hints[0]]["value"]
|
||||
self.data_x.append(data_x)
|
||||
|
||||
for ii, dev_y in enumerate(self.y_values):
|
||||
data_y = msg["data"][dev_y][dev[dev_y]._hints[0]]["value"]
|
||||
self.data_y[ii].append(data_y)
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(colormap: str, num: int) -> list:
|
||||
"""
|
||||
Extract num colors for from the specified colormap following golden angle distribution.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap
|
||||
num (int): Number of requested colors
|
||||
|
||||
Returns:
|
||||
list: List of colors with length <num>
|
||||
|
||||
Raises:
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap_colors = cmap.color
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = PlotApp.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = [
|
||||
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
|
||||
]
|
||||
return colors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import yaml
|
||||
|
||||
with open("config_noworker.yaml", "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
x_value = config["x_value"]
|
||||
y_values = config["y_values"]
|
||||
dap_worker = config["dap_worker"]
|
||||
|
||||
dap_worker = None if dap_worker == "None" else dap_worker
|
||||
|
||||
# BECclient global variables
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
queue = client.queue
|
||||
|
||||
app = QApplication([])
|
||||
plotApp = PlotApp(x_value=x_value, y_values=y_values, dap_worker=dap_worker)
|
||||
|
||||
# Connecting signals from bec_dispatcher
|
||||
bec_dispatcher.connect_slot(plotApp.on_dap_update, MessageEndpoints.processed_data(dap_worker))
|
||||
bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
ctrl_c.setup(app)
|
||||
|
||||
window = plotApp
|
||||
window.show()
|
||||
app.exec()
|
||||
75
bec_widgets/examples/oneplot/oneplot.ui
Normal file
75
bec_widgets/examples/oneplot/oneplot.ui
Normal file
@@ -0,0 +1,75 @@
|
||||
<?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>547</width>
|
||||
<height>653</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,2">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout" stretch="2,1">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>Cursor</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QTableWidget" name="tableWidget_crosshair">
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Moved</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Clicked</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Fit</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="TableWidget" name="tableWidget_fit"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</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>
|
||||
<customwidget>
|
||||
<class>TableWidget</class>
|
||||
<extends>QTableWidget</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
130
bec_widgets/examples/plot_app/config_example.yaml
Normal file
130
bec_widgets/examples/plot_app/config_example.yaml
Normal file
@@ -0,0 +1,130 @@
|
||||
plot_settings:
|
||||
background_color: "black"
|
||||
num_columns: 2
|
||||
colormap: "plasma"
|
||||
scan_types: False # True to show scan types
|
||||
|
||||
# example to use without scan_type -> only one general configuration
|
||||
plot_data:
|
||||
- plot_name: "BPM4i plots vs samy"
|
||||
x:
|
||||
label: 'Motor Y'
|
||||
signals:
|
||||
- name: "samy"
|
||||
# entry: "samy" # here I also forgot to specify entry
|
||||
y:
|
||||
label: 'bpm4i'
|
||||
signals:
|
||||
- name: "bpm4i"
|
||||
entry: "bpm4i"
|
||||
# I will not specify entry, because I want to take hint from gauss_adc2
|
||||
- plot_name: "BPM4i plots vs samx"
|
||||
x:
|
||||
label: 'Motor Y'
|
||||
signals:
|
||||
- name: "samy"
|
||||
# entry: "samy" # here I also forgot to specify entry
|
||||
y:
|
||||
label: 'bpm4i'
|
||||
signals:
|
||||
- name: "bpm4i"
|
||||
entry: "bpm4i"
|
||||
# I will not specify entry, because I want to take hint from gauss_adc2
|
||||
- plot_name: "MCS Channel 4 (Cyberstar) vs samx"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samx"
|
||||
entry: "samx"
|
||||
y:
|
||||
label: 'mcs4 cyberstar'
|
||||
signals:
|
||||
- name: "mcs"
|
||||
entry: "mca4"
|
||||
- plot_name: "MCS Channel 4 (Cyberstar) vs samy"
|
||||
x:
|
||||
label: 'Motor X'
|
||||
signals:
|
||||
- name: "samy"
|
||||
entry: "samy"
|
||||
y:
|
||||
label: 'mcs4 cyberstar'
|
||||
signals:
|
||||
- name: "mcs"
|
||||
entry: "mca4"
|
||||
|
||||
|
||||
|
||||
# example to use with scan_type -> different configuration for different scan types
|
||||
#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", "samx_setpoint"]
|
||||
#
|
||||
# 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"
|
||||
|
||||
91
bec_widgets/examples/plot_app/config_scans_example.yaml
Normal file
91
bec_widgets/examples/plot_app/config_scans_example.yaml
Normal file
@@ -0,0 +1,91 @@
|
||||
plot_settings:
|
||||
background_color: "black"
|
||||
num_columns: 2
|
||||
colormap: "plasma"
|
||||
scan_types: True # True to show scan types
|
||||
|
||||
# example to use with scan_type -> different configuration for different scan types
|
||||
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", "samx_setpoint"]
|
||||
|
||||
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"
|
||||
|
||||
730
bec_widgets/examples/plot_app/plot_app.py
Normal file
730
bec_widgets/examples/plot_app/plot_app.py
Normal file
@@ -0,0 +1,730 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
# import traceback
|
||||
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
|
||||
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QWidget,
|
||||
QTableWidgetItem,
|
||||
QTableWidget,
|
||||
QFileDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
from pyqtgraph import ColorButton
|
||||
from pyqtgraph import mkBrush, mkPen
|
||||
from pyqtgraph.Qt import QtCore, uic
|
||||
from pyqtgraph.Qt import QtWidgets
|
||||
|
||||
from bec_lib import MessageEndpoints
|
||||
from bec_widgets.utils import Crosshair, Colors
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
# TODO implement:
|
||||
# - implement scanID database for visualizing previous scans
|
||||
|
||||
|
||||
class PlotApp(QWidget):
|
||||
"""
|
||||
Main class for PlotApp, designed to plot multiple signals in a grid layout
|
||||
based on a flexible YAML configuration.
|
||||
|
||||
Attributes:
|
||||
update_signal (pyqtSignal): Signal to trigger plot updates.
|
||||
plot_data (list of dict): List of dictionaries containing plot configurations.
|
||||
Each dictionary specifies x and y signals, including their
|
||||
name and entry, for a particular plot.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration dictionary containing all settings for the plotting app.
|
||||
It should include the following keys:
|
||||
- 'plot_settings': Dictionary containing global plot settings.
|
||||
- 'plot_data': List of dictionaries specifying the signals to plot.
|
||||
parent (QWidget, optional): Parent widget.
|
||||
|
||||
Example:
|
||||
General Plot Configuration:
|
||||
{
|
||||
'plot_settings': {'background_color': 'black', 'num_columns': 2, 'colormap': 'plasma', 'scan_types': False},
|
||||
'plot_data': [
|
||||
{
|
||||
'plot_name': 'Plot A',
|
||||
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x', 'entry': 'entry_x'}]},
|
||||
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y', 'entry': 'entry_y'}]}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Different Scans Mode Configuration:
|
||||
{
|
||||
'plot_settings': {'background_color': 'black', 'num_columns': 2, 'colormap': 'plasma', 'scan_types': True},
|
||||
'plot_data': {
|
||||
'scan_type_1': [
|
||||
{
|
||||
'plot_name': 'Plot 1',
|
||||
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x1', 'entry': 'entry_x1'}]},
|
||||
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y1', 'entry': 'entry_y1'}]}
|
||||
}
|
||||
],
|
||||
'scan_type_2': [
|
||||
{
|
||||
'plot_name': 'Plot 2',
|
||||
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x2', 'entry': 'entry_x2'}]},
|
||||
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y2', 'entry': 'entry_y2'}]}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
update_signal = pyqtSignal()
|
||||
update_dap_signal = pyqtSignal()
|
||||
|
||||
def __init__(self, config: dict, client=None, parent=None):
|
||||
super(PlotApp, self).__init__(parent)
|
||||
|
||||
# Error handler
|
||||
self.error_handler = ErrorHandler(parent=self)
|
||||
|
||||
# Client and device manager from BEC
|
||||
self.client = BECDispatcher().client if client is None else client
|
||||
self.dev = self.client.device_manager.devices
|
||||
|
||||
# Loading UI
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "plot_app.ui"), self)
|
||||
|
||||
self.data = {}
|
||||
|
||||
self.crosshairs = None
|
||||
self.plots = None
|
||||
self.curves_data = None
|
||||
self.grid_coordinates = None
|
||||
self.scanID = None
|
||||
|
||||
self.user_colors = {} # key: (plot_name, y_name, y_entry), value: color
|
||||
|
||||
# Default config
|
||||
self.config = config
|
||||
|
||||
# Validate the configuration before proceeding
|
||||
self.load_config(self.config)
|
||||
|
||||
# Default splitter size
|
||||
self.splitter.setSizes([400, 100])
|
||||
|
||||
# Buttons
|
||||
self.pushButton_save.clicked.connect(self.save_settings_to_yaml)
|
||||
self.pushButton_load.clicked.connect(self.load_settings_from_yaml)
|
||||
|
||||
# Connect the update signal to the update plot method
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self.update_plot
|
||||
)
|
||||
|
||||
# Change layout of plots when the number of columns is changed in GUI
|
||||
self.spinBox_N_columns.valueChanged.connect(lambda x: self.init_ui(x))
|
||||
|
||||
def load_config(self, config: dict) -> None:
|
||||
"""
|
||||
Load and validate the configuration, retrying until a valid configuration is provided or the user cancels.
|
||||
Args:
|
||||
config (dict): Configuration dictionary form .yaml file.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
valid_config = False
|
||||
self.error_handler.set_retry_action(self.load_settings_from_yaml)
|
||||
while not valid_config:
|
||||
if config is None:
|
||||
self.config = (
|
||||
self.load_settings_from_yaml()
|
||||
) # Load config if it hasn't been loaded yet
|
||||
try: # Validate loaded config file
|
||||
self.error_handler.validate_config_file(config)
|
||||
valid_config = True
|
||||
except ValueError as e:
|
||||
self.config = None # Reset config_to_test to force reloading configuration
|
||||
self.config = self.error_handler.handle_error(str(e))
|
||||
if valid_config is True: # Initialize config if validation succeeds
|
||||
self.init_config(self.config)
|
||||
|
||||
def init_config(self, config: dict) -> None:
|
||||
"""
|
||||
Initializes or update the configuration settings for the PlotApp.
|
||||
|
||||
Args:
|
||||
config (dict): Dictionary containing plot settings and data configurations.
|
||||
"""
|
||||
|
||||
# YAML config
|
||||
self.plot_settings = config.get("plot_settings", {})
|
||||
self.plot_data_config = 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: # setup first line scan as default, then changed with different scan type
|
||||
self.plot_data = self.plot_data_config[list(self.plot_data_config.keys())[0]]
|
||||
|
||||
# Setting global plot settings
|
||||
self.init_plot_background(self.plot_settings["background_color"])
|
||||
|
||||
# Initialize the UI
|
||||
self.init_ui(self.plot_settings["num_columns"])
|
||||
self.spinBox_N_columns.setValue(
|
||||
self.plot_settings["num_columns"]
|
||||
) # TODO has to be checked if it will not setup more columns than plots
|
||||
self.spinBox_N_columns.setMaximum(len(self.plot_data))
|
||||
|
||||
def init_plot_background(self, background_color: str) -> None:
|
||||
"""
|
||||
Initialize plot settings based on the background color.
|
||||
|
||||
Args:
|
||||
background_color (str): The background color ('white' or 'black').
|
||||
|
||||
This method sets the background and foreground colors for pyqtgraph.
|
||||
If the background is dark ('black'), the foreground will be set to 'white',
|
||||
and vice versa.
|
||||
"""
|
||||
if background_color.lower() == "black":
|
||||
pg.setConfigOption("background", "k")
|
||||
pg.setConfigOption("foreground", "w")
|
||||
elif background_color.lower() == "white":
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid background color {background_color}. Allowed values are 'white' or 'black'."
|
||||
)
|
||||
|
||||
# TODO simplify -> find way how to setup also foreground color
|
||||
# if background_color.lower() not in ["black", "white"]:
|
||||
# raise ValueError(
|
||||
# f"Invalid background color {background_color}. Allowed values are 'white' or 'black'."
|
||||
# )
|
||||
# self.glw.setBackground(background_color.lower())
|
||||
|
||||
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.glw.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(
|
||||
f"Warning: num_columns in the YAML file was greater than the number of plots. 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["x"].get("label", "")
|
||||
y_label = plot_config["y"].get("label", "")
|
||||
|
||||
plot = self.glw.addPlot(row=row, col=col, colspan=colspan, title=plot_name)
|
||||
plot.setLabel("bottom", x_label)
|
||||
plot.setLabel("left", y_label)
|
||||
plot.addLegend()
|
||||
|
||||
self.plots[plot_name] = plot
|
||||
self.grid_coordinates.append((row, col))
|
||||
|
||||
self.init_curves()
|
||||
|
||||
def init_curves(self) -> None:
|
||||
"""
|
||||
Initialize curve data and properties, and update table row labels.
|
||||
|
||||
This method initializes a nested dictionary `self.curves_data` to store
|
||||
the curve objects for each x and y signal pair. It also updates the row labels
|
||||
in `self.tableWidget_crosshair` to include the grid position for each y-value.
|
||||
"""
|
||||
self.curves_data = {}
|
||||
row_labels = []
|
||||
|
||||
for idx, plot_config in enumerate(self.plot_data):
|
||||
plot_name = plot_config.get("plot_name", "")
|
||||
plot = self.plots[plot_name]
|
||||
plot.clear()
|
||||
|
||||
y_configs = plot_config["y"]["signals"]
|
||||
colors_ys = Colors.golden_angle_color(
|
||||
colormap=self.plot_settings["colormap"], num=len(y_configs)
|
||||
)
|
||||
|
||||
curve_list = []
|
||||
for i, (y_config, color) in enumerate(zip(y_configs, colors_ys)):
|
||||
# print(y_config)
|
||||
y_name = y_config["name"]
|
||||
y_entries = y_config.get("entry", [y_name])
|
||||
|
||||
if not isinstance(y_entries, list):
|
||||
y_entries = [y_entries]
|
||||
|
||||
for y_entry in y_entries:
|
||||
user_color = self.user_colors.get((plot_name, y_name, y_entry), 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)
|
||||
|
||||
curve_data = pg.PlotDataItem(
|
||||
symbolSize=5,
|
||||
symbolBrush=brush_curve,
|
||||
pen=pen_curve,
|
||||
skipFiniteCheck=True,
|
||||
name=f"{y_name} ({y_entry})",
|
||||
)
|
||||
|
||||
curve_list.append((y_name, y_entry, curve_data))
|
||||
plot.addItem(curve_data)
|
||||
row_labels.append(f"{y_name} ({y_entry}) - {plot_name}")
|
||||
|
||||
# Create a ColorButton and set its color
|
||||
color_btn = ColorButton()
|
||||
color_btn.setColor(color_to_use)
|
||||
color_btn.sigColorChanged.connect(
|
||||
lambda btn=color_btn, plot=plot_name, yname=y_name, yentry=y_entry, curve=curve_data: self.change_curve_color(
|
||||
btn, plot, yname, yentry, curve
|
||||
)
|
||||
)
|
||||
|
||||
# Add the ColorButton as a QWidget to the table
|
||||
color_widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QHBoxLayout()
|
||||
layout.addWidget(color_btn)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
color_widget.setLayout(layout)
|
||||
|
||||
row = len(row_labels) - 1 # The row index in the table
|
||||
self.tableWidget_crosshair.setCellWidget(row, 2, color_widget)
|
||||
|
||||
self.curves_data[plot_name] = curve_list
|
||||
|
||||
self.tableWidget_crosshair.setRowCount(len(row_labels))
|
||||
self.tableWidget_crosshair.setVerticalHeaderLabels(row_labels)
|
||||
self.hook_crosshair()
|
||||
|
||||
def change_curve_color(
|
||||
self,
|
||||
btn: pyqtgraph.ColorButton,
|
||||
plot_name: str,
|
||||
y_name: str,
|
||||
y_entry: str,
|
||||
curve: pyqtgraph.PlotDataItem,
|
||||
) -> None:
|
||||
"""
|
||||
Change the color of a curve and update the corresponding ColorButton.
|
||||
|
||||
Args:
|
||||
btn (ColorButton): The ColorButton that was clicked.
|
||||
plot_name (str): The name of the plot where the curve belongs.
|
||||
y_name (str): The name of the y signal.
|
||||
y_entry (str): The entry of the y signal.
|
||||
curve (PlotDataItem): The curve to be changed.
|
||||
"""
|
||||
color = btn.color()
|
||||
pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine)
|
||||
brush_curve = mkBrush(color=color)
|
||||
curve.setPen(pen_curve)
|
||||
curve.setSymbolBrush(brush_curve)
|
||||
self.user_colors[(plot_name, y_name, y_entry)] = color
|
||||
|
||||
def hook_crosshair(self) -> None:
|
||||
"""Attach crosshairs to each plot and connect them to the update_table method."""
|
||||
self.crosshairs = {}
|
||||
for plot_name, plot in self.plots.items():
|
||||
crosshair = Crosshair(plot, precision=3)
|
||||
crosshair.coordinatesChanged1D.connect(
|
||||
lambda x, y, plot=plot: self.update_table(
|
||||
self.tableWidget_crosshair, x, y, column=0, plot=plot
|
||||
)
|
||||
)
|
||||
crosshair.coordinatesClicked1D.connect(
|
||||
lambda x, y, plot=plot: self.update_table(
|
||||
self.tableWidget_crosshair, x, y, column=1, plot=plot
|
||||
)
|
||||
)
|
||||
self.crosshairs[plot_name] = crosshair
|
||||
|
||||
def update_table(
|
||||
self, table_widget: QTableWidget, x: float, y_values: list, column: int, plot: pg.PlotItem
|
||||
) -> None:
|
||||
"""
|
||||
Update the table with coordinates based on cursor movements and clicks.
|
||||
|
||||
Args:
|
||||
table_widget (QTableWidget): The table to be updated.
|
||||
x (float): The x-coordinate from the plot.
|
||||
y_values (list): The y-coordinates from the plot.
|
||||
column (int): The column in the table to be updated.
|
||||
plot (PlotItem): The plot from which the coordinates are taken.
|
||||
|
||||
This method calculates the correct row in the table for each y-value
|
||||
and updates the cell at (row, column) with the new x and y coordinates.
|
||||
"""
|
||||
plot_name = [name for name, value in self.plots.items() if value == plot][0]
|
||||
|
||||
starting_row = 0
|
||||
for plot_config in self.plot_data:
|
||||
if plot_config.get("plot_name", "") == plot_name:
|
||||
break
|
||||
for y_config in plot_config.get("y", {}).get("signals", []):
|
||||
y_entries = y_config.get("entry", [y_config.get("name", "")])
|
||||
if not isinstance(y_entries, list):
|
||||
y_entries = [y_entries]
|
||||
starting_row += len(y_entries)
|
||||
|
||||
for i, y in enumerate(y_values):
|
||||
row = starting_row + i
|
||||
table_widget.setItem(row, column, QTableWidgetItem(f"({x}, {y})"))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update_plot(self) -> None:
|
||||
"""Update the plot data based on the stored data dictionary."""
|
||||
for plot_name, curve_list in self.curves_data.items():
|
||||
for y_name, y_entry, curve in curve_list:
|
||||
x_config = next(
|
||||
(pc["x"] for pc in self.plot_data if pc.get("plot_name") == plot_name), {}
|
||||
)
|
||||
x_signal_config = x_config["signals"][0]
|
||||
x_name = x_signal_config.get("name", "")
|
||||
x_entry = x_signal_config.get("entry", x_name)
|
||||
|
||||
key = (x_name, x_entry, y_name, y_entry)
|
||||
data_x = self.data.get(key, {}).get("x", [])
|
||||
data_y = self.data.get(key, {}).get("y", [])
|
||||
|
||||
curve.setData(data_x, data_y)
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(
|
||||
self, msg, metadata
|
||||
) -> None: # TODO the logic should be separated from GUI operation
|
||||
"""
|
||||
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_scanID = msg.get("scanID", None)
|
||||
if current_scanID is None:
|
||||
return
|
||||
|
||||
if current_scanID != self.scanID:
|
||||
if self.scan_types is False:
|
||||
self.plot_data = self.plot_data_config
|
||||
elif self.scan_types is True:
|
||||
currentName = metadata.get("scan_name")
|
||||
if currentName is None:
|
||||
raise ValueError(
|
||||
f"Scan name not found in metadata. Please check the scan_name in the YAML config or in bec "
|
||||
f"configuration."
|
||||
)
|
||||
self.plot_data = self.plot_data_config.get(currentName, [])
|
||||
if self.plot_data == []:
|
||||
raise ValueError(
|
||||
f"Scan name {currentName} not found in the YAML config. Please check the scan_name in the "
|
||||
f"YAML config or in bec configuration."
|
||||
)
|
||||
|
||||
# Init UI
|
||||
self.init_ui(self.plot_settings["num_columns"])
|
||||
self.spinBox_N_columns.setValue(
|
||||
self.plot_settings["num_columns"]
|
||||
) # TODO has to be checked if it will not setup more columns than plots
|
||||
self.spinBox_N_columns.setMaximum(len(self.plot_data))
|
||||
|
||||
self.scanID = current_scanID
|
||||
self.data = {}
|
||||
self.init_curves()
|
||||
|
||||
for plot_config in self.plot_data:
|
||||
plot_name = plot_config.get("plot_name", "Unnamed Plot")
|
||||
x_config = plot_config["x"]
|
||||
x_signal_config = x_config["signals"][0] # Assuming there's at least one signal for x
|
||||
|
||||
x_name = x_signal_config.get("name", "")
|
||||
if not x_name:
|
||||
raise ValueError(f"Name for x signal must be specified in plot: {plot_name}.")
|
||||
|
||||
x_entry_list = x_signal_config.get("entry", [])
|
||||
if not x_entry_list:
|
||||
x_entry_list = (
|
||||
self.dev[x_name]._hints if hasattr(self.dev[x_name], "_hints") else [x_name]
|
||||
)
|
||||
|
||||
if not isinstance(x_entry_list, list):
|
||||
x_entry_list = [x_entry_list]
|
||||
|
||||
y_configs = plot_config["y"]["signals"]
|
||||
|
||||
for x_entry in x_entry_list:
|
||||
for y_config in y_configs:
|
||||
y_name = y_config.get("name", "")
|
||||
if not y_name:
|
||||
raise ValueError(
|
||||
f"Name for y signal must be specified in plot: {plot_name}."
|
||||
)
|
||||
|
||||
y_entry_list = y_config.get("entry", [])
|
||||
if not y_entry_list:
|
||||
y_entry_list = (
|
||||
self.dev[y_name]._hints
|
||||
if hasattr(self.dev[y_name], "_hints")
|
||||
else [y_name]
|
||||
)
|
||||
|
||||
if not isinstance(y_entry_list, list):
|
||||
y_entry_list = [y_entry_list]
|
||||
|
||||
for y_entry in y_entry_list:
|
||||
key = (x_name, x_entry, y_name, y_entry)
|
||||
|
||||
data_x = msg["data"].get(x_name, {}).get(x_entry, {}).get("value", None)
|
||||
data_y = msg["data"].get(y_name, {}).get(y_entry, {}).get("value", None)
|
||||
|
||||
if data_x is None:
|
||||
raise ValueError(
|
||||
f"Incorrect entry '{x_entry}' specified for x in plot: {plot_name}, x name: {x_name}"
|
||||
)
|
||||
|
||||
if data_y is None:
|
||||
if hasattr(self.dev[y_name], "_hints"):
|
||||
raise ValueError(
|
||||
f"Incorrect entry '{y_entry}' specified for y in plot: {plot_name}, y name: {y_name}"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"No hints available for y in plot: {plot_name}, and name '{y_name}' did not work as entry"
|
||||
)
|
||||
|
||||
if data_x is not None:
|
||||
self.data.setdefault(key, {}).setdefault("x", []).append(data_x)
|
||||
|
||||
if data_y is not None:
|
||||
self.data.setdefault(key, {}).setdefault("y", []).append(data_y)
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
def save_settings_to_yaml(self):
|
||||
"""Save the current settings to a .yaml file using a file dialog."""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
if file_path:
|
||||
try:
|
||||
if not file_path.endswith(".yaml"):
|
||||
file_path += ".yaml"
|
||||
|
||||
with open(file_path, "w") as file:
|
||||
yaml.dump(
|
||||
{"plot_settings": self.plot_settings, "plot_data": self.plot_data_config},
|
||||
file,
|
||||
)
|
||||
print(f"Settings saved to {file_path}")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while saving the settings to {file_path}: {e}")
|
||||
|
||||
def load_settings_from_yaml(self) -> dict: # TODO can be replace by the utils function
|
||||
"""Load settings from a .yaml file using a file dialog and update the current settings."""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Load Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
if file_path:
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
self.config = yaml.safe_load(file)
|
||||
self.load_config(self.config) # validate new config
|
||||
return config
|
||||
except FileNotFoundError:
|
||||
print(f"The file {file_path} was not found.")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while loading the settings from {file_path}: {e}")
|
||||
return None # Return None on exception to indicate failure
|
||||
|
||||
|
||||
class ErrorHandler:
|
||||
def __init__(self, parent=None):
|
||||
self.parent = parent
|
||||
self.errors = []
|
||||
self.retry_action = None
|
||||
logging.basicConfig(level=logging.ERROR) # Configure logging
|
||||
|
||||
def set_retry_action(self, action):
|
||||
self.retry_action = action # Store a reference to the retry action
|
||||
|
||||
def handle_error(self, error_message: str):
|
||||
# logging.error(f"{error_message}\n{traceback.format_exc()}") #TODO decide if useful
|
||||
|
||||
choice = QMessageBox.critical(
|
||||
self.parent,
|
||||
"Error",
|
||||
f"{error_message}\n\nWould you like to retry?",
|
||||
QMessageBox.Retry | QMessageBox.Cancel,
|
||||
)
|
||||
if choice == QMessageBox.Retry and self.retry_action is not None:
|
||||
return self.retry_action()
|
||||
else:
|
||||
exit(1) # Exit the program if the user selects Cancel or if no retry_action is provided
|
||||
|
||||
def validate_config_file(self, config: dict) -> None:
|
||||
"""
|
||||
Validate the configuration dictionary.
|
||||
Args:
|
||||
config (dict): Configuration dictionary form .yaml file.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.errors = []
|
||||
|
||||
# Validate common keys
|
||||
required_top_level_keys = ["plot_settings", "plot_data"]
|
||||
for key in required_top_level_keys:
|
||||
if key not in config:
|
||||
self.errors.append(f"Missing required key: {key}")
|
||||
|
||||
# Only continue if no errors so far
|
||||
if not self.errors:
|
||||
# Determine the configuration mode (device or scan)
|
||||
plot_settings = config.get("plot_settings", {})
|
||||
scan_types = plot_settings.get("scan_types", False)
|
||||
|
||||
plot_data = config.get("plot_data", [])
|
||||
|
||||
if scan_types:
|
||||
# Validate scan mode configuration
|
||||
for scan_type, plots in plot_data.items():
|
||||
for i, plot_config in enumerate(plots):
|
||||
self.validate_plot_config(plot_config, i)
|
||||
else:
|
||||
# Validate device mode configuration
|
||||
for i, plot_config in enumerate(plot_data):
|
||||
self.validate_plot_config(plot_config, i)
|
||||
|
||||
if self.errors != []:
|
||||
self.handle_error("\n".join(self.errors))
|
||||
|
||||
def validate_plot_config(self, plot_config: dict, i: int):
|
||||
"""
|
||||
Validate individual plot configuration.
|
||||
Args:
|
||||
plot_config (dict): Individual plot configuration.
|
||||
i (int): Index of the plot configuration.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
for axis in ["x", "y"]:
|
||||
axis_config = plot_config.get(axis)
|
||||
plot_name = plot_config.get("plot_name", "")
|
||||
if axis_config is None:
|
||||
error_msg = f"Missing '{axis}' configuration in plot {i} - {plot_name}"
|
||||
logging.error(error_msg) # Log the error
|
||||
self.errors.append(error_msg)
|
||||
|
||||
signals_config = axis_config.get("signals")
|
||||
if signals_config is None:
|
||||
error_msg = (
|
||||
f"Missing 'signals' configuration for {axis} axis in plot {i} - '{plot_name}'"
|
||||
)
|
||||
logging.error(error_msg) # Log the error
|
||||
self.errors.append(error_msg)
|
||||
elif not isinstance(signals_config, list) or len(signals_config) == 0:
|
||||
error_msg = (
|
||||
f"'signals' configuration for {axis} axis in plot {i} must be a non-empty list"
|
||||
)
|
||||
logging.error(error_msg) # Log the error
|
||||
self.errors.append(error_msg)
|
||||
# TODO add condition for name and entry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import yaml
|
||||
import argparse
|
||||
|
||||
# from bec_widgets import ctrl_c
|
||||
parser = argparse.ArgumentParser(description="Plotting App")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
"-c",
|
||||
help="Path to the .yaml configuration file",
|
||||
default="config_example.yaml",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
with open(args.config, "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"The file {args.config} was not found.")
|
||||
exit(1)
|
||||
except Exception as e:
|
||||
print(f"An error occurred while loading the config file: {e}")
|
||||
exit(1)
|
||||
|
||||
# BECclient global variables
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
app = QApplication([])
|
||||
plotApp = PlotApp(config=config, client=client)
|
||||
|
||||
# Connecting signals from bec_dispatcher
|
||||
bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
# ctrl_c.setup(app)
|
||||
|
||||
window = plotApp
|
||||
window.show()
|
||||
app.exec()
|
||||
115
bec_widgets/examples/plot_app/plot_app.ui
Normal file
115
bec_widgets/examples/plot_app/plot_app.ui
Normal file
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MultiWindow</class>
|
||||
<widget class="QWidget" name="MultiWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1248</width>
|
||||
<height>564</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MultiWindow</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="GraphicsLayoutWidget" name="glw"/>
|
||||
<widget class="QWidget" name="">
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="1" column="0">
|
||||
<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>
|
||||
<item row="2" column="0" colspan="3">
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>Cursor</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QTableWidget" name="tableWidget_crosshair">
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Moved</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Clicked</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Color</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Number of Columns:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QSpinBox" name="spinBox_N_columns">
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>3</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPushButton" name="pushButton_load">
|
||||
<property name="text">
|
||||
<string>Load Config</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="pushButton_save">
|
||||
<property name="text">
|
||||
<string>Save Config</string>
|
||||
</property>
|
||||
</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>
|
||||
155
bec_widgets/examples/stream_plot/line_plot.ui
Normal file
155
bec_widgets/examples/stream_plot/line_plot.ui
Normal file
@@ -0,0 +1,155 @@
|
||||
<?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>
|
||||
335
bec_widgets/examples/stream_plot/stream_plot.py
Normal file
335
bec_widgets/examples/stream_plot/stream_plot.py
Normal file
@@ -0,0 +1,335 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import messages, MessageEndpoints
|
||||
from bec_lib.redis_connector import RedisConnector
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QTableWidgetItem
|
||||
from pyqtgraph import mkBrush, mkPen
|
||||
from pyqtgraph.Qt import QtCore, QtWidgets, uic
|
||||
from pyqtgraph.Qt.QtCore import pyqtSignal
|
||||
from bec_widgets.utils import Crosshair, Colors
|
||||
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()
|
||||
56
bec_widgets/qtdesigner_plugins/scan2d_plot_plugin.py
Normal file
56
bec_widgets/qtdesigner_plugins/scan2d_plot_plugin.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.scan_plot.scan2d_plot import BECScanPlot2D
|
||||
|
||||
|
||||
class BECScanPlot2DPlugin(QPyDesignerCustomWidgetPlugin):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self, formEditor):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def isInitialized(self):
|
||||
return self._initialized
|
||||
|
||||
def createWidget(self, parent):
|
||||
return BECScanPlot2D(parent)
|
||||
|
||||
def name(self):
|
||||
return "BECScanPlot2D"
|
||||
|
||||
def group(self):
|
||||
return "BEC widgets"
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def toolTip(self):
|
||||
return "BEC plot for 2D scans"
|
||||
|
||||
def whatsThis(self):
|
||||
return "BEC plot for 2D scans"
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def domXml(self):
|
||||
return (
|
||||
'<widget class="BECScanPlot2D" name="BECScanPlot2D">\n'
|
||||
' <property name="toolTip" >\n'
|
||||
" <string>BEC plot for 2D scans</string>\n"
|
||||
" </property>\n"
|
||||
' <property name="whatsThis" >\n'
|
||||
" <string>BEC plot for 2D scans in Python using PyQt.</string>\n"
|
||||
" </property>\n"
|
||||
"</widget>\n"
|
||||
)
|
||||
|
||||
def includeFile(self):
|
||||
return "scan2d_plot"
|
||||
56
bec_widgets/qtdesigner_plugins/scan_plot_plugin.py
Normal file
56
bec_widgets/qtdesigner_plugins/scan_plot_plugin.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.scan_plot.scan_plot import BECScanPlot
|
||||
|
||||
|
||||
class BECScanPlotPlugin(QPyDesignerCustomWidgetPlugin):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self, formEditor):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def isInitialized(self):
|
||||
return self._initialized
|
||||
|
||||
def createWidget(self, parent):
|
||||
return BECScanPlot(parent)
|
||||
|
||||
def name(self):
|
||||
return "BECScanPlot"
|
||||
|
||||
def group(self):
|
||||
return "BEC widgets"
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def toolTip(self):
|
||||
return "BEC plot for scans"
|
||||
|
||||
def whatsThis(self):
|
||||
return "BEC plot for scans"
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def domXml(self):
|
||||
return (
|
||||
'<widget class="BECScanPlot" name="BECScanPlot">\n'
|
||||
' <property name="toolTip" >\n'
|
||||
" <string>BEC plot for scans</string>\n"
|
||||
" </property>\n"
|
||||
' <property name="whatsThis" >\n'
|
||||
" <string>BEC plot for scans in Python using PyQt.</string>\n"
|
||||
" </property>\n"
|
||||
"</widget>\n"
|
||||
)
|
||||
|
||||
def includeFile(self):
|
||||
return "scan_plot"
|
||||
@@ -1,13 +1,8 @@
|
||||
from qtpy.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from .crosshair import Crosshair
|
||||
from .colors import Colors
|
||||
from .validator_delegate import DoubleValidationDelegate
|
||||
from .bec_table import BECTable
|
||||
from .bec_connector import BECConnector, ConnectionConfig
|
||||
from .bec_dispatcher import BECDispatcher
|
||||
from .bec_table import BECTable
|
||||
from .colors import Colors
|
||||
from .container_utils import WidgetContainerUtils
|
||||
from .crosshair import Crosshair
|
||||
from .rpc_decorator import rpc_public, register_rpc_methods
|
||||
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
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional, Type
|
||||
from typing import Type, Optional
|
||||
|
||||
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
|
||||
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
class ConnectionConfig(BaseModel):
|
||||
@@ -20,10 +17,8 @@ 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:
|
||||
@@ -36,16 +31,13 @@ class ConnectionConfig(BaseModel):
|
||||
class BECConnector:
|
||||
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
|
||||
|
||||
USER_ACCESS = ["config_dict", "get_all_rpc"]
|
||||
|
||||
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.bec_dispatcher = BECDispatcher()
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
|
||||
if config:
|
||||
self.config = config
|
||||
self.config.widget_class = self.__class__.__name__
|
||||
else:
|
||||
print(
|
||||
f"No initial config found for {self.__class__.__name__}.\n"
|
||||
@@ -59,50 +51,10 @@ class BECConnector:
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# register widget to rpc register
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self)
|
||||
|
||||
def get_all_rpc(self) -> dict:
|
||||
"""Get all registered RPC objects."""
|
||||
all_connections = self.rpc_register.list_all_connections()
|
||||
return dict(all_connections)
|
||||
|
||||
@property
|
||||
def rpc_id(self) -> str:
|
||||
"""Get the RPC ID of the widget."""
|
||||
return self.gui_id
|
||||
|
||||
@rpc_id.setter
|
||||
def rpc_id(self, rpc_id: str) -> None:
|
||||
"""Set the RPC ID of the widget."""
|
||||
self.gui_id = rpc_id
|
||||
|
||||
@property
|
||||
def config_dict(self) -> dict:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
return self.config.model_dump()
|
||||
|
||||
@config_dict.setter
|
||||
def config_dict(self, config: BaseModel) -> None:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
@pyqtSlot(str)
|
||||
def set_gui_id(self, gui_id: str) -> None:
|
||||
"""
|
||||
Set the GUI ID for the widget.
|
||||
|
||||
Args:
|
||||
gui_id(str): GUI ID
|
||||
"""
|
||||
@@ -123,7 +75,6 @@ class BECConnector:
|
||||
|
||||
def update_client(self, client) -> None:
|
||||
"""Update the client and device manager from BEC and create object for BEC shortcuts.
|
||||
|
||||
Args:
|
||||
client: BEC client
|
||||
"""
|
||||
@@ -134,7 +85,6 @@ class BECConnector:
|
||||
def on_config_update(self, config: ConnectionConfig | dict) -> None:
|
||||
"""
|
||||
Update the configuration for the widget.
|
||||
|
||||
Args:
|
||||
config(ConnectionConfig): Configuration settings.
|
||||
"""
|
||||
@@ -147,10 +97,8 @@ class BECConnector:
|
||||
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Args:
|
||||
dict_output(bool): If True, return the configuration as a dictionary. If False, return the configuration as a pydantic model.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the plot widget.
|
||||
"""
|
||||
@@ -158,15 +106,3 @@ class BECConnector:
|
||||
return self.config.model_dump()
|
||||
else:
|
||||
return self.config
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
self.rpc_register.remove_rpc(self)
|
||||
all_connections = self.rpc_register.list_all_connections()
|
||||
if len(all_connections) == 0:
|
||||
print("No more connections. Shutting down GUI BEC client.")
|
||||
self.client.shutdown()
|
||||
|
||||
# def closeEvent(self, event):
|
||||
# self.cleanup()
|
||||
# super().closeEvent(event)
|
||||
|
||||
@@ -1,162 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import itertools
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Union
|
||||
from typing import Union
|
||||
|
||||
import redis
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.redis_connector import MessageObject, RedisConnector
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QCoreApplication, QObject
|
||||
from bec_lib import BECClient, ServiceConfig
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.endpoints import EndpointInfo
|
||||
# Adding a new pyqt signal requires a class factory, as they must be part of the class definition
|
||||
# and cannot be dynamically added as class attributes after the class has been defined.
|
||||
_signal_class_factory = (
|
||||
type(f"Signal{i}", (QObject,), dict(signal=pyqtSignal(dict, dict))) for i in itertools.count()
|
||||
)
|
||||
|
||||
|
||||
class QtThreadSafeCallback(QObject):
|
||||
cb_signal = pyqtSignal(dict, dict)
|
||||
|
||||
def __init__(self, cb):
|
||||
super().__init__()
|
||||
|
||||
self.cb = cb
|
||||
self.cb_signal.connect(self.cb)
|
||||
|
||||
def __hash__(self):
|
||||
# make 2 differents QtThreadSafeCallback to look
|
||||
# identical when used as dictionary keys, if the
|
||||
# callback is the same
|
||||
return id(self.cb)
|
||||
|
||||
def __call__(self, msg_content, metadata):
|
||||
self.cb_signal.emit(msg_content, metadata)
|
||||
|
||||
|
||||
class QtRedisConnector(RedisConnector):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _execute_callback(self, cb, msg, kwargs):
|
||||
if not isinstance(cb, QtThreadSafeCallback):
|
||||
return super()._execute_callback(cb, msg, kwargs)
|
||||
# if msg.msg_type == "bundle_message":
|
||||
# # big warning: how to handle bundle messages?
|
||||
# # message with messages inside ; which slot to call?
|
||||
# # bundle_msg = msg
|
||||
# # for msg in bundle_msg:
|
||||
# # ...
|
||||
# # for now, only consider the 1st message
|
||||
# msg = msg[0]
|
||||
# raise RuntimeError(f"
|
||||
if isinstance(msg, MessageObject):
|
||||
if isinstance(msg.value, list):
|
||||
msg = msg.value[0]
|
||||
else:
|
||||
msg = msg.value
|
||||
|
||||
# we can notice kwargs are lost when passed to Qt slot
|
||||
metadata = msg.metadata
|
||||
cb(msg.content, metadata)
|
||||
else:
|
||||
# from stream
|
||||
msg = msg["data"]
|
||||
cb(msg.content, msg.metadata)
|
||||
|
||||
|
||||
class BECDispatcher:
|
||||
class _Connection:
|
||||
"""Utility class to keep track of slots connected to a particular redis connector"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
qapp = None
|
||||
def __init__(self, callback) -> None:
|
||||
self.callback = callback
|
||||
|
||||
def __new__(cls, client=None, config: str = None, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(BECDispatcher, cls).__new__(cls)
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
self.slots = set()
|
||||
# keep a reference to a new signal class, so it is not gc'ed
|
||||
self._signal_container = next(_signal_class_factory)()
|
||||
self.signal: pyqtSignal = self._signal_container.signal
|
||||
|
||||
def __init__(self, client=None, config: str = None):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
if not QCoreApplication.instance():
|
||||
BECDispatcher.qapp = QCoreApplication([])
|
||||
class _BECDispatcher(QObject):
|
||||
"""Utility class to keep track of slots connected to a particular redis connector"""
|
||||
|
||||
self._slots = collections.defaultdict(set)
|
||||
self.client = client
|
||||
def __init__(self, bec_config=None):
|
||||
super().__init__()
|
||||
self.client = BECClient(forced=True) # make a new instance
|
||||
|
||||
if self.client is None:
|
||||
if config is not None:
|
||||
host, port = config.split(":")
|
||||
redis_config = {"host": host, "port": port}
|
||||
self.client = BECClient(
|
||||
config=ServiceConfig(redis=redis_config), connector_cls=QtRedisConnector
|
||||
) # , forced=True)
|
||||
else:
|
||||
self.client = BECClient(connector_cls=QtRedisConnector) # , forced=True)
|
||||
else:
|
||||
if self.client.started:
|
||||
# have to reinitialize client to use proper connector
|
||||
self.client.shutdown()
|
||||
self.client._BECClient__init_params["connector_cls"] = QtRedisConnector
|
||||
# TODO: this is a workaround for now to provide service config within qtdesigner, but is
|
||||
# it possible to provide config via a cli arg?
|
||||
if bec_config is None and os.path.isfile("bec_config.yaml"):
|
||||
bec_config = "bec_config.yaml"
|
||||
|
||||
try:
|
||||
self.client.start()
|
||||
except redis.exceptions.ConnectionError:
|
||||
print("Could not connect to Redis, skipping start of BECClient.")
|
||||
|
||||
self._initialized = True
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
if cls.qapp:
|
||||
cls.qapp.exit()
|
||||
cls.qapp = None
|
||||
self.client.initialize(config=ServiceConfig(config_path=bec_config))
|
||||
except redis.exceptions.ConnectionError as e:
|
||||
print(f"Failed to initialize BECClient: {e}")
|
||||
self._connections = {}
|
||||
|
||||
def connect_slot(
|
||||
self, slot: Callable, topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]]
|
||||
self, slot: Callable, topics: Union[str, list], single_callback_for_all_topics=False
|
||||
) -> None:
|
||||
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message.
|
||||
|
||||
Args:
|
||||
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
|
||||
the corresponding pub/sub message
|
||||
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
topics (str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
single_callback_for_all_topics (bool): If True, use the same callback for all topics, otherwise use
|
||||
separate callbacks.
|
||||
"""
|
||||
slot = QtThreadSafeCallback(slot)
|
||||
self.client.connector.register(topics, cb=slot)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[slot].update(set(topics_str))
|
||||
if isinstance(topics, str):
|
||||
topics = [topics]
|
||||
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
|
||||
# find the right slot to disconnect from ;
|
||||
# slot callbacks are wrapped in QtThreadSafeCallback objects,
|
||||
# but the slot we receive here is the original callable
|
||||
for connected_slot in self._slots:
|
||||
if connected_slot.cb == slot:
|
||||
break
|
||||
else:
|
||||
return
|
||||
self.client.connector.unregister(topics, cb=connected_slot)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[slot].difference_update(set(topics_str))
|
||||
if not self._slots[slot]:
|
||||
del self._slots[slot]
|
||||
# Ensure topics_key is a tuple, whether single_callback_for_all_topics is True or False.
|
||||
topics_key = tuple(sorted(topics)) if single_callback_for_all_topics else tuple(topics)
|
||||
|
||||
def disconnect_topics(self, topics: Union[str, list]):
|
||||
self.client.connector.unregister(topics)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
for slot in list(self._slots.keys()):
|
||||
slot_topics = self._slots[slot]
|
||||
slot_topics.difference_update(set(topics_str))
|
||||
if not slot_topics:
|
||||
del self._slots[slot]
|
||||
if topics_key not in self._connections:
|
||||
self._connections[topics_key] = self._create_connection(topics)
|
||||
connection = self._connections[topics_key]
|
||||
if slot not in connection.slots:
|
||||
connection.signal.connect(slot)
|
||||
connection.slots.add(slot)
|
||||
|
||||
def disconnect_all(self, *args, **kwargs):
|
||||
self.disconnect_topics(self.client.connector._topics_cb)
|
||||
def _create_connection(self, topics: list) -> _Connection:
|
||||
"""Creates a new connection for given topics."""
|
||||
|
||||
def cb(msg):
|
||||
msg = msg.value
|
||||
for connection_key, connection in self._connections.items():
|
||||
if set(topics).intersection(connection_key):
|
||||
connection.signal.emit(msg.content, msg.metadata)
|
||||
|
||||
try:
|
||||
self.client.connector.register(topics=topics, cb=cb)
|
||||
except redis.exceptions.ConnectionError:
|
||||
print("Could not connect to Redis, skipping registration of topics.")
|
||||
|
||||
return _Connection(cb)
|
||||
|
||||
def _do_disconnect_slot(self, topic, slot):
|
||||
print(f"Disconnecting {slot} from {topic}")
|
||||
connection = self._connections[topic]
|
||||
try:
|
||||
connection.signal.disconnect(slot)
|
||||
except TypeError:
|
||||
print(f"Could not disconnect slot:'{slot}' from topic:'{topic}'")
|
||||
print("Continue to remove slot:'{slot}' from 'connection.slots'.")
|
||||
connection.slots.remove(slot)
|
||||
if not connection.slots:
|
||||
del self._connections[topic]
|
||||
|
||||
def _disconnect_slot_from_topic(self, slot: Callable, topic: str) -> None:
|
||||
"""A helper method to disconnect a slot from a specific topic.
|
||||
|
||||
Args:
|
||||
slot (Callable): A slot to be disconnected
|
||||
topic (str): A corresponding topic that can typically be acquired via
|
||||
bec_lib.MessageEndpoints
|
||||
"""
|
||||
connection = self._connections.get(topic)
|
||||
if connection and slot in connection.slots:
|
||||
self._do_disconnect_slot(topic, slot)
|
||||
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]) -> None:
|
||||
"""Disconnect widget's pyqt slot from pub/sub updates on a topic.
|
||||
|
||||
Args:
|
||||
slot (Callable): A slot to be disconnected
|
||||
topics (str | list): A corresponding topic or list of topics that can typically be acquired via
|
||||
bec_lib.MessageEndpoints
|
||||
"""
|
||||
if isinstance(topics, str):
|
||||
topics = [topics]
|
||||
|
||||
for key, connection in list(self._connections.items()):
|
||||
if slot in connection.slots:
|
||||
common_topics = set(topics).intersection(key)
|
||||
if common_topics:
|
||||
remaining_topics = set(key) - set(topics)
|
||||
# Disconnect slot from common topics
|
||||
self._do_disconnect_slot(key, slot)
|
||||
# Reconnect slot to remaining topics if any
|
||||
if remaining_topics:
|
||||
self.connect_slot(slot, list(remaining_topics), True)
|
||||
|
||||
def disconnect_all(self):
|
||||
"""Disconnect all slots from all topics."""
|
||||
for key, connection in list(self._connections.items()):
|
||||
for slot in list(connection.slots):
|
||||
self._disconnect_slot_from_topic(slot, key)
|
||||
|
||||
|
||||
# variable holding the Singleton instance of BECDispatcher
|
||||
_bec_dispatcher = None
|
||||
|
||||
|
||||
def BECDispatcher():
|
||||
global _bec_dispatcher
|
||||
if _bec_dispatcher is None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bec-config", default=None)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
_bec_dispatcher = _BECDispatcher(args.bec_config)
|
||||
return _bec_dispatcher
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QTableWidget
|
||||
from qtpy.QtCore import Qt
|
||||
|
||||
|
||||
class BECTable(QTableWidget):
|
||||
@@ -8,7 +8,6 @@ class BECTable(QTableWidget):
|
||||
def keyPressEvent(self, event) -> None:
|
||||
"""
|
||||
Delete selected rows with backspace or delete key
|
||||
|
||||
Args:
|
||||
event: keyPressEvent
|
||||
"""
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
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.
|
||||
@@ -66,211 +63,3 @@ 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
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import itertools
|
||||
from typing import Type
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
|
||||
class WidgetContainerUtils:
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
|
||||
"""
|
||||
Generate a unique widget ID.
|
||||
|
||||
Args:
|
||||
container(dict): The container of widgets.
|
||||
prefix(str): The prefix of the widget ID.
|
||||
|
||||
Returns:
|
||||
widget_id(str): The unique widget ID.
|
||||
"""
|
||||
existing_ids = set(container.keys())
|
||||
for i in itertools.count(1):
|
||||
widget_id = f"{prefix}_{i}"
|
||||
if widget_id not in existing_ids:
|
||||
return widget_id
|
||||
|
||||
@staticmethod
|
||||
def find_first_widget_by_class(
|
||||
container: dict, widget_class: Type[QWidget], can_fail: bool = True
|
||||
) -> QWidget | None:
|
||||
"""
|
||||
Find the first widget of a given class in the figure.
|
||||
|
||||
Args:
|
||||
container(dict): The container of widgets.
|
||||
widget_class(Type): The class of the widget to find.
|
||||
can_fail(bool): If True, the method will return None if no widget is found. If False, it will raise an error.
|
||||
|
||||
Returns:
|
||||
widget: The widget of the given class.
|
||||
"""
|
||||
for widget_id, widget in container.items():
|
||||
if isinstance(widget, widget_class):
|
||||
return widget
|
||||
if can_fail:
|
||||
return None
|
||||
else:
|
||||
raise ValueError(f"No widget of class {widget_class} found.")
|
||||
@@ -2,22 +2,20 @@ import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
# from qtpy.QtCore import QObject, pyqtSignal
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import QObject, Signal as pyqtSignal
|
||||
|
||||
|
||||
class Crosshair(QObject):
|
||||
# Signal for 1D plot
|
||||
coordinatesChanged1D = pyqtSignal(tuple)
|
||||
coordinatesClicked1D = pyqtSignal(tuple)
|
||||
coordinatesChanged1D = pyqtSignal(float, list)
|
||||
coordinatesClicked1D = pyqtSignal(float, list)
|
||||
# Signal for 2D plot
|
||||
coordinatesChanged2D = pyqtSignal(tuple)
|
||||
coordinatesClicked2D = pyqtSignal(tuple)
|
||||
coordinatesChanged2D = pyqtSignal(float, float)
|
||||
coordinatesClicked2D = pyqtSignal(float, float)
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
|
||||
def __init__(self, plot_item: pg.PlotItem, precision: int = None, parent=None):
|
||||
"""
|
||||
Crosshair for 1D and 2D plots.
|
||||
|
||||
Args:
|
||||
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
|
||||
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
|
||||
@@ -174,11 +172,10 @@ class Crosshair(QObject):
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
if x is None or all(v is None for v in y_values):
|
||||
return
|
||||
coordinate_to_emit = (
|
||||
self.coordinatesChanged1D.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)],
|
||||
@@ -187,8 +184,7 @@ class Crosshair(QObject):
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
if x is None or y_values is None:
|
||||
return
|
||||
coordinate_to_emit = (x, y_values)
|
||||
self.coordinatesChanged2D.emit(coordinate_to_emit)
|
||||
self.coordinatesChanged2D.emit(x, y_values)
|
||||
|
||||
def mouse_clicked(self, event):
|
||||
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
|
||||
@@ -211,11 +207,10 @@ class Crosshair(QObject):
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
if x is None or all(v is None for v in y_values):
|
||||
return
|
||||
coordinate_to_emit = (
|
||||
self.coordinatesClicked1D.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(
|
||||
@@ -225,8 +220,7 @@ class Crosshair(QObject):
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
if x is None or y_values is None:
|
||||
return
|
||||
coordinate_to_emit = (x, y_values)
|
||||
self.coordinatesClicked2D.emit(coordinate_to_emit)
|
||||
self.coordinatesClicked2D.emit(x, y_values)
|
||||
self.marker_2d.setPos([x, y_values])
|
||||
|
||||
def check_log(self):
|
||||
|
||||
39
bec_widgets/utils/ctrl_c.py
Normal file
39
bec_widgets/utils/ctrl_c.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# TODO haven't found yet how to deal with QAbstractSocket in qtpy
|
||||
# import signal
|
||||
# import socket
|
||||
# from PyQt5.QtNetwork import QAbstractSocket
|
||||
#
|
||||
#
|
||||
# def setup(app):
|
||||
# app.signalwatchdog = SignalWatchdog() # need to store to keep socket pair alive
|
||||
# signal.signal(signal.SIGINT, make_quit_handler(app))
|
||||
#
|
||||
#
|
||||
# def make_quit_handler(app):
|
||||
# def handler(*args):
|
||||
# print() # make ^C appear on its own line
|
||||
# app.quit()
|
||||
#
|
||||
# return handler
|
||||
#
|
||||
#
|
||||
# class SignalWatchdog(QAbstractSocket):
|
||||
# def __init__(self):
|
||||
# """
|
||||
# Propagates system signals from Python to QEventLoop
|
||||
# adapted from https://stackoverflow.com/a/65802260/655404
|
||||
# """
|
||||
# super().__init__(QAbstractSocket.SctpSocket, None)
|
||||
#
|
||||
# self.writer, self.reader = writer, reader = socket.socketpair()
|
||||
# writer.setblocking(False)
|
||||
#
|
||||
# fd_writer = writer.fileno()
|
||||
# fd_reader = reader.fileno()
|
||||
#
|
||||
# signal.set_wakeup_fd(fd_writer) # Python hook
|
||||
# self.setSocketDescriptor(fd_reader) # Qt hook
|
||||
#
|
||||
# self.readyRead.connect(
|
||||
# lambda: None
|
||||
# ) # dummy function call that lets the Python interpreter run
|
||||
@@ -3,16 +3,6 @@ class EntryValidator:
|
||||
self.devices = devices
|
||||
|
||||
def validate_signal(self, name: str, entry: str = None) -> str:
|
||||
"""
|
||||
Validate a signal entry for a given device. If the entry is not provided, the first signal entry will be used from the device hints.
|
||||
|
||||
Args:
|
||||
name(str): Device name
|
||||
entry(str): Signal entry
|
||||
|
||||
Returns:
|
||||
str: Signal entry
|
||||
"""
|
||||
if name not in self.devices:
|
||||
raise ValueError(f"Device '{name}' not found in current BEC session")
|
||||
|
||||
@@ -25,18 +15,3 @@ class EntryValidator:
|
||||
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
|
||||
|
||||
return entry
|
||||
|
||||
def validate_monitor(self, monitor: str) -> str:
|
||||
"""
|
||||
Validate a monitor entry for a given device.
|
||||
|
||||
Args:
|
||||
monitor(str): Monitor entry
|
||||
|
||||
Returns:
|
||||
str: Monitor entry
|
||||
"""
|
||||
if monitor not in self.devices:
|
||||
raise ValueError(f"Device '{monitor}' not found in current BEC session")
|
||||
|
||||
return monitor
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
from collections import OrderedDict
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import QGridLayout, QWidget
|
||||
|
||||
|
||||
class GridLayoutManager:
|
||||
"""
|
||||
GridLayoutManager class is used to manage widgets in a QGridLayout and extend its functionality.
|
||||
|
||||
The GridLayoutManager class provides methods to add, move, and check the position of widgets in a QGridLayout.
|
||||
It also provides a method to get the positions of all widgets in the layout.
|
||||
|
||||
Args:
|
||||
layout(QGridLayout): The layout to manage.
|
||||
"""
|
||||
|
||||
def __init__(self, layout: QGridLayout):
|
||||
self.layout = layout
|
||||
|
||||
def is_position_occupied(self, row: int, col: int) -> bool:
|
||||
"""
|
||||
Check if the position in the layout is occupied by a widget.
|
||||
|
||||
Args:
|
||||
row(int): The row to check.
|
||||
col(int): The column to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the position is occupied, False otherwise.
|
||||
"""
|
||||
for i in range(self.layout.count()):
|
||||
widget_row, widget_col, _, _ = self.layout.getItemPosition(i)
|
||||
if widget_row == row and widget_col == col:
|
||||
return True
|
||||
return False
|
||||
|
||||
def shift_widgets(
|
||||
self,
|
||||
direction: Literal["down", "up", "left", "right"] = "down",
|
||||
start_row: int = 0,
|
||||
start_col: int = 0,
|
||||
):
|
||||
"""
|
||||
Shift widgets in the layout in the specified direction starting from the specified position.
|
||||
|
||||
Args:
|
||||
direction(str): The direction to shift the widgets. Can be "down", "up", "left", or "right".
|
||||
start_row(int): The row to start shifting from. Default is 0.
|
||||
start_col(int): The column to start shifting from. Default is 0.
|
||||
"""
|
||||
for i in reversed(range(self.layout.count())):
|
||||
widget_item = self.layout.itemAt(i)
|
||||
widget = widget_item.widget()
|
||||
row, col, rowspan, colspan = self.layout.getItemPosition(i)
|
||||
if direction == "down" and row >= start_row:
|
||||
self.layout.addWidget(widget, row + 1, col, rowspan, colspan)
|
||||
elif direction == "up" and row > start_row:
|
||||
self.layout.addWidget(widget, row - 1, col, rowspan, colspan)
|
||||
elif direction == "right" and col >= start_col:
|
||||
self.layout.addWidget(widget, row, col + 1, rowspan, colspan)
|
||||
elif direction == "left" and col > start_col:
|
||||
self.layout.addWidget(widget, row, col - 1, rowspan, colspan)
|
||||
|
||||
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
|
||||
"""
|
||||
Move a widget to a new position in the layout.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to move.
|
||||
new_row(int): The new row to move the widget to.
|
||||
new_col(int): The new column to move the widget to.
|
||||
"""
|
||||
self.layout.removeWidget(widget)
|
||||
self.layout.addWidget(widget, new_row, new_col)
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
widget: QWidget,
|
||||
row=None,
|
||||
col=0,
|
||||
rowspan=1,
|
||||
colspan=1,
|
||||
shift: Literal["down", "up", "left", "right"] = "down",
|
||||
):
|
||||
"""
|
||||
Add a widget to the layout at the specified position.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to add.
|
||||
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
|
||||
col(int): The column to add the widget to. Default is 0.
|
||||
rowspan(int): The number of rows the widget will span. Default is 1.
|
||||
colspan(int): The number of columns the widget will span. Default is 1.
|
||||
shift(str): The direction to shift the widgets if the position is occupied. Can be "down", "up", "left", or "right".
|
||||
"""
|
||||
if row is None:
|
||||
row = self.layout.rowCount()
|
||||
if self.is_position_occupied(row, col):
|
||||
self.shift_widgets(shift, start_row=row)
|
||||
self.layout.addWidget(widget, row, col, rowspan, colspan)
|
||||
|
||||
def get_widgets_positions(self) -> dict:
|
||||
"""
|
||||
Get the positions of all widgets in the layout.
|
||||
Returns:
|
||||
dict: A dictionary with the positions of the widgets in the layout.
|
||||
|
||||
"""
|
||||
positions = []
|
||||
for i in range(self.layout.count()):
|
||||
widget_item = self.layout.itemAt(i)
|
||||
widget = widget_item.widget()
|
||||
if widget:
|
||||
position = self.layout.getItemPosition(i)
|
||||
positions.append((position, widget))
|
||||
positions.sort(key=lambda x: (x[0][0], x[0][1], x[0][2], x[0][3]))
|
||||
ordered_positions = OrderedDict()
|
||||
for pos, widget in positions:
|
||||
ordered_positions[pos] = widget
|
||||
return ordered_positions
|
||||
@@ -1,88 +0,0 @@
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
|
||||
def get_plugin_widgets() -> dict[str, BECConnector]:
|
||||
"""
|
||||
Get all available widgets from the plugin directory. Widgets are classes that inherit from BECConnector.
|
||||
The plugins are provided through python plugins and specified in the respective pyproject.toml file using
|
||||
the following key:
|
||||
|
||||
[project.entry-points."bec.widgets.user_widgets"]
|
||||
plugin_widgets = "path.to.plugin.module"
|
||||
|
||||
e.g.
|
||||
[project.entry-points."bec.widgets.user_widgets"]
|
||||
plugin_widgets = "pxiii_bec.bec_widgets.widgets"
|
||||
|
||||
assuming that the widgets module for the package pxiii_bec is located at pxiii_bec/bec_widgets/widgets and
|
||||
contains the widgets to be loaded within the pxiii_bec/bec_widgets/widgets/__init__.py file.
|
||||
|
||||
Returns:
|
||||
dict[str, BECConnector]: A dictionary of widget names and their respective classes.
|
||||
"""
|
||||
modules = _get_available_plugins("bec.widgets.user_widgets")
|
||||
loaded_plugins = {}
|
||||
print(modules)
|
||||
for module in modules:
|
||||
mods = inspect.getmembers(module, predicate=_filter_plugins)
|
||||
for name, mod_cls in mods:
|
||||
if name in loaded_plugins:
|
||||
print(f"Duplicated widgets plugin {name}.")
|
||||
loaded_plugins[name] = mod_cls
|
||||
return loaded_plugins
|
||||
|
||||
|
||||
def _filter_plugins(obj):
|
||||
return inspect.isclass(obj) and issubclass(obj, BECConnector)
|
||||
|
||||
|
||||
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}
|
||||
@@ -1,37 +0,0 @@
|
||||
import threading
|
||||
|
||||
|
||||
class ThreadTracker:
|
||||
def __init__(self, exclude_names=None):
|
||||
self.exclude_names = exclude_names if exclude_names else []
|
||||
self.initial_threads = self._capture_threads()
|
||||
|
||||
def _capture_threads(self):
|
||||
return set(
|
||||
th
|
||||
for th in threading.enumerate()
|
||||
if not any(ex_name in th.name for ex_name in self.exclude_names)
|
||||
and th is not threading.main_thread()
|
||||
)
|
||||
|
||||
def _thread_info(self, threads):
|
||||
return ", \n".join(f"{th.name}(ID: {th.ident})" for th in threads)
|
||||
|
||||
def check_unfinished_threads(self):
|
||||
current_threads = self._capture_threads()
|
||||
additional_threads = current_threads - self.initial_threads
|
||||
closed_threads = self.initial_threads - current_threads
|
||||
if additional_threads:
|
||||
raise Exception(
|
||||
f"###### Initial threads ######:\n {self._thread_info(self.initial_threads)}\n"
|
||||
f"###### Current threads ######:\n {self._thread_info(current_threads)}\n"
|
||||
f"###### Closed threads ######:\n {self._thread_info(closed_threads)}\n"
|
||||
f"###### Unfinished threads ######:\n {self._thread_info(additional_threads)}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"All threads properly closed.\n"
|
||||
f"###### Initial threads ######:\n {self._thread_info(self.initial_threads)}\n"
|
||||
f"###### Current threads ######:\n {self._thread_info(current_threads)}\n"
|
||||
f"###### Closed threads ######:\n {self._thread_info(closed_threads)}"
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
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)
|
||||
@@ -2,7 +2,7 @@
|
||||
# from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit
|
||||
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QLineEdit, QStyledItemDelegate
|
||||
from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit
|
||||
|
||||
|
||||
class DoubleValidationDelegate(QStyledItemDelegate):
|
||||
|
||||
@@ -3,16 +3,16 @@ from abc import ABC, abstractmethod
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QLabel,
|
||||
QWidget,
|
||||
QLineEdit,
|
||||
QSpinBox,
|
||||
QComboBox,
|
||||
QTableWidget,
|
||||
QSpinBox,
|
||||
QDoubleSpinBox,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QCheckBox,
|
||||
QLabel,
|
||||
)
|
||||
|
||||
|
||||
@@ -114,7 +114,6 @@ class WidgetIO:
|
||||
def get_value(widget, ignore_errors=False):
|
||||
"""
|
||||
Retrieve value from the widget instance.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
@@ -130,7 +129,6 @@ class WidgetIO:
|
||||
def set_value(widget, value, ignore_errors=False):
|
||||
"""
|
||||
Set a value on the widget instance.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
value: Value to set.
|
||||
@@ -157,7 +155,6 @@ class WidgetHierarchy:
|
||||
) -> None:
|
||||
"""
|
||||
Print the widget hierarchy to the console.
|
||||
|
||||
Args:
|
||||
widget: Widget to print the hierarchy of
|
||||
indent(int, optional): Level of indentation.
|
||||
@@ -199,7 +196,6 @@ class WidgetHierarchy:
|
||||
) -> dict:
|
||||
"""
|
||||
Export the widget hierarchy to a dictionary.
|
||||
|
||||
Args:
|
||||
widget: Widget to print the hierarchy of.
|
||||
config(dict,optional): Dictionary to export the hierarchy to.
|
||||
@@ -249,7 +245,6 @@ class WidgetHierarchy:
|
||||
def import_config_from_dict(widget, config: dict, set_values: bool = False) -> None:
|
||||
"""
|
||||
Import the widget hierarchy from a dictionary.
|
||||
|
||||
Args:
|
||||
widget: Widget to import the hierarchy to.
|
||||
config:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
|
||||
from typing import Union
|
||||
|
||||
import yaml
|
||||
from qtpy.QtWidgets import QFileDialog
|
||||
|
||||
@@ -9,7 +8,6 @@ from qtpy.QtWidgets import QFileDialog
|
||||
def load_yaml(instance) -> Union[dict, None]:
|
||||
"""
|
||||
Load YAML file from disk.
|
||||
|
||||
Args:
|
||||
instance: Instance of the calling widget.
|
||||
|
||||
@@ -41,7 +39,6 @@ def load_yaml(instance) -> Union[dict, None]:
|
||||
def save_yaml(instance, config: dict) -> None:
|
||||
"""
|
||||
Save YAML file to disk.
|
||||
|
||||
Args:
|
||||
instance: Instance of the calling widget.
|
||||
config: Configuration data to be saved.
|
||||
|
||||
2
bec_widgets/validation/__init__.py
Normal file
2
bec_widgets/validation/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# from .monitor_config import validate_monitor_config, ValidationError
|
||||
from .monitor_config_validator import MonitorConfigValidator
|
||||
257
bec_widgets/validation/monitor_config_validator.py
Normal file
257
bec_widgets/validation/monitor_config_validator.py
Normal file
@@ -0,0 +1,257 @@
|
||||
from typing import Optional, Union, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator, ValidationError
|
||||
from pydantic_core import PydanticCustomError
|
||||
|
||||
|
||||
class Signal(BaseModel):
|
||||
"""
|
||||
Represents a signal in a plot configuration.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the signal.
|
||||
entry (Optional[str]): The entry point of the signal, optional.
|
||||
"""
|
||||
|
||||
name: str
|
||||
entry: Optional[str] = Field(None, validate_default=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def validate_fields(cls, values):
|
||||
"""Validate the fields of the model.
|
||||
First validate the 'name' field, then validate the 'entry' field.
|
||||
Args:
|
||||
values (dict): The values to be validated."""
|
||||
devices = MonitorConfigValidator.devices
|
||||
|
||||
# Validate 'name'
|
||||
name = values.get("name")
|
||||
|
||||
# Check if device name provided
|
||||
if name is None:
|
||||
raise PydanticCustomError(
|
||||
"no_device_name", "Device name must be provided", {"wrong_value": name}
|
||||
)
|
||||
# Check if device exists in BEC
|
||||
if name not in devices:
|
||||
raise PydanticCustomError(
|
||||
"no_device_bec",
|
||||
'Device "{wrong_value}" not found in current BEC session',
|
||||
{"wrong_value": name},
|
||||
)
|
||||
|
||||
device = devices[name] # get the device to check if it has signals
|
||||
|
||||
# Get device description
|
||||
description = device.describe()
|
||||
|
||||
# Validate 'entry'
|
||||
entry = values.get("entry")
|
||||
|
||||
# Set entry based on hints if not provided
|
||||
if entry is None:
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in description:
|
||||
raise PydanticCustomError(
|
||||
"no_entry_for_device",
|
||||
'Entry "{wrong_value}" not found in device "{device_name}" signals',
|
||||
{"wrong_value": entry, "device_name": name},
|
||||
)
|
||||
|
||||
values["entry"] = entry
|
||||
return values
|
||||
|
||||
|
||||
class AxisSignal(BaseModel):
|
||||
"""
|
||||
Configuration signal axis for a single plot.
|
||||
Attributes:
|
||||
x (list): Signal for the X axis.
|
||||
y (list): Signals for the Y axis.
|
||||
"""
|
||||
|
||||
x: list[Signal] = Field(default_factory=list)
|
||||
y: list[Signal] = Field(default_factory=list)
|
||||
|
||||
@field_validator("x")
|
||||
@classmethod
|
||||
def validate_x_signals(cls, v):
|
||||
"""Ensure that there is only one signal for x-axis."""
|
||||
if len(v) != 1:
|
||||
raise PydanticCustomError(
|
||||
"x_axis_multiple_signals",
|
||||
'There must be exactly one signal for x axis. Number of x signals: "{wrong_value}"',
|
||||
{"wrong_value": v},
|
||||
)
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class SourceHistoryValidator(BaseModel):
|
||||
"""History source validator
|
||||
Attributes:
|
||||
type (str): type of source - history
|
||||
scanID (str): Scan ID for history source.
|
||||
signals (list): Signal for the source.
|
||||
"""
|
||||
|
||||
type: Literal["history"]
|
||||
scanID: str # TODO can be validated if it is a valid scanID
|
||||
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)
|
||||
scanID (Optional[str]): Scan ID for history source.
|
||||
signals (Optional[AxisSignal]): Signal for the source.
|
||||
"""
|
||||
|
||||
type: Literal["scan_segment", "history", "redis"]
|
||||
scanID: 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,5 +1,15 @@
|
||||
# from .buttons import StopButton
|
||||
# from .dock import BECDock, BECDockArea
|
||||
# from .figure import BECFigure, FigureConfig
|
||||
# from .scan_control import ScanControl
|
||||
# from .spiral_progress_bar import SpiralProgressBar
|
||||
from .monitor import BECMonitor, ConfigDialog
|
||||
from .motor_map import MotorMap
|
||||
from .scan_control import ScanControl
|
||||
from .toolbar import ModularToolBar
|
||||
from .editor import BECEditor
|
||||
from .monitor_scatter_2D import BECMonitor2DScatter
|
||||
from .motor_control import (
|
||||
MotorControlRelative,
|
||||
MotorControlAbsolute,
|
||||
MotorControlSelection,
|
||||
MotorThread,
|
||||
MotorCoordinateTable,
|
||||
)
|
||||
from .figure import FigureConfig, BECFigure
|
||||
from .plots import BECWaveform1D, BECCurve, BECPlotBase
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .stop_button.stop_button import StopButton
|
||||
@@ -1,32 +0,0 @@
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
|
||||
class StopButton(BECConnector, QPushButton):
|
||||
"""A button that stops the current scan."""
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QPushButton.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
self.setText("Stop")
|
||||
self.setStyleSheet("background-color: #cc181e; color: white")
|
||||
self.clicked.connect(self.stop_scan)
|
||||
|
||||
def stop_scan(self):
|
||||
"""Stop the scan."""
|
||||
self.queue.request_scan_abortion()
|
||||
self.queue.request_queue_reset()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = StopButton()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,2 +0,0 @@
|
||||
from .device_combobox.device_combobox import DeviceComboBox
|
||||
from .device_line_edit.device_line_edit import DeviceLineEdit
|
||||
@@ -1,95 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtWidgets import QComboBox
|
||||
|
||||
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase, DeviceInputConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputConfig
|
||||
|
||||
|
||||
class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
"""
|
||||
Line edit widget for device input with autocomplete for device names.
|
||||
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
client: BEC client object.
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class.
|
||||
default_device: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: DeviceInputConfig = None,
|
||||
gui_id: str | None = None,
|
||||
device_filter: str | None = None,
|
||||
default_device: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QComboBox.__init__(self, parent=parent)
|
||||
|
||||
self.populate_combobox()
|
||||
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
if device_filter is not None:
|
||||
self.set_device_filter(device_filter)
|
||||
if default_device is not None:
|
||||
self.set_default_device(default_device)
|
||||
|
||||
def set_device_filter(self, device_filter: str):
|
||||
"""
|
||||
Set the device filter.
|
||||
|
||||
Args:
|
||||
device_filter(str): Device filter, name of the device class.
|
||||
"""
|
||||
super().set_device_filter(device_filter)
|
||||
self.populate_combobox()
|
||||
|
||||
def set_default_device(self, default_device: str):
|
||||
"""
|
||||
Set the default device.
|
||||
|
||||
Args:
|
||||
default_device(str): Default device name.
|
||||
"""
|
||||
super().set_default_device(default_device)
|
||||
self.setCurrentText(default_device)
|
||||
|
||||
def populate_combobox(self):
|
||||
"""Populate the combobox with the devices."""
|
||||
self.devices = self.get_device_list(self.config.device_filter)
|
||||
self.clear()
|
||||
self.addItems(self.devices)
|
||||
|
||||
def get_device(self) -> object:
|
||||
"""
|
||||
Get the selected device object.
|
||||
|
||||
Returns:
|
||||
object: Device object.
|
||||
"""
|
||||
device_name = self.currentText()
|
||||
device_obj = getattr(self.dev, device_name.lower(), None)
|
||||
if device_obj is None:
|
||||
raise ValueError(f"Device {device_name} is not found.")
|
||||
return device_obj
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = DeviceComboBox(default_device="samx")
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,120 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
|
||||
|
||||
class DeviceInputConfig(ConnectionConfig):
|
||||
device_filter: str | list[str] | None = None
|
||||
default_device: str | None = None
|
||||
arg_name: str | None = None
|
||||
|
||||
|
||||
class DeviceInputBase(BECConnector):
|
||||
"""
|
||||
Mixin class for device input widgets. This class provides methods to get the device list and device object based
|
||||
on the current text of the widget.
|
||||
"""
|
||||
|
||||
def __init__(self, client=None, config=None, gui_id=None):
|
||||
if config is None:
|
||||
config = DeviceInputConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DeviceInputConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
self._devices = []
|
||||
|
||||
@property
|
||||
def devices(self) -> list[str]:
|
||||
"""
|
||||
Get the list of devices.
|
||||
|
||||
Returns:
|
||||
list[str]: List of devices.
|
||||
"""
|
||||
return self._devices
|
||||
|
||||
@devices.setter
|
||||
def devices(self, value: list[str]):
|
||||
"""
|
||||
Set the list of devices.
|
||||
|
||||
Args:
|
||||
value: List of devices.
|
||||
"""
|
||||
self._devices = value
|
||||
|
||||
def set_device_filter(self, device_filter: str | list[str]):
|
||||
"""
|
||||
Set the device filter.
|
||||
|
||||
Args:
|
||||
device_filter(str): Device filter, name of the device class.
|
||||
"""
|
||||
self.validate_device_filter(device_filter)
|
||||
self.config.device_filter = device_filter
|
||||
|
||||
def set_default_device(self, default_device: str):
|
||||
"""
|
||||
Set the default device.
|
||||
|
||||
Args:
|
||||
default_device(str): Default device name.
|
||||
"""
|
||||
self.validate_device(default_device)
|
||||
self.config.default_device = default_device
|
||||
|
||||
def get_device_list(self, filter: str | list[str] | None = None) -> list[str]:
|
||||
"""
|
||||
Get the list of device names based on the filter of current BEC client.
|
||||
|
||||
Args:
|
||||
filter(str|None): Class name filter to apply on the device list.
|
||||
|
||||
Returns:
|
||||
devices(list[str]): List of device names.
|
||||
"""
|
||||
all_devices = self.dev.enabled_devices
|
||||
if filter is not None:
|
||||
self.validate_device_filter(filter)
|
||||
if isinstance(filter, str):
|
||||
filter = [filter]
|
||||
devices = [device.name for device in all_devices if device.__class__.__name__ in filter]
|
||||
else:
|
||||
devices = [device.name for device in all_devices]
|
||||
return devices
|
||||
|
||||
def get_available_filters(self):
|
||||
"""
|
||||
Get the available device classes which can be used as filters.
|
||||
"""
|
||||
all_devices = self.dev.enabled_devices
|
||||
filters = {device.__class__.__name__ for device in all_devices}
|
||||
return filters
|
||||
|
||||
def validate_device_filter(self, filter: str | list[str]) -> None:
|
||||
"""
|
||||
Validate the device filter if the class name is present in the current BEC instance.
|
||||
|
||||
Args:
|
||||
filter(str|list[str]): Class name to use as a device filter.
|
||||
"""
|
||||
if isinstance(filter, str):
|
||||
filter = [filter]
|
||||
available_filters = self.get_available_filters()
|
||||
for f in filter:
|
||||
if f not in available_filters:
|
||||
raise ValueError(f"Device filter {f} is not valid.")
|
||||
|
||||
def validate_device(self, device: str) -> None:
|
||||
"""
|
||||
Validate the device if it is present in current BEC instance.
|
||||
|
||||
Args:
|
||||
device(str): Device to validate.
|
||||
"""
|
||||
if device not in self.get_device_list(self.config.device_filter):
|
||||
raise ValueError(f"Device {device} is not valid.")
|
||||
@@ -1,102 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtWidgets import QCompleter, QLineEdit
|
||||
|
||||
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase, DeviceInputConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputConfig
|
||||
|
||||
|
||||
class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
"""
|
||||
Line edit widget for device input with autocomplete for device names.
|
||||
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
client: BEC client object.
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class.
|
||||
default_device: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: DeviceInputConfig = None,
|
||||
gui_id: str | None = None,
|
||||
device_filter: str | list[str] | None = None,
|
||||
default_device: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
):
|
||||
QLineEdit.__init__(self, parent=parent)
|
||||
DeviceInputBase.__init__(self, client=client, config=config, gui_id=gui_id)
|
||||
|
||||
self.completer = QCompleter(self)
|
||||
self.setCompleter(self.completer)
|
||||
self.populate_completer()
|
||||
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
if device_filter is not None:
|
||||
self.set_device_filter(device_filter)
|
||||
if default_device is not None:
|
||||
self.set_default_device(default_device)
|
||||
|
||||
def set_device_filter(self, device_filter: str | list[str]):
|
||||
"""
|
||||
Set the device filter.
|
||||
|
||||
Args:
|
||||
device_filter (str | list[str]): Device filter, name of the device class.
|
||||
"""
|
||||
super().set_device_filter(device_filter)
|
||||
self.populate_completer()
|
||||
|
||||
def set_default_device(self, default_device: str):
|
||||
"""
|
||||
Set the default device.
|
||||
|
||||
Args:
|
||||
default_device (str): Default device name.
|
||||
"""
|
||||
super().set_default_device(default_device)
|
||||
self.setText(default_device)
|
||||
|
||||
def populate_completer(self):
|
||||
"""Populate the completer with the devices."""
|
||||
self.devices = self.get_device_list(self.config.device_filter)
|
||||
self.completer.setModel(self.create_completer_model(self.devices))
|
||||
|
||||
def create_completer_model(self, devices: list[str]):
|
||||
"""Create a model for the completer."""
|
||||
from qtpy.QtCore import QStringListModel
|
||||
|
||||
return QStringListModel(devices, self)
|
||||
|
||||
def get_device(self) -> object:
|
||||
"""
|
||||
Get the selected device object.
|
||||
|
||||
Returns:
|
||||
object: Device object.
|
||||
"""
|
||||
device_name = self.text()
|
||||
device_obj = getattr(self.dev, device_name.lower(), None)
|
||||
if device_obj is None:
|
||||
raise ValueError(f"Device {device_name} is not found.")
|
||||
return device_obj
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = DeviceLineEdit(default_device="samx")
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,2 +0,0 @@
|
||||
from .dock import BECDock
|
||||
from .dock_area import BECDockArea
|
||||
@@ -1,249 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea import Dock
|
||||
|
||||
from bec_widgets.cli.rpc_wigdet_handler import widget_handler
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig, GridLayoutManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.widgets.dock import BECDockArea
|
||||
|
||||
|
||||
class DockConfig(ConnectionConfig):
|
||||
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."
|
||||
)
|
||||
parent_dock_area: Optional[str] = Field(
|
||||
None, description="The GUI ID of parent dock area of the dock."
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
"list_eligible_widgets",
|
||||
"move_widget",
|
||||
"remove_widget",
|
||||
"remove",
|
||||
"attach",
|
||||
"detach",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
parent_dock_area: BECDockArea | None = None,
|
||||
config: DockConfig | None = None,
|
||||
name: str | None = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = DockConfig(
|
||||
widget_class=self.__class__.__name__, parent_dock_area=parent_dock_area.gui_id
|
||||
)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DockConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
Dock.__init__(self, name=name, **kwargs)
|
||||
|
||||
# Layout Manager
|
||||
self.layout_manager = GridLayoutManager(self.layout)
|
||||
|
||||
def dropEvent(self, event):
|
||||
source = event.source()
|
||||
old_area = source.area
|
||||
self.setOrientation("horizontal", force=True)
|
||||
super().dropEvent(event)
|
||||
if old_area in self.orig_area.tempAreas and old_area != self.orig_area:
|
||||
self.orig_area.removeTempArea(old_area)
|
||||
|
||||
def float(self):
|
||||
"""
|
||||
Float the dock.
|
||||
Overwrites the default pyqtgraph dock float.
|
||||
"""
|
||||
|
||||
# need to check if the dock is temporary and if it is the only dock in the area
|
||||
# fixes bug in pyqtgraph detaching
|
||||
if self.area.temporary == True and len(self.area.docks) <= 1:
|
||||
return
|
||||
elif self.area.temporary == True and len(self.area.docks) > 1:
|
||||
self.area.docks.pop(self.name(), None)
|
||||
super().float()
|
||||
else:
|
||||
super().float()
|
||||
|
||||
@property
|
||||
def widget_list(self) -> list[BECConnector]:
|
||||
"""
|
||||
Get the widgets in the dock.
|
||||
|
||||
Returns:
|
||||
widgets(list): The widgets in the dock.
|
||||
"""
|
||||
return self.widgets
|
||||
|
||||
@widget_list.setter
|
||||
def widget_list(self, value: list[BECConnector]):
|
||||
self.widgets = value
|
||||
|
||||
def hide_title_bar(self):
|
||||
"""
|
||||
Hide the title bar of the dock.
|
||||
"""
|
||||
# self.hideTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation
|
||||
self.label.hide()
|
||||
self.labelHidden = True
|
||||
|
||||
def show_title_bar(self):
|
||||
"""
|
||||
Hide the title bar of the dock.
|
||||
"""
|
||||
# self.showTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation
|
||||
self.label.show()
|
||||
self.labelHidden = False
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""
|
||||
Set the title of the dock.
|
||||
|
||||
Args:
|
||||
title(str): The title of the dock.
|
||||
"""
|
||||
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
|
||||
self.setTitle(title)
|
||||
self._name = title
|
||||
|
||||
def get_widgets_positions(self) -> dict:
|
||||
"""
|
||||
Get the positions of the widgets in the dock.
|
||||
|
||||
Returns:
|
||||
dict: The positions of the widgets in the dock as dict -> {(row, col, rowspan, colspan):widget}
|
||||
"""
|
||||
return self.layout_manager.get_widgets_positions()
|
||||
|
||||
def list_eligible_widgets(
|
||||
self,
|
||||
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
|
||||
"""
|
||||
List all widgets that can be added to the dock.
|
||||
|
||||
Returns:
|
||||
list: The list of eligible widgets.
|
||||
"""
|
||||
return list(widget_handler.widget_classes.keys())
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
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.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to add.
|
||||
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
|
||||
col(int): The column to add the widget to.
|
||||
rowspan(int): The number of rows the widget should span.
|
||||
colspan(int): The number of columns the widget should span.
|
||||
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
|
||||
"""
|
||||
if row is None:
|
||||
row = self.layout.rowCount()
|
||||
|
||||
if self.layout_manager.is_position_occupied(row, col):
|
||||
self.layout_manager.shift_widgets(shift, start_row=row)
|
||||
|
||||
if isinstance(widget, str):
|
||||
widget = widget_handler.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.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to move.
|
||||
new_row(int): The new row to move the widget to.
|
||||
new_col(int): The new column to move the widget to.
|
||||
"""
|
||||
self.layout_manager.move_widget(widget, new_row, new_col)
|
||||
|
||||
def attach(self):
|
||||
"""
|
||||
Attach the dock to the parent dock area.
|
||||
"""
|
||||
self.orig_area.removeTempArea(self.area)
|
||||
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the dock from the parent dock area.
|
||||
"""
|
||||
self.float()
|
||||
|
||||
def remove_widget(self, widget_rpc_id: str):
|
||||
"""
|
||||
Remove a widget from the dock.
|
||||
|
||||
Args:
|
||||
widget_rpc_id(str): The ID of the widget to remove.
|
||||
"""
|
||||
widget = self.rpc_register.get_rpc_by_id(widget_rpc_id)
|
||||
self.layout.removeWidget(widget)
|
||||
self.config.widgets.pop(widget_rpc_id, None)
|
||||
widget.close()
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the dock from the parent dock area.
|
||||
"""
|
||||
# self.cleanup()
|
||||
self.orig_area.remove_dock(self.name())
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the dock, including all its widgets.
|
||||
"""
|
||||
for widget in self.widgets:
|
||||
if hasattr(widget, "cleanup"):
|
||||
widget.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the dock area and cleanup.
|
||||
Has to be implemented to overwrite pyqtgraph event accept in Container close.
|
||||
"""
|
||||
self.cleanup()
|
||||
super().close()
|
||||
@@ -1,244 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea.DockArea import DockArea
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QPainter, QPaintEvent
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig, WidgetContainerUtils
|
||||
|
||||
from .dock import BECDock, DockConfig
|
||||
|
||||
|
||||
class DockAreaConfig(ConnectionConfig):
|
||||
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
|
||||
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",
|
||||
"restore_state",
|
||||
"add_dock",
|
||||
"clear_all",
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"get_all_rpc",
|
||||
"temp_areas",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: DockAreaConfig | None = None,
|
||||
client=None,
|
||||
gui_id: str = None,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = DockAreaConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DockAreaConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
DockArea.__init__(self, parent=parent)
|
||||
|
||||
self._instructions_visible = True
|
||||
|
||||
def paintEvent(self, event: QPaintEvent):
|
||||
super().paintEvent(event)
|
||||
if self._instructions_visible:
|
||||
painter = QPainter(self)
|
||||
painter.drawText(self.rect(), Qt.AlignCenter, "Add docks using 'add_dock' method")
|
||||
|
||||
@property
|
||||
def panels(self) -> dict[str, BECDock]:
|
||||
"""
|
||||
Get the docks in the dock area.
|
||||
Returns:
|
||||
dock_dict(dict): The docks in the dock area.
|
||||
"""
|
||||
return dict(self.docks)
|
||||
|
||||
@panels.setter
|
||||
def panels(self, value: dict[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"
|
||||
):
|
||||
"""
|
||||
Restore the state of the dock area. If no state is provided, the last state is restored.
|
||||
|
||||
Args:
|
||||
state(dict): The state to restore.
|
||||
missing(Literal['ignore','error']): What to do if a dock is missing.
|
||||
extra(str): Extra docks that are in the dockarea but that are not mentioned in state will be added to the bottom of the dockarea, unless otherwise specified by the extra argument.
|
||||
"""
|
||||
if state is None:
|
||||
state = self.config.docks_state
|
||||
self.restoreState(state, missing=missing, extra=extra)
|
||||
|
||||
def save_state(self) -> dict:
|
||||
"""
|
||||
Save the state of the dock area.
|
||||
|
||||
Returns:
|
||||
dict: The state of the dock area.
|
||||
"""
|
||||
last_state = self.saveState()
|
||||
self.config.docks_state = last_state
|
||||
return last_state
|
||||
|
||||
def remove_dock(self, name: str):
|
||||
"""
|
||||
Remove a dock by name and ensure it is properly closed and cleaned up.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock to remove.
|
||||
"""
|
||||
dock = self.docks.pop(name, None)
|
||||
self.config.docks.pop(name, None)
|
||||
if dock:
|
||||
dock.close()
|
||||
if len(self.docks) <= 1:
|
||||
for dock in self.docks.values():
|
||||
dock.hide_title_bar()
|
||||
|
||||
else:
|
||||
raise ValueError(f"Dock with name {name} does not exist.")
|
||||
|
||||
def add_dock(
|
||||
self,
|
||||
name: str = None,
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
|
||||
relative_to: BECDock | None = None,
|
||||
closable: bool = False,
|
||||
floating: bool = False,
|
||||
prefix: str = "dock",
|
||||
widget: str | QWidget | None = None,
|
||||
row: int = None,
|
||||
col: int = 0,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
) -> BECDock:
|
||||
"""
|
||||
Add a dock to the dock area. Dock has QGridLayout as layout manager by default.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock to be displayed and for further references. Has to be unique.
|
||||
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
|
||||
relative_to(BECDock): The dock to which the new dock should be added relative to.
|
||||
closable(bool): Whether the dock is closable.
|
||||
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.
|
||||
col(int): The column of the added widget.
|
||||
rowspan(int): The rowspan of the added widget.
|
||||
colspan(int): The colspan of the added widget.
|
||||
|
||||
Returns:
|
||||
BECDock: The created dock.
|
||||
"""
|
||||
if name is None:
|
||||
name = WidgetContainerUtils.generate_unique_widget_id(
|
||||
container=self.docks, prefix=prefix
|
||||
)
|
||||
|
||||
if name in set(self.docks.keys()):
|
||||
raise ValueError(f"Dock with name {name} already exists.")
|
||||
|
||||
if position is None:
|
||||
position = "bottom"
|
||||
|
||||
dock = BECDock(name=name, parent_dock_area=self, closable=closable)
|
||||
dock.config.position = position
|
||||
self.config.docks[name] = dock.config
|
||||
|
||||
self.addDock(dock=dock, position=position, relativeTo=relative_to)
|
||||
|
||||
if len(self.docks) <= 1:
|
||||
dock.hide_title_bar()
|
||||
elif len(self.docks) > 1:
|
||||
for dock in self.docks.values():
|
||||
dock.show_title_bar()
|
||||
|
||||
if widget is not None and isinstance(widget, str):
|
||||
dock.add_widget(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:
|
||||
"""
|
||||
Undock a dock from the dock area.
|
||||
|
||||
Args:
|
||||
dock_name(str): The dock to undock.
|
||||
|
||||
Returns:
|
||||
BECDock: The undocked dock.
|
||||
"""
|
||||
dock = self.docks[dock_name]
|
||||
dock.detach()
|
||||
return dock
|
||||
|
||||
def attach_all(self):
|
||||
"""
|
||||
Return all floating docks to the dock area.
|
||||
"""
|
||||
while self.tempAreas:
|
||||
for temp_area in self.tempAreas:
|
||||
self.removeTempArea(temp_area)
|
||||
|
||||
def clear_all(self):
|
||||
"""
|
||||
Close all docks and remove all temp areas.
|
||||
"""
|
||||
self.attach_all()
|
||||
for dock in dict(self.docks).values():
|
||||
dock.remove()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dock area.
|
||||
"""
|
||||
self.clear_all()
|
||||
super().cleanup()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the dock area and cleanup.
|
||||
Has to be implemented to overwrite pyqtgraph event accept in Container close.
|
||||
"""
|
||||
self.cleanup()
|
||||
super().close()
|
||||
1
bec_widgets/widgets/editor/__init__.py
Normal file
1
bec_widgets/widgets/editor/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .editor import BECEditor
|
||||
415
bec_widgets/widgets/editor/editor.py
Normal file
415
bec_widgets/widgets/editor/editor.py
Normal file
@@ -0,0 +1,415 @@
|
||||
import subprocess
|
||||
|
||||
import qdarktheme
|
||||
from jedi import Script
|
||||
from jedi.api import Completion
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from qtpy.Qsci import QsciScintilla, QsciLexerPython, QsciAPIs
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtCore import Signal, QThread
|
||||
from qtpy.QtGui import QColor, QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from qtpy.QtWidgets import QSplitter
|
||||
from qtconsole.manager import QtKernelManager
|
||||
from qtconsole.rich_jupyter_widget import RichJupyterWidget
|
||||
|
||||
from bec_widgets.widgets import ModularToolBar
|
||||
|
||||
|
||||
class AutoCompleter(QThread):
|
||||
"""Initializes the AutoCompleter thread for handling autocompletion and signature help.
|
||||
|
||||
Args:
|
||||
file_path (str): The path to the file for which autocompletion is required.
|
||||
api (QsciAPIs): The QScintilla API instance used for managing autocompletions.
|
||||
enable_docstring (bool, optional): Flag to determine if docstrings should be included in the signatures.
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: str, api: QsciAPIs, enable_docstring: bool = False):
|
||||
super().__init__(None)
|
||||
self.file_path = file_path
|
||||
self.script: Script = None
|
||||
self.api: QsciAPIs = api
|
||||
self.completions: list[Completion] = None
|
||||
self.line = 0
|
||||
self.index = 0
|
||||
self.text = ""
|
||||
|
||||
# TODO so far disabled, quite buggy, docstring extraction has to be generalised
|
||||
self.enable_docstring = enable_docstring
|
||||
|
||||
def update_script(self, text: str):
|
||||
"""Updates the script for Jedi completion based on the current editor text.
|
||||
|
||||
Args:
|
||||
text (str): The current text of the editor.
|
||||
"""
|
||||
if self.script is None or self.script.path != text:
|
||||
self.script = Script(text, path=self.file_path)
|
||||
|
||||
def run(self):
|
||||
"""Runs the thread for generating autocompletions. Overrides QThread.run."""
|
||||
self.update_script(self.text)
|
||||
try:
|
||||
self.completions = self.script.complete(self.line, self.index)
|
||||
self.load_autocomplete(self.completions)
|
||||
except Exception as err:
|
||||
print(err)
|
||||
self.finished.emit()
|
||||
|
||||
def get_function_signature(self, line: int, index: int, text: str) -> str:
|
||||
"""Fetches the function signature for a given position in the text.
|
||||
|
||||
Args:
|
||||
line (int): The line number in the editor.
|
||||
index (int): The index (column number) in the line.
|
||||
text (str): The current text of the editor.
|
||||
|
||||
Returns:
|
||||
str: A string containing the function signature or an empty string if not available.
|
||||
"""
|
||||
self.update_script(text)
|
||||
try:
|
||||
signatures = self.script.get_signatures(line, index)
|
||||
if signatures and self.enable_docstring is True:
|
||||
full_docstring = signatures[0].docstring(raw=True)
|
||||
compact_docstring = self.get_compact_docstring(full_docstring)
|
||||
return compact_docstring
|
||||
if signatures and self.enable_docstring is False:
|
||||
return signatures[0].to_string()
|
||||
except Exception as err:
|
||||
print(f"Signature Error:{err}")
|
||||
return ""
|
||||
|
||||
def load_autocomplete(self, completions: list):
|
||||
"""Loads the autocomplete suggestions into the QScintilla API.
|
||||
|
||||
Args:
|
||||
completions (list[Completion]): A list of Completion objects to be added to the API.
|
||||
"""
|
||||
self.api.clear()
|
||||
for i in completions:
|
||||
self.api.add(i.name)
|
||||
self.api.prepare()
|
||||
|
||||
def get_completions(self, line: int, index: int, text: str):
|
||||
"""Starts the autocompletion process for a given position in the text.
|
||||
|
||||
Args:
|
||||
line (int): The line number in the editor.
|
||||
index (int): The index (column number) in the line.
|
||||
text (str): The current text of the editor.
|
||||
"""
|
||||
self.line = line
|
||||
self.index = index
|
||||
self.text = text
|
||||
self.start()
|
||||
|
||||
def get_compact_docstring(self, full_docstring):
|
||||
"""Generates a compact version of a function's docstring.
|
||||
|
||||
Args:
|
||||
full_docstring (str): The full docstring of a function.
|
||||
|
||||
Returns:
|
||||
str: A compact version of the docstring.
|
||||
"""
|
||||
lines = full_docstring.split("\n")
|
||||
# TODO make it also for different docstring styles, now it is only for numpy style
|
||||
cutoff_indices = [
|
||||
i
|
||||
for i, line in enumerate(lines)
|
||||
if line.strip().lower() in ["parameters", "returns", "examples", "see also", "warnings"]
|
||||
]
|
||||
|
||||
if cutoff_indices:
|
||||
lines = lines[: cutoff_indices[0]]
|
||||
|
||||
compact_docstring = "\n".join(lines).strip()
|
||||
return compact_docstring
|
||||
|
||||
|
||||
class ScriptRunnerThread(QThread):
|
||||
"""Initializes the thread for running a Python script.
|
||||
|
||||
Args:
|
||||
script (str): The script to be executed.
|
||||
"""
|
||||
|
||||
outputSignal = Signal(str)
|
||||
|
||||
def __init__(self, script):
|
||||
super().__init__()
|
||||
self.script = script
|
||||
|
||||
def run(self):
|
||||
"""Executes the script in a subprocess and emits output through a signal. Overrides QThread.run."""
|
||||
process = subprocess.Popen(
|
||||
["python", "-u", "-c", self.script],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
while True:
|
||||
output = process.stdout.readline()
|
||||
if output == "" and process.poll() is not None:
|
||||
break
|
||||
if output:
|
||||
self.outputSignal.emit(output)
|
||||
error = process.communicate()[1]
|
||||
if error:
|
||||
self.outputSignal.emit(error)
|
||||
|
||||
|
||||
class BECEditor(QWidget):
|
||||
"""Initializes the BEC Editor widget.
|
||||
|
||||
Args:
|
||||
toolbar_enabled (bool, optional): Determines if the toolbar should be enabled. Defaults to True.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, toolbar_enabled=True, jupyter_terminal_enabled=False, docstring_tooltip=False
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.script_runner_thread = None
|
||||
self.file_path = None
|
||||
self.docstring_tooltip = docstring_tooltip
|
||||
self.jupyter_terminal_enabled = jupyter_terminal_enabled
|
||||
# TODO just temporary solution, could be extended to other languages
|
||||
self.is_python_file = True
|
||||
|
||||
# Initialize the editor and terminal
|
||||
self.editor = QsciScintilla()
|
||||
if self.jupyter_terminal_enabled:
|
||||
self.terminal = self.make_jupyter_widget_with_kernel()
|
||||
else:
|
||||
self.terminal = QTextEdit()
|
||||
self.terminal.setReadOnly(True)
|
||||
|
||||
# Layout
|
||||
self.layout = QVBoxLayout()
|
||||
|
||||
# Initialize and add the toolbar if enabled
|
||||
if toolbar_enabled:
|
||||
self.toolbar = ModularToolBar(self)
|
||||
self.layout.addWidget(self.toolbar)
|
||||
|
||||
# Initialize the splitter
|
||||
self.splitter = QSplitter(Qt.Orientation.Vertical, self)
|
||||
self.splitter.addWidget(self.editor)
|
||||
self.splitter.addWidget(self.terminal)
|
||||
self.splitter.setSizes([400, 200])
|
||||
|
||||
# Add Splitter to layout
|
||||
self.layout.addWidget(self.splitter)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
self.setup_editor()
|
||||
|
||||
def setup_editor(self):
|
||||
"""Sets up the editor with necessary configurations like lexer, auto indentation, and line numbers."""
|
||||
# Set the lexer for Python
|
||||
self.lexer = QsciLexerPython()
|
||||
self.editor.setLexer(self.lexer)
|
||||
|
||||
# Enable auto indentation and competition within the editor
|
||||
self.editor.setAutoIndent(True)
|
||||
self.editor.setIndentationsUseTabs(False)
|
||||
self.editor.setIndentationWidth(4)
|
||||
self.editor.setAutoCompletionSource(QsciScintilla.AutoCompletionSource.AcsAll)
|
||||
self.editor.setAutoCompletionThreshold(1)
|
||||
|
||||
# Autocomplete for python file
|
||||
# Connect cursor position change signal for autocompletion
|
||||
self.editor.cursorPositionChanged.connect(self.on_cursor_position_changed)
|
||||
|
||||
# if self.is_python_file: #TODO can be changed depending on supported languages
|
||||
self.__api = QsciAPIs(self.lexer)
|
||||
self.auto_completer = AutoCompleter(
|
||||
self.editor.text(), self.__api, enable_docstring=self.docstring_tooltip
|
||||
)
|
||||
self.auto_completer.finished.connect(self.loaded_autocomplete)
|
||||
|
||||
# Enable line numbers in the margin
|
||||
self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin)
|
||||
self.editor.setMarginWidth(0, "0000") # Adjust the width as needed
|
||||
|
||||
# Additional UI elements like menu for load/save can be added here
|
||||
self.set_editor_style()
|
||||
|
||||
@staticmethod
|
||||
def make_jupyter_widget_with_kernel() -> object:
|
||||
"""Start a kernel, connect to it, and create a RichJupyterWidget to use it"""
|
||||
kernel_manager = QtKernelManager(kernel_name="python3")
|
||||
kernel_manager.start_kernel()
|
||||
|
||||
kernel_client = kernel_manager.client()
|
||||
kernel_client.start_channels()
|
||||
|
||||
jupyter_widget = RichJupyterWidget()
|
||||
jupyter_widget.set_default_style("linux")
|
||||
jupyter_widget.kernel_manager = kernel_manager
|
||||
jupyter_widget.kernel_client = kernel_client
|
||||
return jupyter_widget
|
||||
|
||||
def show_call_tip(self, position):
|
||||
"""Shows a call tip at the given position in the editor.
|
||||
|
||||
Args:
|
||||
position (int): The position in the editor where the call tip should be shown.
|
||||
"""
|
||||
line, index = self.editor.lineIndexFromPosition(position)
|
||||
signature = self.auto_completer.get_function_signature(line + 1, index, self.editor.text())
|
||||
if signature:
|
||||
self.editor.showUserList(1, [signature])
|
||||
|
||||
def on_cursor_position_changed(self, line, index):
|
||||
"""Handles the event of cursor position change in the editor.
|
||||
|
||||
Args:
|
||||
line (int): The current line number where the cursor is.
|
||||
index (int): The current column index where the cursor is.
|
||||
"""
|
||||
# if self.is_python_file: #TODO can be changed depending on supported languages
|
||||
# Get completions
|
||||
self.auto_completer.get_completions(line + 1, index, self.editor.text())
|
||||
self.editor.autoCompleteFromAPIs()
|
||||
|
||||
# Show call tip - signature
|
||||
position = self.editor.positionFromLineIndex(line, index)
|
||||
self.show_call_tip(position)
|
||||
|
||||
def loaded_autocomplete(self):
|
||||
"""Placeholder method for actions after autocompletion data is loaded."""
|
||||
|
||||
def set_editor_style(self):
|
||||
"""Sets the style and color scheme for the editor."""
|
||||
# Dracula Theme Colors
|
||||
background_color = QColor("#282a36")
|
||||
text_color = QColor("#f8f8f2")
|
||||
keyword_color = QColor("#8be9fd")
|
||||
string_color = QColor("#f1fa8c")
|
||||
comment_color = QColor("#6272a4")
|
||||
class_function_color = QColor("#50fa7b")
|
||||
|
||||
# Set Font
|
||||
font = QFont()
|
||||
font.setFamily("Consolas")
|
||||
font.setPointSize(10)
|
||||
self.editor.setFont(font)
|
||||
self.editor.setMarginsFont(font)
|
||||
|
||||
# Set Editor Colors
|
||||
self.editor.setMarginsBackgroundColor(background_color)
|
||||
self.editor.setMarginsForegroundColor(text_color)
|
||||
self.editor.setCaretForegroundColor(text_color)
|
||||
self.editor.setCaretLineBackgroundColor(QColor("#44475a"))
|
||||
self.editor.setPaper(background_color) # Set the background color for the entire paper
|
||||
self.editor.setColor(text_color)
|
||||
|
||||
# Set editor
|
||||
# Syntax Highlighting Colors
|
||||
lexer = self.editor.lexer()
|
||||
if lexer:
|
||||
lexer.setDefaultPaper(background_color) # Set the background color for the text area
|
||||
lexer.setDefaultColor(text_color)
|
||||
lexer.setColor(keyword_color, QsciLexerPython.Keyword)
|
||||
lexer.setColor(string_color, QsciLexerPython.DoubleQuotedString)
|
||||
lexer.setColor(string_color, QsciLexerPython.SingleQuotedString)
|
||||
lexer.setColor(comment_color, QsciLexerPython.Comment)
|
||||
lexer.setColor(class_function_color, QsciLexerPython.ClassName)
|
||||
lexer.setColor(class_function_color, QsciLexerPython.FunctionMethodName)
|
||||
|
||||
# Set the style for all text to have a transparent background
|
||||
# TODO find better way how to do it!
|
||||
for style in range(
|
||||
128
|
||||
): # QsciScintilla supports 128 styles by default, this set all to transparent background
|
||||
self.lexer.setPaper(background_color, style)
|
||||
|
||||
def run_script(self):
|
||||
"""Runs the current script in the editor."""
|
||||
if self.jupyter_terminal_enabled:
|
||||
script = self.editor.text()
|
||||
self.terminal.execute(script)
|
||||
|
||||
else:
|
||||
script = self.editor.text()
|
||||
self.script_runner_thread = ScriptRunnerThread(script)
|
||||
self.script_runner_thread.outputSignal.connect(self.update_terminal)
|
||||
self.script_runner_thread.start()
|
||||
|
||||
def update_terminal(self, text):
|
||||
"""Updates the terminal with new text.
|
||||
|
||||
Args:
|
||||
text (str): The text to be appended to the terminal.
|
||||
"""
|
||||
self.terminal.append(text)
|
||||
|
||||
def enable_docstring_tooltip(self):
|
||||
"""Enables the docstring tooltip."""
|
||||
self.docstring_tooltip = True
|
||||
self.auto_completer.enable_docstring = True
|
||||
|
||||
def open_file(self):
|
||||
"""Opens a file dialog for selecting and opening a Python file in the editor."""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Open file", "", "Python files (*.py);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
text = file.read()
|
||||
self.editor.setText(text)
|
||||
except FileNotFoundError:
|
||||
print(f"The file {file_path} was not found.")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while opening the file {file_path}: {e}")
|
||||
|
||||
def save_file(self):
|
||||
"""Opens a save file dialog for saving the current script in the editor."""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save file", "", "Python files (*.py);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
if not file_path.endswith(".py"):
|
||||
file_path += ".py"
|
||||
|
||||
with open(file_path, "w") as file:
|
||||
text = self.editor.text()
|
||||
file.write(text)
|
||||
print(f"File saved to {file_path}")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while saving the file to {file_path}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication([])
|
||||
qdarktheme.setup_theme("auto")
|
||||
|
||||
mainWin = BECEditor(jupyter_terminal_enabled=True)
|
||||
|
||||
mainWin.show()
|
||||
app.exec()
|
||||
@@ -1 +1 @@
|
||||
from .figure import BECFigure, FigureConfig
|
||||
from .figure import FigureConfig, BECFigure
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import itertools
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import Literal, Optional
|
||||
|
||||
@@ -9,15 +10,12 @@ import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import qdarktheme
|
||||
from pydantic import Field
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from typeguard import typechecked
|
||||
from pyqtgraph.Qt import uic
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
from qtpy.QtWidgets import QVBoxLayout, QMainWindow
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.widgets.figure.plots.image.image import BECImageShow, ImageConfig
|
||||
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap, MotorMapConfig
|
||||
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
|
||||
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform, Waveform1DConfig
|
||||
from bec_widgets.utils import BECConnector, BECDispatcher, ConnectionConfig
|
||||
from bec_widgets.widgets.plots import BECPlotBase, BECWaveform1D, Waveform1DConfig, WidgetConfig
|
||||
|
||||
|
||||
class FigureConfig(ConnectionConfig):
|
||||
@@ -26,7 +24,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, Waveform1DConfig | ImageConfig | MotorMapConfig | SubplotConfig] = Field(
|
||||
widgets: dict[str, WidgetConfig] = Field(
|
||||
{}, description="The list of widgets to be added to the figure widget."
|
||||
)
|
||||
|
||||
@@ -36,10 +34,8 @@ class WidgetHandler:
|
||||
|
||||
def __init__(self):
|
||||
self.widget_factory = {
|
||||
"PlotBase": (BECPlotBase, SubplotConfig),
|
||||
"Waveform1D": (BECWaveform, Waveform1DConfig),
|
||||
"ImShow": (BECImageShow, ImageConfig),
|
||||
"MotorMap": (BECMotorMap, MotorMapConfig),
|
||||
"PlotBase": (BECPlotBase, WidgetConfig),
|
||||
"Waveform1D": (BECWaveform1D, Waveform1DConfig),
|
||||
}
|
||||
|
||||
def create_widget(
|
||||
@@ -69,8 +65,6 @@ class WidgetHandler:
|
||||
raise ValueError(f"Unsupported widget type: {widget_type}")
|
||||
|
||||
widget_class, config_class = entry
|
||||
if config is not None and isinstance(config, config_class):
|
||||
config = config.model_dump()
|
||||
widget_config_dict = {
|
||||
"widget_class": widget_class.__name__,
|
||||
"parent_id": parent_id,
|
||||
@@ -89,26 +83,7 @@ class WidgetHandler:
|
||||
|
||||
|
||||
class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
USER_ACCESS = [
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"axes",
|
||||
"widgets",
|
||||
"add_plot",
|
||||
"add_image",
|
||||
"add_motor_map",
|
||||
"plot",
|
||||
"image",
|
||||
"motor_map",
|
||||
"remove",
|
||||
"change_layout",
|
||||
"change_theme",
|
||||
"clear_all",
|
||||
"get_all_rpc",
|
||||
"widget_list",
|
||||
]
|
||||
|
||||
clean_signal = pyqtSignal()
|
||||
USER_ACCESS = ["add_plot", "remove", "change_layout", "change_theme", "clear_all"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -129,194 +104,34 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
self.widget_handler = WidgetHandler()
|
||||
|
||||
# Widget container to reference widgets by 'widget_id'
|
||||
self._widgets = defaultdict(dict)
|
||||
self.widgets = defaultdict(dict)
|
||||
|
||||
# Container to keep track of the grid
|
||||
self.grid = []
|
||||
|
||||
def __getitem__(self, key: tuple | str):
|
||||
if isinstance(key, tuple) and len(key) == 2:
|
||||
return self.axes(*key)
|
||||
elif isinstance(key, str):
|
||||
widget = self._widgets.get(key)
|
||||
if widget is None:
|
||||
raise KeyError(f"No widget with ID {key}")
|
||||
return self._widgets.get(key)
|
||||
else:
|
||||
raise TypeError(
|
||||
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
|
||||
)
|
||||
|
||||
@property
|
||||
def widget_list(self) -> list[BECPlotBase]:
|
||||
def change_theme(self, theme: Literal["dark", "light"]) -> None:
|
||||
"""
|
||||
Access all widget in BECFigure as a list
|
||||
Returns:
|
||||
list[BECPlotBase]: List of all widgets in the figure.
|
||||
"""
|
||||
axes = [value for value in self._widgets.values() if isinstance(value, BECPlotBase)]
|
||||
return axes
|
||||
|
||||
@widget_list.setter
|
||||
def widget_list(self, value: list[BECPlotBase]):
|
||||
"""
|
||||
Access all widget in BECFigure as a list
|
||||
Returns:
|
||||
list[BECPlotBase]: List of all widgets in the figure.
|
||||
"""
|
||||
self._axes = value
|
||||
|
||||
@property
|
||||
def widgets(self) -> dict:
|
||||
"""
|
||||
All widgets within the figure with gui ids as keys.
|
||||
Returns:
|
||||
dict: All widgets within the figure.
|
||||
"""
|
||||
return self._widgets
|
||||
|
||||
@widgets.setter
|
||||
def widgets(self, value: dict):
|
||||
"""
|
||||
All widgets within the figure with gui ids as keys.
|
||||
Returns:
|
||||
dict: All widgets within the figure.
|
||||
"""
|
||||
self._widgets = value
|
||||
|
||||
def _init_waveform(
|
||||
self,
|
||||
waveform,
|
||||
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,
|
||||
):
|
||||
"""
|
||||
Configure the waveform based on the provided parameters.
|
||||
|
||||
Change the theme of the figure widget.
|
||||
Args:
|
||||
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.
|
||||
theme(Literal["dark","light"]): The theme to set for the figure widget.
|
||||
"""
|
||||
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(
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
validate=validate,
|
||||
color=color,
|
||||
label=label,
|
||||
)
|
||||
# User wants to add scan curve -> 2D Waveform Scatter
|
||||
if (
|
||||
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:
|
||||
waveform.add_curve_custom(x=x, y=y, color=color, label=label)
|
||||
|
||||
return waveform
|
||||
qdarktheme.setup_theme(theme)
|
||||
self.setBackground("k" if theme == "dark" else "w")
|
||||
self.config.theme = theme
|
||||
|
||||
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,
|
||||
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:
|
||||
self, widget_id: str = None, row: int = None, col: int = None, config=None, **axis_kwargs
|
||||
) -> BECWaveform1D:
|
||||
"""
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
widget_id = str(uuid.uuid4())
|
||||
waveform = self.add_widget(
|
||||
return self.add_widget(
|
||||
widget_type="Waveform1D",
|
||||
widget_id=widget_id,
|
||||
row=row,
|
||||
@@ -325,295 +140,9 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
**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.
|
||||
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.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECWaveform: The waveform plot widget.
|
||||
"""
|
||||
waveform = WidgetContainerUtils.find_first_widget_by_class(
|
||||
self._widgets, BECWaveform, can_fail=True
|
||||
)
|
||||
if waveform is not None:
|
||||
if axis_kwargs:
|
||||
waveform.set(**axis_kwargs)
|
||||
else:
|
||||
waveform = self.add_plot(**axis_kwargs)
|
||||
|
||||
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,
|
||||
color_bar: Literal["simple", "full"] = "full",
|
||||
color_map: str = "magma",
|
||||
data: np.ndarray = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
**axis_kwargs,
|
||||
) -> BECImageShow:
|
||||
"""
|
||||
Add an image to the figure. Always access the first image widget in the figure.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to display.
|
||||
color_bar(Literal["simple","full"]): The type of color bar to display.
|
||||
color_map(str): The color map to use for the image.
|
||||
data(np.ndarray): Custom data to display.
|
||||
vrange(tuple[float, float]): The range of values to display.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECImageShow: The image widget.
|
||||
"""
|
||||
image = WidgetContainerUtils.find_first_widget_by_class(
|
||||
self._widgets, BECImageShow, can_fail=True
|
||||
)
|
||||
if image is not None:
|
||||
if axis_kwargs:
|
||||
image.set(**axis_kwargs)
|
||||
else:
|
||||
image = self.add_image(color_bar=color_bar, **axis_kwargs)
|
||||
|
||||
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(
|
||||
self,
|
||||
monitor: str = None,
|
||||
color_bar: Literal["simple", "full"] = "full",
|
||||
color_map: str = "magma",
|
||||
data: np.ndarray = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> BECImageShow:
|
||||
"""
|
||||
Add an image to the figure at the specified position.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to display.
|
||||
color_bar(Literal["simple","full"]): The type of color bar to display.
|
||||
color_map(str): The color map to use for the image.
|
||||
data(np.ndarray): Custom data to display.
|
||||
vrange(tuple[float, float]): The range of values to display.
|
||||
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: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECImageShow: The image widget.
|
||||
"""
|
||||
|
||||
widget_id = str(uuid.uuid4())
|
||||
if config is None:
|
||||
config = ImageConfig(
|
||||
widget_class="BECImageShow",
|
||||
gui_id=widget_id,
|
||||
parent_id=self.gui_id,
|
||||
color_map=color_map,
|
||||
color_bar=color_bar,
|
||||
vrange=vrange,
|
||||
)
|
||||
image = self.add_widget(
|
||||
widget_type="ImShow",
|
||||
widget_id=widget_id,
|
||||
row=row,
|
||||
col=col,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
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:
|
||||
"""
|
||||
Add a motor map to the figure. Always access the first motor map widget in the figure.
|
||||
|
||||
Args:
|
||||
motor_x(str): The name of the motor for the X axis.
|
||||
motor_y(str): The name of the motor for the Y axis.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECMotorMap: The motor map widget.
|
||||
"""
|
||||
motor_map = WidgetContainerUtils.find_first_widget_by_class(
|
||||
self._widgets, BECMotorMap, can_fail=True
|
||||
)
|
||||
if motor_map is not None:
|
||||
if axis_kwargs:
|
||||
motor_map.set(**axis_kwargs)
|
||||
else:
|
||||
motor_map = self.add_motor_map(**axis_kwargs)
|
||||
|
||||
if motor_x is not None and motor_y is not None:
|
||||
motor_map.change_motors(motor_x, motor_y)
|
||||
|
||||
return motor_map
|
||||
|
||||
def add_motor_map(
|
||||
self,
|
||||
motor_x: str = None,
|
||||
motor_y: str = None,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> BECMotorMap:
|
||||
"""
|
||||
|
||||
Args:
|
||||
motor_x(str): The name of the motor for the X axis.
|
||||
motor_y(str): The name of the motor for the Y axis.
|
||||
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:
|
||||
|
||||
Returns:
|
||||
BECMotorMap: The motor map widget.
|
||||
"""
|
||||
widget_id = str(uuid.uuid4())
|
||||
if config is None:
|
||||
config = MotorMapConfig(
|
||||
widget_class="BECMotorMap", gui_id=widget_id, parent_id=self.gui_id
|
||||
)
|
||||
motor_map = self.add_widget(
|
||||
widget_type="MotorMap",
|
||||
widget_id=widget_id,
|
||||
row=row,
|
||||
col=col,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
|
||||
if motor_x is not None and motor_y is not None:
|
||||
motor_map.change_motors(motor_x, motor_y)
|
||||
|
||||
return motor_map
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
widget_type: Literal["PlotBase", "Waveform1D", "ImShow"] = "PlotBase",
|
||||
widget_type: Literal["PlotBase", "Waveform1D"] = "PlotBase",
|
||||
widget_id: str = None,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
@@ -622,7 +151,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
) -> BECPlotBase:
|
||||
"""
|
||||
Add a widget to the figure at the specified position.
|
||||
|
||||
Args:
|
||||
widget_type(Literal["PlotBase","Waveform1D"]): The type of the widget to add.
|
||||
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
|
||||
@@ -632,8 +160,8 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
|
||||
"""
|
||||
if not widget_id:
|
||||
widget_id = str(uuid.uuid4())
|
||||
if widget_id in self._widgets:
|
||||
widget_id = self._generate_unique_widget_id()
|
||||
if widget_id in self.widgets:
|
||||
raise ValueError(f"Widget with ID '{widget_id}' already exists.")
|
||||
|
||||
widget = self.widget_handler.create_widget(
|
||||
@@ -669,7 +197,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
|
||||
# Saving config for future referencing
|
||||
self.config.widgets[widget_id] = widget.config
|
||||
self._widgets[widget_id] = widget
|
||||
self.widgets[widget_id] = widget
|
||||
|
||||
# Reflect the grid coordinates
|
||||
self._change_grid(widget_id, row, col)
|
||||
@@ -685,7 +213,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
) -> None:
|
||||
"""
|
||||
Remove a widget from the figure. Can be removed by its unique identifier or by its coordinates.
|
||||
|
||||
Args:
|
||||
row(int): The row coordinate of the widget to remove.
|
||||
col(int): The column coordinate of the widget to remove.
|
||||
@@ -701,59 +228,53 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
else:
|
||||
raise ValueError("Must provide either widget_id or coordinates for removal.")
|
||||
|
||||
def change_theme(self, theme: Literal["dark", "light"]) -> None:
|
||||
"""
|
||||
Change the theme of the figure widget.
|
||||
|
||||
Args:
|
||||
theme(Literal["dark","light"]): The theme to set for the figure widget.
|
||||
"""
|
||||
qdarktheme.setup_theme(theme)
|
||||
self.setBackground("k" if theme == "dark" else "w")
|
||||
self.config.theme = theme
|
||||
for plot in self.widget_list:
|
||||
plot.set_x_label(plot.plot_item.getAxis("bottom").label.toPlainText())
|
||||
plot.set_y_label(plot.plot_item.getAxis("left").label.toPlainText())
|
||||
if plot.plot_item.titleLabel.text:
|
||||
plot.set_title(plot.plot_item.titleLabel.text)
|
||||
plot.set_legend_label_size()
|
||||
|
||||
def _remove_by_coordinates(self, row: int, col: int) -> None:
|
||||
"""
|
||||
Remove a widget from the figure by its coordinates.
|
||||
|
||||
Args:
|
||||
row(int): The row coordinate of the widget to remove.
|
||||
col(int): The column coordinate of the widget to remove.
|
||||
"""
|
||||
widget = self.axes(row, col)
|
||||
widget = self._get_widget_by_coordinates(row, col)
|
||||
if widget:
|
||||
widget_id = widget.config.gui_id
|
||||
if widget_id in self._widgets:
|
||||
if widget_id in self.widgets:
|
||||
self._remove_by_id(widget_id)
|
||||
|
||||
def _remove_by_id(self, widget_id: str) -> None:
|
||||
"""
|
||||
Remove a widget from the figure by its unique identifier.
|
||||
|
||||
Args:
|
||||
widget_id(str): The unique identifier of the widget to remove.
|
||||
"""
|
||||
if widget_id in self._widgets:
|
||||
widget = self._widgets.pop(widget_id)
|
||||
if widget_id in self.widgets:
|
||||
widget = self.widgets.pop(widget_id)
|
||||
widget.cleanup()
|
||||
self.removeItem(widget)
|
||||
self.grid[widget.config.row][widget.config.col] = None
|
||||
self._reindex_grid()
|
||||
if widget_id in self.config.widgets:
|
||||
self.config.widgets.pop(widget_id)
|
||||
print(f"Removed widget {widget_id}.")
|
||||
else:
|
||||
raise ValueError(f"Widget with ID '{widget_id}' does not exist.")
|
||||
|
||||
def axes(self, row: int, col: int) -> BECPlotBase:
|
||||
def __getitem__(self, key: tuple | str):
|
||||
if isinstance(key, tuple) and len(key) == 2:
|
||||
return self._get_widget_by_coordinates(*key)
|
||||
elif isinstance(key, str):
|
||||
widget = self.widgets.get(key)
|
||||
if widget is None:
|
||||
raise KeyError(f"No widget with ID {key}")
|
||||
return self.widgets.get(key)
|
||||
else:
|
||||
raise TypeError(
|
||||
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
|
||||
)
|
||||
|
||||
def _get_widget_by_coordinates(self, row: int, col: int) -> BECPlotBase:
|
||||
"""
|
||||
Get widget by its coordinates in the figure.
|
||||
|
||||
Args:
|
||||
row(int): the row coordinate
|
||||
col(int): the column coordinate
|
||||
@@ -773,10 +294,17 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
row += 1
|
||||
return row, col
|
||||
|
||||
def _generate_unique_widget_id(self):
|
||||
"""Generate a unique widget ID."""
|
||||
existing_ids = set(self.widgets.keys())
|
||||
for i in itertools.count(1):
|
||||
widget_id = f"widget_{i}"
|
||||
if widget_id not in existing_ids:
|
||||
return widget_id
|
||||
|
||||
def _change_grid(self, widget_id: str, row: int, col: int):
|
||||
"""
|
||||
Change the grid to reflect the new position of the widget.
|
||||
|
||||
Args:
|
||||
widget_id(str): The unique identifier of the widget.
|
||||
row(int): The new row coordinate of the widget in the figure.
|
||||
@@ -791,6 +319,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
|
||||
def _reindex_grid(self):
|
||||
"""Reindex the grid to remove empty rows and columns."""
|
||||
print(f"old grid: {self.grid}")
|
||||
new_grid = []
|
||||
for row in self.grid:
|
||||
new_row = [widget for widget in row if widget is not None]
|
||||
@@ -800,10 +329,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
# Update the config of each object to reflect its new position
|
||||
for row_idx, row in enumerate(new_grid):
|
||||
for col_idx, widget in enumerate(row):
|
||||
self._widgets[widget].config.row, self._widgets[widget].config.col = (
|
||||
row_idx,
|
||||
col_idx,
|
||||
)
|
||||
self.widgets[widget].config.row, self.widgets[widget].config.col = row_idx, col_idx
|
||||
|
||||
self.grid = new_grid
|
||||
self._replot_layout()
|
||||
@@ -813,7 +339,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
self.clear()
|
||||
for row_idx, row in enumerate(self.grid):
|
||||
for col_idx, widget in enumerate(row):
|
||||
self.addItem(self._widgets[widget], row=row_idx, col=col_idx)
|
||||
self.addItem(self.widgets[widget], row=row_idx, col=col_idx)
|
||||
|
||||
def change_layout(self, max_columns=None, max_rows=None):
|
||||
"""
|
||||
@@ -825,7 +351,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
max_rows (Optional[int]): The new maximum number of rows in the figure.
|
||||
"""
|
||||
# Calculate total number of widgets
|
||||
total_widgets = len(self._widgets)
|
||||
total_widgets = len(self.widgets)
|
||||
|
||||
if max_columns:
|
||||
# Calculate the required number of rows based on max_columns
|
||||
@@ -841,7 +367,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
|
||||
# Populate the new grid with widgets' IDs
|
||||
current_idx = 0
|
||||
for widget_id, widget in self._widgets.items():
|
||||
for widget_id, widget in self.widgets.items():
|
||||
row = current_idx // len(new_grid[0])
|
||||
col = current_idx % len(new_grid[0])
|
||||
new_grid[row][col] = widget_id
|
||||
@@ -857,16 +383,142 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all widgets from the figure and reset to default state"""
|
||||
for widget in list(self._widgets.values()):
|
||||
widget.remove()
|
||||
# self.clear()
|
||||
self._widgets = defaultdict(dict)
|
||||
self.clear()
|
||||
self.widgets = defaultdict(dict)
|
||||
self.grid = []
|
||||
theme = self.config.theme
|
||||
self.config = FigureConfig(
|
||||
widget_class=self.__class__.__name__, gui_id=self.gui_id, theme=theme
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
self.clear_all()
|
||||
super().cleanup()
|
||||
def start(self):
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
win = QMainWindow()
|
||||
win.setCentralWidget(self)
|
||||
win.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
##################################################
|
||||
##################################################
|
||||
# Debug window
|
||||
##################################################
|
||||
##################################################
|
||||
|
||||
from qtconsole.inprocess import QtInProcessKernelManager
|
||||
from qtconsole.rich_jupyter_widget import RichJupyterWidget
|
||||
|
||||
|
||||
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 DebugWindow(QWidget): # pragma: no cover:
|
||||
"""Debug window for BEC widgets"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "figure_debug_minimal.ui"), self)
|
||||
|
||||
self._init_ui()
|
||||
|
||||
self.splitter.setSizes([200, 100])
|
||||
|
||||
# console push
|
||||
self.console.kernel_manager.kernel.shell.push(
|
||||
{"fig": self.figure, "w1": self.w1, "w2": self.w2}
|
||||
)
|
||||
|
||||
def _init_ui(self):
|
||||
# Plotting window
|
||||
self.glw_1_layout = QVBoxLayout(self.glw) # Create a new QVBoxLayout
|
||||
self.figure = BECFigure(parent=self) # Create a new BECDeviceMonitor
|
||||
self.glw_1_layout.addWidget(self.figure) # Add BECDeviceMonitor to the layout
|
||||
|
||||
# add stuff to figure
|
||||
self._init_figure()
|
||||
|
||||
self.console_layout = QVBoxLayout(self.widget_console)
|
||||
self.console = JupyterConsoleWidget()
|
||||
self.console_layout.addWidget(self.console)
|
||||
self.console.set_default_style("linux")
|
||||
|
||||
def _init_figure(self):
|
||||
self.figure.add_widget(widget_type="Waveform1D", row=0, col=0, title="Widget 1")
|
||||
self.figure.add_widget(widget_type="Waveform1D", row=1, col=0, title="Widget 2")
|
||||
self.figure.add_widget(widget_type="Waveform1D", row=0, col=1, title="Widget 3")
|
||||
self.figure.add_widget(widget_type="Waveform1D", row=1, col=1, title="Widget 4")
|
||||
|
||||
self.w1 = self.figure[0, 0]
|
||||
self.w2 = self.figure[1, 0]
|
||||
self.w3 = self.figure[0, 1]
|
||||
self.w4 = self.figure[1, 1]
|
||||
|
||||
# curves for w1
|
||||
self.w1.add_curve_scan("samx", "bpm4i", pen_style="dash")
|
||||
self.w1.add_curve_custom(
|
||||
x=[1, 2, 3, 4, 5],
|
||||
y=[1, 2, 3, 4, 5],
|
||||
label="curve-custom",
|
||||
color="blue",
|
||||
pen_style="dashdot",
|
||||
)
|
||||
self.c1 = self.w1.get_config()
|
||||
|
||||
# curves for w2
|
||||
self.w2.add_curve_scan("samx", "bpm3a", pen_style="solid")
|
||||
self.w2.add_curve_scan("samx", "bpm4d", pen_style="dot")
|
||||
self.w2.add_curve_custom(
|
||||
x=[1, 2, 3, 4, 5], y=[5, 4, 3, 2, 1], color="red", pen_style="dashdot"
|
||||
)
|
||||
|
||||
# curves for w3
|
||||
self.w3.add_curve_scan("samx", "bpm4i", pen_style="dash")
|
||||
self.w3.add_curve_custom(
|
||||
x=[1, 2, 3, 4, 5],
|
||||
y=[1, 2, 3, 4, 5],
|
||||
label="curve-custom",
|
||||
color="blue",
|
||||
pen_style="dashdot",
|
||||
)
|
||||
|
||||
# curves for w4
|
||||
self.w4.add_curve_scan("samx", "bpm4i", pen_style="dash")
|
||||
self.w4.add_curve_custom(
|
||||
x=[1, 2, 3, 4, 5],
|
||||
y=[1, 2, 3, 4, 5],
|
||||
label="curve-custom",
|
||||
color="blue",
|
||||
pen_style="dashdot",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
win = DebugWindow()
|
||||
win.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
35
bec_widgets/widgets/figure/figure_debug_minimal.ui
Normal file
35
bec_widgets/widgets/figure/figure_debug_minimal.ui
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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>901</width>
|
||||
<height>1000</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="QWidget" name="glw" native="true"/>
|
||||
</widget>
|
||||
<widget class="QWidget" name="widget_console" native="true"/>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,559 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
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 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):
|
||||
images: dict[str, ImageItemConfig] = Field(
|
||||
{},
|
||||
description="The configuration of the images. The key is the name of the image (source).",
|
||||
)
|
||||
|
||||
|
||||
class BECImageShow(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"add_image_by_config",
|
||||
"get_image_config",
|
||||
"get_image_dict",
|
||||
"add_monitor_image",
|
||||
"add_custom_image",
|
||||
"set_vrange",
|
||||
"set_color_map",
|
||||
"set_autorange",
|
||||
"set_monitor",
|
||||
"set_processing",
|
||||
"set_image_properties",
|
||||
"set_fft",
|
||||
"set_log",
|
||||
"set_rotation",
|
||||
"set_transpose",
|
||||
"toggle_threading",
|
||||
"set",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
"set_y_label",
|
||||
"set_x_scale",
|
||||
"set_y_scale",
|
||||
"set_x_lim",
|
||||
"set_y_lim",
|
||||
"set_grid",
|
||||
"lock_aspect_ratio",
|
||||
"remove",
|
||||
"images",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent_figure=None,
|
||||
config: Optional[ImageConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
):
|
||||
if config is None:
|
||||
config = ImageConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
|
||||
)
|
||||
# Get bec shortcuts dev, scans, queue, scan_storage, dap
|
||||
self.get_bec_shortcuts()
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
self._images = defaultdict(dict)
|
||||
self.apply_config(self.config)
|
||||
self.processor = ImageProcessor()
|
||||
self.use_threading = False # TODO WILL be moved to the init method and to figure method
|
||||
|
||||
def _create_thread_worker(self, device: str, image: np.ndarray):
|
||||
thread = QThread()
|
||||
worker = ProcessorWorker(self.processor)
|
||||
worker.moveToThread(thread)
|
||||
|
||||
# Connect signals and slots
|
||||
thread.started.connect(lambda: worker.process_image(device, image))
|
||||
worker.processed.connect(self.update_image)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.finished.connect(thread.wait)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
thread.start()
|
||||
|
||||
def find_image_by_monitor(self, item_id: str) -> BECImageItem:
|
||||
"""
|
||||
Find the widget by its gui_id.
|
||||
|
||||
Args:
|
||||
item_id(str): The gui_id of the widget.
|
||||
|
||||
Returns:
|
||||
BECImageItem: The widget with the given gui_id.
|
||||
"""
|
||||
for source, images in self._images.items():
|
||||
for key, value in images.items():
|
||||
if key == item_id and isinstance(value, BECImageItem):
|
||||
return value
|
||||
elif isinstance(value, dict):
|
||||
result = self.find_image_by_monitor(item_id)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
def apply_config(self, config: dict | SubplotConfig):
|
||||
"""
|
||||
Apply the configuration to the 1D waveform widget.
|
||||
|
||||
Args:
|
||||
config(dict|SubplotConfig): Configuration settings.
|
||||
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
try:
|
||||
config = ImageConfig(**config)
|
||||
except ValidationError as e:
|
||||
print(f"Validation error when applying config to BECImageShow: {e}")
|
||||
return
|
||||
self.config = config
|
||||
self.plot_item.clear()
|
||||
|
||||
self.apply_axis_config()
|
||||
self._images = defaultdict(dict)
|
||||
|
||||
# TODO extend by adding image by config
|
||||
|
||||
def change_gui_id(self, new_gui_id: str):
|
||||
"""
|
||||
Change the GUI ID of the image widget and update the parent_id in all associated curves.
|
||||
|
||||
Args:
|
||||
new_gui_id (str): The new GUI ID to be set for the image widget.
|
||||
"""
|
||||
self.gui_id = new_gui_id
|
||||
self.config.gui_id = new_gui_id
|
||||
|
||||
for source, images in self._images.items():
|
||||
for id, image_item in images.items():
|
||||
image_item.config.parent_id = new_gui_id
|
||||
|
||||
def add_image_by_config(self, config: ImageItemConfig | dict) -> BECImageItem:
|
||||
"""
|
||||
Add an image to the widget by configuration.
|
||||
|
||||
Args:
|
||||
config(ImageItemConfig|dict): The configuration of the image.
|
||||
|
||||
Returns:
|
||||
BECImageItem: The image object.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
config = ImageItemConfig(**config)
|
||||
config.parent_id = self.gui_id
|
||||
name = config.monitor if config.monitor is not None else config.gui_id
|
||||
image = self._add_image_object(source=config.source, name=name, config=config)
|
||||
return image
|
||||
|
||||
def get_image_config(self, image_id, dict_output: bool = True) -> ImageItemConfig | dict:
|
||||
"""
|
||||
Get the configuration of the image.
|
||||
|
||||
Args:
|
||||
image_id(str): The ID of the image.
|
||||
dict_output(bool): Whether to return the configuration as a dictionary. Defaults to True.
|
||||
|
||||
Returns:
|
||||
ImageItemConfig|dict: The configuration of the image.
|
||||
"""
|
||||
for source, images in self._images.items():
|
||||
for id, image in images.items():
|
||||
if id == image_id:
|
||||
if dict_output:
|
||||
return image.config.dict()
|
||||
else:
|
||||
return image.config # TODO check if this works
|
||||
|
||||
@property
|
||||
def images(self) -> list[BECImageItem]:
|
||||
"""
|
||||
Get the list of images.
|
||||
Returns:
|
||||
list[BECImageItem]: The list of images.
|
||||
"""
|
||||
images = []
|
||||
for source, images_dict in self._images.items():
|
||||
for id, image in images_dict.items():
|
||||
images.append(image)
|
||||
return images
|
||||
|
||||
@images.setter
|
||||
def images(self, value: dict[str, dict[str, BECImageItem]]):
|
||||
"""
|
||||
Set the images from a dictionary.
|
||||
|
||||
Args:
|
||||
value (dict[str, dict[str, BECImageItem]]): The images to set, organized by source and id.
|
||||
"""
|
||||
self._images = value
|
||||
|
||||
def get_image_dict(self) -> dict[str, dict[str, BECImageItem]]:
|
||||
"""
|
||||
Get all images.
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, BECImageItem]]: The dictionary of images.
|
||||
"""
|
||||
return self._images
|
||||
|
||||
def add_monitor_image(
|
||||
self,
|
||||
monitor: str,
|
||||
color_map: Optional[str] = "magma",
|
||||
color_bar: Optional[Literal["simple", "full"]] = "simple",
|
||||
downsample: Optional[bool] = True,
|
||||
opacity: Optional[float] = 1.0,
|
||||
vrange: Optional[tuple[int, int]] = None,
|
||||
# post_processing: Optional[PostProcessingConfig] = None,
|
||||
**kwargs,
|
||||
) -> BECImageItem:
|
||||
image_source = "device_monitor"
|
||||
|
||||
image_exits = self._check_image_id(monitor, self._images)
|
||||
if image_exits:
|
||||
raise ValueError(
|
||||
f"Monitor with ID '{monitor}' already exists in widget '{self.gui_id}'."
|
||||
)
|
||||
|
||||
monitor = self.entry_validator.validate_monitor(monitor)
|
||||
|
||||
image_config = ImageItemConfig(
|
||||
widget_class="BECImageItem",
|
||||
parent_id=self.gui_id,
|
||||
color_map=color_map,
|
||||
color_bar=color_bar,
|
||||
downsample=downsample,
|
||||
opacity=opacity,
|
||||
vrange=vrange,
|
||||
# post_processing=post_processing,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
image = self._add_image_object(source=image_source, name=monitor, config=image_config)
|
||||
self._connect_device_monitor(monitor)
|
||||
return image
|
||||
|
||||
def add_custom_image(
|
||||
self,
|
||||
name: str,
|
||||
data: Optional[np.ndarray] = None,
|
||||
color_map: Optional[str] = "magma",
|
||||
color_bar: Optional[Literal["simple", "full"]] = "simple",
|
||||
downsample: Optional[bool] = True,
|
||||
opacity: Optional[float] = 1.0,
|
||||
vrange: Optional[tuple[int, int]] = None,
|
||||
# post_processing: Optional[PostProcessingConfig] = None,
|
||||
**kwargs,
|
||||
):
|
||||
image_source = "device_monitor"
|
||||
|
||||
image_exits = self._check_curve_id(name, self._images)
|
||||
if image_exits:
|
||||
raise ValueError(f"Monitor with ID '{name}' already exists in widget '{self.gui_id}'.")
|
||||
|
||||
image_config = ImageItemConfig(
|
||||
widget_class="BECImageItem",
|
||||
parent_id=self.gui_id,
|
||||
monitor=name,
|
||||
color_map=color_map,
|
||||
color_bar=color_bar,
|
||||
downsample=downsample,
|
||||
opacity=opacity,
|
||||
vrange=vrange,
|
||||
# post_processing=post_processing,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
image = self._add_image_object(source=image_source, config=image_config, data=data)
|
||||
return image
|
||||
|
||||
def apply_setting_to_images(
|
||||
self, setting_method_name: str, args: list, kwargs: dict, image_id: str = None
|
||||
):
|
||||
"""
|
||||
Apply a setting to all images or a specific image by its ID.
|
||||
|
||||
Args:
|
||||
setting_method_name (str): The name of the method to apply (e.g., 'set_color_map').
|
||||
args (list): Positional arguments for the setting method.
|
||||
kwargs (dict): Keyword arguments for the setting method.
|
||||
image_id (str, optional): The ID of the specific image to apply the setting to. If None, applies to all images.
|
||||
"""
|
||||
if image_id:
|
||||
image = self.find_image_by_monitor(image_id)
|
||||
if image:
|
||||
getattr(image, setting_method_name)(*args, **kwargs)
|
||||
else:
|
||||
for source, images in self._images.items():
|
||||
for _, image in images.items():
|
||||
getattr(image, setting_method_name)(*args, **kwargs)
|
||||
|
||||
def set_vrange(self, vmin: float, vmax: float, name: str = None):
|
||||
"""
|
||||
Set the range of the color bar.
|
||||
If name is not specified, then set vrange for all images.
|
||||
|
||||
Args:
|
||||
vmin(float): Minimum value of the color bar.
|
||||
vmax(float): Maximum value of the color bar.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_vrange", args=[vmin, vmax], kwargs={}, image_id=name)
|
||||
|
||||
def set_color_map(self, cmap: str, name: str = None):
|
||||
"""
|
||||
Set the color map of the image.
|
||||
If name is not specified, then set color map for all images.
|
||||
|
||||
Args:
|
||||
cmap(str): The color map of the image.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_color_map", args=[cmap], kwargs={}, image_id=name)
|
||||
|
||||
def set_autorange(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the autoscale of the image.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to autoscale the color bar.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_autorange", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def set_monitor(self, monitor: str, name: str = None):
|
||||
"""
|
||||
Set the monitor of the image.
|
||||
If name is not specified, then set monitor for all images.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_monitor", args=[monitor], kwargs={}, image_id=name)
|
||||
|
||||
def set_processing(self, name: str = None, **kwargs):
|
||||
"""
|
||||
Set the post processing of the image.
|
||||
If name is not specified, then set post processing for all images.
|
||||
|
||||
Args:
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
Possible properties:
|
||||
- fft: bool
|
||||
- log: bool
|
||||
- rot: int
|
||||
- transpose: bool
|
||||
"""
|
||||
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
|
||||
|
||||
def set_image_properties(self, name: str = None, **kwargs):
|
||||
"""
|
||||
Set the properties of the image.
|
||||
|
||||
Args:
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
Possible properties:
|
||||
- downsample: bool
|
||||
- color_map: str
|
||||
- monitor: str
|
||||
- opacity: float
|
||||
- vrange: tuple[int,int]
|
||||
- fft: bool
|
||||
- log: bool
|
||||
- rot: int
|
||||
- transpose: bool
|
||||
"""
|
||||
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
|
||||
|
||||
def set_fft(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the FFT of the image.
|
||||
If name is not specified, then set FFT for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform FFT on the monitor data.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_fft", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def set_log(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the log of the image.
|
||||
If name is not specified, then set log for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform log on the monitor data.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_log", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def set_rotation(self, deg_90: int = 0, name: str = None):
|
||||
"""
|
||||
Set the rotation of the image.
|
||||
If name is not specified, then set rotation for all images.
|
||||
|
||||
Args:
|
||||
deg_90(int): The rotation angle of the monitor data before displaying.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_rotation", args=[deg_90], kwargs={}, image_id=name)
|
||||
|
||||
def set_transpose(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the transpose of the image.
|
||||
If name is not specified, then set transpose for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to transpose the monitor data before displaying.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_transpose", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def toggle_threading(self, use_threading: bool):
|
||||
"""
|
||||
Toggle threading for the widgets postprocessing and updating.
|
||||
|
||||
Args:
|
||||
use_threading(bool): Whether to use threading.
|
||||
"""
|
||||
self.use_threading = use_threading
|
||||
if self.use_threading is False and self.thread.isRunning():
|
||||
self.cleanup()
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def on_image_update(self, msg: dict):
|
||||
"""
|
||||
Update the image of the device monitor from bec.
|
||||
|
||||
Args:
|
||||
msg(dict): The message from bec.
|
||||
"""
|
||||
data = msg["data"]
|
||||
device = msg["device"]
|
||||
image_to_update = self._images["device_monitor"][device]
|
||||
processing_config = image_to_update.config.processing
|
||||
self.processor.set_config(processing_config)
|
||||
if self.use_threading:
|
||||
self._create_thread_worker(device, data)
|
||||
else:
|
||||
data = self.processor.process_image(data)
|
||||
self.update_image(device, data)
|
||||
|
||||
@pyqtSlot(str, np.ndarray)
|
||||
def update_image(self, device: str, data: np.ndarray):
|
||||
"""
|
||||
Update the image of the device monitor.
|
||||
|
||||
Args:
|
||||
device(str): The name of the device.
|
||||
data(np.ndarray): The data to be updated.
|
||||
"""
|
||||
image_to_update = self._images["device_monitor"][device]
|
||||
image_to_update.updateImage(data, autoLevels=image_to_update.config.autorange)
|
||||
|
||||
def _connect_device_monitor(self, monitor: str):
|
||||
"""
|
||||
Connect to the device monitor.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor.
|
||||
"""
|
||||
image_item = self.find_image_by_monitor(monitor)
|
||||
try:
|
||||
previous_monitor = image_item.config.monitor
|
||||
except AttributeError:
|
||||
previous_monitor = None
|
||||
if previous_monitor != monitor:
|
||||
if previous_monitor:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor(previous_monitor)
|
||||
)
|
||||
if monitor:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor(monitor)
|
||||
)
|
||||
image_item.set_monitor(monitor)
|
||||
|
||||
def _add_image_object(
|
||||
self, source: str, name: str, config: ImageItemConfig, data=None
|
||||
) -> BECImageItem: # TODO fix types
|
||||
config.parent_id = self.gui_id
|
||||
image = BECImageItem(config=config, parent_image=self)
|
||||
self.plot_item.addItem(image)
|
||||
self._images[source][name] = image
|
||||
self.config.images[name] = config
|
||||
if data is not None:
|
||||
image.setImage(data)
|
||||
return image
|
||||
|
||||
def _check_image_id(self, val: Any, dict_to_check: dict) -> bool:
|
||||
"""
|
||||
Check if val is in the values of the dict_to_check or in the values of the nested dictionaries.
|
||||
|
||||
Args:
|
||||
val(Any): Value to check.
|
||||
dict_to_check(dict): Dictionary to check.
|
||||
|
||||
Returns:
|
||||
bool: True if val is in the values of the dict_to_check or in the values of the nested dictionaries, False otherwise.
|
||||
"""
|
||||
if val in dict_to_check.keys():
|
||||
return True
|
||||
for key in dict_to_check:
|
||||
if isinstance(dict_to_check[key], dict):
|
||||
if self._check_image_id(val, dict_to_check[key]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _validate_monitor(self, monitor: str, validate_bec: bool = True):
|
||||
"""
|
||||
Validate the monitor name.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor.
|
||||
validate_bec(bool): Whether to validate the monitor name with BEC.
|
||||
|
||||
Returns:
|
||||
bool: True if the monitor name is valid, False otherwise.
|
||||
"""
|
||||
if not monitor or monitor == "":
|
||||
return False
|
||||
if validate_bec:
|
||||
return monitor in self.dev
|
||||
return True
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the widget.
|
||||
"""
|
||||
for monitor in self._images["device_monitor"]:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor(monitor)
|
||||
)
|
||||
for image in self.images:
|
||||
image.cleanup()
|
||||
|
||||
super().cleanup()
|
||||
@@ -1,277 +0,0 @@
|
||||
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'")
|
||||
@@ -1,153 +0,0 @@
|
||||
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
|
||||
@@ -1,435 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field
|
||||
from qtpy import QtCore, QtGui
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import EntryValidator
|
||||
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):
|
||||
signals: Optional[Signal] = Field(None, description="Signals of the motor map")
|
||||
color_map: Optional[str] = Field(
|
||||
"Greys", description="Color scheme of the motor position gradient."
|
||||
) # TODO decide if useful for anything, or just keep GREYS always
|
||||
scatter_size: Optional[int] = Field(5, description="Size of the scatter points.")
|
||||
max_points: Optional[int] = Field(1000, description="Maximum number of points to display.")
|
||||
num_dim_points: Optional[int] = Field(
|
||||
100,
|
||||
description="Number of points to dim before the color remains same for older recorded position.",
|
||||
)
|
||||
precision: Optional[int] = Field(2, description="Decimal precision of the motor position.")
|
||||
background_value: Optional[int] = Field(
|
||||
25, description="Background value of the motor map."
|
||||
) # TODO can be percentage from 255 calculated
|
||||
|
||||
|
||||
class BECMotorMap(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"config_dict",
|
||||
"change_motors",
|
||||
"set_max_points",
|
||||
"set_precision",
|
||||
"set_num_dim_points",
|
||||
"set_background_value",
|
||||
"set_scatter_size",
|
||||
"get_data",
|
||||
]
|
||||
|
||||
# QT Signals
|
||||
update_signal = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent_figure=None,
|
||||
config: Optional[MotorMapConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
):
|
||||
if config is None:
|
||||
config = MotorMapConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
|
||||
)
|
||||
|
||||
# Get bec shortcuts dev, scans, queue, scan_storage, dap
|
||||
self.get_bec_shortcuts()
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
|
||||
self.motor_x = None
|
||||
self.motor_y = None
|
||||
self.database_buffer = {"x": [], "y": []}
|
||||
self.plot_components = defaultdict(dict) # container for plot components
|
||||
|
||||
# connect update signal to update plot
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self._update_plot
|
||||
)
|
||||
|
||||
# TODO decide if needed to implement, maybe there will be no children widgets for motormap for now...
|
||||
# def find_widget_by_id(self, item_id: str) -> BECCurve:
|
||||
# """
|
||||
# Find the curve by its ID.
|
||||
# Args:
|
||||
# item_id(str): ID of the curve.
|
||||
#
|
||||
# Returns:
|
||||
# BECCurve: The curve object.
|
||||
# """
|
||||
# for curve in self.plot_item.curves:
|
||||
# if curve.gui_id == item_id:
|
||||
# return curve
|
||||
|
||||
@pyqtSlot(str, str, str, str, bool)
|
||||
def change_motors(
|
||||
self,
|
||||
motor_x: str,
|
||||
motor_y: str,
|
||||
motor_x_entry: str = None,
|
||||
motor_y_entry: str = None,
|
||||
validate_bec: bool = True,
|
||||
) -> 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.
|
||||
motor_x_entry(str): Motor entry for the X axis.
|
||||
motor_y_entry(str): Motor entry for the Y axis.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
"""
|
||||
motor_x_entry, motor_y_entry = self._validate_signal_entries(
|
||||
motor_x, motor_y, motor_x_entry, motor_y_entry, validate_bec
|
||||
)
|
||||
|
||||
motor_x_limit = self._get_motor_limit(motor_x)
|
||||
motor_y_limit = self._get_motor_limit(motor_y)
|
||||
|
||||
signal = Signal(
|
||||
source="device_readback",
|
||||
x=SignalData(name=motor_x, entry=motor_x_entry, limits=motor_x_limit),
|
||||
y=SignalData(name=motor_y, entry=motor_y_entry, limits=motor_y_limit),
|
||||
)
|
||||
self.config.signals = signal
|
||||
|
||||
# reconnect the signals
|
||||
self._connect_motor_to_slots()
|
||||
|
||||
# Redraw the motor map
|
||||
self._make_motor_map()
|
||||
|
||||
def get_data(self) -> dict:
|
||||
"""
|
||||
Get the data of the motor map.
|
||||
Returns:
|
||||
dict: Data of the motor map.
|
||||
"""
|
||||
data = {"x": self.database_buffer["x"], "y": self.database_buffer["y"]}
|
||||
return data
|
||||
|
||||
# TODO setup all visual properties
|
||||
def set_max_points(self, max_points: int) -> None:
|
||||
"""
|
||||
Set the maximum number of points to display.
|
||||
|
||||
Args:
|
||||
max_points(int): Maximum number of points to display.
|
||||
"""
|
||||
self.config.max_points = max_points
|
||||
|
||||
def set_precision(self, precision: int) -> None:
|
||||
"""
|
||||
Set the decimal precision of the motor position.
|
||||
|
||||
Args:
|
||||
precision(int): Decimal precision of the motor position.
|
||||
"""
|
||||
self.config.precision = precision
|
||||
|
||||
def set_num_dim_points(self, num_dim_points: int) -> None:
|
||||
"""
|
||||
Set the number of dim points for the motor map.
|
||||
|
||||
Args:
|
||||
num_dim_points(int): Number of dim points.
|
||||
"""
|
||||
self.config.num_dim_points = num_dim_points
|
||||
|
||||
def set_background_value(self, background_value: int) -> None:
|
||||
"""
|
||||
Set the background value of the motor map.
|
||||
|
||||
Args:
|
||||
background_value(int): Background value of the motor map.
|
||||
"""
|
||||
self.config.background_value = background_value
|
||||
|
||||
def set_scatter_size(self, scatter_size: int) -> None:
|
||||
"""
|
||||
Set the scatter size of the motor map plot.
|
||||
|
||||
Args:
|
||||
scatter_size(int): Size of the scatter points.
|
||||
"""
|
||||
self.config.scatter_size = scatter_size
|
||||
|
||||
def _disconnect_current_motors(self):
|
||||
"""Disconnect the current motors from the slots."""
|
||||
if self.motor_x is not None and self.motor_y is not None:
|
||||
endpoints = [
|
||||
MessageEndpoints.device_readback(self.motor_x),
|
||||
MessageEndpoints.device_readback(self.motor_y),
|
||||
]
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, endpoints)
|
||||
|
||||
def _connect_motor_to_slots(self):
|
||||
"""Connect motors to slots."""
|
||||
self._disconnect_current_motors()
|
||||
|
||||
self.motor_x = self.config.signals.x.name
|
||||
self.motor_y = self.config.signals.y.name
|
||||
|
||||
endpoints = [
|
||||
MessageEndpoints.device_readback(self.motor_x),
|
||||
MessageEndpoints.device_readback(self.motor_y),
|
||||
]
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints)
|
||||
|
||||
def _make_motor_map(self):
|
||||
"""
|
||||
Create the motor map plot.
|
||||
"""
|
||||
# Create limit map
|
||||
motor_x_limit = self.config.signals.x.limits
|
||||
motor_y_limit = self.config.signals.y.limits
|
||||
self.plot_components["limit_map"] = self._make_limit_map(motor_x_limit, motor_y_limit)
|
||||
self.plot_item.addItem(self.plot_components["limit_map"])
|
||||
self.plot_components["limit_map"].setZValue(-1)
|
||||
|
||||
# Create scatter plot
|
||||
scatter_size = self.config.scatter_size
|
||||
self.plot_components["scatter"] = pg.ScatterPlotItem(
|
||||
size=scatter_size, brush=pg.mkBrush(255, 255, 255, 255)
|
||||
)
|
||||
self.plot_item.addItem(self.plot_components["scatter"])
|
||||
self.plot_components["scatter"].setZValue(0)
|
||||
|
||||
# Enable Grid
|
||||
self.set_grid(True, True)
|
||||
|
||||
# Add the crosshair for initial motor coordinates
|
||||
initial_position_x = self._get_motor_init_position(
|
||||
self.motor_x, self.config.signals.x.entry, self.config.precision
|
||||
)
|
||||
initial_position_y = self._get_motor_init_position(
|
||||
self.motor_y, self.config.signals.y.entry, self.config.precision
|
||||
)
|
||||
|
||||
self.database_buffer["x"] = [initial_position_x]
|
||||
self.database_buffer["y"] = [initial_position_y]
|
||||
|
||||
self.plot_components["scatter"].setData([initial_position_x], [initial_position_y])
|
||||
self._add_coordinantes_crosshair(initial_position_x, initial_position_y)
|
||||
|
||||
# Set default labels for the plot
|
||||
self.set(x_label=f"Motor X ({self.motor_x})", y_label=f"Motor Y ({self.motor_y})")
|
||||
|
||||
def _add_coordinantes_crosshair(self, x: float, y: float) -> None:
|
||||
"""
|
||||
Add crosshair to the plot to highlight the current position.
|
||||
|
||||
Args:
|
||||
x(float): X coordinate.
|
||||
y(float): Y coordinate.
|
||||
"""
|
||||
|
||||
# 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.plot_components["highlight_H"] = highlight_H
|
||||
self.plot_components["highlight_V"] = highlight_V
|
||||
|
||||
# Add crosshair to the plot
|
||||
self.plot_item.addItem(highlight_H)
|
||||
self.plot_item.addItem(highlight_V)
|
||||
|
||||
highlight_V.setPos(x)
|
||||
highlight_H.setPos(y)
|
||||
|
||||
def _make_limit_map(self, limits_x: list, limits_y: list) -> pg.ImageItem:
|
||||
"""
|
||||
Create a limit map for the motor map plot.
|
||||
|
||||
Args:
|
||||
limits_x(list): Motor limits for the x axis.
|
||||
limits_y(list): Motor limits for the y axis.
|
||||
|
||||
Returns:
|
||||
pg.ImageItem: Limit map.
|
||||
"""
|
||||
limit_x_min, limit_x_max = limits_x
|
||||
limit_y_min, limit_y_max = limits_y
|
||||
|
||||
map_width = int(limit_x_max - limit_x_min + 1)
|
||||
map_height = int(limit_y_max - limit_y_min + 1)
|
||||
|
||||
# Create limits map
|
||||
background_value = self.config.background_value
|
||||
limit_map_data = np.full((map_width, map_height), background_value, dtype=np.float32)
|
||||
limit_map = pg.ImageItem()
|
||||
limit_map.setImage(limit_map_data)
|
||||
|
||||
# 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)
|
||||
|
||||
return limit_map
|
||||
|
||||
def _get_motor_init_position(self, name: str, entry: str, precision: int) -> float:
|
||||
"""
|
||||
Get the motor initial position from the config.
|
||||
|
||||
Args:
|
||||
name(str): Motor name.
|
||||
entry(str): Motor entry.
|
||||
precision(int): Decimal precision of the motor position.
|
||||
|
||||
Returns:
|
||||
float: Motor initial position.
|
||||
"""
|
||||
init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
|
||||
return init_position
|
||||
|
||||
def _validate_signal_entries(
|
||||
self,
|
||||
x_name: str,
|
||||
y_name: str,
|
||||
x_entry: str | None,
|
||||
y_entry: str | None,
|
||||
validate_bec: bool = True,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Validate the signal name and entry.
|
||||
|
||||
Args:
|
||||
x_name(str): Name of the x signal.
|
||||
y_name(str): Name of the y signal.
|
||||
x_entry(str|None): Entry of the x signal.
|
||||
y_entry(str|None): Entry of the y signal.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
|
||||
Returns:
|
||||
tuple[str,str]: Validated x and y entries.
|
||||
"""
|
||||
if validate_bec:
|
||||
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
|
||||
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
|
||||
else:
|
||||
x_entry = x_name if x_entry is None else x_entry
|
||||
y_entry = y_name if y_entry is None else y_entry
|
||||
return x_entry, y_entry
|
||||
|
||||
def _get_motor_limit(self, motor: str) -> Union[list | None]: # TODO check if works correctly
|
||||
"""
|
||||
Get the motor limit from the config.
|
||||
|
||||
Args:
|
||||
motor(str): Motor name.
|
||||
|
||||
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 _update_plot(self):
|
||||
"""Update the motor map plot."""
|
||||
x = self.database_buffer["x"]
|
||||
y = self.database_buffer["y"]
|
||||
|
||||
# Setup gradient brush for history
|
||||
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(x)
|
||||
|
||||
# Calculate the decrement step based on self.num_dim_points
|
||||
num_dim_points = self.config.num_dim_points
|
||||
decrement_step = (255 - 50) / num_dim_points
|
||||
for i in range(1, min(num_dim_points + 1, len(x) + 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
|
||||
scatter_size = self.config.scatter_size
|
||||
|
||||
# Update the scatter plot
|
||||
self.plot_components["scatter"].setData(
|
||||
x=x, y=y, brush=brushes, pen=None, size=scatter_size
|
||||
)
|
||||
|
||||
# Get last know position for crosshair
|
||||
current_x = x[-1]
|
||||
current_y = y[-1]
|
||||
|
||||
# Update the crosshair
|
||||
self.plot_components["highlight_V"].setPos(current_x)
|
||||
self.plot_components["highlight_H"].setPos(current_y)
|
||||
|
||||
# TODO not update title but some label
|
||||
# Update plot title
|
||||
precision = self.config.precision
|
||||
self.set_title(
|
||||
f"Motor position: ({round(float(current_x),precision)}, {round(float(current_y),precision)})"
|
||||
)
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def on_device_readback(self, msg: dict) -> None:
|
||||
"""
|
||||
Update the motor map plot with the new motor position.
|
||||
|
||||
Args:
|
||||
msg(dict): Message from the device readback.
|
||||
"""
|
||||
if self.motor_x is None or self.motor_y is None:
|
||||
return
|
||||
|
||||
if self.motor_x in msg["signals"]:
|
||||
x = msg["signals"][self.motor_x]["value"]
|
||||
self.database_buffer["x"].append(x)
|
||||
self.database_buffer["y"].append(self.database_buffer["y"][-1])
|
||||
|
||||
elif self.motor_y in msg["signals"]:
|
||||
y = msg["signals"][self.motor_y]["value"]
|
||||
self.database_buffer["y"].append(y)
|
||||
self.database_buffer["x"].append(self.database_buffer["x"][-1])
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
self._disconnect_current_motors()
|
||||
super().cleanup()
|
||||
@@ -1,245 +0,0 @@
|
||||
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()
|
||||
@@ -1,72 +0,0 @@
|
||||
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_())
|
||||
2
bec_widgets/widgets/monitor/__init__.py
Normal file
2
bec_widgets/widgets/monitor/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .monitor import BECMonitor
|
||||
from .config_dialog import ConfigDialog
|
||||
591
bec_widgets/widgets/monitor/config_dialog.py
Normal file
591
bec_widgets/widgets/monitor/config_dialog.py
Normal file
@@ -0,0 +1,591 @@
|
||||
import os
|
||||
|
||||
from qtpy import uic
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QTableWidget,
|
||||
QTabWidget,
|
||||
QTableWidgetItem,
|
||||
QLineEdit,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, save_yaml
|
||||
from bec_widgets.validation import MonitorConfigValidator
|
||||
from pydantic import ValidationError
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
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()
|
||||
210
bec_widgets/widgets/monitor/config_dialog.ui
Normal file
210
bec_widgets/widgets/monitor/config_dialog.ui
Normal file
@@ -0,0 +1,210 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,60 @@
|
||||
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"
|
||||
@@ -0,0 +1,92 @@
|
||||
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"
|
||||
|
||||
855
bec_widgets/widgets/monitor/monitor.py
Normal file
855
bec_widgets/widgets/monitor/monitor.py
Normal file
@@ -0,0 +1,855 @@
|
||||
# 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.validation import MonitorConfigValidator
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
# 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",
|
||||
"scanID": "<scanID>",
|
||||
"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.scanID = 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.scanID 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."""
|
||||
from bec_widgets.widgets import ConfigDialog
|
||||
|
||||
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_scanID = msg.get("scanID", None)
|
||||
if current_scanID is None:
|
||||
return
|
||||
|
||||
if current_scanID != self.scanID:
|
||||
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.scanID = current_scanID
|
||||
self.scan_data = self.queue.scan_storage.find_scan_by_ID(self.scanID)
|
||||
if not self.scan_data:
|
||||
print(f"No data found for scanID: {self.scanID}") # 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 scanID.
|
||||
"""
|
||||
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())
|
||||
180
bec_widgets/widgets/monitor/tab_template.ui
Normal file
180
bec_widgets/widgets/monitor/tab_template.ui
Normal file
@@ -0,0 +1,180 @@
|
||||
<?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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user