1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 01:37:53 +02:00

Compare commits

..

3 Commits

196 changed files with 14429 additions and 10930 deletions

View File

@@ -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')

View File

@@ -1,58 +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
# different stages in the pipeline
stages:
- Formatter
- test
- AdditionalTests
- End2End
- Deploy
before_script:
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
fi
formatter:
stage: Formatter
needs: []
script:
- pip install black isort
- isort --check --diff ./
- 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 $?
@@ -63,8 +39,6 @@ pylint:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint-check:
stage: Formatter
@@ -96,8 +70,6 @@ pylint-check:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
tests:
stage: test
@@ -105,14 +77,10 @@ tests:
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- 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+)?\%)$/'
@@ -123,171 +91,35 @@ tests:
coverage_format: cobertura
path: coverage.xml
tests-3.10-pyside6:
#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
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyside6]
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
image: $CI_DOCKER_REGISTRY/python:3.10
allow_failure: true
tests-3.12-pyside6:
tests-3.11:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyside6]
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
image: $CI_DOCKER_REGISTRY/python:3.11
allow_failure: true
tests-3.10-pyqt5:
tests-3.12:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt5]
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
image: $CI_DOCKER_REGISTRY/python:3.12
allow_failure: true
tests-3.11-pyqt5:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt5]
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
allow_failure: true
tests-3.12-pyqt5:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt5]
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
allow_failure: true
tests-3.10-pyqt6:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt6]
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
allow_failure: true
tests-3.11-pyqt6:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt6]
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
allow_failure: true
tests-3.12-pyqt6:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev,pyqt6]
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
allow_failure: true
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- 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
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- cd ./bec
- source ./bin/install_bec_dev.sh -t
- 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
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
@@ -303,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
@@ -326,6 +158,6 @@ pages:
- 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/

View File

@@ -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.

View File

@@ -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:

View File

@@ -1,167 +1,859 @@
# CHANGELOG
# Changelog
<!--next-version-placeholder-->
## v0.57.2 (2024-06-06)
## v0.41.1 (2024-02-26)
### Fix
* fix(test/e2e): autoupdate e2e rewritten ([`e1af5ca`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e1af5ca60f0616835f9f41d84412f29dc298c644))
* **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))
* fix(test/e2e): spiral_progress_bar e2e tests rewritten to use config_dict ([`7fb31fc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7fb31fc4d762ff4ca839971b3092a084186f81b8))
* fix(test/e2e): dockarea and dock e2e tests changed to check asserts against config_dict ([`5c6ba65`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5c6ba65469863ea1e6fc5abdc742650e20eba9b9))
* fix: rpc_server_dock fixture now spawns the server process ([`cd9fc46`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cd9fc46ff8a947242c8c28adcd73d7de60b11c44))
* fix: accept scalars or numpy arrays of 1 element ([`2a88e17`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2a88e17b23436c55d25b7d3449e4af3a7689661c))
### Refactor
* refactor: move _get_output and _start_plot_process at the module level ([`69f4371`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/69f4371007c66aee6b7521a6803054025adf8c92))
## v0.57.1 (2024-06-06)
### Documentation
* docs: docs refactored from add_widget_bec to add_widget ([`c3f4845`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c3f4845b4f95005ff737fed5542600b0b9a9cc2b))
### Fix
* fix: tests references to add_widget_bec refactored ([`f51b25f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f51b25f0af4ab8b0a75ee48a40bfbb079c16e9d1))
* fix(dock): add_widget and add_widget_bec consolidated ([`8ae323f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8ae323f5c3c0d9d0f202d31d5e8374a272a26be2))
## v0.57.0 (2024-06-05)
### Documentation
* docs: extend user documentation for BEC Widgets ([`4160f3d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4160f3d6d7ec1122785b5e3fdfc4afe67a95e9a1))
## v0.41.0 (2024-02-26)
### Feature
* feat(widgets/console): BECJupyterConsole added ([`8c03034`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8c03034acf6b3ed1e346ebf1b785d41068513cc5))
## v0.56.3 (2024-06-05)
### Ci
* ci: increased verbosity for e2e tests ([`4af1abe`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4af1abe4e15b62d2f7e70bf987a1a7d8694ef4d5))
* **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: fixed support for auto updates ([`131f49d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/131f49da8ea65af4d44b50e81c1acfc29cd92093))
* **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.40.1 (2024-02-23)
## v0.56.2 (2024-06-05)
### Fix
* **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.40.0 (2024-02-16)
### Feature
* **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))
## 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
* Adapt code to BEC 1.0 ([`b36131e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b36131eed5c3a3ea58c0fa4d083e63a3717cdf22))
## v0.38.1 (2024-01-26)
### Fix
* 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: restructured docs layout ([`3c9181d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3c9181d93d68faa4efb3b91c486ca9ca935975a0))
* 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))
## v0.38.0 (2024-01-23)
### Feature
* 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(bar): ring saves current value in config ([`9648e3e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9648e3ea96a4109be6be694d855151ed6d3ad661))
* Monitor_scatter_2D.py changed to new BECDispatcher definition ([`747e97e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/747e97e0c924cdedb85e9fe7d47512002b791b10))
* fix(dock): dock saves configs of all children widgets ([`4be756a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4be756a8676421c3a3451458995232407295df84))
* fix(dock_area): save/restore state is saved in config ([`46face0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/46face0ee59122f04cb383da685a6658beeeb389))
* fix(figure): added correct types of configs to subplot widgets ([`6f3b1ea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6f3b1ea985c18929b9bab54239eeb600f03b274a))
## v0.56.1 (2024-06-04)
## v0.37.1 (2024-01-23)
### Fix
* fix(spiral_progress_bar/rings): config min/max values added check for floats ([`9d615c9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9d615c915c8f7cc2ea8f1dc17993b98fe462c682))
* **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))
* fix(spiral_progress_bar): Endpoint is always stored as a string in the RingConnection Config ([`d253991`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d2539918b296559e1d684344e179775a2423daa9))
## v0.37.0 (2024-01-17)
### Feature
## v0.56.0 (2024-05-29)
* Independent motor_map widget ([`1a429b3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1a429b3024e76446ed530bee71ed797c20843fba))
### Build
## v0.36.2 (2024-01-17)
* build: added pyside6 as dependency ([`db301b1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/db301b1be27bba76c8bb21fbff93cb4902b592a5))
### Fix
### Ci
* 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))
* ci: added tests for pyside6, pyqt6 and pyqt5, default test and e2e is python 3.11 and pyqt6 ([`855be35`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/855be3551a1372bcbebba6f8930903f99202bbca))
## v0.36.1 (2024-01-15)
### 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(examples): example apps section deleted ([`ad208a5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ad208a5ef8495c45a8b83a4850ba9a1041b42717))
* 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(utils/ui_loader): universal ui loader for pyside/pyqt ([`0fea8d6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0fea8d606574fa99dda3b117da5d5209c251f694))
* Monitor.py error message popup ([`a3b24f9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a3b24f92420420c8968ef4793342c3857c826e57))
### Fix
* fix(examples): outdated examples removed (mca_plot.py, stream_plot.py, motor_example.py) ([`ddc9510`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ddc9510c2ba8dadf291809eeb5b135a105259492))
* 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: compatibility adjustment to .ui loading and tests for PySide6 ([`07b99d9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/07b99d91a57a645cddd76294f48d78773e4c9ea5))
## v0.55.0 (2024-05-24)
## v0.33.0 (2023-12-07)
### Feature
* feat(widgets/progressbar): SpiralProgressBar added with rpc interface ([`76bd0d3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/76bd0d339ac9ae9e8a3baa0d0d4e951ec1d09670))
* Added axis_width and axis_color as optional plot settings ([`504944f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/504944f696a7b2881adec06d29c271fec7e2c981))
### Fix
## v0.54.0 (2024-05-24)
* 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))
### Build
## v0.32.2 (2023-12-06)
* build: added pyqt6 as sphinx build dependency ([`a47a8ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a47a8ec413934cf7fce8d5b7a5913371d4b3b4a5))
### 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
* feat(figure): changes to support direct plot functionality ([`fc4d0f3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc4d0f3bb2a7c2fca9c326d86eb68b305bcd548b))
### Refactor
* refactor(reconstruction): repository structure is changed to separate assets needed for each widget ([`3455c60`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3455c602361d3b5cc3ff9190f9d2870474becf8a))
* refactor(clean-up): 1st generation widgets are removed ([`edc25fb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/edc25fbf9d5a0321e5f0a80b492b6337df807849))
## v0.53.3 (2024-05-16)
* 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
* fix: removed apparently unnecessary sleep while waiting for an rpc response ([`7d64cac`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7d64cac6610b39d3553ff650354f78ead8ee6b55))
* 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)
## v0.53.2 (2024-05-15)
### Feature
### Ci
* Pydantic validation module for monitor.py ([`7fec0c7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7fec0c7e4411c221413d69aeeb4d68ade10d502b))
* ci: added echo to highlight the current branch ([`0490e80`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0490e80c48563e4fb486bce903b3ce1f08863e83))
### 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
* fix: check device class without importing to speed up initial import time ([`9f8fbdd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9f8fbdd5fc13cf2be10eacb41e10cf742864cd92))
* 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))
* fix: speed up initial import times using lazy import (from bec_lib) ([`d1e6cd3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d1e6cd388c6c9f345f52d6096d8a75a1fa7e6934))
## v0.29.0 (2023-10-31)
* fix: adapt to bec_lib changes (no more submodules in `__init__.py`) ([`5d09a13`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d09a13d8820a8bdb900733c97593b723a2fce1d))
### Feature
## v0.53.1 (2024-05-09)
### Ci
* ci: fixed rtd pages url ([`8ff3610`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8ff36105d1e637c429915b4bfc2852d54a3c6f19))
* 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
* fix: docs config ([`0f6a5e5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0f6a5e5fa9530969c98a9266c9ca7b89a378ff70))
* 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
## v0.53.0 (2024-05-09)
* 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))

View File

@@ -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

View File

@@ -1 +0,0 @@
from .client import BECDockArea, BECFigure

View File

@@ -1,150 +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
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)
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}")
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}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +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 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
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):
@@ -43,132 +25,22 @@ 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
monitor_module = importlib.import_module("bec_widgets.cli.server")
monitor_path = monitor_module.__file__
command = [
sys.executable,
"-u",
monitor_path,
"--id",
gui_id,
"--config",
config,
"--gui_class",
gui_class.__name__,
]
process = subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
process_output_processing_thread = threading.Thread(target=_get_output, args=(process,))
process_output_processing_thread.start()
return process, process_output_processing_thread
class BECGuiClientMixin:
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:
"""
@@ -176,14 +48,22 @@ class BECGuiClientMixin:
"""
if self._process is None:
return
if self.gui_is_alive():
self._run_rpc("close", (), wait_for_rpc_response=True)
else:
self._run_rpc("close", (), wait_for_rpc_response=False)
self._process.terminate()
self._process_output_processing_thread.join()
self._run_rpc("close", (), wait_for_rpc_response=False)
self._process.kill()
self._process = None
self._client.shutdown()
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:
"""
@@ -191,18 +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}"
)
def __del__(self) -> None:
self.close()
class RPCBase:
@@ -212,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):
@@ -250,10 +129,10 @@ class RPCBase:
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
metadata={"request_id": request_id},
)
print(f"RPCBase: {rpc_msg}")
# pylint: disable=protected-access
receiver = self._root._gui_id
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
self._client.connector.send(MessageEndpoints.gui_instructions(receiver), rpc_msg)
if not wait_for_rpc_response:
return None
@@ -271,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)
@@ -281,37 +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 _wait_for_response(self, request_id: str, timeout: int = 5):
def _wait_for_response(self, request_id):
"""
Wait for the response from the server.
Args:
request_id(str): The request ID.
timeout(int): The timeout in seconds.
Returns:
The response from the server.
"""
start_time = time.time()
response = None
while response is None and self.gui_is_alive() and (time.time() - start_time) < timeout:
while response is None:
response = self._client.connector.get(
MessageEndpoints.gui_instruction_response(request_id)
)
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
if response is None and (time.time() - start_time) >= timeout:
raise RPCResponseTimeoutError(request_id, timeout)
return response
def gui_is_alive(self):
"""
Check if the GUI is alive.
"""
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
return heart is not None

View File

@@ -1,28 +1,11 @@
# pylint: disable=missing-module-docstring
from __future__ import annotations
import inspect
import sys
import black
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
from bec_widgets.cli.client_utils import rpc_call, RPCBase, BECGuiClientMixin
from bec_widgets.cli.client_utils import rpc_call, RPCBase, BECFigureClientMixin
from typing import Literal, Optional, overload"""
self.content = ""
@@ -41,7 +24,6 @@ from typing import Literal, Optional, overload"""
def generate_content_for_class(self, cls):
"""
Generate the content for the class.
Args:
cls: The class for which to generate the content.
"""
@@ -54,9 +36,9 @@ from typing import Literal, Optional, overload"""
# 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):"""
@@ -71,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"""
@@ -89,51 +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
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
file.write(self.header)
file.write(self.content)
if __name__ == "__main__": # pragma: no cover
if __name__ == "__main__":
import os
from bec_widgets.utils import BECConnector
from bec_widgets.widgets import BECDock, BECDockArea, BECFigure, SpiralProgressBar
from bec_widgets.widgets.figure.plots.image.image import BECImageShow
from bec_widgets.widgets.figure.plots.image.image_item import BECImageItem
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform
from bec_widgets.widgets.figure.plots.waveform.waveform_curve import BECCurve
from bec_widgets.widgets.spiral_progress_bar.ring import Ring
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.plots import BECPlotBase, BECWaveform1D # ,BECCurve
from bec_widgets.widgets.plots.waveform1d import BECCurve
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
clss = [
BECPlotBase,
BECWaveform,
BECFigure,
BECCurve,
BECImageShow,
BECConnector,
BECImageItem,
BECMotorMap,
BECDock,
BECDockArea,
SpiralProgressBar,
Ring,
]
clss = [BECPlotBase, BECWaveform1D, BECFigure, BECCurve]
generator = ClientGenerator()
generator.generate_client(clss)
generator.write(client_path)

View File

@@ -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

View File

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

View File

@@ -1,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) # Emit heartbeat every 1 seconds
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,69 +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=10,
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self._shutdown_event = True
self._heartbeat_timer.stop()
self.client.shutdown()
if __name__ == "__main__": # pragma: no cover
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())
server = BECWidgetsCLIServer(gui_id=args.id)
# server = BECWidgetsCLIServer(gui_id="test")
server.start()

View File

@@ -2,8 +2,8 @@ from .motor_movement import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
MotorControlPanelAbsolute,
MotorCoordinateTable,
MotorThread,
)

View 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()

View 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())

View 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>

View File

@@ -1,154 +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 import BECFigure
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
# class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
# def __init__(self):
# super().__init__()
#
# self.kernel_manager = QtInProcessKernelManager()
# self.kernel_manager.start_kernel(show_banner=False)
# self.kernel_client = self.kernel_manager.client()
# self.kernel_client.start_channels()
#
# self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
#
# 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="bpm4d")
self.figure.motor_map("samx", "samy")
self.figure.image("eiger", color_map="viridis", vrange=(0, 100))
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.w1.add_curve_scan("samx", "samy", "bpm4i", pen_style="dash")
self.w1.add_curve_scan("samx", "samy", "bpm3a", pen_style="dash")
self.c1 = self.w1.get_config()
def _init_dock(self):
self.d0 = self.dock.add_dock(name="dock_0")
self.fig0 = self.d0.add_widget("BECFigure")
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.motor_map(x_name="samx", y_name="samy")
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__)
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
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)
win = JupyterConsoleWindow()
win.show()
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -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>

View 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()

View 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)

View 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>

View 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()

View File

@@ -2,8 +2,8 @@ from .motor_control_compilations import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
MotorControlPanelAbsolute,
MotorCoordinateTable,
MotorThread,
)

View 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

View File

@@ -0,0 +1,10 @@
redis:
host: pc15543
port: 6379
mongodb:
host: localhost
port: 27017
scibec:
host: http://localhost
port: 3030
beamline: MyBeamline

View 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

View File

@@ -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())

View File

@@ -883,7 +883,7 @@
</column>
<column>
<property name="text">
<string>scan_id</string>
<string>scanID</string>
</property>
</column>
<column>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
x_value: "samx"
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
dap_worker: "gaussian_fit_worker_3"

View File

@@ -0,0 +1,3 @@
x_value: "samx"
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
dap_worker: None

View 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()

View 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>

View 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"

View 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"

View 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()

View 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>

View 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>

View 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()

View 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"

View 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"

View File

@@ -1,11 +1,8 @@
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

View File

@@ -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):
@@ -34,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"
@@ -57,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
"""
@@ -121,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
"""
@@ -132,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.
"""
@@ -145,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.
"""
@@ -156,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)

View File

@@ -1,155 +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 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
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
self._slots = collections.defaultdict(set)
self.client = client
class _BECDispatcher(QObject):
"""Utility class to keep track of slots connected to a particular redis connector"""
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
def __init__(self, bec_config=None):
super().__init__()
self.client = BECClient(forced=True) # make a new instance
# 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
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

View File

@@ -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
"""

View File

@@ -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.")

View File

@@ -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):

View 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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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)}"
)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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:

View File

@@ -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.

View File

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

View 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

View File

@@ -1,4 +1,15 @@
from .dock import BECDock, BECDockArea
from .figure import BECFigure, FigureConfig
from .monitor import BECMonitor, ConfigDialog
from .motor_map import MotorMap
from .scan_control import ScanControl
from .spiral_progress_bar import SpiralProgressBar
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

View File

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

View File

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

View File

@@ -1,240 +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:
"""
Get the docks in the dock area.
Returns:
dock_dict(dict): The docks in the dock area.
"""
return dict(self.docks)
@panels.setter
def panels(self, value: dict):
self.docks = WeakValueDictionary(value)
@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,
prefix: str = "dock",
widget: str | QWidget | None = None,
row: int = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
) -> BECDock:
"""
Add a dock to the dock area. Dock has QGridLayout as layout manager by default.
Args:
name(str): The name of the dock to be displayed and for further references. Has to be unique.
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
relative_to(BECDock): The dock to which the new dock should be added relative to.
closable(bool): Whether the dock is closable.
prefix(str): The prefix for the dock name if no name is provided.
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
row(int): The row of the added widget.
col(int): The column of the added widget.
rowspan(int): The rowspan of the added widget.
colspan(int): The colspan of the added widget.
Returns:
BECDock: The created dock.
"""
if name is None:
name = WidgetContainerUtils.generate_unique_widget_id(
container=self.docks, prefix=prefix
)
if name in set(self.docks.keys()):
raise ValueError(f"Dock with name {name} already exists.")
if position is None:
position = "bottom"
dock = BECDock(name=name, parent_dock_area=self, closable=closable)
dock.config.position = position
self.config.docks[name] = dock.config
self.addDock(dock=dock, position=position, relativeTo=relative_to)
if len(self.docks) <= 1:
dock.hide_title_bar()
elif len(self.docks) > 1:
for dock in self.docks.values():
dock.show_title_bar()
if widget is not None and isinstance(widget, str):
dock.add_widget(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()
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()

View File

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

View 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()

View File

@@ -1 +1 @@
from .figure import BECFigure, FigureConfig
from .figure import FigureConfig, BECFigure

View File

@@ -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,83 +104,26 @@ 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.
Change the theme of the figure widget.
Args:
theme(Literal["dark","light"]): The theme to set for the figure widget.
"""
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
qdarktheme.setup_theme(theme)
self.setBackground("k" if theme == "dark" else "w")
self.config.theme = theme
def add_plot(
self,
x_name: str = None,
y_name: str = None,
z_name: str = None,
x_entry: str = None,
y_entry: str = None,
z_entry: str = None,
x: list | np.ndarray = None,
y: list | np.ndarray = None,
color: Optional[str] = None,
color_map_z: Optional[str] = "plasma",
label: Optional[str] = None,
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:
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.
@@ -213,8 +131,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
config(dict): Additional configuration for the widget.
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
"""
widget_id = str(uuid.uuid4())
waveform = self.add_widget(
return self.add_widget(
widget_type="Waveform1D",
widget_id=widget_id,
row=row,
@@ -223,343 +140,9 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
**axis_kwargs,
)
# TODO remove repetition from .plot method
# 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
@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)
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,
color=color,
color_map_z="plasma",
label=label,
validate=validate,
)
# User wants to add scan curve -> 2D Waveform Scatter
elif (
x_name is not None
and y_name is not None
and z_name is not None
and x is None
and y is None
):
waveform.add_curve_scan(
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
color=color,
color_map_z=color_map_z,
label=label,
validate=validate,
)
# User wants to add custom curve
elif (
x is not None and y is not None and x_name is None and y_name is None and z_name is None
):
waveform.add_curve_custom(x=x, y=y, color=color, label=label)
else:
raise ValueError(
"Invalid input. Provide either device names (x_name, y_name) or custom data."
)
return waveform
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)
# Setting data #TODO check logic if monitor or data are already created
if monitor is not None and data is None:
image.add_monitor_image(
monitor=monitor, color_map=color_map, vrange=vrange, color_bar=color_bar
)
elif data is not None and monitor is None:
image.add_custom_image(
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
)
elif data is None and monitor is None:
# Setting appearance
if vrange is not None:
image.set_vrange(vmin=vrange[0], vmax=vrange[1])
if color_map is not None:
image.set_color_map(color_map)
else:
raise ValueError("Invalid input. Provide either monitor name or custom data.")
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,
)
# TODO remove repetition from .image method
if monitor is not None and data is None:
image.add_monitor_image(
monitor=monitor, color_map=color_map, vrange=vrange, color_bar=color_bar
)
elif data is not None and monitor is None:
image.add_custom_image(
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
)
elif data is None and monitor is None:
# Setting appearance
if vrange is not None:
image.set_vrange(vmin=vrange[0], vmax=vrange[1])
if color_map is not None:
image.set_color_map(color_map)
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,
@@ -568,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.
@@ -578,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(
@@ -615,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)
@@ -631,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.
@@ -647,53 +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
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
@@ -713,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.
@@ -731,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]
@@ -740,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()
@@ -753,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):
"""
@@ -765,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
@@ -781,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
@@ -797,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_())

View 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>

View File

@@ -1,560 +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",
"plot",
"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()

View File

@@ -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'")

View File

@@ -1,152 +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."
)
class ImageProcessor:
"""
Class for processing the image data.
"""
def __init__(self, config: ProcessingConfig = None):
if config is None:
config = ProcessingConfig()
self.config = config
def set_config(self, config: ProcessingConfig):
"""
Set the configuration of the processor.
Args:
config(ProcessingConfig): The configuration of the processor.
"""
self.config = config
def FFT(self, data: np.ndarray) -> np.ndarray:
"""
Perform FFT on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.abs(np.fft.fftshift(np.fft.fft2(data)))
def rotation(self, data: np.ndarray, rotate_90: int) -> np.ndarray:
"""
Rotate the data by 90 degrees n times.
Args:
data(np.ndarray): The data to be processed.
rotate_90(int): The number of 90 degree rotations.
Returns:
np.ndarray: The processed data.
"""
return np.rot90(data, k=rotate_90, axes=(0, 1))
def transpose(self, data: np.ndarray) -> np.ndarray:
"""
Transpose the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.transpose(data)
def log(self, data: np.ndarray) -> np.ndarray:
"""
Perform log on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
# TODO this is not final solution -> data should stay as int16
data = data.astype(np.float32)
offset = 1e-6
data_offset = data + offset
return np.log10(data_offset)
# def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality
# return np.unravel_index(np.argmax(data), data.shape)
def process_image(self, data: np.ndarray) -> np.ndarray:
"""
Process the data according to the configuration.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
if self.config.fft:
data = self.FFT(data)
if self.config.rotation is not None:
data = self.rotation(data, self.config.rotation)
if self.config.transpose:
data = self.transpose(data)
if self.config.log:
data = self.log(data)
return data
class ProcessorWorker(QObject):
"""
Worker for processing the image data.
"""
processed = Signal(str, np.ndarray)
stopRequested = Signal()
finished = Signal()
def __init__(self, processor):
super().__init__()
self.processor = processor
self._isRunning = False
self.stopRequested.connect(self.stop)
@Slot(str, np.ndarray)
def process_image(self, device: str, image: np.ndarray):
"""
Process the image data.
Args:
device(str): The name of the device.
image(np.ndarray): The image data.
"""
self._isRunning = True
processed_image = self.processor.process_image(image)
self._isRunning = False
if not self._isRunning:
self.processed.emit(device, processed_image)
self.finished.emit()
def stop(self):
self._isRunning = False

View File

@@ -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()

View File

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

View File

@@ -1,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_())

View File

@@ -0,0 +1,2 @@
from .monitor import BECMonitor
from .config_dialog import ConfigDialog

View 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()

View 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>

View File

@@ -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"

View File

@@ -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"

View 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())

View 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>

View File

@@ -0,0 +1 @@
from .monitor_scatter_2D import BECMonitor2DScatter

View File

@@ -0,0 +1,382 @@
# pylint: disable = no-name-in-module,missing-module-docstring
import time
from collections import defaultdict
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QApplication
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_lib import MessageEndpoints
from bec_widgets.utils import yaml_dialog
from bec_widgets.utils.bec_dispatcher import BECDispatcher
CONFIG_DEFAULT = {
"plot_settings": {
"colormap": "CET-L4",
"num_columns": 1,
},
"waveform2D": [
{
"plot_name": "Waveform 2D Scatter (1)",
"x_label": "Sam X",
"y_label": "Sam Y",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "samy", "entry": "samy"}],
"z": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
},
},
{
"plot_name": "Waveform 2D Scatter (2)",
"x_label": "Sam Y",
"y_label": "Sam X",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "samx", "entry": "samx"}],
"z": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
},
},
],
}
class BECMonitor2DScatter(QWidget):
update_signal = pyqtSignal()
def __init__(
self,
parent=None,
client=None,
config: dict = None,
enable_crosshair: bool = True,
gui_id=None,
skip_validation: bool = True,
toolbar_enabled=True,
):
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 = None # TODO implement validator when ready
self.gui_id = gui_id
if self.gui_id is None:
self.gui_id = self.__class__.__name__ + str(time.time())
# Connect dispatcher slots #TODO connect endpoints related to CLI
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
# Config related variables
self.plot_data = None
self.plot_settings = None
self.num_columns = None
self.database = {}
self.plots = {}
self.grid_coordinates = []
self.curves_data = {}
# Current configuration
self.config = config
self.skip_validation = skip_validation
# Enable crosshair
self.enable_crosshair = enable_crosshair
# Displayed Data
self.database = {}
self.crosshairs = None
self.plots = None
self.curves_data = None
self.grid_coordinates = None
self.scanID = None
# Connect the update signal to the update plot method
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=10, slot=self.update_plot
)
# Init UI
self.layout = QVBoxLayout(self)
self.setLayout(self.layout)
if toolbar_enabled: # TODO implement toolbar when ready
self._init_toolbar()
self.glw = pg.GraphicsLayoutWidget()
self.layout.addWidget(self.glw)
if self.config is None:
print("No initial config found for BECDeviceMonitor")
else:
self.on_config_update(self.config)
def _init_toolbar(self):
"""Initialize the toolbar."""
# TODO implement toolbar when ready
# from bec_widgets.widgets import ModularToolBar
#
# # Create and configure the toolbar
# self.toolbar = ModularToolBar(self)
#
# # Add the toolbar to the layout
# self.layout.addWidget(self.toolbar)
def _init_config(self):
"""Initialize the configuration."""
# Global widget settings
self._get_global_settings()
# Plot data
self.plot_data = self.config.get("waveform2D", [])
# Initiate database
self.database = self._init_database()
# Initialize the plot UI
self._init_ui()
def _get_global_settings(self):
"""Get the global widget settings."""
self.plot_settings = self.config.get("plot_settings", {})
self.num_columns = self.plot_settings.get("num_columns", 1)
self.colormap = self.plot_settings.get("colormap", "viridis")
def _init_database(self) -> dict:
"""
Initialize the database to store the data for each plot.
Returns:
dict: The database.
"""
database = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
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.glw.clear()
self.plots = {}
self.imageItems = {}
self.grid_coordinates = []
self.scatterPlots = {}
self.colorBars = {}
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.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):
"""Init scatter plot pg containers"""
self.scatterPlots = {}
for i, plot_config in enumerate(self.plot_data):
plot_name = plot_config.get("plot_name", "")
plot = self.plots[plot_name]
plot.clear()
# Create ScatterPlotItem for each plot
scatterPlot = pg.ScatterPlotItem(size=10)
plot.addItem(scatterPlot)
self.scatterPlots[plot_name] = scatterPlot
@pyqtSlot(dict)
def on_config_update(self, config: dict):
"""
Validate and update the configuration settings.
Args:
config(dict): Configuration settings
"""
# TODO implement BEC CLI commands similar to BECPlotter
# convert config from BEC CLI to correct formatting
config_tag = config.get("config", None)
if config_tag is not None:
config = config["config"]
if self.skip_validation is True:
self.config = config
self._init_config()
else: # TODO implement validator
print("Do validation")
def flush(self):
"""Reset current plot"""
self.database = self._init_database()
self._init_curves()
@pyqtSlot(dict, dict)
def on_scan_segment(self, msg, metadata):
"""
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.
"""
# TODO check if this is correct
current_scanID = msg.get("scanID", None)
if current_scanID is None:
return
if current_scanID != self.scanID:
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()
# Update the database with new data
self.update_database_with_scan_data(msg)
# Emit signal to update plot #TODO could be moved to update_database_with_scan_data just for coresponding plot name
self.update_signal.emit()
def update_database_with_scan_data(self, msg):
"""
Update the database with data from the new scan segment.
Args:
msg (dict): Message containing the new scan data.
"""
data = msg.get("data", {})
for plot_config in self.plot_data: # Iterate over the list
plot_name = plot_config["plot_name"]
x_signal = plot_config["signals"]["x"][0]["name"]
y_signal = plot_config["signals"]["y"][0]["name"]
z_signal = plot_config["signals"]["z"][0]["name"]
if x_signal in data and y_signal in data and z_signal in data:
x_value = data[x_signal][x_signal]["value"]
y_value = data[y_signal][y_signal]["value"]
z_value = data[z_signal][z_signal]["value"]
# Update database for the corresponding plot
self.database[plot_name]["x"][x_signal].append(x_value)
self.database[plot_name]["y"][y_signal].append(y_value)
self.database[plot_name]["z"][z_signal].append(z_value)
def update_plot(self):
"""
Update the plots with the latest data from the database.
"""
for plot_name, scatterPlot in self.scatterPlots.items():
x_data = self.database[plot_name]["x"]
y_data = self.database[plot_name]["y"]
z_data = self.database[plot_name]["z"]
if x_data and y_data and z_data:
# Extract values for each axis
x_values = next(iter(x_data.values()), [])
y_values = next(iter(y_data.values()), [])
z_values = next(iter(z_data.values()), [])
# Check if the data lists are not empty
if x_values and y_values and z_values:
# Normalize z_values for color mapping
z_min, z_max = np.min(z_values), np.max(z_values)
if z_max != z_min: # Ensure that there is a range in the z values
z_values_norm = (z_values - z_min) / (z_max - z_min)
colormap = pg.colormap.get(
self.colormap
) # using colormap from global settings
colors = [colormap.map(z) for z in z_values_norm]
# Update scatter plot data with colors
scatterPlot.setData(x=x_values, y=y_values, brush=colors)
else:
# Handle case where all z values are the same (e.g., avoid division by zero)
scatterPlot.setData(x=x_values, y=y_values) # Default brush can be used
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_DEFAULT
client = BECDispatcher().client
client.start()
app = QApplication(sys.argv)
monitor = BECMonitor2DScatter(
config=config,
gui_id=args.id,
skip_validation=True,
)
monitor.show()
sys.exit(app.exec())

View File

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

View File

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

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