mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 01:37:53 +02:00
Compare commits
179 Commits
v0.31.0
...
94-bec-fig
| Author | SHA1 | Date | |
|---|---|---|---|
| d996ff9284 | |||
| 8b43eba282 | |||
| 9c822ec480 | |||
|
|
44b451e66b | ||
| a2ed2ebe00 | |||
| 8127fc2960 | |||
|
|
6171790f66 | ||
|
|
ebb36f62dd | ||
|
|
644f1031f6 | ||
|
|
fd711b475f | ||
|
|
57132a4721 | ||
| f71dc5c5ab | |||
| 4630d78fc2 | |||
| da640e888d | |||
|
|
35cd4fd6f1 | ||
|
|
f06e652b82 | ||
|
|
5fc8047c8f | ||
|
|
0363fd5194 | ||
|
|
826a5e9874 | ||
|
|
f668eb8b9b | ||
|
|
5964778a64 | ||
|
|
8135f68230 | ||
|
|
24c77376b2 | ||
|
|
f364afcb42 | ||
|
|
4051902f09 | ||
|
|
a28b9c8981 | ||
|
|
9a5c86ea35 | ||
|
|
08534a4739 | ||
|
|
1db77b969b | ||
|
|
99dce077c4 | ||
|
|
402adc44e8 | ||
|
|
c6bdf0b6a5 | ||
|
|
1c2fb8b972 | ||
| a61bf36df5 | |||
|
|
d678a85957 | ||
|
|
684592ae37 | ||
|
|
f0ed243c91 | ||
|
|
cba3863e5a | ||
|
|
1d26b23221 | ||
|
|
b827e9eaa7 | ||
|
|
60d150a411 | ||
|
|
c781b1b4e4 | ||
|
|
565e475ace | ||
|
|
7c15d75011 | ||
|
|
b676877242 | ||
|
|
7768e594b5 | ||
|
|
9ef331c272 | ||
|
|
4a1792c209 | ||
|
|
91447a2d62 | ||
|
|
ed5bdd99e6 | ||
|
|
feca7a3dcd | ||
|
|
2d9020358d | ||
|
|
51259097fa | ||
|
|
8a4aeb8dfe | ||
|
|
4b0542a513 | ||
|
|
bf04a4e04a | ||
|
|
fa4ca935bb | ||
|
|
b52e22d81f | ||
|
|
2f96e10b9d | ||
|
|
031cb094e7 | ||
|
|
8afc5f0c0c | ||
|
|
17f14581d7 | ||
|
|
8361736679 | ||
|
|
0b9927fcf5 | ||
|
|
8139e271de | ||
|
|
6fe08e6b82 | ||
|
|
968da6f558 | ||
|
|
11ae0b1054 | ||
| 5ebfd2a3c2 | |||
| b36131eed5 | |||
|
|
a7bfcc12b9 | ||
|
|
ab275b8e5f | ||
|
|
d211b47f4c | ||
|
|
812ffaf8ea | ||
|
|
f7a496723c | ||
|
|
48847a19c7 | ||
|
|
8d0083c4aa | ||
|
|
3c143274c5 | ||
|
|
747e97e0c9 | ||
|
|
c6fe9d2026 | ||
|
|
75090b8575 | ||
|
|
8f76c789cf | ||
| 4664568672 | |||
| 3fb6644543 | |||
| d909673071 | |||
|
|
d281d6576c | ||
|
|
8bebc4f692 | ||
|
|
1cd273c375 | ||
|
|
249170ea30 | ||
|
|
1a429b3024 | ||
|
|
e05cab812a | ||
|
|
7607d7a3b6 | ||
|
|
e51be04b95 | ||
|
|
de1f5c968a | ||
|
|
bf819bcf48 | ||
|
|
6f26e5cc3d | ||
|
|
f9c5c82381 | ||
|
|
79487dbec2 | ||
| 58721bea1a | |||
|
|
03e96669da | ||
|
|
eb529d24d2 | ||
|
|
ebd4fccda2 | ||
|
|
97dcc5ac76 | ||
|
|
9c7a189beb | ||
|
|
6061b3150e | ||
|
|
3982c5d498 | ||
|
|
404ca49821 | ||
|
|
6e4775a124 | ||
|
|
5ab82bc133 | ||
|
|
00ef3ae925 | ||
|
|
90d8069cc3 | ||
|
|
457567ef74 | ||
|
|
1128ca5252 | ||
|
|
86c5f25205 | ||
|
|
a706da2490 | ||
|
|
d67bdd2616 | ||
|
|
c3f2ad45c3 | ||
|
|
26c07c3205 | ||
|
|
c995e0d235 | ||
|
|
463a60a99c | ||
|
|
98a46a85b2 | ||
|
|
186c42d667 | ||
|
|
f3a47a5b08 | ||
|
|
af995a74f3 | ||
|
|
3abd955465 | ||
|
|
cba8131367 | ||
|
|
831eddc136 | ||
| 9e852d1afc | |||
| 3ec9caae09 | |||
| 11281fef53 | |||
|
|
9d497b70bf | ||
|
|
2a334156a8 | ||
|
|
086804780d | ||
|
|
731fba55ec | ||
|
|
a3b24f9242 | ||
|
|
af71e35e73 | ||
|
|
3e8996a024 | ||
| 03bdf980bc | |||
| 1084bc0a80 | |||
| 504944f696 | |||
|
|
b53f72f0ad | ||
|
|
aad754f472 | ||
|
|
f5d1127d21 | ||
|
|
080c258d15 | ||
|
|
5adde23a45 | ||
|
|
2359a08519 | ||
|
|
d1f9979ab1 | ||
|
|
bcc47f3740 | ||
|
|
4f700976dd | ||
| 039f963661 | |||
|
|
3ebdb4bed0 | ||
|
|
016b26f5cf | ||
|
|
e5010c7772 | ||
|
|
a4d9713785 | ||
|
|
b21c1db2a9 | ||
|
|
d967fafe3c | ||
|
|
7d15397ce3 | ||
|
|
d978740f98 | ||
|
|
c174326762 | ||
|
|
3d9dc5c008 | ||
|
|
3cc05cde14 | ||
|
|
f96caccfcb | ||
|
|
d7a2c6830f | ||
|
|
045b1baa60 | ||
|
|
fb555b278a | ||
|
|
d865e2f1af | ||
|
|
c70ddb3cb1 | ||
|
|
8ad3059592 | ||
|
|
ee3b616ec1 | ||
|
|
286e62df92 | ||
|
|
b07bb3dde2 | ||
|
|
10dfe9fb65 | ||
|
|
a0d172e3dc | ||
|
|
94878448c8 | ||
|
|
745aa6e812 | ||
|
|
b14d95ad2b | ||
| 65cbd6ef28 | |||
|
|
bb64088282 | ||
|
|
b6f6bc5b20 |
@@ -14,6 +14,7 @@ include:
|
||||
stages:
|
||||
- Formatter
|
||||
- test
|
||||
- AdditionalTests
|
||||
- Deploy
|
||||
|
||||
formatter:
|
||||
@@ -25,9 +26,10 @@ formatter:
|
||||
pylint:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- pip install -e .[dev]
|
||||
script:
|
||||
- mkdir ./pylint
|
||||
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
|
||||
@@ -38,6 +40,37 @@ pylint:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
|
||||
pylint-check:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
allow_failure: true
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- apt-get update
|
||||
- apt-get install -y bc
|
||||
script:
|
||||
# Identify changed Python files
|
||||
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
|
||||
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse $CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
|
||||
CHANGED_FILES=$(git diff --name-only $SOURCE_BRANCH_COMMIT_SHA $TARGET_BRANCH_COMMIT_SHA | grep '\.py$' || true);
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
|
||||
fi
|
||||
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
|
||||
|
||||
# Run pylint only on changed files
|
||||
- mkdir ./pylint
|
||||
- pylint $CHANGED_FILES --output-format=text . | tee ./pylint/pylint_changed_files.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
|
||||
# Fail the job if the pylint score is below 9
|
||||
- if [ "$(echo "$PYLINT_SCORE < 9" | bc)" -eq 1 ]; then echo "Your pylint score is below the acceptable threshold (9)."; exit 1; fi
|
||||
artifacts:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
|
||||
tests:
|
||||
stage: test
|
||||
needs: []
|
||||
@@ -45,7 +78,7 @@ tests:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx x11-utils libxkbcommon-x11-0
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install .[dev]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests
|
||||
- coverage report
|
||||
@@ -54,7 +87,38 @@ tests:
|
||||
artifacts:
|
||||
reports:
|
||||
junit: report.xml
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
|
||||
#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_DOCKER_REGISTRY/python:3.10
|
||||
allow_failure: true
|
||||
|
||||
|
||||
tests-3.11:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DOCKER_REGISTRY/python:3.11
|
||||
allow_failure: true
|
||||
|
||||
tests-3.12:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DOCKER_REGISTRY/python:3.12
|
||||
allow_failure: true
|
||||
|
||||
|
||||
semver:
|
||||
@@ -80,22 +144,20 @@ semver:
|
||||
semantic-release publish -v DEBUG
|
||||
-D version_variable=./setup.py:__version__
|
||||
-D hvcs=gitlab
|
||||
|
||||
|
||||
allow_failure: false
|
||||
rules:
|
||||
- if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
|
||||
# pages:
|
||||
# stage: Deploy
|
||||
# needs: ["tests"]
|
||||
# script:
|
||||
# - git clone --branch $OPHYD_DEVICES_BRANCH https://oauth2:$CI_OPHYD_DEVICES_KEY@gitlab.psi.ch/bec/ophyd_devices.git
|
||||
# - export OPHYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
# - pip install -r ./docs/source/requirements.txt
|
||||
# - apt-get install -y gcc
|
||||
# - *install-bec-services
|
||||
# - cd ./docs/source; make html
|
||||
# - curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/beamline-experiment-control/221870/
|
||||
# rules:
|
||||
# - if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
# - if: '$CI_COMMIT_REF_NAME == "production"'
|
||||
pages:
|
||||
stage: Deploy
|
||||
needs: ["semver"]
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_REF_NAME
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG != null'
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_TAG
|
||||
- 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/
|
||||
|
||||
17
.gitlab/issue_templates/bug_report_template.md
Normal file
17
.gitlab/issue_templates/bug_report_template.md
Normal file
@@ -0,0 +1,17 @@
|
||||
## Bug report
|
||||
|
||||
## Summary
|
||||
|
||||
[Provide a brief description of the bug.]
|
||||
|
||||
## Expected Behavior vs Actual Behavior
|
||||
|
||||
[Describe what you expected to happen and what actually happened.]
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
[Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.]
|
||||
|
||||
## Related Issues
|
||||
|
||||
[Paste links to any related issues or feature requests.]
|
||||
27
.gitlab/issue_templates/documentation_update_template.md
Normal file
27
.gitlab/issue_templates/documentation_update_template.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## Documentation Section
|
||||
|
||||
[Specify the section or page of the documentation that needs updating]
|
||||
|
||||
## Current Information
|
||||
|
||||
[Provide the current information in the documentation that needs to be updated]
|
||||
|
||||
## Proposed Update
|
||||
|
||||
[Describe the proposed update or correction. Be specific about the changes that need to be made]
|
||||
|
||||
## Reason for Update
|
||||
|
||||
[Explain the reason for the documentation update. Include any recent changes, new features, or corrections that necessitate the update]
|
||||
|
||||
## Additional Context
|
||||
|
||||
[Include any additional context or information that can help the documentation team understand the update better]
|
||||
|
||||
## Attachments
|
||||
|
||||
[Attach any files, screenshots, or references that can assist in making the documentation update]
|
||||
|
||||
## Priority
|
||||
|
||||
[Assign a priority level to the documentation update based on its urgency. Use a scale such as Low, Medium, High]
|
||||
40
.gitlab/issue_templates/feature_request_template.md
Normal file
40
.gitlab/issue_templates/feature_request_template.md
Normal file
@@ -0,0 +1,40 @@
|
||||
## Feature Summary
|
||||
|
||||
[Provide a brief and clear summary of the new feature you are requesting]
|
||||
|
||||
## Problem Description
|
||||
|
||||
[Explain the problem or need that this feature aims to address. Be specific about the issues or gaps in the current functionality]
|
||||
|
||||
## Use Case
|
||||
|
||||
[Describe a real-world scenario or use case where this feature would be beneficial. Explain how it would improve the user experience or workflow]
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
[If you have a specific solution in mind, describe it here. Explain how it would work and how it would address the problem described above]
|
||||
|
||||
## Benefits
|
||||
|
||||
[Explain the benefits and advantages of implementing this feature. Highlight how it adds value to the product or improves user satisfaction]
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
[If you've considered alternative solutions or workarounds, mention them here. Explain why the proposed feature is the preferred option]
|
||||
|
||||
## Impact on Existing Functionality
|
||||
|
||||
[Discuss how the new feature might impact or interact with existing features. Address any potential conflicts or dependencies]
|
||||
|
||||
## Priority
|
||||
|
||||
[Assign a priority level to the feature request based on its importance. Use a scale such as Low, Medium, High]
|
||||
|
||||
## Attachments
|
||||
|
||||
[Include any relevant attachments, such as sketches, diagrams, or references that can help the development team understand your feature request better]
|
||||
|
||||
## Additional Information
|
||||
|
||||
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]
|
||||
|
||||
28
.gitlab/merge_request_templates/default.md
Normal file
28
.gitlab/merge_request_templates/default.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## Description
|
||||
|
||||
[Provide a brief description of the changes introduced by this merge request.]
|
||||
|
||||
## Related Issues
|
||||
|
||||
[Cite any related issues or feature requests that are addressed or resolved by this merge request. Use the gitlab syntax for linking issues, for example, `fixes #123` or `closes #123`.]
|
||||
|
||||
## Type of Change
|
||||
|
||||
- Change 1
|
||||
- Change 2
|
||||
|
||||
## Potential side effects
|
||||
|
||||
[Describe any potential side effects or risks of merging this MR.]
|
||||
|
||||
## Screenshots / GIFs (if applicable)
|
||||
|
||||
[Include any relevant screenshots or GIFs to showcase the changes made.]
|
||||
|
||||
## Additional Comments
|
||||
|
||||
[Add any additional comments or information that may be helpful for reviewers.]
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Documentation is up-to-date.
|
||||
|
||||
215
CHANGELOG.md
215
CHANGELOG.md
@@ -2,6 +2,221 @@
|
||||
|
||||
<!--next-version-placeholder-->
|
||||
|
||||
## v0.41.1 (2024-02-26)
|
||||
|
||||
### Fix
|
||||
|
||||
* **bec_dispatcher:** Handle redis connection errors more gracefully ([`a2ed2eb`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a2ed2ebe00c623eb183b03f8182ffd672fbf9e1e))
|
||||
* **bec_dispatcher:** Adapt code to redis connector refactoring ([`8127fc2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8127fc2960bebd3e862dbe55ac9401af4a6dccb6))
|
||||
|
||||
## v0.41.0 (2024-02-26)
|
||||
|
||||
### Feature
|
||||
|
||||
* **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
|
||||
|
||||
* **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)
|
||||
|
||||
### 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
|
||||
|
||||
* 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
|
||||
|
||||
* Monitor_scatter_2D.py changed to new BECDispatcher definition ([`747e97e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/747e97e0c924cdedb85e9fe7d47512002b791b10))
|
||||
|
||||
## v0.37.1 (2024-01-23)
|
||||
|
||||
### Fix
|
||||
|
||||
* **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))
|
||||
|
||||
## v0.37.0 (2024-01-17)
|
||||
|
||||
### Feature
|
||||
|
||||
* Independent motor_map widget ([`1a429b3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1a429b3024e76446ed530bee71ed797c20843fba))
|
||||
|
||||
## v0.36.2 (2024-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
* 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))
|
||||
|
||||
## 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
|
||||
|
||||
* 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
|
||||
|
||||
* Monitor.py error message popup ([`a3b24f9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a3b24f92420420c8968ef4793342c3857c826e57))
|
||||
|
||||
### Fix
|
||||
|
||||
* 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))
|
||||
|
||||
## v0.33.0 (2023-12-07)
|
||||
|
||||
### Feature
|
||||
|
||||
* Added axis_width and axis_color as optional plot settings ([`504944f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/504944f696a7b2881adec06d29c271fec7e2c981))
|
||||
|
||||
### Fix
|
||||
|
||||
* 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))
|
||||
|
||||
## v0.32.2 (2023-12-06)
|
||||
|
||||
### Fix
|
||||
|
||||
* Changed exec_ to exec for all apps ([`080c258`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/080c258d1542aaace093bca74225297b30453f77))
|
||||
* Yaml_dialog.py changed to use native solution of OS -> should prevent crashing on py3.11 ([`5adde23`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5adde23a457bbd3ae1488b77d4b927b5bded0473))
|
||||
|
||||
## v0.32.1 (2023-12-06)
|
||||
|
||||
### Fix
|
||||
|
||||
* Widget_io print_widget_hierarchy fix comboboxes ([`d1f9979`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d1f9979ab1372c2f650c8aff12ccb17d668b52eb))
|
||||
* WidgetIO combobox fixed for qt6 distributions ([`4f70097`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4f700976ddd78a6f06e358950786b731ef9051ce))
|
||||
|
||||
## v0.32.0 (2023-11-30)
|
||||
|
||||
### Feature
|
||||
|
||||
* Jupyter rich console added as alternative to default QTextEdit terminal output ([`016b26f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/016b26f5cf05e90da144487a9359ac2a54c8e549))
|
||||
* Editor.py basic signature calltip ([`045b1ba`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/045b1baa60a93d2266800821940d7aa29bd8bbe1))
|
||||
* Editor.py jedi autocomplete hooked ([`fb555b2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fb555b278a5139f180592280408742d34dc5fa84))
|
||||
* Editor.py added splitter between editor and terminal ([`c70ddb3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c70ddb3cb19fecf7ce14b551d7d265e2e0cff357))
|
||||
* Toolbar.py proof-of-concept ([`286e62d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/286e62df92927d2efe0b4ab07995f7b5e36a0435))
|
||||
* Basic text editor + running terminal output ([`9487844`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/94878448c8f39a69e9e65df2789da029a9acfc0e))
|
||||
|
||||
### Fix
|
||||
|
||||
* Added missing dependency jedi ([`d978740`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d978740f9879580d01e092ad1fead46786d3ed5c))
|
||||
* Editor.py switch to disable docstring ([`3cc05cd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3cc05cde147cd520b98f0896beb64781ea47d816))
|
||||
* Editor.py compact signature on tooltip ([`f96cacc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f96caccfcb43c904887ebfc0b34fd779ffff8bf1))
|
||||
* Editor.py removed automatic background behind edited text ([`d865e2f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d865e2f1af6eb3d5fb31f9c53088b629a232343f))
|
||||
* Toolbar.py automatic initialisation works ([`8ad3059`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8ad305959257a58b297896218baae06d09520ee1))
|
||||
* Terminal output as QThread ([`a0d172e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a0d172e3dc35bdc2d7e1b43185e31bb9a3629631))
|
||||
|
||||
## v0.31.0 (2023-11-13)
|
||||
|
||||
### Feature
|
||||
|
||||
71
README.md
71
README.md
@@ -1,2 +1,73 @@
|
||||
# BEC Widgets
|
||||
|
||||
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
|
||||
## Installation
|
||||
|
||||
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
|
||||
|
||||
```bash
|
||||
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]
|
||||
```
|
||||
|
||||
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]
|
||||
```
|
||||
or
|
||||
|
||||
```bash
|
||||
pip install bec-widgets[pyqt5]
|
||||
```
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://beamline-experiment-control.readthedocs.io/en/latest/).
|
||||
|
||||
## Contributing
|
||||
|
||||
All commits should use the Angular commit scheme:
|
||||
|
||||
> #### <a name="commit-header"></a>Angular Commit Message Header
|
||||
>
|
||||
> ```
|
||||
> <type>(<scope>): <short summary>
|
||||
> │ │ │
|
||||
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
|
||||
> │ │
|
||||
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
|
||||
> │ elements|forms|http|language-service|localize|platform-browser|
|
||||
> │ platform-browser-dynamic|platform-server|router|service-worker|
|
||||
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
|
||||
> │ devtools
|
||||
> │
|
||||
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
|
||||
> ```
|
||||
>
|
||||
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
|
||||
|
||||
> ##### Type
|
||||
>
|
||||
> Must be one of the following:
|
||||
>
|
||||
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
|
||||
> * **docs**: Documentation only changes
|
||||
> * **feat**: A new feature
|
||||
> * **fix**: A bug fix
|
||||
> * **perf**: A code change that improves performance
|
||||
> * **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
> * **test**: Adding missing tests or correcting existing tests
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
@@ -1,99 +0,0 @@
|
||||
import argparse
|
||||
import itertools
|
||||
import os
|
||||
from typing import Callable
|
||||
|
||||
from bec_lib import BECClient, messages, ServiceConfig
|
||||
from bec_lib.redis_connector import RedisConsumerThreaded
|
||||
from PyQt5.QtCore import QObject, pyqtSignal
|
||||
|
||||
# Adding a new pyqt signal requres 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 _Connection:
|
||||
"""Utility class to keep track of slots connected to a particular redis consumer"""
|
||||
|
||||
def __init__(self, consumer) -> None:
|
||||
self.consumer: RedisConsumerThreaded = consumer
|
||||
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
|
||||
|
||||
|
||||
class _BECDispatcher(QObject):
|
||||
def __init__(self, bec_config=None):
|
||||
super().__init__()
|
||||
self.client = BECClient()
|
||||
|
||||
# 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"
|
||||
|
||||
self.client.initialize(config=ServiceConfig(config_path=bec_config))
|
||||
self._connections = {}
|
||||
|
||||
def connect_slot(self, slot: Callable, topic: str) -> 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
|
||||
topic (str): A topic that can typically be acquired via bec_lib.MessageEndpoints
|
||||
"""
|
||||
# create new connection for topic if it doesn't exist
|
||||
if topic not in self._connections:
|
||||
|
||||
def cb(msg):
|
||||
msg = messages.MessageReader.loads(msg.value)
|
||||
# TODO: this can could be replaced with a simple
|
||||
# self._connections[topic].signal.emit(msg.content, msg.metadata)
|
||||
# once all dispatcher.connect_slot calls are made with a single topic only
|
||||
if not isinstance(msg, list):
|
||||
msg = [msg]
|
||||
for msg_i in msg:
|
||||
self._connections[topic].signal.emit(msg_i.content, msg_i.metadata)
|
||||
|
||||
consumer = self.client.connector.consumer(topics=topic, cb=cb)
|
||||
consumer.start()
|
||||
|
||||
self._connections[topic] = _Connection(consumer)
|
||||
|
||||
# connect slot if it's not connected
|
||||
if slot not in self._connections[topic].slots:
|
||||
self._connections[topic].signal.connect(slot)
|
||||
self._connections[topic].slots.add(slot)
|
||||
|
||||
def disconnect_slot(self, slot: Callable, topic: str) -> None:
|
||||
"""Disconnect widget's pyqt slot from pub/sub updates on a topic.
|
||||
|
||||
Args:
|
||||
slot (Callable): A slot to be disconnected
|
||||
topic (str): A corresponding topic that can typically be acquired via
|
||||
bec_lib.MessageEndpoints
|
||||
"""
|
||||
if topic not in self._connections:
|
||||
return
|
||||
|
||||
if slot not in self._connections[topic].slots:
|
||||
return
|
||||
|
||||
self._connections[topic].signal.disconnect(slot)
|
||||
self._connections[topic].slots.remove(slot)
|
||||
|
||||
if not self._connections[topic].slots:
|
||||
# shutdown consumer if there are no more connected slots
|
||||
self._connections[topic].consumer.shutdown()
|
||||
del self._connections[topic]
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bec-config", default=None)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
bec_dispatcher = _BECDispatcher(args.bec_config)
|
||||
386
bec_widgets/cli/client.py
Normal file
386
bec_widgets/cli/client.py
Normal file
@@ -0,0 +1,386 @@
|
||||
# This file was automatically generated by generate_cli.py
|
||||
|
||||
from bec_widgets.cli.client_utils import rpc_call, RPCBase, BECFigureClientMixin
|
||||
from typing import Literal, Optional, overload
|
||||
|
||||
|
||||
class BECPlotBase(RPCBase):
|
||||
@rpc_call
|
||||
def set(self, **kwargs) -> "None":
|
||||
"""
|
||||
Set the properties of the plot widget.
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
Possible properties:
|
||||
- title: str
|
||||
- x_label: str
|
||||
- y_label: str
|
||||
- x_scale: Literal["linear", "log"]
|
||||
- y_scale: Literal["linear", "log"]
|
||||
- x_lim: tuple
|
||||
- y_lim: tuple
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_title(self, title: "str"):
|
||||
"""
|
||||
Set the title of the plot widget.
|
||||
Args:
|
||||
title(str): Title of the plot widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_x_label(self, label: "str"):
|
||||
"""
|
||||
Set the label of the x-axis.
|
||||
Args:
|
||||
label(str): Label of the x-axis.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_y_label(self, label: "str"):
|
||||
"""
|
||||
Set the label of the y-axis.
|
||||
Args:
|
||||
label(str): Label of the y-axis.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_x_scale(self, scale: "Literal['linear', 'log']" = "linear"):
|
||||
"""
|
||||
Set the scale of the x-axis.
|
||||
Args:
|
||||
scale(Literal["linear", "log"]): Scale of the x-axis.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_y_scale(self, scale: "Literal['linear', 'log']" = "linear"):
|
||||
"""
|
||||
Set the scale of the y-axis.
|
||||
Args:
|
||||
scale(Literal["linear", "log"]): Scale of the y-axis.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_x_lim(self, *args) -> "None":
|
||||
"""
|
||||
Set the limits of the x-axis. This method can accept either two separate arguments
|
||||
for the minimum and maximum x-axis values, or a single tuple containing both limits.
|
||||
|
||||
Usage:
|
||||
set_x_lim(x_min, x_max)
|
||||
set_x_lim((x_min, x_max))
|
||||
|
||||
Args:
|
||||
*args: A variable number of arguments. Can be two integers (x_min and x_max)
|
||||
or a single tuple with two integers.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_y_lim(self, *args) -> "None":
|
||||
"""
|
||||
Set the limits of the y-axis. This method can accept either two separate arguments
|
||||
for the minimum and maximum y-axis values, or a single tuple containing both limits.
|
||||
|
||||
Usage:
|
||||
set_y_lim(y_min, y_max)
|
||||
set_y_lim((y_min, y_max))
|
||||
|
||||
Args:
|
||||
*args: A variable number of arguments. Can be two integers (y_min and y_max)
|
||||
or a single tuple with two integers.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_grid(self, x: "bool" = False, y: "bool" = False):
|
||||
"""
|
||||
Set the grid of the plot widget.
|
||||
Args:
|
||||
x(bool): Show grid on the x-axis.
|
||||
y(bool): Show grid on the y-axis.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def plot_data(self, data_x: "list | np.ndarray", data_y: "list | np.ndarray", **kwargs):
|
||||
"""
|
||||
Plot custom data on the plot widget. These data are not saved in config.
|
||||
Args:
|
||||
data_x(list|np.ndarray): x-axis data
|
||||
data_y(list|np.ndarray): y-axis data
|
||||
**kwargs: Keyword arguments for the plot.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the plot widget from the figure.
|
||||
"""
|
||||
|
||||
|
||||
class BECWaveform1D(RPCBase):
|
||||
@rpc_call
|
||||
def add_curve_scan(
|
||||
self,
|
||||
x_name: "str",
|
||||
y_name: "str",
|
||||
x_entry: "Optional[str]" = None,
|
||||
y_entry: "Optional[str]" = None,
|
||||
color: "Optional[str]" = None,
|
||||
label: "Optional[str]" = None,
|
||||
validate_bec: "bool" = True,
|
||||
**kwargs
|
||||
) -> "BECCurve":
|
||||
"""
|
||||
Add a curve to the plot widget from the scan segment.
|
||||
Args:
|
||||
x_name(str): Name of the x signal.
|
||||
x_entry(str): Entry of the x signal.
|
||||
y_name(str): Name of the y signal.
|
||||
y_entry(str): Entry of the y signal.
|
||||
color(str, optional): Color of the curve. Defaults to None.
|
||||
label(str, optional): Label of the curve. Defaults to None.
|
||||
**kwargs: Additional keyword arguments for the curve configuration.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_curve_custom(
|
||||
self,
|
||||
x: "list | np.ndarray",
|
||||
y: "list | np.ndarray",
|
||||
label: "str" = None,
|
||||
color: "str" = None,
|
||||
**kwargs
|
||||
) -> "BECCurve":
|
||||
"""
|
||||
Add a custom data curve to the plot widget.
|
||||
Args:
|
||||
x(list|np.ndarray): X data of the curve.
|
||||
y(list|np.ndarray): Y data of the curve.
|
||||
label(str, optional): Label of the curve. Defaults to None.
|
||||
color(str, optional): Color of the curve. Defaults to None.
|
||||
**kwargs: Additional keyword arguments for the curve configuration.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove_curve(self, *identifiers):
|
||||
"""
|
||||
Remove a curve from the plot widget.
|
||||
Args:
|
||||
*identifiers: Identifier of the curve to be removed. Can be either an integer (index) or a string (curve_id).
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def scan_history(self, scan_index: "int" = None, scanID: "str" = None):
|
||||
"""
|
||||
Update the scan curves with the data from the scan storage.
|
||||
Provide only one of scanID or scan_index.
|
||||
Args:
|
||||
scanID(str, optional): ScanID of the scan to be updated. Defaults to None.
|
||||
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def curves(self) -> "list[BECCurve]":
|
||||
"""
|
||||
Get the curves of the plot widget as a list
|
||||
Returns:
|
||||
list: List of curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def curves_data(self) -> "dict":
|
||||
"""
|
||||
Get the curves data of the plot widget as a dictionary
|
||||
Returns:
|
||||
dict: Dictionary of curves data.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_curve(self, identifier) -> "BECCurve":
|
||||
"""
|
||||
Get the curve by its index or ID.
|
||||
Args:
|
||||
identifier(int|str): Identifier of the curve. Can be either an integer (index) or a string (curve_id).
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_curve_config(self, curve_id: "str", dict_output: "bool" = True) -> "CurveConfig | dict":
|
||||
"""
|
||||
Get the configuration of a curve by its ID.
|
||||
Args:
|
||||
curve_id(str): ID of the curve.
|
||||
Returns:
|
||||
CurveConfig|dict: Configuration of the curve.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def apply_config(self, config: "dict | WidgetConfig", replot_last_scan: "bool" = False):
|
||||
"""
|
||||
Apply the configuration to the 1D waveform widget.
|
||||
Args:
|
||||
config(dict|WidgetConfig): Configuration settings.
|
||||
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_all_data(self, output: "Literal['dict', 'pandas']" = "dict") -> "dict | pd.DataFrame":
|
||||
"""
|
||||
Extract all curve data into a dictionary or a pandas DataFrame.
|
||||
Args:
|
||||
output (Literal["dict", "pandas"]): Format of the output data.
|
||||
Returns:
|
||||
dict | pd.DataFrame: Data of all curves in the specified format.
|
||||
"""
|
||||
|
||||
|
||||
class BECFigure(RPCBase, BECFigureClientMixin):
|
||||
@rpc_call
|
||||
def add_plot(
|
||||
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.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove(
|
||||
self,
|
||||
row: "int" = None,
|
||||
col: "int" = None,
|
||||
widget_id: "str" = None,
|
||||
coordinates: "tuple[int, int]" = None,
|
||||
) -> "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.
|
||||
widget_id(str): The unique identifier of the widget to remove.
|
||||
coordinates(tuple[int, int], optional): The coordinates of the widget to remove.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def change_layout(self, max_columns=None, max_rows=None):
|
||||
"""
|
||||
Reshuffle the layout of the figure to adjust to a new number of max_columns or max_rows.
|
||||
If both max_columns and max_rows are provided, max_rows is ignored.
|
||||
|
||||
Args:
|
||||
max_columns (Optional[int]): The new maximum number of columns in the figure.
|
||||
max_rows (Optional[int]): The new maximum number of rows in the figure.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
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.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def clear_all(self):
|
||||
"""
|
||||
Clear all widgets from the figure and reset to default state
|
||||
"""
|
||||
|
||||
|
||||
class BECCurve(RPCBase):
|
||||
@rpc_call
|
||||
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"]
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_data(self, x, y):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
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.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_symbol(self, symbol: "str"):
|
||||
"""
|
||||
Change the symbol of the curve.
|
||||
Args:
|
||||
symbol(str): Symbol of the curve.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_symbol_color(self, symbol_color: "str"):
|
||||
"""
|
||||
Change the symbol color of the curve.
|
||||
Args:
|
||||
symbol_color(str): Color of the symbol.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_symbol_size(self, symbol_size: "int"):
|
||||
"""
|
||||
Change the symbol size of the curve.
|
||||
Args:
|
||||
symbol_size(int): Size of the symbol.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_pen_width(self, pen_width: "int"):
|
||||
"""
|
||||
Change the pen width of the curve.
|
||||
Args:
|
||||
pen_width(int): Width of the pen.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
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.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
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.
|
||||
"""
|
||||
175
bec_widgets/cli/client_utils.py
Normal file
175
bec_widgets/cli/client_utils.py
Normal file
@@ -0,0 +1,175 @@
|
||||
import importlib
|
||||
import select
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from qtpy.QtCore import QCoreApplication
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_lib import MessageEndpoints, messages
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
"""
|
||||
A decorator for calling a function on the server.
|
||||
|
||||
Args:
|
||||
func: The function to call.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class BECFigureClientMixin:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._process = None
|
||||
|
||||
def show(self) -> None:
|
||||
"""
|
||||
Show the figure.
|
||||
"""
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
self._start_plot_process()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the figure.
|
||||
"""
|
||||
if self._process is None:
|
||||
return
|
||||
self._run_rpc("close", (), wait_for_rpc_response=False)
|
||||
self._process.kill()
|
||||
self._process = None
|
||||
|
||||
def _start_plot_process(self) -> None:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
monitor_module = importlib.import_module("bec_widgets.cli.server")
|
||||
monitor_path = monitor_module.__file__
|
||||
|
||||
command = f"python {monitor_path} --id {self._gui_id}"
|
||||
self._process = subprocess.Popen(
|
||||
command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
def print_log(self) -> None:
|
||||
"""
|
||||
Print the log of the plot process.
|
||||
"""
|
||||
if self._process is None:
|
||||
return
|
||||
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)
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
||||
self._client = BECDispatcher().client
|
||||
self._config = config if config is not None else {}
|
||||
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}")
|
||||
|
||||
@property
|
||||
def _root(self):
|
||||
"""
|
||||
Get the root widget. This is the BECFigure widget that holds
|
||||
the anchor gui_id.
|
||||
"""
|
||||
parent = self
|
||||
# pylint: disable=protected-access
|
||||
while parent._parent is not None:
|
||||
parent = parent._parent
|
||||
return parent
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, **kwargs):
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
Args:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
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.send(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
if not wait_for_rpc_response:
|
||||
return None
|
||||
response = self._wait_for_response(request_id)
|
||||
# get class name
|
||||
if not response.content["accepted"]:
|
||||
raise ValueError(response.content["message"]["error"])
|
||||
msg_result = response.content["message"].get("result")
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
return None
|
||||
if isinstance(msg_result, list):
|
||||
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 msg_result
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
if not cls:
|
||||
return msg_result
|
||||
|
||||
cls = getattr(client, cls)
|
||||
print(msg_result)
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
|
||||
def _wait_for_response(self, request_id):
|
||||
"""
|
||||
Wait for the response from the server.
|
||||
"""
|
||||
response = None
|
||||
while response is None:
|
||||
response = self._client.connector.get(
|
||||
MessageEndpoints.gui_instruction_response(request_id)
|
||||
)
|
||||
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
|
||||
return response
|
||||
96
bec_widgets/cli/generate_cli.py
Normal file
96
bec_widgets/cli/generate_cli.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import inspect
|
||||
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, BECFigureClientMixin
|
||||
from typing import Literal, Optional, overload"""
|
||||
|
||||
self.content = ""
|
||||
|
||||
def generate_client(self, published_classes: list):
|
||||
"""
|
||||
Generate the client for the published classes.
|
||||
|
||||
Args:
|
||||
published_classes(list): The list of published classes (e.g. [BECWaveform1D, BECFigure]).
|
||||
"""
|
||||
for cls in published_classes:
|
||||
self.content += "\n\n"
|
||||
self.generate_content_for_class(cls)
|
||||
|
||||
def generate_content_for_class(self, cls):
|
||||
"""
|
||||
Generate the content for the class.
|
||||
Args:
|
||||
cls: The class for which to generate the content.
|
||||
"""
|
||||
|
||||
class_name = cls.__name__
|
||||
module = cls.__module__
|
||||
|
||||
# Generate the header
|
||||
# self.header += f"""
|
||||
# from {module} import {class_name}"""
|
||||
|
||||
# Generate the content
|
||||
if cls.__name__ == "BECFigure":
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase, BECFigureClientMixin):"""
|
||||
else:
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
for method in cls.USER_ACCESS:
|
||||
obj = getattr(cls, method)
|
||||
if isinstance(obj, property):
|
||||
self.content += """
|
||||
@property
|
||||
@rpc_call"""
|
||||
sig = str(inspect.signature(obj.fget))
|
||||
doc = inspect.getdoc(obj.fget)
|
||||
else:
|
||||
sig = str(inspect.signature(obj))
|
||||
doc = inspect.getdoc(obj)
|
||||
overloads = typing.get_overloads(obj)
|
||||
for overload in overloads:
|
||||
sig_overload = str(inspect.signature(overload))
|
||||
self.content += f"""
|
||||
@overload
|
||||
def {method}{str(sig_overload)}: ...
|
||||
"""
|
||||
|
||||
self.content += """
|
||||
@rpc_call"""
|
||||
self.content += f"""
|
||||
def {method}{str(sig)}:
|
||||
\"\"\"
|
||||
{doc}
|
||||
\"\"\""""
|
||||
|
||||
def write(self, file_name: str):
|
||||
"""
|
||||
Write the content to a file.
|
||||
|
||||
Args:
|
||||
file_name(str): The name of the file to write to.
|
||||
"""
|
||||
with open(file_name, "w", encoding="utf-8") as file:
|
||||
file.write(self.header)
|
||||
file.write(self.content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
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, BECWaveform1D, BECFigure, BECCurve]
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(clss)
|
||||
generator.write(client_path)
|
||||
110
bec_widgets/cli/server.py
Normal file
110
bec_widgets/cli/server.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import inspect
|
||||
|
||||
from bec_lib import MessageEndpoints, messages
|
||||
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
from bec_widgets.widgets.plots import BECCurve, BECWaveform1D
|
||||
|
||||
|
||||
class BECWidgetsCLIServer:
|
||||
WIDGETS = [BECWaveform1D, BECFigure, BECCurve]
|
||||
|
||||
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.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)
|
||||
)
|
||||
|
||||
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:
|
||||
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)
|
||||
self.send_response(request_id, False, {"error": str(e)})
|
||||
else:
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
messages.RequestResponseMessage(accepted=accepted, message=msg),
|
||||
expire=60,
|
||||
)
|
||||
|
||||
def get_object_from_config(self, config: dict):
|
||||
gui_id = config.get("gui_id")
|
||||
# check if the object is the figure
|
||||
if gui_id == self.fig.gui_id:
|
||||
return self.fig
|
||||
# check if the object is a widget
|
||||
if gui_id in self.fig.widgets:
|
||||
obj = self.fig.widgets[config["gui_id"]]
|
||||
return obj
|
||||
if self.fig.widgets:
|
||||
for widget in self.fig.widgets.values():
|
||||
item = widget.find_widget_by_id(gui_id)
|
||||
if item:
|
||||
return item
|
||||
raise 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)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
res = method_obj
|
||||
else:
|
||||
sig = inspect.signature(method_obj)
|
||||
if sig.parameters:
|
||||
res = method_obj(*args, **kwargs)
|
||||
else:
|
||||
res = method_obj()
|
||||
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
else:
|
||||
res = self.serialize_object(res)
|
||||
return res
|
||||
|
||||
def serialize_object(self, obj):
|
||||
if isinstance(obj, BECConnector):
|
||||
return {
|
||||
"gui_id": obj.gui_id,
|
||||
"widget_class": obj.__class__.__name__,
|
||||
"config": obj.config.model_dump(),
|
||||
"__rpc__": True,
|
||||
}
|
||||
return obj
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
|
||||
parser.add_argument("--id", type=str, help="The id of the server")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
server = BECWidgetsCLIServer(gui_id=args.id)
|
||||
# server = BECWidgetsCLIServer(gui_id="test")
|
||||
server.start()
|
||||
@@ -1,129 +0,0 @@
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import mkPen
|
||||
from pyqtgraph.Qt import QtCore, QtWidgets
|
||||
|
||||
|
||||
class ConfigPlotter(pg.GraphicsWidget):
|
||||
"""
|
||||
ConfigPlotter is a widget that can be used to plot data from multiple channels
|
||||
in a grid layout. The layout is specified by a list of dicts, where each dict
|
||||
specifies the position of the plot in the grid, the channels to plot, and the
|
||||
type of plot to use. The plot type is specified by the name of the pyqtgraph
|
||||
item to use. For example, to plot a single channel in a PlotItem, the config
|
||||
would look like this:
|
||||
|
||||
config = [
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 0,
|
||||
"x": 0,
|
||||
"config": {"channels": ["a"], "label_xy": ["", "a"], "item": "PlotItem"},
|
||||
}
|
||||
]
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, configs: List[dict], parent=None):
|
||||
super().__init__(parent)
|
||||
self.configs = configs
|
||||
self.plots = {}
|
||||
self._init_ui()
|
||||
self._init_plots()
|
||||
|
||||
def _init_ui(self):
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
|
||||
# pylint: disable=no-member
|
||||
self.pen = mkPen(color=(56, 76, 107), width=4, style=QtCore.Qt.SolidLine)
|
||||
|
||||
self.view = pg.GraphicsView()
|
||||
self.view.setAntialiasing(True)
|
||||
self.view.show()
|
||||
|
||||
self.layout = pg.GraphicsLayout()
|
||||
self.view.setCentralWidget(self.layout)
|
||||
|
||||
def _init_plots(self):
|
||||
for config in self.configs:
|
||||
channels = config["config"]["channels"]
|
||||
for channel in channels:
|
||||
item = pg.PlotItem()
|
||||
self.layout.addItem(
|
||||
item,
|
||||
row=config["y"],
|
||||
col=config["x"],
|
||||
rowspan=config["rows"],
|
||||
colspan=config["cols"],
|
||||
)
|
||||
|
||||
# call the corresponding init function, e.g. init_plotitem
|
||||
init_func = getattr(self, f"init_{config['config']['item']}")
|
||||
init_func(channel, config["config"], item)
|
||||
|
||||
# self.init_ImageItem(channel, config["config"], item)
|
||||
|
||||
def init_PlotItem(self, channel: str, config: dict, item: pg.GraphicsItem):
|
||||
"""
|
||||
Initialize a PlotItem
|
||||
|
||||
Args:
|
||||
channel(str): channel to plot
|
||||
config(dict): config dict for the channel
|
||||
item(pg.GraphicsItem): PlotItem to plot the data
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
plot_data = item.plot(np.random.rand(100), pen=self.pen)
|
||||
item.setLabel("left", channel)
|
||||
self.plots[channel] = {"item": item, "plot_data": plot_data}
|
||||
|
||||
def init_ImageItem(self, channel: str, config: dict, item: pg.GraphicsItem):
|
||||
"""
|
||||
Initialize an ImageItem
|
||||
|
||||
Args:
|
||||
channel(str): channel to plot
|
||||
config(dict): config dict for the channel
|
||||
item(pg.GraphicsItem): ImageItem to plot the data
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
img = pg.ImageItem()
|
||||
item.addItem(img)
|
||||
img.setImage(np.random.rand(100, 100))
|
||||
self.plots[channel] = {"item": item, "plot_data": img}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
CONFIG = [
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 0,
|
||||
"x": 0,
|
||||
"config": {"channels": ["a"], "label_xy": ["", "a"], "item": "PlotItem"},
|
||||
},
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 1,
|
||||
"x": 0,
|
||||
"config": {"channels": ["b"], "label_xy": ["", "b"], "item": "PlotItem"},
|
||||
},
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 2,
|
||||
"y": 0,
|
||||
"x": 1,
|
||||
"config": {"channels": ["c"], "label_xy": ["", "c"], "item": "ImageItem"},
|
||||
},
|
||||
]
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
win = ConfigPlotter(CONFIG)
|
||||
pg.exec()
|
||||
@@ -1,38 +0,0 @@
|
||||
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
|
||||
@@ -1,33 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PyQt5 import QtWidgets, uic
|
||||
|
||||
|
||||
class UI(QtWidgets.QWidget):
|
||||
def __init__(self, uipath):
|
||||
super().__init__()
|
||||
|
||||
self.ui = uic.loadUi(uipath, self)
|
||||
|
||||
_, fname = os.path.split(uipath)
|
||||
self.setWindowTitle(fname)
|
||||
|
||||
self.show()
|
||||
|
||||
|
||||
def main():
|
||||
"""A basic script to display UI file
|
||||
|
||||
Run the script, passing UI file path as an argument, e.g.
|
||||
$ python bec_widgets/display_ui_file.py bec_widgets/line_plot.ui
|
||||
"""
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
|
||||
UI(sys.argv[1])
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
from .motor_movement import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelRelative,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtWidgets import (
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
@@ -12,7 +12,7 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
from pyqtgraph import mkPen
|
||||
from pyqtgraph.Qt import QtCore
|
||||
from bec_widgets.qt_utils import Crosshair
|
||||
from bec_widgets.utils import Crosshair
|
||||
|
||||
|
||||
class ExampleApp(QWidget):
|
||||
@@ -165,4 +165,4 @@ if __name__ == "__main__":
|
||||
app = QApplication([])
|
||||
window = ExampleApp()
|
||||
window.show()
|
||||
app.exec_()
|
||||
app.exec()
|
||||
@@ -6,10 +6,10 @@ import h5py
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import zmq
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtGui import QKeySequence
|
||||
from PyQt5.QtWidgets import (
|
||||
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,
|
||||
@@ -47,7 +47,13 @@ class EigerPlot(QWidget):
|
||||
self.key_bindings()
|
||||
|
||||
# ZMQ Consumer
|
||||
self.start_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
|
||||
@@ -182,25 +188,36 @@ class EigerPlot(QWidget):
|
||||
###############################
|
||||
|
||||
def start_zmq_consumer(self):
|
||||
consumer_thread = threading.Thread(target=self.zmq_consumer, daemon=True).start()
|
||||
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):
|
||||
try:
|
||||
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, "")
|
||||
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)
|
||||
|
||||
while True:
|
||||
raw_meta, raw_data = receiver.recv_multipart()
|
||||
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
|
||||
|
||||
finally:
|
||||
receiver.disconnect(live_stream_url)
|
||||
receiver.context.term()
|
||||
receiver.disconnect(live_stream_url)
|
||||
|
||||
###############################
|
||||
# just simulations from here
|
||||
@@ -257,7 +274,7 @@ class EigerPlot(QWidget):
|
||||
)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
dialog.exec_()
|
||||
dialog.exec()
|
||||
|
||||
###############################
|
||||
# just simulations from here
|
||||
@@ -290,9 +307,9 @@ class EigerPlot(QWidget):
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
plot = EigerPlot()
|
||||
plot.show()
|
||||
sys.exit(app.exec_())
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# import simulation_progress as SP
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import (
|
||||
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -102,7 +102,7 @@ class StreamApp(QWidget):
|
||||
|
||||
@staticmethod
|
||||
def _streamer_cb(msg, *, parent, **_kwargs) -> None:
|
||||
msgMCS = messages.DeviceMessage.loads(msg.value)
|
||||
msgMCS = msg.value
|
||||
print(msgMCS)
|
||||
row = msgMCS.content["signals"][parent.sub_device]
|
||||
metadata = msgMCS.metadata
|
||||
@@ -123,7 +123,7 @@ class StreamApp(QWidget):
|
||||
def _device_cv(msg, *, parent, **_kwargs) -> None:
|
||||
print("Getting ScanID")
|
||||
|
||||
msgDEV = messages.ScanStatusMessage.loads(msg.value)
|
||||
msgDEV = msg.value
|
||||
|
||||
current_scanID = msgDEV.content["scanID"]
|
||||
|
||||
@@ -158,4 +158,4 @@ if __name__ == "__main__":
|
||||
streamApp = StreamApp(device=args.device, sub_device=args.sub_device)
|
||||
|
||||
streamApp.show()
|
||||
app.exec_()
|
||||
app.exec()
|
||||
|
||||
@@ -2,7 +2,6 @@ from bec_lib import messages, MessageEndpoints, RedisConnector
|
||||
import time
|
||||
|
||||
connector = RedisConnector("localhost:6379")
|
||||
producer = connector.producer()
|
||||
metadata = {}
|
||||
|
||||
scanID = "ScanID1"
|
||||
@@ -20,13 +19,11 @@ for ii in range(20):
|
||||
metadata=metadata,
|
||||
).dumps()
|
||||
|
||||
# producer.send(topic=MessageEndpoints.device_status(device="mca"), msg=msg)
|
||||
|
||||
producer.xadd(
|
||||
connector.xadd(
|
||||
topic=MessageEndpoints.device_async_readback(
|
||||
scanID=scanID, device="mca"
|
||||
), # scanID will be different for each scan
|
||||
msg={"data": msg},
|
||||
msg={"data": msg}, # TODO should be msg_dict
|
||||
expire=1800,
|
||||
)
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1433</width>
|
||||
<height>24</height>
|
||||
<height>37</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
|
||||
@@ -1,45 +1,13 @@
|
||||
import os
|
||||
|
||||
from PyQt5 import uic
|
||||
from PyQt5.QtWidgets import QMainWindow, QApplication, QVBoxLayout
|
||||
from qtpy import uic
|
||||
from qtpy.QtWidgets import QMainWindow, QApplication
|
||||
|
||||
from bec_widgets.widgets.monitor import BECMonitor
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets import BECMonitor
|
||||
|
||||
# some default configs for demonstration purposes
|
||||
config_1 = {
|
||||
"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",
|
||||
"signals": [{"name": "samx", "entry": "samx"}],
|
||||
},
|
||||
"y": {
|
||||
"label": "bpm4i",
|
||||
"signals": [{"name": "bpm4i", "entry": "bpm4i"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"plot_name": "Gauss plots vs samx",
|
||||
"x": {
|
||||
"label": "Motor X",
|
||||
"signals": [{"name": "samx", "entry": "samx"}],
|
||||
},
|
||||
"y": {
|
||||
"label": "Gauss",
|
||||
"signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
config_2 = {
|
||||
CONFIG_SIMPLE = {
|
||||
"plot_settings": {
|
||||
"background_color": "black",
|
||||
"num_columns": 2,
|
||||
@@ -49,41 +17,52 @@ config_2 = {
|
||||
"plot_data": [
|
||||
{
|
||||
"plot_name": "BPM4i plots vs samx",
|
||||
"x": {
|
||||
"label": "Motor Y",
|
||||
"signals": [{"name": "samx", "entry": "samx"}],
|
||||
},
|
||||
"y": {
|
||||
"label": "bpm4i",
|
||||
"signals": [{"name": "samy", "entry": "samy"}],
|
||||
},
|
||||
"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",
|
||||
"signals": [{"name": "samx", "entry": "samx"}],
|
||||
},
|
||||
"y": {
|
||||
"label": "Gauss ADC",
|
||||
"signals": [{"name": "gauss_adc1", "entry": "gauss_adc1"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"plot_name": "Plot 3",
|
||||
"x": {
|
||||
"label": "Motor X",
|
||||
"signals": [{"name": "samx", "entry": "samx"}],
|
||||
},
|
||||
"y": {
|
||||
"label": "Gauss ADC",
|
||||
"signals": [{"name": "gauss_adc3", "entry": "gauss_adc3"}],
|
||||
},
|
||||
"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 = {
|
||||
|
||||
CONFIG_SCAN_MODE = {
|
||||
"plot_settings": {
|
||||
"background_color": "white",
|
||||
"num_columns": 3,
|
||||
@@ -94,77 +73,89 @@ config_scan_mode = {
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
"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", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "BPM",
|
||||
"signals": [
|
||||
{"name": "gauss_bpm", "entry": "gauss_bpm"},
|
||||
{"name": "gauss_adc1", "entry": "gauss_adc1"},
|
||||
],
|
||||
},
|
||||
"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 Y", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "BPM",
|
||||
"signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
|
||||
},
|
||||
"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 Y", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "BPM",
|
||||
"signals": [{"name": "gauss_adc3", "entry": "gauss_adc3"}],
|
||||
},
|
||||
"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 plot",
|
||||
"x": {"label": "Motor X", "signals": [{"name": "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": "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": "Multi",
|
||||
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "Multi",
|
||||
"signals": [
|
||||
{"name": "gauss_bpm", "entry": "gauss_bpm"},
|
||||
{"name": "samx", "entry": "samx"},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
"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"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -176,7 +167,7 @@ class ModularApp(QMainWindow):
|
||||
super(ModularApp, self).__init__(parent)
|
||||
|
||||
# Client and device manager from BEC
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.client = BECDispatcher().client if client is None else client
|
||||
|
||||
# Loading UI
|
||||
current_path = os.path.dirname(__file__)
|
||||
@@ -187,7 +178,7 @@ class ModularApp(QMainWindow):
|
||||
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_1, config_2, config_scan_mode]
|
||||
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
|
||||
@@ -197,10 +188,8 @@ class ModularApp(QMainWindow):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
# BECclient global variables
|
||||
client = bec_dispatcher.client
|
||||
client = BECDispatcher().client
|
||||
client.start()
|
||||
|
||||
app = QApplication([])
|
||||
@@ -208,4 +197,4 @@ if __name__ == "__main__":
|
||||
|
||||
window = modularApp
|
||||
window.show()
|
||||
app.exec_()
|
||||
app.exec()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from .motor_control_compilations import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelRelative,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# 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 bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets import (
|
||||
MotorControlAbsolute,
|
||||
MotorControlRelative,
|
||||
MotorControlSelection,
|
||||
MotorThread,
|
||||
MotorMap,
|
||||
MotorCoordinateTable,
|
||||
)
|
||||
|
||||
CONFIG_DEFAULT = {
|
||||
"motor_control": {
|
||||
"motor_x": "samx",
|
||||
"motor_y": "samy",
|
||||
"step_size_x": 3,
|
||||
"step_size_y": 3,
|
||||
"precision": 4,
|
||||
"step_x_y_same": False,
|
||||
"move_with_arrows": False,
|
||||
},
|
||||
"plot_settings": {
|
||||
"colormap": "Greys",
|
||||
"scatter_size": 5,
|
||||
"max_points": 1000,
|
||||
"num_dim_points": 100,
|
||||
"precision": 2,
|
||||
"num_columns": 1,
|
||||
"background_value": 25,
|
||||
},
|
||||
"motors": [
|
||||
{
|
||||
"plot_name": "Motor Map",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Motor Y",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "samy", "entry": "samy"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class MotorControlApp(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
# Widgets
|
||||
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
|
||||
# Create MotorMap
|
||||
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.motor_control_panel)
|
||||
splitter.addWidget(self.motor_table)
|
||||
|
||||
# Set the main layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(splitter)
|
||||
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.absolute_widget.coordinates_signal.connect(
|
||||
self.motor_table.add_coordinate
|
||||
)
|
||||
self.motor_control_panel.relative_widget.precision_signal.connect(
|
||||
self.motor_table.set_precision
|
||||
)
|
||||
self.motor_control_panel.relative_widget.precision_signal.connect(
|
||||
self.motor_control_panel.absolute_widget.set_precision
|
||||
)
|
||||
|
||||
self.motor_table.plot_coordinates_signal.connect(self.motion_map.plot_saved_coordinates)
|
||||
|
||||
|
||||
class MotorControlMap(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
# Widgets
|
||||
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
|
||||
# Create MotorMap
|
||||
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.motor_control_panel)
|
||||
|
||||
# Set the main layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(splitter)
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
class MotorControlPanel(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.relative_widget = MotorControlRelative(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.absolute_widget = MotorControlAbsolute(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.relative_widget)
|
||||
layout.addWidget(self.absolute_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
|
||||
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 MotorControlPanelAbsolute(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.absolute_widget = MotorControlAbsolute(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.absolute_widget)
|
||||
|
||||
# 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):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.relative_widget = MotorControlRelative(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.relative_widget)
|
||||
|
||||
# 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
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run various Motor Control Widgets compositions.")
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--variant",
|
||||
type=str,
|
||||
choices=["app", "map", "panel", "panel_abs", "panel_rel"],
|
||||
help="Select the variant of the motor control to run. "
|
||||
"'app' for the full application, "
|
||||
"'map' for MotorMap, "
|
||||
"'panel' for the MotorControlPanel, "
|
||||
"'panel_abs' for MotorControlPanel with absolute control, "
|
||||
"'panel_rel' for MotorControlPanel with relative control.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
app = QApplication([])
|
||||
qdarktheme.setup_theme("auto")
|
||||
|
||||
if args.variant == "app":
|
||||
window = MotorControlApp(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "map":
|
||||
window = MotorControlMap(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel":
|
||||
window = MotorControlPanel(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_abs":
|
||||
window = MotorControlPanelAbsolute(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_rel":
|
||||
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.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -5,12 +5,12 @@ from functools import partial
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5 import QtGui
|
||||
from PyQt5.QtCore import QThread, pyqtSlot
|
||||
from PyQt5.QtCore import pyqtSignal, Qt
|
||||
from PyQt5.QtGui import QDoubleValidator
|
||||
from PyQt5.QtGui import QKeySequence
|
||||
from PyQt5.QtWidgets import (
|
||||
from qtpy import QtGui
|
||||
from qtpy.QtCore import QThread, Slot as pyqtSlot
|
||||
from qtpy.QtCore import Signal as pyqtSignal, Qt
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtGui import QKeySequence
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QWidget,
|
||||
QFileDialog,
|
||||
@@ -20,12 +20,12 @@ from PyQt5.QtWidgets import (
|
||||
QPushButton,
|
||||
QFrame,
|
||||
)
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
from PyQt5.QtWidgets import QShortcut
|
||||
from qtpy.QtWidgets import QMessageBox
|
||||
from qtpy.QtWidgets import QShortcut
|
||||
from pyqtgraph.Qt import QtWidgets, uic, QtCore
|
||||
|
||||
from bec_lib import MessageEndpoints, messages
|
||||
from bec_widgets.qt_utils import DoubleValidationDelegate
|
||||
from bec_widgets.utils import DoubleValidationDelegate
|
||||
|
||||
|
||||
# TODO - General features
|
||||
@@ -1081,7 +1081,7 @@ class MotorApp(QWidget):
|
||||
layout.addWidget(ok_button)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
dialog.exec_()
|
||||
dialog.exec()
|
||||
|
||||
@staticmethod
|
||||
def param_changed(ui_element):
|
||||
@@ -1129,10 +1129,12 @@ class MotorControl(QThread):
|
||||
motor_x_name (str): The name of the motor for the x-axis.
|
||||
motor_y_name (str): The name of the motor for the y-axis.
|
||||
"""
|
||||
self.motor_x_name = motor_x_name
|
||||
self.motor_y_name = motor_y_name
|
||||
|
||||
self.motor_x, self.motor_y = (
|
||||
dev[motor_x_name],
|
||||
dev[motor_y_name],
|
||||
dev[self.motor_x_name],
|
||||
dev[self.motor_y_name],
|
||||
)
|
||||
|
||||
(self.current_x, self.current_y) = self.get_coordinates()
|
||||
@@ -1179,8 +1181,8 @@ class MotorControl(QThread):
|
||||
|
||||
def get_coordinates(self) -> tuple:
|
||||
"""Get current motor position"""
|
||||
x = self.motor_x.read(cached=True)["value"]
|
||||
y = self.motor_y.read(cached=True)["value"]
|
||||
x = self.motor_x.readback.get()
|
||||
y = self.motor_y.readback.get()
|
||||
return x, y
|
||||
|
||||
def retrieve_coordinates(self) -> tuple:
|
||||
@@ -1295,7 +1297,7 @@ class MotorControl(QThread):
|
||||
|
||||
@staticmethod
|
||||
def _device_status_callback_motors(msg, *, parent, **_kwargs) -> None:
|
||||
deviceMSG = messages.DeviceMessage.loads(msg.value)
|
||||
deviceMSG = msg.value
|
||||
if parent.motor_x.name in deviceMSG.content["signals"]:
|
||||
parent.current_x = deviceMSG.content["signals"][parent.motor_x.name]["value"]
|
||||
elif parent.motor_y.name in deviceMSG.content["signals"]:
|
||||
@@ -1348,4 +1350,4 @@ if __name__ == "__main__":
|
||||
MotorApp = MotorApp(selected_motors=selected_motors, plot_motors=plot_motors)
|
||||
window = MotorApp
|
||||
window.show()
|
||||
app.exec_()
|
||||
app.exec()
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import PyQt5.QtWidgets
|
||||
import qtpy.QtWidgets
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import MessageEndpoints
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import QApplication, QTableWidgetItem, QWidget
|
||||
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.qt_utils import Crosshair
|
||||
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
|
||||
@@ -123,7 +125,7 @@ class PlotApp(QWidget):
|
||||
)
|
||||
|
||||
def update_table(
|
||||
self, table_widget: PyQt5.QtWidgets.QTableWidget, x: float, y_values: list, column: int
|
||||
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})"))
|
||||
@@ -238,9 +240,6 @@ class PlotApp(QWidget):
|
||||
if __name__ == "__main__":
|
||||
import yaml
|
||||
|
||||
from bec_widgets import ctrl_c
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
with open("config_noworker.yaml", "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
|
||||
@@ -251,6 +250,7 @@ if __name__ == "__main__":
|
||||
dap_worker = None if dap_worker == "None" else dap_worker
|
||||
|
||||
# BECclient global variables
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
@@ -268,4 +268,4 @@ if __name__ == "__main__":
|
||||
|
||||
window = plotApp
|
||||
window.show()
|
||||
app.exec_()
|
||||
app.exec()
|
||||
|
||||
0
bec_widgets/examples/plot_app/__init__.py
Normal file
0
bec_widgets/examples/plot_app/__init__.py
Normal file
@@ -5,8 +5,10 @@ import os
|
||||
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import (
|
||||
|
||||
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QWidget,
|
||||
QTableWidgetItem,
|
||||
@@ -20,7 +22,8 @@ from pyqtgraph.Qt import QtCore, uic
|
||||
from pyqtgraph.Qt import QtWidgets
|
||||
|
||||
from bec_lib import MessageEndpoints
|
||||
from bec_widgets.qt_utils import Crosshair, Colors
|
||||
from bec_widgets.utils import Crosshair, Colors
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
# TODO implement:
|
||||
@@ -90,12 +93,12 @@ class PlotApp(QWidget):
|
||||
self.error_handler = ErrorHandler(parent=self)
|
||||
|
||||
# Client and device manager from BEC
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
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, "extreme.ui"), self)
|
||||
uic.loadUi(os.path.join(current_path, "plot_app.ui"), self)
|
||||
|
||||
self.data = {}
|
||||
|
||||
@@ -568,7 +571,7 @@ class PlotApp(QWidget):
|
||||
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 qt_utils function
|
||||
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
|
||||
@@ -690,8 +693,6 @@ if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
# from bec_widgets import ctrl_c
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
parser = argparse.ArgumentParser(description="Plotting App")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
@@ -713,6 +714,7 @@ if __name__ == "__main__":
|
||||
exit(1)
|
||||
|
||||
# BECclient global variables
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
@@ -725,4 +727,4 @@ if __name__ == "__main__":
|
||||
|
||||
window = plotApp
|
||||
window.show()
|
||||
app.exec_()
|
||||
app.exec()
|
||||
@@ -1,23 +1,19 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import messages, MessageEndpoints
|
||||
from bec_lib.redis_connector import MessageObject, RedisConnector
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtWidgets import QCheckBox, QTableWidgetItem
|
||||
from pyqtgraph import mkBrush, mkColor, mkPen
|
||||
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.qt_utils import Crosshair, Colors
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
# client = bec_dispatcher.client
|
||||
from bec_widgets.utils import Crosshair, Colors
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
class StreamPlot(QtWidgets.QWidget):
|
||||
@@ -34,7 +30,7 @@ class StreamPlot(QtWidgets.QWidget):
|
||||
"""
|
||||
|
||||
# Client and device manager from BEC
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.client = BECDispatcher().client if client is None else client
|
||||
|
||||
super(StreamPlot, self).__init__()
|
||||
# Set style for pyqtgraph plots
|
||||
@@ -44,7 +40,7 @@ class StreamPlot(QtWidgets.QWidget):
|
||||
uic.loadUi(os.path.join(current_path, "line_plot.ui"), self)
|
||||
|
||||
self._idle_time = 100
|
||||
self.producer = RedisConnector(["localhost:6379"]).producer()
|
||||
self.connector = RedisConnector(["localhost:6379"])
|
||||
|
||||
self.y_value_list = y_value_list
|
||||
self.previous_y_value_list = None
|
||||
@@ -58,7 +54,10 @@ class StreamPlot(QtWidgets.QWidget):
|
||||
|
||||
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
|
||||
|
||||
self.data_retriever = threading.Thread(target=self.on_projection, daemon=True)
|
||||
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()
|
||||
|
||||
##########################
|
||||
@@ -68,6 +67,11 @@ class StreamPlot(QtWidgets.QWidget):
|
||||
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"""
|
||||
##########################
|
||||
@@ -171,8 +175,7 @@ class StreamPlot(QtWidgets.QWidget):
|
||||
self.hook_crosshair()
|
||||
self.init_table()
|
||||
|
||||
def splitter_sizes(self):
|
||||
...
|
||||
def splitter_sizes(self): ...
|
||||
|
||||
def hook_crosshair(self):
|
||||
self.crosshair_1d = Crosshair(self.plot, precision=4)
|
||||
@@ -211,7 +214,7 @@ class StreamPlot(QtWidgets.QWidget):
|
||||
]
|
||||
}
|
||||
msg = messages.DeviceMessage(signals=return_dict).dumps()
|
||||
self.producer.set_and_publish("px_stream/gui_event", msg=msg)
|
||||
self.connector.set_and_publish("px_stream/gui_event", msg=msg)
|
||||
self.roi_signal.emit(region)
|
||||
|
||||
def init_table(self):
|
||||
@@ -261,14 +264,14 @@ class StreamPlot(QtWidgets.QWidget):
|
||||
# else:
|
||||
# return
|
||||
|
||||
def on_projection(self):
|
||||
while True:
|
||||
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.producer.lrange(topic=endpoint, start=-1, end=-1)
|
||||
data = [messages.DeviceMessage.loads(msg) for msg in msgs]
|
||||
msgs = self.client.connector.lrange(topic=endpoint, start=-1, end=-1)
|
||||
data = msgs
|
||||
if not data:
|
||||
continue
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
@@ -292,7 +295,7 @@ class StreamPlot(QtWidgets.QWidget):
|
||||
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.producer.get(topic=endpoint)
|
||||
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"]
|
||||
@@ -305,7 +308,7 @@ class StreamPlot(QtWidgets.QWidget):
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
from bec_widgets import ctrl_c
|
||||
# from bec_widgets import ctrl_c # TODO uncomment when ctrl_c is ready to be compatible with qtpy
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
@@ -316,10 +319,11 @@ if __name__ == "__main__":
|
||||
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)
|
||||
# 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")
|
||||
@@ -328,4 +332,4 @@ if __name__ == "__main__":
|
||||
)
|
||||
plot.show()
|
||||
# client.callbacks.register("scan_segment", plot, sync=False)
|
||||
app.exec_()
|
||||
app.exec()
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from .crosshair import Crosshair
|
||||
from .colors import Colors
|
||||
from .validator_delegate import DoubleValidationDelegate
|
||||
from .bec_table import BECTable
|
||||
@@ -1,50 +0,0 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import mkColor
|
||||
|
||||
|
||||
class Colors:
|
||||
@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 = Colors.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
|
||||
@@ -1,7 +1,7 @@
|
||||
from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from PyQt5.QtGui import QIcon
|
||||
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.scan2d_plot import BECScanPlot2D
|
||||
from bec_widgets.widgets.scan_plot.scan2d_plot import BECScanPlot2D
|
||||
|
||||
|
||||
class BECScanPlot2DPlugin(QPyDesignerCustomWidgetPlugin):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from PyQt5.QtGui import QIcon
|
||||
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.scan_plot import BECScanPlot
|
||||
from bec_widgets.widgets.scan_plot.scan_plot import BECScanPlot
|
||||
|
||||
|
||||
class BECScanPlotPlugin(QPyDesignerCustomWidgetPlugin):
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
Add/modify the path in the following variable to make the plugin avaiable in Qt Designer:
|
||||
```
|
||||
$ export PYQTDESIGNERPATH=/<path to repo>/bec_widgets/qtdesigner_plugins
|
||||
```
|
||||
|
||||
It can be done when activating a conda environment (run with the corresponding env already activated):
|
||||
```
|
||||
$ conda env config vars set PYQTDESIGNERPATH=/<path to repo>/bec_widgets/qtdesigner_plugins
|
||||
```
|
||||
|
||||
All the available conda-forge `pyqt >=5.15` packages don't seem to support loading Qt Designer
|
||||
python plugins at the time of writing. Use `pyqt =5.12` to solve the issue for now.
|
||||
8
bec_widgets/utils/__init__.py
Normal file
8
bec_widgets/utils/__init__.py
Normal file
@@ -0,0 +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 .rpc_decorator import rpc_public, register_rpc_methods
|
||||
from .entry_validator import EntryValidator
|
||||
108
bec_widgets/utils/bec_connector.py
Normal file
108
bec_widgets/utils/bec_connector.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Type, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
class ConnectionConfig(BaseModel):
|
||||
"""Configuration for BECConnector mixin class"""
|
||||
|
||||
widget_class: str = Field(default="NonSpecifiedWidget", description="The class of the widget.")
|
||||
gui_id: Optional[str] = Field(
|
||||
default=None, validate_default=True, description="The GUI ID of the widget."
|
||||
)
|
||||
|
||||
@field_validator("gui_id")
|
||||
def generate_gui_id(cls, v, values):
|
||||
"""Generate a GUI ID if none is provided."""
|
||||
if v is None:
|
||||
widget_class = values.data["widget_class"]
|
||||
v = f"{widget_class}_{str(time.time())}"
|
||||
return v
|
||||
return v
|
||||
|
||||
|
||||
class BECConnector:
|
||||
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
|
||||
|
||||
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher()
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
|
||||
if config:
|
||||
self.config = config
|
||||
else:
|
||||
print(
|
||||
f"No initial config found for {self.__class__.__name__}.\n"
|
||||
f"Initializing with default config."
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
if gui_id:
|
||||
self.config.gui_id = gui_id
|
||||
self.gui_id = gui_id
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
@pyqtSlot(str)
|
||||
def set_gui_id(self, gui_id: str) -> None:
|
||||
"""
|
||||
Set the GUI ID for the widget.
|
||||
Args:
|
||||
gui_id(str): GUI ID
|
||||
"""
|
||||
self.config.gui_id = gui_id
|
||||
self.gui_id = gui_id
|
||||
|
||||
def get_obj_by_id(self, obj_id: str):
|
||||
if obj_id == self.gui_id:
|
||||
return self
|
||||
|
||||
def get_bec_shortcuts(self):
|
||||
"""Get BEC shortcuts for the widget."""
|
||||
self.dev = self.client.device_manager.devices
|
||||
self.scans = self.client.scans
|
||||
self.queue = self.client.queue
|
||||
self.scan_storage = self.queue.scan_storage
|
||||
self.dap = self.client.dap
|
||||
|
||||
def update_client(self, client) -> None:
|
||||
"""Update the client and device manager from BEC and create object for BEC shortcuts.
|
||||
Args:
|
||||
client: BEC client
|
||||
"""
|
||||
self.client = client
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@pyqtSlot(ConnectionConfig) # TODO can be also dict
|
||||
def on_config_update(self, config: ConnectionConfig | dict) -> None:
|
||||
"""
|
||||
Update the configuration for the widget.
|
||||
Args:
|
||||
config(ConnectionConfig): Configuration settings.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
config = ConnectionConfig(**config)
|
||||
# TODO add error handler
|
||||
|
||||
self.config = config
|
||||
|
||||
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.
|
||||
"""
|
||||
if dict_output:
|
||||
return self.config.model_dump()
|
||||
else:
|
||||
return self.config
|
||||
157
bec_widgets/utils/bec_dispatcher.py
Normal file
157
bec_widgets/utils/bec_dispatcher.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Union
|
||||
|
||||
import redis
|
||||
from bec_lib import BECClient, ServiceConfig
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
# 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 _Connection:
|
||||
"""Utility class to keep track of slots connected to a particular redis connector"""
|
||||
|
||||
def __init__(self, callback) -> None:
|
||||
self.callback = callback
|
||||
|
||||
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
|
||||
|
||||
|
||||
class _BECDispatcher(QObject):
|
||||
"""Utility class to keep track of slots connected to a particular redis connector"""
|
||||
|
||||
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.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[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 (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.
|
||||
"""
|
||||
if isinstance(topics, str):
|
||||
topics = [topics]
|
||||
|
||||
# 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)
|
||||
|
||||
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 _create_connection(self, topics: list) -> _Connection:
|
||||
"""Creates a new connection for given topics."""
|
||||
|
||||
def cb(msg):
|
||||
msg = msg.value
|
||||
for connection_key, connection in self._connections.items():
|
||||
if set(topics).intersection(connection_key):
|
||||
connection.signal.emit(msg.content, msg.metadata)
|
||||
|
||||
try:
|
||||
self.client.connector.register(topics=topics, cb=cb)
|
||||
except redis.exceptions.ConnectionError:
|
||||
print("Could not connect to Redis, skipping registration of topics.")
|
||||
|
||||
return _Connection(cb)
|
||||
|
||||
def _do_disconnect_slot(self, topic, slot):
|
||||
print(f"Disconnecting {slot} from {topic}")
|
||||
connection = self._connections[topic]
|
||||
try:
|
||||
connection.signal.disconnect(slot)
|
||||
except TypeError:
|
||||
print(f"Could not disconnect slot:'{slot}' from topic:'{topic}'")
|
||||
print("Continue to remove slot:'{slot}' from 'connection.slots'.")
|
||||
connection.slots.remove(slot)
|
||||
if not connection.slots:
|
||||
del self._connections[topic]
|
||||
|
||||
def _disconnect_slot_from_topic(self, slot: Callable, topic: str) -> None:
|
||||
"""A helper method to disconnect a slot from a specific topic.
|
||||
|
||||
Args:
|
||||
slot (Callable): A slot to be disconnected
|
||||
topic (str): A corresponding topic that can typically be acquired via
|
||||
bec_lib.MessageEndpoints
|
||||
"""
|
||||
connection = self._connections.get(topic)
|
||||
if connection and slot in connection.slots:
|
||||
self._do_disconnect_slot(topic, slot)
|
||||
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]) -> None:
|
||||
"""Disconnect widget's pyqt slot from pub/sub updates on a topic.
|
||||
|
||||
Args:
|
||||
slot (Callable): A slot to be disconnected
|
||||
topics (str | list): A corresponding topic or list of topics that can typically be acquired via
|
||||
bec_lib.MessageEndpoints
|
||||
"""
|
||||
if isinstance(topics, str):
|
||||
topics = [topics]
|
||||
|
||||
for key, connection in list(self._connections.items()):
|
||||
if slot in connection.slots:
|
||||
common_topics = set(topics).intersection(key)
|
||||
if common_topics:
|
||||
remaining_topics = set(key) - set(topics)
|
||||
# Disconnect slot from common topics
|
||||
self._do_disconnect_slot(key, slot)
|
||||
# Reconnect slot to remaining topics if any
|
||||
if remaining_topics:
|
||||
self.connect_slot(slot, list(remaining_topics), True)
|
||||
|
||||
def disconnect_all(self):
|
||||
"""Disconnect all slots from all topics."""
|
||||
for key, connection in list(self._connections.items()):
|
||||
for slot in list(connection.slots):
|
||||
self._disconnect_slot_from_topic(slot, key)
|
||||
|
||||
|
||||
# variable holding the Singleton instance of BECDispatcher
|
||||
_bec_dispatcher = None
|
||||
|
||||
|
||||
def BECDispatcher():
|
||||
global _bec_dispatcher
|
||||
if _bec_dispatcher is None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bec-config", default=None)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
_bec_dispatcher = _BECDispatcher(args.bec_config)
|
||||
return _bec_dispatcher
|
||||
@@ -1,5 +1,5 @@
|
||||
from PyQt5.QtWidgets import QTableWidget
|
||||
from PyQt5.QtCore import Qt
|
||||
from qtpy.QtWidgets import QTableWidget
|
||||
from qtpy.QtCore import Qt
|
||||
|
||||
|
||||
class BECTable(QTableWidget):
|
||||
65
bec_widgets/utils/colors.py
Normal file
65
bec_widgets/utils/colors.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtGui import QColor
|
||||
|
||||
|
||||
class Colors:
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
|
||||
Returns:
|
||||
list: List of angles calculated using the golden ratio.
|
||||
"""
|
||||
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, format: Literal["QColor", "HEX", "RGB"] = "QColor"
|
||||
) -> list:
|
||||
"""
|
||||
Extract num colors from the specified colormap following golden angle distribution and return them in the specified format.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap.
|
||||
num (int): Number of requested colors.
|
||||
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
|
||||
|
||||
Returns:
|
||||
list: List of colors in the specified format.
|
||||
|
||||
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.getColors(mode="float")
|
||||
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 = Colors.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = []
|
||||
for ii in color_selection[:num]:
|
||||
color = cmap_colors[int(ii)]
|
||||
if format.upper() == "HEX":
|
||||
colors.append(QColor.fromRgbF(*color).name())
|
||||
elif format.upper() == "RGB":
|
||||
colors.append(tuple((np.array(color) * 255).astype(int)))
|
||||
elif format.upper() == "QCOLOR":
|
||||
colors.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return colors
|
||||
@@ -1,6 +1,8 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import QObject, pyqtSignal
|
||||
|
||||
# from qtpy.QtCore import QObject, pyqtSignal
|
||||
from qtpy.QtCore import QObject, Signal as pyqtSignal
|
||||
|
||||
|
||||
class Crosshair(QObject):
|
||||
39
bec_widgets/utils/ctrl_c.py
Normal file
39
bec_widgets/utils/ctrl_c.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# TODO haven't found yet how to deal with QAbstractSocket in qtpy
|
||||
# import signal
|
||||
# import socket
|
||||
# from PyQt5.QtNetwork import QAbstractSocket
|
||||
#
|
||||
#
|
||||
# def setup(app):
|
||||
# app.signalwatchdog = SignalWatchdog() # need to store to keep socket pair alive
|
||||
# signal.signal(signal.SIGINT, make_quit_handler(app))
|
||||
#
|
||||
#
|
||||
# def make_quit_handler(app):
|
||||
# def handler(*args):
|
||||
# print() # make ^C appear on its own line
|
||||
# app.quit()
|
||||
#
|
||||
# return handler
|
||||
#
|
||||
#
|
||||
# class SignalWatchdog(QAbstractSocket):
|
||||
# def __init__(self):
|
||||
# """
|
||||
# Propagates system signals from Python to QEventLoop
|
||||
# adapted from https://stackoverflow.com/a/65802260/655404
|
||||
# """
|
||||
# super().__init__(QAbstractSocket.SctpSocket, None)
|
||||
#
|
||||
# self.writer, self.reader = writer, reader = socket.socketpair()
|
||||
# writer.setblocking(False)
|
||||
#
|
||||
# fd_writer = writer.fileno()
|
||||
# fd_reader = reader.fileno()
|
||||
#
|
||||
# signal.set_wakeup_fd(fd_writer) # Python hook
|
||||
# self.setSocketDescriptor(fd_reader) # Qt hook
|
||||
#
|
||||
# self.readyRead.connect(
|
||||
# lambda: None
|
||||
# ) # dummy function call that lets the Python interpreter run
|
||||
17
bec_widgets/utils/entry_validator.py
Normal file
17
bec_widgets/utils/entry_validator.py
Normal file
@@ -0,0 +1,17 @@
|
||||
class EntryValidator:
|
||||
def __init__(self, devices):
|
||||
self.devices = devices
|
||||
|
||||
def validate_signal(self, name: str, entry: str = None) -> str:
|
||||
if name not in self.devices:
|
||||
raise ValueError(f"Device '{name}' not found in current BEC session")
|
||||
|
||||
device = self.devices[name]
|
||||
description = device.describe()
|
||||
|
||||
if entry is None:
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in description:
|
||||
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
|
||||
|
||||
return entry
|
||||
15
bec_widgets/utils/rpc_decorator.py
Normal file
15
bec_widgets/utils/rpc_decorator.py
Normal file
@@ -0,0 +1,15 @@
|
||||
def rpc_public(func):
|
||||
func.rpc_public = True # Mark the function for later processing by the class decorator
|
||||
return func
|
||||
|
||||
|
||||
def register_rpc_methods(cls):
|
||||
"""
|
||||
Class decorator to scan for rpc_public methods and add them to USER_ACCESS.
|
||||
"""
|
||||
if not hasattr(cls, "USER_ACCESS"):
|
||||
cls.USER_ACCESS = set()
|
||||
for name, method in cls.__dict__.items():
|
||||
if getattr(method, "rpc_public", False):
|
||||
cls.USER_ACCESS.add(name)
|
||||
return cls
|
||||
@@ -1,5 +1,8 @@
|
||||
from PyQt5.QtGui import QDoubleValidator
|
||||
from PyQt5.QtWidgets import QStyledItemDelegate, QLineEdit
|
||||
# from qtpy.QtGui import QDoubleValidator
|
||||
# from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit
|
||||
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit
|
||||
|
||||
|
||||
class DoubleValidationDelegate(QStyledItemDelegate):
|
||||
@@ -1,4 +1,7 @@
|
||||
from PyQt5.QtWidgets import (
|
||||
# pylint: disable=no-name-in-module
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QWidget,
|
||||
QLineEdit,
|
||||
@@ -12,8 +15,6 @@ from PyQt5.QtWidgets import (
|
||||
QLabel,
|
||||
)
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class WidgetHandler(ABC):
|
||||
"""Abstract base class for all widget handlers."""
|
||||
@@ -21,15 +22,15 @@ class WidgetHandler(ABC):
|
||||
@abstractmethod
|
||||
def get_value(self, widget: QWidget):
|
||||
"""Retrieve value from the widget instance."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_value(self, widget: QWidget, value):
|
||||
"""Set a value on the widget instance."""
|
||||
pass
|
||||
|
||||
|
||||
class LineEditHandler(WidgetHandler):
|
||||
"""Handler for QLineEdit widgets."""
|
||||
|
||||
def get_value(self, widget: QLineEdit) -> str:
|
||||
return widget.text()
|
||||
|
||||
@@ -38,6 +39,8 @@ class LineEditHandler(WidgetHandler):
|
||||
|
||||
|
||||
class ComboBoxHandler(WidgetHandler):
|
||||
"""Handler for QComboBox widgets."""
|
||||
|
||||
def get_value(self, widget: QComboBox) -> int:
|
||||
return widget.currentIndex()
|
||||
|
||||
@@ -46,6 +49,8 @@ class ComboBoxHandler(WidgetHandler):
|
||||
|
||||
|
||||
class TableWidgetHandler(WidgetHandler):
|
||||
"""Handler for QTableWidget widgets."""
|
||||
|
||||
def get_value(self, widget: QTableWidget) -> list:
|
||||
return [
|
||||
[
|
||||
@@ -63,6 +68,8 @@ class TableWidgetHandler(WidgetHandler):
|
||||
|
||||
|
||||
class SpinBoxHandler(WidgetHandler):
|
||||
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
|
||||
|
||||
def get_value(self, widget):
|
||||
return widget.value()
|
||||
|
||||
@@ -71,6 +78,8 @@ class SpinBoxHandler(WidgetHandler):
|
||||
|
||||
|
||||
class CheckBoxHandler(WidgetHandler):
|
||||
"""Handler for QCheckBox widgets."""
|
||||
|
||||
def get_value(self, widget):
|
||||
return widget.isChecked()
|
||||
|
||||
@@ -79,6 +88,8 @@ class CheckBoxHandler(WidgetHandler):
|
||||
|
||||
|
||||
class LabelHandler(WidgetHandler):
|
||||
"""Handler for QLabel widgets."""
|
||||
|
||||
def get_value(self, widget):
|
||||
return widget.text()
|
||||
|
||||
@@ -130,21 +141,26 @@ class WidgetIO:
|
||||
raise ValueError(f"No handler for widget type: {type(widget)}")
|
||||
|
||||
|
||||
##################### Public interface for exporting and importing widget hierarchies #####################
|
||||
################## for exporting and importing widget hierarchies ##################
|
||||
|
||||
|
||||
class WidgetHierarchy:
|
||||
@staticmethod
|
||||
def print_widget_hierarchy(
|
||||
widget, indent: int = 0, grab_values: bool = False, prefix: str = ""
|
||||
widget,
|
||||
indent: int = 0,
|
||||
grab_values: bool = False,
|
||||
prefix: str = "",
|
||||
exclude_internal_widgets: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Print the widget hierarchy to the console.
|
||||
Args:
|
||||
widget: Widget to print the hierarchy of.
|
||||
widget: Widget to print the hierarchy of
|
||||
indent(int, optional): Level of indentation.
|
||||
grab_values(bool,optional): Whether to grab the values of the widgets.
|
||||
prefix(stc,optional): Custom string prefix for indentation.
|
||||
exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6).
|
||||
"""
|
||||
widget_info = f"{widget.__class__.__name__} ({widget.objectName()})"
|
||||
if grab_values:
|
||||
@@ -156,6 +172,12 @@ class WidgetHierarchy:
|
||||
|
||||
children = widget.children()
|
||||
for child in children:
|
||||
if (
|
||||
exclude_internal_widgets
|
||||
and isinstance(widget, QComboBox)
|
||||
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
|
||||
):
|
||||
continue
|
||||
child_prefix = prefix + " "
|
||||
arrow = "├─ " if child != children[-1] else "└─ "
|
||||
WidgetHierarchy.print_widget_hierarchy(
|
||||
@@ -164,12 +186,13 @@ class WidgetHierarchy:
|
||||
|
||||
@staticmethod
|
||||
def export_config_to_dict(
|
||||
widget,
|
||||
config=None,
|
||||
indent=0,
|
||||
widget: QWidget,
|
||||
config: dict = None,
|
||||
indent: int = 0,
|
||||
grab_values: bool = False,
|
||||
print_hierarchy: bool = False,
|
||||
save_all: bool = True,
|
||||
exclude_internal_widgets: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Export the widget hierarchy to a dictionary.
|
||||
@@ -180,6 +203,7 @@ class WidgetHierarchy:
|
||||
grab_values(bool,optional): Whether to grab the values of the widgets.
|
||||
print_hierarchy(bool,optional): Whether to print the hierarchy to the console.
|
||||
save_all(bool,optional): Whether to save all widgets or only those with values.
|
||||
exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6).
|
||||
Returns:
|
||||
config(dict): Dictionary containing the widget hierarchy.
|
||||
"""
|
||||
@@ -200,6 +224,13 @@ class WidgetHierarchy:
|
||||
WidgetHierarchy.print_widget_hierarchy(widget, indent, grab_values)
|
||||
|
||||
for child in widget.children():
|
||||
# Skip internal widgets of QComboBox in PyQt6
|
||||
if (
|
||||
exclude_internal_widgets
|
||||
and isinstance(widget, QComboBox)
|
||||
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
|
||||
):
|
||||
continue
|
||||
child_config = WidgetHierarchy.export_config_to_dict(
|
||||
child, None, indent + 1, grab_values, print_hierarchy, save_all
|
||||
)
|
||||
@@ -1,10 +1,20 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
|
||||
from typing import Union
|
||||
import yaml
|
||||
from PyQt5.QtWidgets import QFileDialog
|
||||
from qtpy.QtWidgets import QFileDialog
|
||||
|
||||
|
||||
def load_yaml(instance) -> dict:
|
||||
def load_yaml(instance) -> Union[dict, None]:
|
||||
"""
|
||||
Load YAML file from disk.
|
||||
Args:
|
||||
instance: Instance of the calling widget.
|
||||
|
||||
Returns:
|
||||
dict: Configuration data loaded from the YAML file.
|
||||
"""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
instance, "Load Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
|
||||
)
|
||||
@@ -18,20 +28,28 @@ def load_yaml(instance) -> dict:
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"The file {file_path} was not found.")
|
||||
except PermissionError:
|
||||
print(f"Permission denied for file {file_path}.")
|
||||
except yaml.YAMLError as e:
|
||||
print(f"Error parsing YAML file {file_path}: {e}")
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
instance, "Save Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return None
|
||||
return
|
||||
try:
|
||||
if not (file_path.endswith(".yaml") or file_path.endswith(".yml")):
|
||||
file_path += ".yaml"
|
||||
@@ -1,10 +1,6 @@
|
||||
from typing import List, Dict, Union, Optional
|
||||
from typing import Optional, Union, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
field_validator,
|
||||
)
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator, ValidationError
|
||||
from pydantic_core import PydanticCustomError
|
||||
|
||||
|
||||
@@ -20,77 +16,128 @@ class Signal(BaseModel):
|
||||
name: str
|
||||
entry: Optional[str] = Field(None, validate_default=True)
|
||||
|
||||
@field_validator("name")
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def validate_name(cls, v):
|
||||
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
|
||||
# Check if device name provided
|
||||
if v is None:
|
||||
raise PydanticCustomError(
|
||||
"no_device_name",
|
||||
"Device name must be provided",
|
||||
dict(wrong_value=v),
|
||||
)
|
||||
|
||||
# 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 v not in devices:
|
||||
if name not in devices:
|
||||
raise PydanticCustomError(
|
||||
"no_device_bec",
|
||||
'Device "{wrong_value}" not found in current BEC session',
|
||||
dict(wrong_value=v),
|
||||
{"wrong_value": name},
|
||||
)
|
||||
|
||||
device = devices.get(v) # get the device to check if it has signals
|
||||
device = devices[name] # get the device to check if it has signals
|
||||
|
||||
# Check if device have signals
|
||||
if not hasattr(device, "signals"):
|
||||
raise PydanticCustomError(
|
||||
"no_device_signals",
|
||||
'Device "{wrong_value}" do not have "signals" defined. Check device configuration.',
|
||||
dict(wrong_value=v),
|
||||
)
|
||||
# Get device description
|
||||
description = device.describe()
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("entry")
|
||||
@classmethod
|
||||
def set_and_validate_entry(cls, v, values):
|
||||
devices = MonitorConfigValidator.devices
|
||||
|
||||
# Get device name from values -> device is already validated
|
||||
device_name = values.data.get("name")
|
||||
device = getattr(devices, device_name, None)
|
||||
# Validate 'entry'
|
||||
entry = values.get("entry")
|
||||
|
||||
# Set entry based on hints if not provided
|
||||
if v is None and hasattr(device, "_hints"):
|
||||
v = next(
|
||||
iter(device._hints), device_name
|
||||
) # TODO check if devices[device_name]._hints in not enough?
|
||||
elif v is None:
|
||||
v = device_name
|
||||
|
||||
# Validate that the entry exists in device signals
|
||||
if v not in device.signals:
|
||||
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",
|
||||
dict(wrong_value=v, device_name=device_name),
|
||||
'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 PlotAxis(BaseModel):
|
||||
"""
|
||||
Represents an axis (X or Y) in a plot configuration.
|
||||
|
||||
class SourceHistoryValidator(BaseModel):
|
||||
"""History source validator
|
||||
Attributes:
|
||||
label (Optional[str]): The label for the axis.
|
||||
signals (List[Signal]): A list of signals to be plotted on this axis.
|
||||
type (str): type of source - history
|
||||
scanID (str): Scan ID for history source.
|
||||
signals (list): Signal for the source.
|
||||
"""
|
||||
|
||||
label: Optional[str]
|
||||
signals: List[Signal] = Field(default_factory=list)
|
||||
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):
|
||||
@@ -99,41 +146,55 @@ class PlotConfig(BaseModel):
|
||||
|
||||
Attributes:
|
||||
plot_name (Optional[str]): Name of the plot.
|
||||
x (PlotAxis): Configuration for the X axis.
|
||||
y (PlotAxis): Configuration for the Y axis.
|
||||
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]
|
||||
x: PlotAxis = Field(...)
|
||||
y: PlotAxis = Field(...)
|
||||
plot_name: Optional[str] = None
|
||||
x_label: Optional[str] = None
|
||||
y_label: Optional[str] = None
|
||||
sources: list = Field(default_factory=list)
|
||||
|
||||
@field_validator("x")
|
||||
def validate_x_signals(cls, v):
|
||||
if len(v.signals) != 1:
|
||||
raise PydanticCustomError(
|
||||
"no_entry_for_device",
|
||||
"There must be exactly one signal for x axis. Number of x signals: '{wrong_value}'",
|
||||
dict(wrong_value=v),
|
||||
)
|
||||
@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)
|
||||
|
||||
return v
|
||||
# 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.
|
||||
Global settings for plotting affecting mostly visuals.
|
||||
|
||||
Attributes:
|
||||
background_color (str): Color of the plot background.
|
||||
num_columns (int): Number of columns in the plot layout.
|
||||
colormap (str): Colormap to be used.
|
||||
scan_types (bool): Indicates if the configuration is for different scan types.
|
||||
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: str
|
||||
num_columns: int
|
||||
colormap: str
|
||||
scan_types: bool
|
||||
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):
|
||||
@@ -142,11 +203,11 @@ class DeviceMonitorConfig(BaseModel):
|
||||
|
||||
Attributes:
|
||||
plot_settings (PlotSettings): Global settings for plotting.
|
||||
plot_data (List[PlotConfig]): List of plot configurations.
|
||||
plot_data (list[PlotConfig]): List of plot configurations.
|
||||
"""
|
||||
|
||||
plot_settings: PlotSettings
|
||||
plot_data: List[PlotConfig]
|
||||
plot_data: list[PlotConfig]
|
||||
|
||||
|
||||
class ScanModeConfig(BaseModel):
|
||||
@@ -155,15 +216,17 @@ class ScanModeConfig(BaseModel):
|
||||
|
||||
Attributes:
|
||||
plot_settings (PlotSettings): Global settings for plotting.
|
||||
plot_data (Dict[str, List[PlotConfig]]): Dictionary of plot configurations,
|
||||
plot_data (dict[str, list[PlotConfig]]): Dictionary of plot configurations,
|
||||
keyed by scan type.
|
||||
"""
|
||||
|
||||
plot_settings: PlotSettings
|
||||
plot_data: Dict[str, List[PlotConfig]]
|
||||
plot_data: dict[str, list[PlotConfig]]
|
||||
|
||||
|
||||
class MonitorConfigValidator:
|
||||
"""Validates the configuration data for the BECMonitor."""
|
||||
|
||||
devices = None
|
||||
|
||||
def __init__(self, devices):
|
||||
@@ -185,7 +248,8 @@ class MonitorConfigValidator:
|
||||
Raises:
|
||||
ValidationError: If the configuration data does not conform to the schema.
|
||||
"""
|
||||
if config_data["plot_settings"]["scan_types"]:
|
||||
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)
|
||||
|
||||
@@ -1,2 +1,15 @@
|
||||
from .monitor import BECMonitor, ConfigDialog
|
||||
from .motor_map import MotorMap
|
||||
from .scan_control import ScanControl
|
||||
from .toolbar import ModularToolBar
|
||||
from .editor import BECEditor
|
||||
from .monitor_scatter_2D import BECMonitor2DScatter
|
||||
from .motor_control import (
|
||||
MotorControlRelative,
|
||||
MotorControlAbsolute,
|
||||
MotorControlSelection,
|
||||
MotorThread,
|
||||
MotorCoordinateTable,
|
||||
)
|
||||
from .figure import FigureConfig, BECFigure
|
||||
from .plots import BECWaveform1D, BECCurve, BECPlotBase
|
||||
|
||||
1
bec_widgets/widgets/editor/__init__.py
Normal file
1
bec_widgets/widgets/editor/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .editor import BECEditor
|
||||
415
bec_widgets/widgets/editor/editor.py
Normal file
415
bec_widgets/widgets/editor/editor.py
Normal file
@@ -0,0 +1,415 @@
|
||||
import subprocess
|
||||
|
||||
import qdarktheme
|
||||
from jedi import Script
|
||||
from jedi.api import Completion
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from qtpy.Qsci import QsciScintilla, QsciLexerPython, QsciAPIs
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtCore import Signal, QThread
|
||||
from qtpy.QtGui import QColor, QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from qtpy.QtWidgets import QSplitter
|
||||
from qtconsole.manager import QtKernelManager
|
||||
from qtconsole.rich_jupyter_widget import RichJupyterWidget
|
||||
|
||||
from bec_widgets.widgets import ModularToolBar
|
||||
|
||||
|
||||
class AutoCompleter(QThread):
|
||||
"""Initializes the AutoCompleter thread for handling autocompletion and signature help.
|
||||
|
||||
Args:
|
||||
file_path (str): The path to the file for which autocompletion is required.
|
||||
api (QsciAPIs): The QScintilla API instance used for managing autocompletions.
|
||||
enable_docstring (bool, optional): Flag to determine if docstrings should be included in the signatures.
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: str, api: QsciAPIs, enable_docstring: bool = False):
|
||||
super().__init__(None)
|
||||
self.file_path = file_path
|
||||
self.script: Script = None
|
||||
self.api: QsciAPIs = api
|
||||
self.completions: list[Completion] = None
|
||||
self.line = 0
|
||||
self.index = 0
|
||||
self.text = ""
|
||||
|
||||
# TODO so far disabled, quite buggy, docstring extraction has to be generalised
|
||||
self.enable_docstring = enable_docstring
|
||||
|
||||
def update_script(self, text: str):
|
||||
"""Updates the script for Jedi completion based on the current editor text.
|
||||
|
||||
Args:
|
||||
text (str): The current text of the editor.
|
||||
"""
|
||||
if self.script is None or self.script.path != text:
|
||||
self.script = Script(text, path=self.file_path)
|
||||
|
||||
def run(self):
|
||||
"""Runs the thread for generating autocompletions. Overrides QThread.run."""
|
||||
self.update_script(self.text)
|
||||
try:
|
||||
self.completions = self.script.complete(self.line, self.index)
|
||||
self.load_autocomplete(self.completions)
|
||||
except Exception as err:
|
||||
print(err)
|
||||
self.finished.emit()
|
||||
|
||||
def get_function_signature(self, line: int, index: int, text: str) -> str:
|
||||
"""Fetches the function signature for a given position in the text.
|
||||
|
||||
Args:
|
||||
line (int): The line number in the editor.
|
||||
index (int): The index (column number) in the line.
|
||||
text (str): The current text of the editor.
|
||||
|
||||
Returns:
|
||||
str: A string containing the function signature or an empty string if not available.
|
||||
"""
|
||||
self.update_script(text)
|
||||
try:
|
||||
signatures = self.script.get_signatures(line, index)
|
||||
if signatures and self.enable_docstring is True:
|
||||
full_docstring = signatures[0].docstring(raw=True)
|
||||
compact_docstring = self.get_compact_docstring(full_docstring)
|
||||
return compact_docstring
|
||||
if signatures and self.enable_docstring is False:
|
||||
return signatures[0].to_string()
|
||||
except Exception as err:
|
||||
print(f"Signature Error:{err}")
|
||||
return ""
|
||||
|
||||
def load_autocomplete(self, completions: list):
|
||||
"""Loads the autocomplete suggestions into the QScintilla API.
|
||||
|
||||
Args:
|
||||
completions (list[Completion]): A list of Completion objects to be added to the API.
|
||||
"""
|
||||
self.api.clear()
|
||||
for i in completions:
|
||||
self.api.add(i.name)
|
||||
self.api.prepare()
|
||||
|
||||
def get_completions(self, line: int, index: int, text: str):
|
||||
"""Starts the autocompletion process for a given position in the text.
|
||||
|
||||
Args:
|
||||
line (int): The line number in the editor.
|
||||
index (int): The index (column number) in the line.
|
||||
text (str): The current text of the editor.
|
||||
"""
|
||||
self.line = line
|
||||
self.index = index
|
||||
self.text = text
|
||||
self.start()
|
||||
|
||||
def get_compact_docstring(self, full_docstring):
|
||||
"""Generates a compact version of a function's docstring.
|
||||
|
||||
Args:
|
||||
full_docstring (str): The full docstring of a function.
|
||||
|
||||
Returns:
|
||||
str: A compact version of the docstring.
|
||||
"""
|
||||
lines = full_docstring.split("\n")
|
||||
# TODO make it also for different docstring styles, now it is only for numpy style
|
||||
cutoff_indices = [
|
||||
i
|
||||
for i, line in enumerate(lines)
|
||||
if line.strip().lower() in ["parameters", "returns", "examples", "see also", "warnings"]
|
||||
]
|
||||
|
||||
if cutoff_indices:
|
||||
lines = lines[: cutoff_indices[0]]
|
||||
|
||||
compact_docstring = "\n".join(lines).strip()
|
||||
return compact_docstring
|
||||
|
||||
|
||||
class ScriptRunnerThread(QThread):
|
||||
"""Initializes the thread for running a Python script.
|
||||
|
||||
Args:
|
||||
script (str): The script to be executed.
|
||||
"""
|
||||
|
||||
outputSignal = Signal(str)
|
||||
|
||||
def __init__(self, script):
|
||||
super().__init__()
|
||||
self.script = script
|
||||
|
||||
def run(self):
|
||||
"""Executes the script in a subprocess and emits output through a signal. Overrides QThread.run."""
|
||||
process = subprocess.Popen(
|
||||
["python", "-u", "-c", self.script],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
while True:
|
||||
output = process.stdout.readline()
|
||||
if output == "" and process.poll() is not None:
|
||||
break
|
||||
if output:
|
||||
self.outputSignal.emit(output)
|
||||
error = process.communicate()[1]
|
||||
if error:
|
||||
self.outputSignal.emit(error)
|
||||
|
||||
|
||||
class BECEditor(QWidget):
|
||||
"""Initializes the BEC Editor widget.
|
||||
|
||||
Args:
|
||||
toolbar_enabled (bool, optional): Determines if the toolbar should be enabled. Defaults to True.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, toolbar_enabled=True, jupyter_terminal_enabled=False, docstring_tooltip=False
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.script_runner_thread = None
|
||||
self.file_path = None
|
||||
self.docstring_tooltip = docstring_tooltip
|
||||
self.jupyter_terminal_enabled = jupyter_terminal_enabled
|
||||
# TODO just temporary solution, could be extended to other languages
|
||||
self.is_python_file = True
|
||||
|
||||
# Initialize the editor and terminal
|
||||
self.editor = QsciScintilla()
|
||||
if self.jupyter_terminal_enabled:
|
||||
self.terminal = self.make_jupyter_widget_with_kernel()
|
||||
else:
|
||||
self.terminal = QTextEdit()
|
||||
self.terminal.setReadOnly(True)
|
||||
|
||||
# Layout
|
||||
self.layout = QVBoxLayout()
|
||||
|
||||
# Initialize and add the toolbar if enabled
|
||||
if toolbar_enabled:
|
||||
self.toolbar = ModularToolBar(self)
|
||||
self.layout.addWidget(self.toolbar)
|
||||
|
||||
# Initialize the splitter
|
||||
self.splitter = QSplitter(Qt.Orientation.Vertical, self)
|
||||
self.splitter.addWidget(self.editor)
|
||||
self.splitter.addWidget(self.terminal)
|
||||
self.splitter.setSizes([400, 200])
|
||||
|
||||
# Add Splitter to layout
|
||||
self.layout.addWidget(self.splitter)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
self.setup_editor()
|
||||
|
||||
def setup_editor(self):
|
||||
"""Sets up the editor with necessary configurations like lexer, auto indentation, and line numbers."""
|
||||
# Set the lexer for Python
|
||||
self.lexer = QsciLexerPython()
|
||||
self.editor.setLexer(self.lexer)
|
||||
|
||||
# Enable auto indentation and competition within the editor
|
||||
self.editor.setAutoIndent(True)
|
||||
self.editor.setIndentationsUseTabs(False)
|
||||
self.editor.setIndentationWidth(4)
|
||||
self.editor.setAutoCompletionSource(QsciScintilla.AutoCompletionSource.AcsAll)
|
||||
self.editor.setAutoCompletionThreshold(1)
|
||||
|
||||
# Autocomplete for python file
|
||||
# Connect cursor position change signal for autocompletion
|
||||
self.editor.cursorPositionChanged.connect(self.on_cursor_position_changed)
|
||||
|
||||
# if self.is_python_file: #TODO can be changed depending on supported languages
|
||||
self.__api = QsciAPIs(self.lexer)
|
||||
self.auto_completer = AutoCompleter(
|
||||
self.editor.text(), self.__api, enable_docstring=self.docstring_tooltip
|
||||
)
|
||||
self.auto_completer.finished.connect(self.loaded_autocomplete)
|
||||
|
||||
# Enable line numbers in the margin
|
||||
self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin)
|
||||
self.editor.setMarginWidth(0, "0000") # Adjust the width as needed
|
||||
|
||||
# Additional UI elements like menu for load/save can be added here
|
||||
self.set_editor_style()
|
||||
|
||||
@staticmethod
|
||||
def make_jupyter_widget_with_kernel() -> object:
|
||||
"""Start a kernel, connect to it, and create a RichJupyterWidget to use it"""
|
||||
kernel_manager = QtKernelManager(kernel_name="python3")
|
||||
kernel_manager.start_kernel()
|
||||
|
||||
kernel_client = kernel_manager.client()
|
||||
kernel_client.start_channels()
|
||||
|
||||
jupyter_widget = RichJupyterWidget()
|
||||
jupyter_widget.set_default_style("linux")
|
||||
jupyter_widget.kernel_manager = kernel_manager
|
||||
jupyter_widget.kernel_client = kernel_client
|
||||
return jupyter_widget
|
||||
|
||||
def show_call_tip(self, position):
|
||||
"""Shows a call tip at the given position in the editor.
|
||||
|
||||
Args:
|
||||
position (int): The position in the editor where the call tip should be shown.
|
||||
"""
|
||||
line, index = self.editor.lineIndexFromPosition(position)
|
||||
signature = self.auto_completer.get_function_signature(line + 1, index, self.editor.text())
|
||||
if signature:
|
||||
self.editor.showUserList(1, [signature])
|
||||
|
||||
def on_cursor_position_changed(self, line, index):
|
||||
"""Handles the event of cursor position change in the editor.
|
||||
|
||||
Args:
|
||||
line (int): The current line number where the cursor is.
|
||||
index (int): The current column index where the cursor is.
|
||||
"""
|
||||
# if self.is_python_file: #TODO can be changed depending on supported languages
|
||||
# Get completions
|
||||
self.auto_completer.get_completions(line + 1, index, self.editor.text())
|
||||
self.editor.autoCompleteFromAPIs()
|
||||
|
||||
# Show call tip - signature
|
||||
position = self.editor.positionFromLineIndex(line, index)
|
||||
self.show_call_tip(position)
|
||||
|
||||
def loaded_autocomplete(self):
|
||||
"""Placeholder method for actions after autocompletion data is loaded."""
|
||||
|
||||
def set_editor_style(self):
|
||||
"""Sets the style and color scheme for the editor."""
|
||||
# Dracula Theme Colors
|
||||
background_color = QColor("#282a36")
|
||||
text_color = QColor("#f8f8f2")
|
||||
keyword_color = QColor("#8be9fd")
|
||||
string_color = QColor("#f1fa8c")
|
||||
comment_color = QColor("#6272a4")
|
||||
class_function_color = QColor("#50fa7b")
|
||||
|
||||
# Set Font
|
||||
font = QFont()
|
||||
font.setFamily("Consolas")
|
||||
font.setPointSize(10)
|
||||
self.editor.setFont(font)
|
||||
self.editor.setMarginsFont(font)
|
||||
|
||||
# Set Editor Colors
|
||||
self.editor.setMarginsBackgroundColor(background_color)
|
||||
self.editor.setMarginsForegroundColor(text_color)
|
||||
self.editor.setCaretForegroundColor(text_color)
|
||||
self.editor.setCaretLineBackgroundColor(QColor("#44475a"))
|
||||
self.editor.setPaper(background_color) # Set the background color for the entire paper
|
||||
self.editor.setColor(text_color)
|
||||
|
||||
# Set editor
|
||||
# Syntax Highlighting Colors
|
||||
lexer = self.editor.lexer()
|
||||
if lexer:
|
||||
lexer.setDefaultPaper(background_color) # Set the background color for the text area
|
||||
lexer.setDefaultColor(text_color)
|
||||
lexer.setColor(keyword_color, QsciLexerPython.Keyword)
|
||||
lexer.setColor(string_color, QsciLexerPython.DoubleQuotedString)
|
||||
lexer.setColor(string_color, QsciLexerPython.SingleQuotedString)
|
||||
lexer.setColor(comment_color, QsciLexerPython.Comment)
|
||||
lexer.setColor(class_function_color, QsciLexerPython.ClassName)
|
||||
lexer.setColor(class_function_color, QsciLexerPython.FunctionMethodName)
|
||||
|
||||
# Set the style for all text to have a transparent background
|
||||
# TODO find better way how to do it!
|
||||
for style in range(
|
||||
128
|
||||
): # QsciScintilla supports 128 styles by default, this set all to transparent background
|
||||
self.lexer.setPaper(background_color, style)
|
||||
|
||||
def run_script(self):
|
||||
"""Runs the current script in the editor."""
|
||||
if self.jupyter_terminal_enabled:
|
||||
script = self.editor.text()
|
||||
self.terminal.execute(script)
|
||||
|
||||
else:
|
||||
script = self.editor.text()
|
||||
self.script_runner_thread = ScriptRunnerThread(script)
|
||||
self.script_runner_thread.outputSignal.connect(self.update_terminal)
|
||||
self.script_runner_thread.start()
|
||||
|
||||
def update_terminal(self, text):
|
||||
"""Updates the terminal with new text.
|
||||
|
||||
Args:
|
||||
text (str): The text to be appended to the terminal.
|
||||
"""
|
||||
self.terminal.append(text)
|
||||
|
||||
def enable_docstring_tooltip(self):
|
||||
"""Enables the docstring tooltip."""
|
||||
self.docstring_tooltip = True
|
||||
self.auto_completer.enable_docstring = True
|
||||
|
||||
def open_file(self):
|
||||
"""Opens a file dialog for selecting and opening a Python file in the editor."""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Open file", "", "Python files (*.py);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
text = file.read()
|
||||
self.editor.setText(text)
|
||||
except FileNotFoundError:
|
||||
print(f"The file {file_path} was not found.")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while opening the file {file_path}: {e}")
|
||||
|
||||
def save_file(self):
|
||||
"""Opens a save file dialog for saving the current script in the editor."""
|
||||
options = QFileDialog.Options()
|
||||
options |= QFileDialog.DontUseNativeDialog
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save file", "", "Python files (*.py);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
if not file_path.endswith(".py"):
|
||||
file_path += ".py"
|
||||
|
||||
with open(file_path, "w") as file:
|
||||
text = self.editor.text()
|
||||
file.write(text)
|
||||
print(f"File saved to {file_path}")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while saving the file to {file_path}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication([])
|
||||
qdarktheme.setup_theme("auto")
|
||||
|
||||
mainWin = BECEditor(jupyter_terminal_enabled=True)
|
||||
|
||||
mainWin.show()
|
||||
app.exec()
|
||||
1
bec_widgets/widgets/figure/__init__.py
Normal file
1
bec_widgets/widgets/figure/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .figure import FigureConfig, BECFigure
|
||||
524
bec_widgets/widgets/figure/figure.py
Normal file
524
bec_widgets/widgets/figure/figure.py
Normal file
@@ -0,0 +1,524 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import qdarktheme
|
||||
from pydantic import Field
|
||||
from pyqtgraph.Qt import uic
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
from qtpy.QtWidgets import QVBoxLayout, QMainWindow
|
||||
|
||||
from bec_widgets.utils import BECConnector, BECDispatcher, ConnectionConfig
|
||||
from bec_widgets.widgets.plots import BECPlotBase, BECWaveform1D, Waveform1DConfig, WidgetConfig
|
||||
|
||||
|
||||
class FigureConfig(ConnectionConfig):
|
||||
"""Configuration for BECFigure. Inheriting from ConnectionConfig widget_class and gui_id"""
|
||||
|
||||
theme: Literal["dark", "light"] = Field("dark", description="The theme of the figure widget.")
|
||||
num_cols: int = Field(1, description="The number of columns in the figure widget.")
|
||||
num_rows: int = Field(1, description="The number of rows in the figure widget.")
|
||||
widgets: dict[str, WidgetConfig] = Field(
|
||||
{}, description="The list of widgets to be added to the figure widget."
|
||||
)
|
||||
|
||||
|
||||
class WidgetHandler:
|
||||
"""Factory for creating and configuring BEC widgets for BECFigure."""
|
||||
|
||||
def __init__(self):
|
||||
self.widget_factory = {
|
||||
"PlotBase": (BECPlotBase, WidgetConfig),
|
||||
"Waveform1D": (BECWaveform1D, Waveform1DConfig),
|
||||
}
|
||||
|
||||
def create_widget(
|
||||
self,
|
||||
widget_type: str,
|
||||
widget_id: str,
|
||||
parent_figure,
|
||||
parent_id: str,
|
||||
config: dict = None,
|
||||
**axis_kwargs,
|
||||
) -> BECPlotBase:
|
||||
"""
|
||||
Create and configure a widget based on its type.
|
||||
|
||||
Args:
|
||||
widget_type (str): The type of the widget to create.
|
||||
widget_id (str): Unique identifier for the widget.
|
||||
parent_id (str): Identifier of the parent figure.
|
||||
config (dict, optional): Additional configuration for the widget.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECPlotBase: The created and configured widget instance.
|
||||
"""
|
||||
entry = self.widget_factory.get(widget_type)
|
||||
if not entry:
|
||||
raise ValueError(f"Unsupported widget type: {widget_type}")
|
||||
|
||||
widget_class, config_class = entry
|
||||
widget_config_dict = {
|
||||
"widget_class": widget_class.__name__,
|
||||
"parent_id": parent_id,
|
||||
"gui_id": widget_id,
|
||||
**(config if config is not None else {}),
|
||||
}
|
||||
widget_config = config_class(**widget_config_dict)
|
||||
widget = widget_class(
|
||||
config=widget_config, parent_figure=parent_figure, client=parent_figure.client
|
||||
)
|
||||
|
||||
if axis_kwargs:
|
||||
widget.set(**axis_kwargs)
|
||||
|
||||
return widget
|
||||
|
||||
|
||||
class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
USER_ACCESS = ["add_plot", "remove", "change_layout", "change_theme", "clear_all"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
config: Optional[FigureConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = FigureConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = FigureConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
pg.GraphicsLayoutWidget.__init__(self, parent)
|
||||
|
||||
self.widget_handler = WidgetHandler()
|
||||
|
||||
# Widget container to reference widgets by 'widget_id'
|
||||
self.widgets = defaultdict(dict)
|
||||
|
||||
# Container to keep track of the grid
|
||||
self.grid = []
|
||||
|
||||
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 add_plot(
|
||||
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.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
|
||||
"""
|
||||
return self.add_widget(
|
||||
widget_type="Waveform1D",
|
||||
widget_id=widget_id,
|
||||
row=row,
|
||||
col=col,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
widget_type: Literal["PlotBase", "Waveform1D"] = "PlotBase",
|
||||
widget_id: str = None,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> 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.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
|
||||
"""
|
||||
if not widget_id:
|
||||
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(
|
||||
widget_type=widget_type,
|
||||
widget_id=widget_id,
|
||||
parent_figure=self,
|
||||
parent_id=self.gui_id,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
|
||||
# Check if position is occupied
|
||||
if row is not None and col is not None:
|
||||
if self.getItem(row, col):
|
||||
raise ValueError(f"Position at row {row} and column {col} is already occupied.")
|
||||
else:
|
||||
widget.config.row = row
|
||||
widget.config.col = col
|
||||
|
||||
# Add widget to the figure
|
||||
self.addItem(widget, row=row, col=col)
|
||||
else:
|
||||
row, col = self._find_next_empty_position()
|
||||
widget.config.row = row
|
||||
widget.config.col = col
|
||||
|
||||
# Add widget to the figure
|
||||
self.addItem(widget, row=row, col=col)
|
||||
|
||||
# Update num_cols and num_rows based on the added widget
|
||||
self.config.num_rows = max(self.config.num_rows, row + 1)
|
||||
self.config.num_cols = max(self.config.num_cols, col + 1)
|
||||
|
||||
# Saving config for future referencing
|
||||
self.config.widgets[widget_id] = widget.config
|
||||
self.widgets[widget_id] = widget
|
||||
|
||||
# Reflect the grid coordinates
|
||||
self._change_grid(widget_id, row, col)
|
||||
|
||||
return widget
|
||||
|
||||
def remove(
|
||||
self,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
widget_id: str = None,
|
||||
coordinates: tuple[int, int] = None,
|
||||
) -> 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.
|
||||
widget_id(str): The unique identifier of the widget to remove.
|
||||
coordinates(tuple[int, int], optional): The coordinates of the widget to remove.
|
||||
"""
|
||||
if widget_id:
|
||||
self._remove_by_id(widget_id)
|
||||
elif row is not None and col is not None:
|
||||
self._remove_by_coordinates(row, col)
|
||||
elif coordinates:
|
||||
self._remove_by_coordinates(*coordinates)
|
||||
else:
|
||||
raise ValueError("Must provide either widget_id or coordinates for removal.")
|
||||
|
||||
def _remove_by_coordinates(self, row: int, col: int) -> None:
|
||||
"""
|
||||
Remove a widget from the figure by its coordinates.
|
||||
Args:
|
||||
row(int): The row coordinate of the widget to remove.
|
||||
col(int): The column coordinate of the widget to remove.
|
||||
"""
|
||||
widget = self._get_widget_by_coordinates(row, col)
|
||||
if widget:
|
||||
widget_id = widget.config.gui_id
|
||||
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)
|
||||
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 __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
|
||||
|
||||
Returns:
|
||||
BECPlotBase: the widget at the given coordinates
|
||||
"""
|
||||
widget = self.getItem(row, col)
|
||||
if widget is None:
|
||||
raise ValueError(f"No widget at coordinates ({row}, {col})")
|
||||
return widget
|
||||
|
||||
def _find_next_empty_position(self):
|
||||
"""Find the next empty position (new row) in the figure."""
|
||||
row, col = 0, 0
|
||||
while self.getItem(row, col):
|
||||
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.
|
||||
col(int): The new column coordinate of the widget in the figure.
|
||||
"""
|
||||
while len(self.grid) <= row:
|
||||
self.grid.append([])
|
||||
row = self.grid[row]
|
||||
while len(row) <= col:
|
||||
row.append(None)
|
||||
row[col] = widget_id
|
||||
|
||||
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]
|
||||
if new_row:
|
||||
new_grid.append(new_row)
|
||||
#
|
||||
# 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.grid = new_grid
|
||||
self._replot_layout()
|
||||
|
||||
def _replot_layout(self):
|
||||
"""Replot the layout based on the current grid configuration."""
|
||||
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)
|
||||
|
||||
def change_layout(self, max_columns=None, max_rows=None):
|
||||
"""
|
||||
Reshuffle the layout of the figure to adjust to a new number of max_columns or max_rows.
|
||||
If both max_columns and max_rows are provided, max_rows is ignored.
|
||||
|
||||
Args:
|
||||
max_columns (Optional[int]): The new maximum number of columns in the figure.
|
||||
max_rows (Optional[int]): The new maximum number of rows in the figure.
|
||||
"""
|
||||
# Calculate total number of widgets
|
||||
total_widgets = len(self.widgets)
|
||||
|
||||
if max_columns:
|
||||
# Calculate the required number of rows based on max_columns
|
||||
required_rows = (total_widgets + max_columns - 1) // max_columns
|
||||
new_grid = [[None for _ in range(max_columns)] for _ in range(required_rows)]
|
||||
elif max_rows:
|
||||
# Calculate the required number of columns based on max_rows
|
||||
required_columns = (total_widgets + max_rows - 1) // max_rows
|
||||
new_grid = [[None for _ in range(required_columns)] for _ in range(max_rows)]
|
||||
else:
|
||||
# If neither max_columns nor max_rows is specified, just return without changing the layout
|
||||
return
|
||||
|
||||
# Populate the new grid with widgets' IDs
|
||||
current_idx = 0
|
||||
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
|
||||
current_idx += 1
|
||||
|
||||
self.config.num_rows = row
|
||||
self.config.num_cols = col
|
||||
|
||||
# Update widgets' positions and replot them according to the new grid
|
||||
self.grid = new_grid
|
||||
self._reindex_grid() # This method should be updated to handle reshuffling correctly
|
||||
self._replot_layout() # Assumes this method re-adds widgets to the layout based on self.grid
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all widgets from the figure and reset to default state"""
|
||||
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 start(self):
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
win = QMainWindow()
|
||||
win.setCentralWidget(self)
|
||||
win.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
##################################################
|
||||
##################################################
|
||||
# Debug window
|
||||
##################################################
|
||||
##################################################
|
||||
|
||||
from qtconsole.inprocess import QtInProcessKernelManager
|
||||
from qtconsole.rich_jupyter_widget import RichJupyterWidget
|
||||
|
||||
|
||||
class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.kernel_manager = QtInProcessKernelManager()
|
||||
self.kernel_manager.start_kernel(show_banner=False)
|
||||
self.kernel_client = self.kernel_manager.client()
|
||||
self.kernel_client.start_channels()
|
||||
|
||||
self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
|
||||
|
||||
def shutdown_kernel(self):
|
||||
self.kernel_client.stop_channels()
|
||||
self.kernel_manager.shutdown_kernel()
|
||||
|
||||
|
||||
class DebugWindow(QWidget): # pragma: no cover:
|
||||
"""Debug window for BEC widgets"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "figure_debug_minimal.ui"), self)
|
||||
|
||||
self._init_ui()
|
||||
|
||||
self.splitter.setSizes([200, 100])
|
||||
|
||||
# console push
|
||||
self.console.kernel_manager.kernel.shell.push(
|
||||
{"fig": self.figure, "w1": self.w1, "w2": self.w2}
|
||||
)
|
||||
|
||||
def _init_ui(self):
|
||||
# Plotting window
|
||||
self.glw_1_layout = QVBoxLayout(self.glw) # Create a new QVBoxLayout
|
||||
self.figure = BECFigure(parent=self) # Create a new BECDeviceMonitor
|
||||
self.glw_1_layout.addWidget(self.figure) # Add BECDeviceMonitor to the layout
|
||||
|
||||
# add stuff to figure
|
||||
self._init_figure()
|
||||
|
||||
self.console_layout = QVBoxLayout(self.widget_console)
|
||||
self.console = JupyterConsoleWidget()
|
||||
self.console_layout.addWidget(self.console)
|
||||
self.console.set_default_style("linux")
|
||||
|
||||
def _init_figure(self):
|
||||
self.figure.add_widget(widget_type="Waveform1D", row=0, col=0, title="Widget 1")
|
||||
self.figure.add_widget(widget_type="Waveform1D", row=1, col=0, title="Widget 2")
|
||||
self.figure.add_widget(widget_type="Waveform1D", row=0, col=1, title="Widget 3")
|
||||
self.figure.add_widget(widget_type="Waveform1D", row=1, col=1, title="Widget 4")
|
||||
|
||||
self.w1 = self.figure[0, 0]
|
||||
self.w2 = self.figure[1, 0]
|
||||
self.w3 = self.figure[0, 1]
|
||||
self.w4 = self.figure[1, 1]
|
||||
|
||||
# curves for w1
|
||||
self.w1.add_curve_scan("samx", "bpm4i", pen_style="dash")
|
||||
self.w1.add_curve_custom(
|
||||
x=[1, 2, 3, 4, 5],
|
||||
y=[1, 2, 3, 4, 5],
|
||||
label="curve-custom",
|
||||
color="blue",
|
||||
pen_style="dashdot",
|
||||
)
|
||||
self.c1 = self.w1.get_config()
|
||||
|
||||
# curves for w2
|
||||
self.w2.add_curve_scan("samx", "bpm3a", pen_style="solid")
|
||||
self.w2.add_curve_scan("samx", "bpm4d", pen_style="dot")
|
||||
self.w2.add_curve_custom(
|
||||
x=[1, 2, 3, 4, 5], y=[5, 4, 3, 2, 1], color="red", pen_style="dashdot"
|
||||
)
|
||||
|
||||
# curves for w3
|
||||
self.w3.add_curve_scan("samx", "bpm4i", pen_style="dash")
|
||||
self.w3.add_curve_custom(
|
||||
x=[1, 2, 3, 4, 5],
|
||||
y=[1, 2, 3, 4, 5],
|
||||
label="curve-custom",
|
||||
color="blue",
|
||||
pen_style="dashdot",
|
||||
)
|
||||
|
||||
# curves for w4
|
||||
self.w4.add_curve_scan("samx", "bpm4i", pen_style="dash")
|
||||
self.w4.add_curve_custom(
|
||||
x=[1, 2, 3, 4, 5],
|
||||
y=[1, 2, 3, 4, 5],
|
||||
label="curve-custom",
|
||||
color="blue",
|
||||
pen_style="dashdot",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
win = DebugWindow()
|
||||
win.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
35
bec_widgets/widgets/figure/figure_debug_minimal.ui
Normal file
35
bec_widgets/widgets/figure/figure_debug_minimal.ui
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>901</width>
|
||||
<height>1000</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="QWidget" name="glw" native="true"/>
|
||||
</widget>
|
||||
<widget class="QWidget" name="widget_console" native="true"/>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,8 +1,8 @@
|
||||
import os
|
||||
|
||||
from PyQt5 import uic
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
from qtpy import uic
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
@@ -12,7 +12,12 @@ from PyQt5.QtWidgets import (
|
||||
QLineEdit,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.yaml_dialog import load_yaml, save_yaml
|
||||
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"))
|
||||
@@ -21,7 +26,7 @@ Tab_Ui_Form, Tab_BaseClass = uic.loadUiType(os.path.join(current_path, "tab_temp
|
||||
# test configs for demonstration purpose
|
||||
|
||||
# Configuration for default mode when only devices are monitored
|
||||
config_default = {
|
||||
CONFIG_DEFAULT = {
|
||||
"plot_settings": {
|
||||
"background_color": "black",
|
||||
"num_columns": 1,
|
||||
@@ -31,35 +36,41 @@ config_default = {
|
||||
"plot_data": [
|
||||
{
|
||||
"plot_name": "BPM4i plots vs samx",
|
||||
"x": {
|
||||
"label": "Motor Y",
|
||||
"signals": [{"name": "samx", "entry": "samx"}],
|
||||
},
|
||||
"y": {
|
||||
"label": "bpm4i",
|
||||
"signals": [{"name": "bpm4i", "entry": "bpm4i"}],
|
||||
},
|
||||
"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",
|
||||
"signals": [{"name": "samx", "entry": "samx"}],
|
||||
},
|
||||
"y": {
|
||||
"label": "Gauss",
|
||||
"signals": [
|
||||
{"name": "gauss_bpm", "entry": "gauss_bpm"},
|
||||
{"name": "gauss_acd1", "entry": "gauss_adc1"},
|
||||
{"name": "gauss_acd2", "entry": "gauss_adc2"},
|
||||
],
|
||||
},
|
||||
"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 = {
|
||||
CONFIG_SCAN_MODE = {
|
||||
"plot_settings": {
|
||||
"background_color": "white",
|
||||
"num_columns": 3,
|
||||
@@ -70,77 +81,89 @@ config_scan = {
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
"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", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "BPM",
|
||||
"signals": [
|
||||
{"name": "gauss_bpm", "entry": "gauss_bpm"},
|
||||
{"name": "gauss_adc1", "entry": "gauss_adc1"},
|
||||
],
|
||||
},
|
||||
"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 Y", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "BPM",
|
||||
"signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
|
||||
},
|
||||
"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 Y", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "BPM",
|
||||
"signals": [{"name": "gauss_adc3", "entry": "gauss_adc3"}],
|
||||
},
|
||||
"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 plot",
|
||||
"x": {"label": "Motor X", "signals": [{"name": "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": "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": "Multi",
|
||||
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "Multi",
|
||||
"signals": [
|
||||
{"name": "gauss_bpm", "entry": "gauss_bpm"},
|
||||
{"name": "samx", "entry": "samx"},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
"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"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -150,10 +173,25 @@ config_scan = {
|
||||
class ConfigDialog(QWidget, Ui_Form):
|
||||
config_updated = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, default_config=None):
|
||||
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)
|
||||
@@ -329,7 +367,15 @@ class ConfigDialog(QWidget, Ui_Form):
|
||||
|
||||
ui = plot_tab.ui
|
||||
table = ui.tableWidget_y_signals
|
||||
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)),
|
||||
@@ -339,19 +385,17 @@ class ConfigDialog(QWidget, Ui_Form):
|
||||
|
||||
plot_data = {
|
||||
"plot_name": self.safe_text(ui.lineEdit_plot_title),
|
||||
"x": {
|
||||
"label": self.safe_text(ui.lineEdit_x_label),
|
||||
"signals": [
|
||||
{
|
||||
"name": self.safe_text(ui.lineEdit_x_name),
|
||||
"entry": self.safe_text(ui.lineEdit_x_entry),
|
||||
}
|
||||
],
|
||||
},
|
||||
"y": {
|
||||
"label": self.safe_text(ui.lineEdit_y_label),
|
||||
"signals": signals,
|
||||
},
|
||||
"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
|
||||
@@ -446,15 +490,14 @@ class ConfigDialog(QWidget, Ui_Form):
|
||||
plot (QWidget): plot tab widget
|
||||
plot_config (dict): config for single plot tab
|
||||
"""
|
||||
x_config = plot_config.get("x", {})
|
||||
x_signals = x_config.get("signals", [{}])[0] # Assuming at least one x signal
|
||||
y_config = plot_config.get("y", {})
|
||||
y_signals = y_config.get("signals", [])
|
||||
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(x_config.get("label", ""))
|
||||
plot.ui.lineEdit_y_label.setText(y_config.get("label", ""))
|
||||
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", ""))
|
||||
@@ -499,12 +542,50 @@ class ConfigDialog(QWidget, Ui_Form):
|
||||
|
||||
def apply_and_close(self):
|
||||
new_config = self.apply_config()
|
||||
self.config_updated.emit(new_config)
|
||||
self.close()
|
||||
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()
|
||||
app.exec_()
|
||||
main_app.load_config(CONFIG_SCAN_MODE)
|
||||
app.exec()
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import os
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
import time
|
||||
|
||||
import pyqtgraph as pg
|
||||
from pydantic import ValidationError
|
||||
|
||||
from bec_lib import MessageEndpoints
|
||||
from PyQt5 import QtCore
|
||||
from PyQt5.QtCore import pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtWidgets import QApplication, QTableWidgetItem, QWidget, QMessageBox
|
||||
from pyqtgraph import mkPen, mkBrush
|
||||
from PyQt5 import uic
|
||||
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
from bec_widgets.qt_utils import Crosshair, Colors
|
||||
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 = {
|
||||
CONFIG_SCAN_MODE = {
|
||||
"plot_settings": {
|
||||
"background_color": "white",
|
||||
"num_columns": 3,
|
||||
@@ -28,72 +26,96 @@ config_scan_mode = {
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
"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", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "BPM",
|
||||
"signals": [
|
||||
{"name": "gauss_bpm", "entry": "gauss_bpm"},
|
||||
{"name": "gauss_adc1", "entry": "gauss_adc1"},
|
||||
],
|
||||
},
|
||||
"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 Y", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "BPM",
|
||||
"signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
|
||||
},
|
||||
"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 Y", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "BPM",
|
||||
"signals": [{"name": "gauss_adc3", "entry": "gauss_adc3"}],
|
||||
},
|
||||
"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 plot",
|
||||
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
|
||||
"y": {
|
||||
"label": "BPM",
|
||||
"signals": [
|
||||
{"name": "gauss_bpm", "entry": "gauss_bpm"},
|
||||
{"name": "gauss_adc1"},
|
||||
{"name": "gauss_adc2", "entry": "gauss_adc2"},
|
||||
],
|
||||
},
|
||||
"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": "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"]},
|
||||
],
|
||||
},
|
||||
"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_simple = {
|
||||
|
||||
CONFIG_WRONG = {
|
||||
"plot_settings": {
|
||||
"background_color": "black",
|
||||
"num_columns": 2,
|
||||
@@ -103,37 +125,121 @@ config_simple = {
|
||||
"plot_data": [
|
||||
{
|
||||
"plot_name": "BPM4i plots vs samx",
|
||||
"x": {
|
||||
"label": "Motor Y",
|
||||
# "signals": [{"name": "samx", "entry": "samx"}],
|
||||
"signals": [{"name": "samy"}],
|
||||
},
|
||||
"y": {
|
||||
"label": "bpm4i",
|
||||
"signals": [{"name": "bpm4i", "entry": "bpm4i"}],
|
||||
},
|
||||
"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",
|
||||
"signals": [{"name": "samx", "entry": "samx"}],
|
||||
},
|
||||
"y": {
|
||||
"label": "Gauss",
|
||||
# "signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
|
||||
"signals": [
|
||||
{"name": "gauss_bpm"},
|
||||
{"name": "samy", "entry": "samy"},
|
||||
],
|
||||
},
|
||||
"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_no_entry = {
|
||||
|
||||
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,
|
||||
@@ -141,14 +247,21 @@ config_no_entry = {
|
||||
"plot_data": [
|
||||
{
|
||||
"plot_name": "BPM4i plots vs samx",
|
||||
"x": {"label": "Motor Y", "signals": [{"name": "samx"}]},
|
||||
"y": {"label": "bpm4i", "signals": [{"name": "bpm4i"}]},
|
||||
},
|
||||
{
|
||||
"plot_name": "Gauss plots vs samx",
|
||||
"x": {"label": "Motor X", "signals": [{"name": "samx"}]},
|
||||
"y": {"label": "Gauss", "signals": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}]},
|
||||
},
|
||||
"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"}]},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@@ -161,33 +274,42 @@ class BECMonitor(pg.GraphicsLayoutWidget):
|
||||
parent=None,
|
||||
client=None,
|
||||
config: dict = None,
|
||||
enable_crosshair: bool = False,
|
||||
enable_crosshair: bool = True,
|
||||
gui_id=None,
|
||||
skip_validation: bool = False,
|
||||
):
|
||||
super(BECMonitor, self).__init__(parent=parent)
|
||||
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 gui_id is None:
|
||||
self.gui_id = self.__class__.__name__ + str(time.time()) # TODO still in discussion
|
||||
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)) #TODO connect when ready
|
||||
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.data = {}
|
||||
self.database = None
|
||||
|
||||
self.crosshairs = None
|
||||
self.plots = None
|
||||
@@ -198,9 +320,9 @@ class BECMonitor(pg.GraphicsLayoutWidget):
|
||||
# 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 #TODO enable when update is fixed
|
||||
# Connect the update signal to the update plot method
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self.update_plot
|
||||
self.update_signal, rateLimit=25, slot=self.update_scan_segment_plot
|
||||
)
|
||||
|
||||
# Init UI
|
||||
@@ -224,11 +346,46 @@ class BECMonitor(pg.GraphicsLayoutWidget):
|
||||
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()))]
|
||||
|
||||
# TODO init plot background -> so far not used, I don't like how it is done in extreme.py
|
||||
# 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.
|
||||
@@ -252,7 +409,8 @@ class BECMonitor(pg.GraphicsLayoutWidget):
|
||||
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}."
|
||||
"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
|
||||
@@ -274,69 +432,117 @@ class BECMonitor(pg.GraphicsLayoutWidget):
|
||||
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", "")
|
||||
|
||||
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, 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.
|
||||
Initialize curve data and properties for each plot and data source.
|
||||
"""
|
||||
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)):
|
||||
y_name = y_config["name"]
|
||||
y_entry = y_config["entry"]
|
||||
|
||||
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})",
|
||||
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)
|
||||
)
|
||||
|
||||
curve_list.append((y_name, y_entry, curve_data))
|
||||
plot.addItem(curve_data)
|
||||
row_labels.append(f"{y_name} ({y_entry}) - {plot_name}")
|
||||
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] = []
|
||||
|
||||
self.curves_data[plot_name] = curve_list
|
||||
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))
|
||||
|
||||
# Hook Crosshair
|
||||
if self.enable_crosshair == True:
|
||||
# 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
|
||||
@@ -345,22 +551,50 @@ class BECMonitor(pg.GraphicsLayoutWidget):
|
||||
crosshair = Crosshair(plot, precision=3)
|
||||
self.crosshairs[plot_name] = crosshair
|
||||
|
||||
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), {}
|
||||
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
|
||||
)
|
||||
x_signal_config = x_config["signals"][0]
|
||||
x_name = x_signal_config.get("name", "")
|
||||
x_entry = x_signal_config.get("entry", x_name)
|
||||
if not plot_config:
|
||||
continue
|
||||
|
||||
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", [])
|
||||
x_name, x_entry = self.extract_x_config(plot_config, src_type)
|
||||
|
||||
curve.setData(data_x, data_y)
|
||||
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."""
|
||||
@@ -368,9 +602,11 @@ class BECMonitor(pg.GraphicsLayoutWidget):
|
||||
|
||||
def show_config_dialog(self):
|
||||
"""Show the configuration dialog."""
|
||||
from .config_dialog import ConfigDialog
|
||||
from bec_widgets.widgets import ConfigDialog
|
||||
|
||||
dialog = ConfigDialog(default_config=self.config)
|
||||
dialog = ConfigDialog(
|
||||
client=self.client, default_config=self.config, skip_validation=self.skip_validation
|
||||
)
|
||||
dialog.config_updated.connect(self.on_config_update)
|
||||
dialog.show()
|
||||
|
||||
@@ -382,6 +618,36 @@ class BECMonitor(pg.GraphicsLayoutWidget):
|
||||
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:
|
||||
"""
|
||||
@@ -389,18 +655,73 @@ class BECMonitor(pg.GraphicsLayoutWidget):
|
||||
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"]
|
||||
|
||||
try:
|
||||
validated_config = self.validator.validate_monitor_config(config)
|
||||
self.config = validated_config.model_dump()
|
||||
if self.skip_validation is True:
|
||||
self.config = config
|
||||
self._init_config()
|
||||
except ValidationError as e:
|
||||
error_message = f"Monitor configuration validation error: {e}"
|
||||
print(error_message)
|
||||
# QMessageBox.critical(self, "Configuration Error", error_message) #TODO do better error popups
|
||||
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, metadata):
|
||||
def on_scan_segment(self, msg: dict, metadata: dict):
|
||||
"""
|
||||
Handle new scan segments and saves data to a dictionary. Linked through bec_dispatcher.
|
||||
|
||||
@@ -408,7 +729,6 @@ class BECMonitor(pg.GraphicsLayoutWidget):
|
||||
msg (dict): Message received with scan data.
|
||||
metadata (dict): Metadata of the scan.
|
||||
"""
|
||||
# TODO for scan mode, if there are same names for different plots, the data are assigned multiple times
|
||||
current_scanID = msg.get("scanID", None)
|
||||
if current_scanID is None:
|
||||
return
|
||||
@@ -417,60 +737,119 @@ class BECMonitor(pg.GraphicsLayoutWidget):
|
||||
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:
|
||||
current_name = metadata.get("scan_name")
|
||||
if current_name 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."
|
||||
"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(currentName, [])
|
||||
if self.plot_data == []:
|
||||
self.plot_data = self.plot_data_config.get(current_name, None)
|
||||
if not 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."
|
||||
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.data = {}
|
||||
self.init_curves()
|
||||
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")
|
||||
|
||||
for plot_config in self.plot_data:
|
||||
x_config = plot_config["x"]
|
||||
x_signal_config = x_config["signals"][0] # There is exactly 1 config for x signals
|
||||
|
||||
x_name = x_signal_config.get("name", "")
|
||||
x_entry = x_signal_config.get("entry", [])
|
||||
|
||||
y_configs = plot_config["y"]["signals"]
|
||||
for y_config in y_configs:
|
||||
y_name = y_config.get("name", "")
|
||||
y_entry = y_config.get("entry", [])
|
||||
|
||||
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 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.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
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
client = bec_dispatcher.client
|
||||
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_simple)
|
||||
monitor = BECMonitor(
|
||||
config=config,
|
||||
gui_id=args.id,
|
||||
skip_validation=False,
|
||||
)
|
||||
monitor.show()
|
||||
sys.exit(app.exec_())
|
||||
# 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())
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
<customwidget>
|
||||
<class>BECTable</class>
|
||||
<extends>QTableWidget</extends>
|
||||
<header>bec_widgets.qt_utils.h</header>
|
||||
<header>bec_widgets.utils.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
|
||||
1
bec_widgets/widgets/monitor_scatter_2D/__init__.py
Normal file
1
bec_widgets/widgets/monitor_scatter_2D/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .monitor_scatter_2D import BECMonitor2DScatter
|
||||
382
bec_widgets/widgets/monitor_scatter_2D/monitor_scatter_2D.py
Normal file
382
bec_widgets/widgets/monitor_scatter_2D/monitor_scatter_2D.py
Normal 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())
|
||||
7
bec_widgets/widgets/motor_control/__init__.py
Normal file
7
bec_widgets/widgets/motor_control/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .motor_control import (
|
||||
MotorControlRelative,
|
||||
MotorControlAbsolute,
|
||||
MotorControlSelection,
|
||||
MotorThread,
|
||||
MotorCoordinateTable,
|
||||
)
|
||||
1194
bec_widgets/widgets/motor_control/motor_control.py
Normal file
1194
bec_widgets/widgets/motor_control/motor_control.py
Normal file
File diff suppressed because it is too large
Load Diff
149
bec_widgets/widgets/motor_control/motor_control_absolute.ui
Normal file
149
bec_widgets/widgets/motor_control/motor_control_absolute.ui
Normal file
@@ -0,0 +1,149 @@
|
||||
<?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>285</width>
|
||||
<height>220</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>285</width>
|
||||
<height>220</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>285</width>
|
||||
<height>220</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Move Movement Absolute</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl_absolute">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>195</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>195</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Move Movement Absolute</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_save_with_go">
|
||||
<property name="text">
|
||||
<string>Save position with Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_save">
|
||||
<property name="text">
|
||||
<string>Save</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_set">
|
||||
<property name="text">
|
||||
<string>Set</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_go_absolute">
|
||||
<property name="text">
|
||||
<string>Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_stop">
|
||||
<property name="text">
|
||||
<string>Stop Movement</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
298
bec_widgets/widgets/motor_control/motor_control_relative.ui
Normal file
298
bec_widgets/widgets/motor_control/motor_control_relative.ui
Normal file
@@ -0,0 +1,298 @@
|
||||
<?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>285</width>
|
||||
<height>405</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>285</width>
|
||||
<height>405</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Motor Control Relative</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>394</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Control Relative</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_5">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_enableArrows">
|
||||
<property name="text">
|
||||
<string>Move with arrow keys</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_same_xy">
|
||||
<property name="text">
|
||||
<string>Step [X] = Step [Y]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="step_grid">
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_step_y">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Step [Y]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Decimal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step_x">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>0.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>99.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_step_x">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Step [X]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step_y">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>0.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>99.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_precision">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>8</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>2</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="direction_grid">
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetDefaultConstraint</enum>
|
||||
</property>
|
||||
<item row="1" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_up">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::UpArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="4">
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<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="3" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_down">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::DownArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QToolButton" name="toolButton_left">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::LeftArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QToolButton" name="toolButton_right">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::RightArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" 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="4" column="2">
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_stop">
|
||||
<property name="text">
|
||||
<string>Stop Movement</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
69
bec_widgets/widgets/motor_control/motor_control_selection.ui
Normal file
69
bec_widgets/widgets/motor_control/motor_control_selection.ui
Normal file
@@ -0,0 +1,69 @@
|
||||
<?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>285</width>
|
||||
<height>156</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>285</width>
|
||||
<height>156</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Motor Control Selection</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorSelection">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>145</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Selection</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Motor X</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="comboBox_motor_x"/>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_8">
|
||||
<property name="text">
|
||||
<string>Motor Y</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0" colspan="2">
|
||||
<widget class="QPushButton" name="pushButton_connecMotors">
|
||||
<property name="text">
|
||||
<string>Connect Motors</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QComboBox" name="comboBox_motor_y"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
113
bec_widgets/widgets/motor_control/motor_control_table.ui
Normal file
113
bec_widgets/widgets/motor_control/motor_control_table.ui
Normal file
@@ -0,0 +1,113 @@
|
||||
<?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>676</width>
|
||||
<height>667</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Motor Coordinates Table</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_4">
|
||||
<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="QCheckBox" name="checkBox_resize_auto">
|
||||
<property name="text">
|
||||
<string>Resize Auto</string>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_editColumns">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Edit Custom Column</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Entries Mode:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBox_mode">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Individual</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Start/Stop</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="table">
|
||||
<property name="gridStyle">
|
||||
<enum>Qt::SolidLine</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_importCSV">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Import CSV</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_exportCSV">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Export CSV</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
1
bec_widgets/widgets/motor_map/__init__.py
Normal file
1
bec_widgets/widgets/motor_map/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .motor_map import MotorMap
|
||||
607
bec_widgets/widgets/motor_map/motor_map.py
Normal file
607
bec_widgets/widgets/motor_map/motor_map.py
Normal file
@@ -0,0 +1,607 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Union
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import MessageEndpoints
|
||||
from qtpy import QtCore
|
||||
from qtpy import QtGui
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
CONFIG_DEFAULT = {
|
||||
"plot_settings": {
|
||||
"colormap": "Greys",
|
||||
"scatter_size": 5,
|
||||
"max_points": 1000,
|
||||
"num_dim_points": 100,
|
||||
"precision": 2,
|
||||
"num_columns": 1,
|
||||
"background_value": 25,
|
||||
},
|
||||
"motors": [
|
||||
{
|
||||
"plot_name": "Motor Map",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Motor Y",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "samy", "entry": "samy"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"plot_name": "Motor Map 2 ",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Motor Y",
|
||||
"signals": {
|
||||
"x": [{"name": "aptrx", "entry": "aptrx"}],
|
||||
"y": [{"name": "aptry", "entry": "aptry"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class MotorMap(pg.GraphicsLayoutWidget):
|
||||
update_signal = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: dict = None,
|
||||
gui_id=None,
|
||||
skip_validation: bool = True,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
# Import BEC related stuff
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.dev = self.client.device_manager.devices
|
||||
|
||||
# TODO import validator when prepared
|
||||
self.gui_id = gui_id
|
||||
|
||||
if self.gui_id is None:
|
||||
self.gui_id = self.__class__.__name__ + str(time.time())
|
||||
|
||||
# Current configuration
|
||||
self.config = config
|
||||
self.skip_validation = skip_validation # TODO implement validation when validator is ready
|
||||
|
||||
# Connect the update signal to the update plot method
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self._update_plots
|
||||
)
|
||||
|
||||
# Config related variables
|
||||
self.plot_data = None
|
||||
self.plot_settings = None
|
||||
self.max_points = None
|
||||
self.num_dim_points = None
|
||||
self.scatter_size = None
|
||||
self.precision = None
|
||||
self.background_value = None
|
||||
self.database = {}
|
||||
self.device_mapping = {}
|
||||
self.plots = {}
|
||||
self.grid_coordinates = []
|
||||
self.curves_data = {}
|
||||
|
||||
# Init UI with config
|
||||
if self.config is None:
|
||||
print("No initial config found for MotorMap. Using default config.")
|
||||
else:
|
||||
self.on_config_update(self.config)
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def on_config_update(self, config: dict) -> None:
|
||||
"""
|
||||
Validate and update the configuration settings for the PlotApp.
|
||||
Args:
|
||||
config(dict): Configuration settings
|
||||
"""
|
||||
# TODO implement BEC CLI commands similar to BECPlotter
|
||||
# convert config from BEC CLI to correct formatting
|
||||
config_tag = config.get("config", None)
|
||||
if config_tag is not None:
|
||||
config = config["config"]
|
||||
|
||||
if self.skip_validation is True:
|
||||
self.config = config
|
||||
self._init_config()
|
||||
|
||||
else: # TODO implement validator
|
||||
print("Do validation")
|
||||
|
||||
@pyqtSlot(str, str, int)
|
||||
def change_motors(self, motor_x: str, motor_y: str, subplot: int = 0) -> None:
|
||||
"""
|
||||
Change the active motors for the plot.
|
||||
Args:
|
||||
motor_x(str): Motor name for the X axis.
|
||||
motor_y(str): Motor name for the Y axis.
|
||||
subplot(int): Subplot number.
|
||||
"""
|
||||
if subplot >= len(self.plot_data):
|
||||
print(f"Invalid subplot index: {subplot}. Available subplots: {len(self.plot_data)}")
|
||||
return
|
||||
|
||||
# Update the motor names in the plot configuration
|
||||
self.config["motors"][subplot]["signals"]["x"][0]["name"] = motor_x
|
||||
self.config["motors"][subplot]["signals"]["x"][0]["entry"] = motor_x
|
||||
self.config["motors"][subplot]["signals"]["y"][0]["name"] = motor_y
|
||||
self.config["motors"][subplot]["signals"]["y"][0]["entry"] = motor_y
|
||||
|
||||
# reinitialise the config and UI
|
||||
self._init_config()
|
||||
|
||||
def _init_config(self):
|
||||
"""Initiate the configuration."""
|
||||
|
||||
# Global widget settings
|
||||
self._get_global_settings()
|
||||
|
||||
# Motor settings
|
||||
self.plot_data = self.config.get("motors", {})
|
||||
|
||||
# Include motor limits into the config
|
||||
self._add_limits_to_plot_data()
|
||||
|
||||
# Initialize the database
|
||||
self.database = self._init_database()
|
||||
|
||||
# Create device mapping for x/y motor pairs
|
||||
self.device_mapping = self._create_device_mapping()
|
||||
|
||||
# Initialize the plot UI
|
||||
self._init_ui()
|
||||
|
||||
# Connect motors to slots
|
||||
self._connect_motors_to_slots()
|
||||
|
||||
# Render init position of selected motors
|
||||
self._update_plots()
|
||||
|
||||
def _get_global_settings(self):
|
||||
"""Get global settings from the config."""
|
||||
self.plot_settings = self.config.get("plot_settings", {})
|
||||
|
||||
self.max_points = self.plot_settings.get("max_points", 5000)
|
||||
self.num_dim_points = self.plot_settings.get("num_dim_points", 100)
|
||||
self.scatter_size = self.plot_settings.get("scatter_size", 5)
|
||||
self.precision = self.plot_settings.get("precision", 2)
|
||||
self.background_value = self.plot_settings.get("background_value", 25)
|
||||
|
||||
def _create_device_mapping(self):
|
||||
"""
|
||||
Create a mapping of device names to their corresponding x/y devices.
|
||||
"""
|
||||
mapping = {}
|
||||
for motor in self.config.get("motors", []):
|
||||
for axis in ["x", "y"]:
|
||||
for signal in motor["signals"][axis]:
|
||||
other_axis = "y" if axis == "x" else "x"
|
||||
corresponding_device = motor["signals"][other_axis][0]["name"]
|
||||
mapping[signal["name"]] = corresponding_device
|
||||
return mapping
|
||||
|
||||
def _connect_motors_to_slots(self):
|
||||
"""Connect motors to slots."""
|
||||
|
||||
# Disconnect all slots before connecting a new ones
|
||||
bec_dispatcher = BECDispatcher()
|
||||
bec_dispatcher.disconnect_all()
|
||||
|
||||
# Get list of all unique motors
|
||||
unique_motors = []
|
||||
for motor_config in self.plot_data:
|
||||
for axis in ["x", "y"]:
|
||||
for signal in motor_config["signals"][axis]:
|
||||
unique_motors.append(signal["name"])
|
||||
unique_motors = list(set(unique_motors))
|
||||
|
||||
# Create list of endpoint
|
||||
endpoints = []
|
||||
for motor in unique_motors:
|
||||
endpoints.append(MessageEndpoints.device_readback(motor))
|
||||
|
||||
# Connect all topics to a single slot
|
||||
bec_dispatcher.connect_slot(
|
||||
self.on_device_readback,
|
||||
endpoints,
|
||||
single_callback_for_all_topics=True,
|
||||
)
|
||||
|
||||
def _add_limits_to_plot_data(self):
|
||||
"""
|
||||
Add limits to each motor signal in the plot_data.
|
||||
"""
|
||||
for motor_config in self.plot_data:
|
||||
for axis in ["x", "y"]:
|
||||
for signal in motor_config["signals"][axis]:
|
||||
motor_name = signal["name"]
|
||||
motor_limits = self._get_motor_limit(motor_name)
|
||||
signal["limits"] = motor_limits
|
||||
|
||||
def _get_motor_limit(self, motor: str) -> Union[list | None]:
|
||||
"""
|
||||
Get the motor limit from the config.
|
||||
Args:
|
||||
motor(str): Motor name.
|
||||
|
||||
Returns:
|
||||
float: Motor limit.
|
||||
"""
|
||||
try:
|
||||
limits = self.dev[motor].limits
|
||||
if limits == [0, 0]:
|
||||
return None
|
||||
return limits
|
||||
except AttributeError: # TODO maybe not needed, if no limits it returns [0,0]
|
||||
# If the motor doesn't have a 'limits' attribute, return a default value or raise a custom exception
|
||||
print(f"The device '{motor}' does not have defined limits.")
|
||||
return None
|
||||
|
||||
def _init_database(self):
|
||||
"""Initiate the database according the config."""
|
||||
database = {}
|
||||
|
||||
for plot in self.plot_data:
|
||||
for axis, signals in plot["signals"].items():
|
||||
for signal in signals:
|
||||
name = signal["name"]
|
||||
entry = signal.get("entry", name)
|
||||
if name not in database:
|
||||
database[name] = {}
|
||||
if entry not in database[name]:
|
||||
database[name][entry] = [self.get_coordinate(name, entry)]
|
||||
return database
|
||||
|
||||
def get_coordinate(self, name, entry):
|
||||
"""Get the initial coordinate value for a motor."""
|
||||
try:
|
||||
return self.dev[name].read()[entry]["value"]
|
||||
except Exception as e:
|
||||
print(f"Error getting initial value for {name}: {e}")
|
||||
return None
|
||||
|
||||
def _init_ui(self, num_columns: int = 3) -> None:
|
||||
"""
|
||||
Initialize the UI components, create plots and store their grid positions.
|
||||
|
||||
Args:
|
||||
num_columns (int): Number of columns to wrap the layout.
|
||||
|
||||
This method initializes a dictionary `self.plots` to store the plot objects
|
||||
along with their corresponding x and y signal names. It dynamically arranges
|
||||
the plots in a grid layout based on the given number of columns and dynamically
|
||||
stretches the last plots to fit the remaining space.
|
||||
"""
|
||||
self.clear()
|
||||
self.plots = {}
|
||||
self.grid_coordinates = []
|
||||
self.curves_data = {} # TODO moved from init_curves
|
||||
|
||||
num_plots = len(self.plot_data)
|
||||
|
||||
# Check if num_columns exceeds the number of plots
|
||||
if num_columns >= num_plots:
|
||||
num_columns = num_plots
|
||||
self.plot_settings["num_columns"] = num_columns # Update the settings
|
||||
print(
|
||||
"Warning: num_columns in the YAML file was greater than the number of plots."
|
||||
f" Resetting num_columns to number of plots:{num_columns}."
|
||||
)
|
||||
else:
|
||||
self.plot_settings["num_columns"] = num_columns # Update the settings
|
||||
|
||||
num_rows = num_plots // num_columns
|
||||
last_row_cols = num_plots % num_columns
|
||||
remaining_space = num_columns - last_row_cols
|
||||
|
||||
for i, plot_config in enumerate(self.plot_data):
|
||||
row, col = i // num_columns, i % num_columns
|
||||
colspan = 1
|
||||
|
||||
if row == num_rows and remaining_space > 0:
|
||||
if last_row_cols == 1:
|
||||
colspan = num_columns
|
||||
else:
|
||||
colspan = remaining_space // last_row_cols + 1
|
||||
remaining_space -= colspan - 1
|
||||
last_row_cols -= 1
|
||||
|
||||
if "plot_name" not in plot_config:
|
||||
plot_name = f"Plot ({row}, {col})"
|
||||
plot_config["plot_name"] = plot_name
|
||||
else:
|
||||
plot_name = plot_config["plot_name"]
|
||||
|
||||
x_label = plot_config.get("x_label", "")
|
||||
y_label = plot_config.get("y_label", "")
|
||||
|
||||
plot = self.addPlot(row=row, col=col, colspan=colspan, title="Motor position: (X, Y)")
|
||||
plot.setLabel("bottom", f"{x_label} ({plot_config['signals']['x'][0]['name']})")
|
||||
plot.setLabel("left", f"{y_label} ({plot_config['signals']['y'][0]['name']})")
|
||||
plot.addLegend()
|
||||
# self._set_plot_colors(plot, self.plot_settings) #TODO implement colors
|
||||
|
||||
self.plots[plot_name] = plot
|
||||
self.grid_coordinates.append((row, col))
|
||||
|
||||
self._init_motor_map(plot_config)
|
||||
|
||||
def _init_motor_map(self, plot_config: dict) -> None:
|
||||
"""
|
||||
Initialize the motor map.
|
||||
Args:
|
||||
plot_config(dict): Plot configuration.
|
||||
"""
|
||||
|
||||
# Get plot name to find appropriate plot
|
||||
plot_name = plot_config.get("plot_name", "")
|
||||
|
||||
# Reset the curves data
|
||||
plot = self.plots[plot_name]
|
||||
plot.clear()
|
||||
|
||||
limits_x, limits_y = plot_config["signals"]["x"][0].get("limits", None), plot_config[
|
||||
"signals"
|
||||
]["y"][0].get("limits", None)
|
||||
if limits_x is not None and limits_y is not None:
|
||||
self._make_limit_map(plot, [limits_x, limits_y])
|
||||
|
||||
# Initiate ScatterPlotItem for motor coordinates
|
||||
self.curves_data[plot_name] = {
|
||||
"pos": pg.ScatterPlotItem(
|
||||
size=self.scatter_size, pen=pg.mkPen(None), brush=pg.mkBrush(255, 255, 255, 255)
|
||||
)
|
||||
}
|
||||
|
||||
# Add the scatter plot to the plot
|
||||
plot.addItem(self.curves_data[plot_name]["pos"])
|
||||
# Set the point map to be always on the top
|
||||
self.curves_data[plot_name]["pos"].setZValue(0)
|
||||
|
||||
# Add all layers to the plot
|
||||
plot.showGrid(x=True, y=True)
|
||||
|
||||
# Add the crosshair for motor coordinates
|
||||
init_position_x = self._get_motor_init_position(
|
||||
plot_config["signals"]["x"][0]["name"], plot_config["signals"]["x"][0]["entry"]
|
||||
)
|
||||
init_position_y = self._get_motor_init_position(
|
||||
plot_config["signals"]["y"][0]["name"], plot_config["signals"]["y"][0]["entry"]
|
||||
)
|
||||
self._add_coordinantes_crosshair(plot_name, init_position_x, init_position_y)
|
||||
|
||||
def _add_coordinantes_crosshair(self, plot_name: str, x: float, y: float) -> None:
|
||||
"""
|
||||
Add crosshair to the plot to highlight the current position.
|
||||
Args:
|
||||
plot_name(str): Name of the plot.
|
||||
x(float): X coordinate.
|
||||
y(float): Y coordinate.
|
||||
"""
|
||||
# find the current plot
|
||||
plot = self.plots[plot_name]
|
||||
|
||||
# Crosshair to highlight the current position
|
||||
highlight_H = pg.InfiniteLine(
|
||||
angle=0, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
|
||||
)
|
||||
highlight_V = pg.InfiniteLine(
|
||||
angle=90, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
|
||||
)
|
||||
|
||||
# Add crosshair to the curve list for future referencing
|
||||
self.curves_data[plot_name]["highlight_H"] = highlight_H
|
||||
self.curves_data[plot_name]["highlight_V"] = highlight_V
|
||||
|
||||
# Add crosshair to the plot
|
||||
plot.addItem(highlight_H)
|
||||
plot.addItem(highlight_V)
|
||||
|
||||
highlight_H.setPos(x)
|
||||
highlight_V.setPos(y)
|
||||
|
||||
def _make_limit_map(self, plot: pg.PlotItem, limits: list):
|
||||
"""
|
||||
Make a limit map from the limits list.
|
||||
|
||||
Args:
|
||||
plot(pg.PlotItem): Plot to add the limit map to.
|
||||
limits(list): List of limits.
|
||||
"""
|
||||
# Define the size of the image map based on the motor's limits
|
||||
limit_x_min, limit_x_max = limits[0]
|
||||
limit_y_min, limit_y_max = limits[1]
|
||||
|
||||
map_width = int(limit_x_max - limit_x_min + 1)
|
||||
map_height = int(limit_y_max - limit_y_min + 1)
|
||||
|
||||
limit_map_data = np.full((map_width, map_height), self.background_value, dtype=np.float32)
|
||||
|
||||
# Create the image map
|
||||
limit_map = pg.ImageItem()
|
||||
limit_map.setImage(limit_map_data)
|
||||
plot.addItem(limit_map)
|
||||
|
||||
# Translate and scale the image item to match the motor coordinates
|
||||
tr = QtGui.QTransform()
|
||||
tr.translate(limit_x_min, limit_y_min)
|
||||
limit_map.setTransform(tr)
|
||||
|
||||
def _get_motor_init_position(self, name: str, entry: str) -> float:
|
||||
"""
|
||||
Get the motor initial position from the config.
|
||||
Args:
|
||||
name(str): Motor name.
|
||||
entry(str): Motor entry.
|
||||
Returns:
|
||||
float: Motor initial position.
|
||||
"""
|
||||
init_position = round(self.dev[name].read()[entry]["value"], self.precision)
|
||||
return init_position
|
||||
|
||||
def _update_plots(self):
|
||||
"""Update the motor position on plots."""
|
||||
for plot_name, curve_list in self.curves_data.items():
|
||||
plot_config = next(
|
||||
(pc for pc in self.plot_data if pc.get("plot_name") == plot_name), None
|
||||
)
|
||||
if not plot_config:
|
||||
continue
|
||||
|
||||
# Get the motor coordinates
|
||||
x_motor_name = plot_config["signals"]["x"][0]["name"]
|
||||
x_motor_entry = plot_config["signals"]["x"][0]["entry"]
|
||||
y_motor_name = plot_config["signals"]["y"][0]["name"]
|
||||
y_motor_entry = plot_config["signals"]["y"][0]["entry"]
|
||||
|
||||
# update motor position only if there is data
|
||||
if (
|
||||
len(self.database[x_motor_name][x_motor_entry]) >= 1
|
||||
and len(self.database[y_motor_name][y_motor_entry]) >= 1
|
||||
):
|
||||
# Relevant data for the plot
|
||||
motor_x_data = self.database[x_motor_name][x_motor_entry]
|
||||
motor_y_data = self.database[y_motor_name][y_motor_entry]
|
||||
|
||||
# Setup gradient brush for history
|
||||
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(motor_x_data)
|
||||
|
||||
# Calculate the decrement step based on self.num_dim_points
|
||||
decrement_step = (255 - 50) / self.num_dim_points
|
||||
|
||||
for i in range(1, min(self.num_dim_points + 1, len(motor_x_data) + 1)):
|
||||
brightness = max(60, 255 - decrement_step * (i - 1))
|
||||
brushes[-i] = pg.mkBrush(brightness, brightness, brightness, 255)
|
||||
|
||||
brushes[-1] = pg.mkBrush(
|
||||
255, 255, 255, 255
|
||||
) # Newest point is always full brightness
|
||||
|
||||
# Update the scatter plot
|
||||
self.curves_data[plot_name]["pos"].setData(
|
||||
x=motor_x_data,
|
||||
y=motor_y_data,
|
||||
brush=brushes,
|
||||
pen=None,
|
||||
size=self.scatter_size,
|
||||
)
|
||||
|
||||
# Get last know position for crosshair
|
||||
current_x = motor_x_data[-1]
|
||||
current_y = motor_y_data[-1]
|
||||
|
||||
# Update plot title
|
||||
self.plots[plot_name].setTitle(
|
||||
f"Motor position: ({round(current_x,self.precision)}, {round(current_y,self.precision)})"
|
||||
)
|
||||
|
||||
# Update the crosshair
|
||||
self.curves_data[plot_name]["highlight_V"].setPos(current_x)
|
||||
self.curves_data[plot_name]["highlight_H"].setPos(current_y)
|
||||
|
||||
@pyqtSlot(list, str, str)
|
||||
def plot_saved_coordinates(self, coordinates: list, tag: str, color: str):
|
||||
"""
|
||||
Plot saved coordinates on the map.
|
||||
Args:
|
||||
coordinates(list): List of coordinates to be plotted.
|
||||
tag(str): Tag for the coordinates for future reference.
|
||||
color(str): Color to plot coordinates in.
|
||||
"""
|
||||
for plot_name in self.plots:
|
||||
plot = self.plots[plot_name]
|
||||
|
||||
# Clear previous saved points
|
||||
if tag in self.curves_data[plot_name]:
|
||||
plot.removeItem(self.curves_data[plot_name][tag])
|
||||
|
||||
# Filter coordinates to be shown
|
||||
visible_coords = [coord[:2] for coord in coordinates if coord[2]]
|
||||
|
||||
if visible_coords:
|
||||
saved_points = pg.ScatterPlotItem(
|
||||
pos=np.array(visible_coords), brush=pg.mkBrush(color)
|
||||
)
|
||||
plot.addItem(saved_points)
|
||||
self.curves_data[plot_name][tag] = saved_points
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def on_device_readback(self, msg: dict):
|
||||
"""
|
||||
Update the motor coordinates on the plots.
|
||||
Args:
|
||||
msg (dict): Message received with device readback data.
|
||||
"""
|
||||
|
||||
for device_name, device_info in msg["signals"].items():
|
||||
# Check if the device is relevant to our current context
|
||||
if device_name in self.device_mapping:
|
||||
self._update_device_data(device_name, device_info["value"])
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
def _update_device_data(self, device_name: str, value: float):
|
||||
"""
|
||||
Update the device data.
|
||||
Args:
|
||||
device_name (str): Device name.
|
||||
value (float): Device value.
|
||||
"""
|
||||
if device_name in self.database:
|
||||
self.database[device_name][device_name].append(value)
|
||||
|
||||
corresponding_device = self.device_mapping.get(device_name)
|
||||
if corresponding_device and corresponding_device in self.database:
|
||||
last_value = (
|
||||
self.database[corresponding_device][corresponding_device][-1]
|
||||
if self.database[corresponding_device][corresponding_device]
|
||||
else None
|
||||
)
|
||||
self.database[corresponding_device][corresponding_device].append(last_value)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config_file", help="Path to the config file.")
|
||||
parser.add_argument("--config", help="Path to the config file.")
|
||||
parser.add_argument("--id", help="GUI ID.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.config is not None:
|
||||
# Load config from file
|
||||
config = json.loads(args.config)
|
||||
elif args.config_file is not None:
|
||||
# Load config from file
|
||||
config = load_yaml(args.config_file)
|
||||
else:
|
||||
config = CONFIG_DEFAULT
|
||||
|
||||
client = BECDispatcher().client
|
||||
client.start()
|
||||
app = QApplication(sys.argv)
|
||||
motor_map = MotorMap(
|
||||
config=config,
|
||||
gui_id=args.id,
|
||||
skip_validation=True,
|
||||
)
|
||||
motor_map.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
2
bec_widgets/widgets/plots/__init__.py
Normal file
2
bec_widgets/widgets/plots/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .plot_base import AxisConfig, WidgetConfig, BECPlotBase
|
||||
from .waveform1d import Waveform1DConfig, BECWaveform1D, BECCurve
|
||||
238
bec_widgets/widgets/plots/plot_base.py
Normal file
238
bec_widgets/widgets/plots/plot_base.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
|
||||
|
||||
class AxisConfig(BaseModel):
|
||||
title: Optional[str] = Field(None, description="The title of the axes.")
|
||||
x_label: Optional[str] = Field(None, description="The label for the x-axis.")
|
||||
y_label: Optional[str] = Field(None, description="The label for the y-axis.")
|
||||
x_scale: Literal["linear", "log"] = Field("linear", description="The scale of the x-axis.")
|
||||
y_scale: Literal["linear", "log"] = Field("linear", description="The scale of the y-axis.")
|
||||
x_lim: Optional[tuple] = Field(None, description="The limits of the x-axis.")
|
||||
y_lim: Optional[tuple] = Field(None, description="The limits of the y-axis.")
|
||||
x_grid: bool = Field(False, description="Show grid on the x-axis.")
|
||||
y_grid: bool = Field(False, description="Show grid on the y-axis.")
|
||||
|
||||
|
||||
class WidgetConfig(ConnectionConfig):
|
||||
parent_id: Optional[str] = Field(None, description="The parent figure of the plot.")
|
||||
|
||||
# Coordinates in the figure
|
||||
row: int = Field(0, description="The row coordinate in the figure.")
|
||||
col: int = Field(0, description="The column coordinate in the figure.")
|
||||
|
||||
# Appearance settings
|
||||
axis: AxisConfig = Field(
|
||||
default_factory=AxisConfig, description="The axis configuration of the plot."
|
||||
)
|
||||
|
||||
|
||||
class BECPlotBase(BECConnector, pg.PlotItem):
|
||||
USER_ACCESS = [
|
||||
"set",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
"set_y_label",
|
||||
"set_x_scale",
|
||||
"set_y_scale",
|
||||
"set_x_lim",
|
||||
"set_y_lim",
|
||||
"set_grid",
|
||||
"plot_data",
|
||||
"remove",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None, # TODO decide if needed for this class
|
||||
parent_figure=None,
|
||||
config: Optional[WidgetConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
):
|
||||
if config is None:
|
||||
config = WidgetConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
pg.PlotItem.__init__(self, parent)
|
||||
|
||||
self.figure = parent_figure
|
||||
|
||||
self.add_legend()
|
||||
|
||||
def set(self, **kwargs) -> None:
|
||||
"""
|
||||
Set the properties of the plot widget.
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
Possible properties:
|
||||
- title: str
|
||||
- x_label: str
|
||||
- y_label: str
|
||||
- x_scale: Literal["linear", "log"]
|
||||
- y_scale: Literal["linear", "log"]
|
||||
- x_lim: tuple
|
||||
- y_lim: tuple
|
||||
"""
|
||||
# Mapping of keywords to setter methods
|
||||
method_map = {
|
||||
"title": self.set_title,
|
||||
"x_label": self.set_x_label,
|
||||
"y_label": self.set_y_label,
|
||||
"x_scale": self.set_x_scale,
|
||||
"y_scale": self.set_y_scale,
|
||||
"x_lim": self.set_x_lim,
|
||||
"y_lim": self.set_y_lim,
|
||||
}
|
||||
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 apply_axis_config(self):
|
||||
"""Apply the axis configuration to the plot widget."""
|
||||
config_mappings = {
|
||||
"title": self.config.axis.title,
|
||||
"x_label": self.config.axis.x_label,
|
||||
"y_label": self.config.axis.y_label,
|
||||
"x_scale": self.config.axis.x_scale,
|
||||
"y_scale": self.config.axis.y_scale,
|
||||
"x_lim": self.config.axis.x_lim,
|
||||
"y_lim": self.config.axis.y_lim,
|
||||
}
|
||||
|
||||
self.set(**{k: v for k, v in config_mappings.items() if v is not None})
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""
|
||||
Set the title of the plot widget.
|
||||
Args:
|
||||
title(str): Title of the plot widget.
|
||||
"""
|
||||
self.setTitle(title)
|
||||
self.config.axis.title = title
|
||||
|
||||
def set_x_label(self, label: str):
|
||||
"""
|
||||
Set the label of the x-axis.
|
||||
Args:
|
||||
label(str): Label of the x-axis.
|
||||
"""
|
||||
self.setLabel("bottom", label)
|
||||
self.config.axis.x_label = label
|
||||
|
||||
def set_y_label(self, label: str):
|
||||
"""
|
||||
Set the label of the y-axis.
|
||||
Args:
|
||||
label(str): Label of the y-axis.
|
||||
"""
|
||||
self.setLabel("left", label)
|
||||
self.config.axis.y_label = label
|
||||
|
||||
def set_x_scale(self, scale: Literal["linear", "log"] = "linear"):
|
||||
"""
|
||||
Set the scale of the x-axis.
|
||||
Args:
|
||||
scale(Literal["linear", "log"]): Scale of the x-axis.
|
||||
"""
|
||||
self.setLogMode(x=(scale == "log"))
|
||||
self.config.axis.x_scale = scale
|
||||
|
||||
def set_y_scale(self, scale: Literal["linear", "log"] = "linear"):
|
||||
"""
|
||||
Set the scale of the y-axis.
|
||||
Args:
|
||||
scale(Literal["linear", "log"]): Scale of the y-axis.
|
||||
"""
|
||||
self.setLogMode(y=(scale == "log"))
|
||||
self.config.axis.y_scale = scale
|
||||
|
||||
def set_x_lim(self, *args) -> None:
|
||||
"""
|
||||
Set the limits of the x-axis. This method can accept either two separate arguments
|
||||
for the minimum and maximum x-axis values, or a single tuple containing both limits.
|
||||
|
||||
Usage:
|
||||
set_x_lim(x_min, x_max)
|
||||
set_x_lim((x_min, x_max))
|
||||
|
||||
Args:
|
||||
*args: A variable number of arguments. Can be two integers (x_min and x_max)
|
||||
or a single tuple with two integers.
|
||||
"""
|
||||
if len(args) == 1 and isinstance(args[0], tuple):
|
||||
x_min, x_max = args[0]
|
||||
elif len(args) == 2:
|
||||
x_min, x_max = args
|
||||
else:
|
||||
raise ValueError("set_x_lim expects either two separate arguments or a single tuple")
|
||||
|
||||
self.setXRange(x_min, x_max)
|
||||
self.config.axis.x_lim = (x_min, x_max)
|
||||
|
||||
def set_y_lim(self, *args) -> None:
|
||||
"""
|
||||
Set the limits of the y-axis. This method can accept either two separate arguments
|
||||
for the minimum and maximum y-axis values, or a single tuple containing both limits.
|
||||
|
||||
Usage:
|
||||
set_y_lim(y_min, y_max)
|
||||
set_y_lim((y_min, y_max))
|
||||
|
||||
Args:
|
||||
*args: A variable number of arguments. Can be two integers (y_min and y_max)
|
||||
or a single tuple with two integers.
|
||||
"""
|
||||
if len(args) == 1 and isinstance(args[0], tuple):
|
||||
y_min, y_max = args[0]
|
||||
elif len(args) == 2:
|
||||
y_min, y_max = args
|
||||
else:
|
||||
raise ValueError("set_y_lim expects either two separate arguments or a single tuple")
|
||||
|
||||
self.setYRange(y_min, y_max)
|
||||
self.config.axis.y_lim = (y_min, y_max)
|
||||
|
||||
def set_grid(self, x: bool = False, y: bool = False):
|
||||
"""
|
||||
Set the grid of the plot widget.
|
||||
Args:
|
||||
x(bool): Show grid on the x-axis.
|
||||
y(bool): Show grid on the y-axis.
|
||||
"""
|
||||
self.showGrid(x, y)
|
||||
self.config.axis.x_grid = x
|
||||
self.config.axis.y_grid = y
|
||||
|
||||
def add_legend(self):
|
||||
self.addLegend()
|
||||
|
||||
def plot_data(self, data_x: list | np.ndarray, data_y: list | np.ndarray, **kwargs):
|
||||
"""
|
||||
Plot custom data on the plot widget. These data are not saved in config.
|
||||
Args:
|
||||
data_x(list|np.ndarray): x-axis data
|
||||
data_y(list|np.ndarray): y-axis data
|
||||
**kwargs: Keyword arguments for the plot.
|
||||
"""
|
||||
# TODO very basic so far, add more options
|
||||
# TODO decide name of the method
|
||||
self.plot(data_x, data_y, **kwargs)
|
||||
|
||||
def remove(self):
|
||||
"""Remove the plot widget from the figure."""
|
||||
if self.figure is not None:
|
||||
self.figure.remove(widget_id=self.gui_id)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the plot widget."""
|
||||
731
bec_widgets/widgets/plots/waveform1d.py
Normal file
731
bec_widgets/widgets/plots/waveform1d.py
Normal file
@@ -0,0 +1,731 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Literal, Optional, Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from pydantic import Field, BaseModel, ValidationError
|
||||
from pyqtgraph import mkBrush
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_lib import MessageEndpoints
|
||||
from bec_lib.scan_data import ScanData
|
||||
from bec_widgets.utils import Colors, ConnectionConfig, BECConnector, EntryValidator
|
||||
from bec_widgets.widgets.plots import BECPlotBase, WidgetConfig
|
||||
|
||||
|
||||
class SignalData(BaseModel):
|
||||
"""The data configuration of a signal in the 1D waveform widget for x and y axis."""
|
||||
|
||||
# TODO add validator on name and entry
|
||||
name: str
|
||||
entry: str
|
||||
unit: Optional[str] = None # todo implement later
|
||||
modifier: Optional[str] = None # todo implement later
|
||||
|
||||
|
||||
class Signal(BaseModel):
|
||||
"""The configuration of a signal in the 1D waveform widget."""
|
||||
|
||||
source: str # TODO add validator on the source type
|
||||
x: SignalData
|
||||
y: SignalData
|
||||
|
||||
|
||||
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."
|
||||
) # TODO here on or curve??
|
||||
signals: Optional[Signal] = Field(None, description="The signal of the curve.")
|
||||
|
||||
|
||||
class Waveform1DConfig(WidgetConfig):
|
||||
color_palette: Literal["plasma", "viridis", "inferno", "magma"] = Field(
|
||||
"plasma", description="The color palette of the figure widget."
|
||||
)
|
||||
curves: dict[str, CurveConfig] = Field(
|
||||
{}, description="The list of curves to be added to the 1D waveform widget."
|
||||
)
|
||||
|
||||
|
||||
class BECCurve(BECConnector, pg.PlotDataItem):
|
||||
USER_ACCESS = [
|
||||
"set",
|
||||
"set_data",
|
||||
"set_color",
|
||||
"set_symbol",
|
||||
"set_symbol_color",
|
||||
"set_symbol_size",
|
||||
"set_pen_width",
|
||||
"set_pen_style",
|
||||
"get_data",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
config: Optional[CurveConfig] = None,
|
||||
gui_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
config = CurveConfig(label=name, widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
else:
|
||||
self.config = config
|
||||
# config.widget_class = self.__class__.__name__
|
||||
super().__init__(config=config, gui_id=gui_id)
|
||||
pg.PlotDataItem.__init__(self, name=name)
|
||||
|
||||
self.apply_config()
|
||||
if kwargs:
|
||||
self.set(**kwargs)
|
||||
|
||||
def apply_config(self):
|
||||
pen_style_map = {
|
||||
"solid": QtCore.Qt.SolidLine,
|
||||
"dash": QtCore.Qt.DashLine,
|
||||
"dot": QtCore.Qt.DotLine,
|
||||
"dashdot": QtCore.Qt.DashDotLine,
|
||||
}
|
||||
pen_style = pen_style_map.get(self.config.pen_style, QtCore.Qt.SolidLine)
|
||||
|
||||
pen = pg.mkPen(color=self.config.color, width=self.config.pen_width, style=pen_style)
|
||||
self.setPen(pen)
|
||||
|
||||
if self.config.symbol:
|
||||
symbol_color = self.config.symbol_color or self.config.color
|
||||
brush = mkBrush(color=symbol_color)
|
||||
self.setSymbolBrush(brush)
|
||||
self.setSymbolSize(self.config.symbol_size)
|
||||
self.setSymbol(self.config.symbol)
|
||||
|
||||
def set_data(self, x, y):
|
||||
if self.config.source == "custom":
|
||||
self.setData(x, y)
|
||||
else:
|
||||
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
|
||||
|
||||
def set(self, **kwargs):
|
||||
"""
|
||||
Set the properties of the curve.
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
Possible properties:
|
||||
- color: str
|
||||
- symbol: str
|
||||
- symbol_color: str
|
||||
- symbol_size: int
|
||||
- pen_width: int
|
||||
- pen_style: Literal["solid", "dash", "dot", "dashdot"]
|
||||
"""
|
||||
|
||||
# Mapping of keywords to setter methods
|
||||
method_map = {
|
||||
"color": self.set_color,
|
||||
"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 get_data(self) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
Get the data of the curve.
|
||||
Returns:
|
||||
tuple[np.ndarray,np.ndarray]: X and Y data of the curve.
|
||||
"""
|
||||
x_data, y_data = self.getData()
|
||||
return x_data, y_data
|
||||
|
||||
|
||||
class BECWaveform1D(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"add_curve_scan",
|
||||
"add_curve_custom",
|
||||
"remove_curve",
|
||||
"scan_history",
|
||||
"curves",
|
||||
"curves_data",
|
||||
"get_curve",
|
||||
"get_curve_config",
|
||||
"apply_config",
|
||||
"get_all_data",
|
||||
]
|
||||
scan_signal_update = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent_figure=None,
|
||||
config: Optional[Waveform1DConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
):
|
||||
if config is None:
|
||||
config = Waveform1DConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
|
||||
)
|
||||
|
||||
self.curves_data = defaultdict(dict)
|
||||
self.scanID = None
|
||||
|
||||
# Scan segment update proxy
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.scan_signal_update, rateLimit=25, slot=self._update_scan_segment_plot
|
||||
)
|
||||
|
||||
# Get bec shortcuts dev, scans, queue, scan_storage, dap
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
# Connect dispatcher signals
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
|
||||
self.addLegend()
|
||||
self.apply_config(self.config)
|
||||
|
||||
# TODO check config assigning
|
||||
# TODO check the functionality of config generator
|
||||
def find_widget_by_id(
|
||||
self, item_id: str
|
||||
): # TODO implement this on level of BECConnector and all other widgets
|
||||
for curve in self.curves:
|
||||
if curve.gui_id == item_id:
|
||||
return curve
|
||||
|
||||
def apply_config(self, config: dict | WidgetConfig, replot_last_scan: bool = False):
|
||||
"""
|
||||
Apply the configuration to the 1D waveform widget.
|
||||
Args:
|
||||
config(dict|WidgetConfig): Configuration settings.
|
||||
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
try:
|
||||
config = Waveform1DConfig(**config)
|
||||
except ValidationError as e:
|
||||
print(f"Validation error when applying config to BECWaveform1D: {e}")
|
||||
return
|
||||
|
||||
self.config = config
|
||||
self.clear()
|
||||
|
||||
self.apply_axis_config()
|
||||
# Reset curves
|
||||
self._curves_data = defaultdict(dict)
|
||||
self._curves = []
|
||||
for curve_id, curve_config in self.config.curves.items():
|
||||
self.add_curve_by_config(curve_config)
|
||||
if replot_last_scan:
|
||||
self.scan_history(scan_index=-1)
|
||||
|
||||
def change_gui_id(self, new_gui_id: str):
|
||||
"""
|
||||
Change the GUI ID of the waveform widget and update the parent_id in all associated curves.
|
||||
|
||||
Args:
|
||||
new_gui_id (str): The new GUI ID to be set for the waveform widget.
|
||||
"""
|
||||
# Update the gui_id in the waveform widget itself
|
||||
self.gui_id = new_gui_id
|
||||
self.config.gui_id = new_gui_id
|
||||
|
||||
for curve in self.curves:
|
||||
curve.config.parent_id = new_gui_id
|
||||
|
||||
def add_curve_by_config(self, curve_config: CurveConfig | dict) -> BECCurve:
|
||||
"""
|
||||
Add a curve to the plot widget by its configuration.
|
||||
Args:
|
||||
curve_config(CurveConfig|dict): Configuration of the curve to be added.
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
if isinstance(curve_config, dict):
|
||||
curve_config = CurveConfig(**curve_config)
|
||||
curve = self._add_curve_object(
|
||||
name=curve_config.label, source=curve_config.source, config=curve_config
|
||||
)
|
||||
return curve
|
||||
|
||||
def get_curve_config(self, curve_id: str, dict_output: bool = True) -> CurveConfig | dict:
|
||||
"""
|
||||
Get the configuration of a curve by its ID.
|
||||
Args:
|
||||
curve_id(str): ID of the curve.
|
||||
Returns:
|
||||
CurveConfig|dict: Configuration of the curve.
|
||||
"""
|
||||
for source, curves in self.curves_data.items():
|
||||
if curve_id in curves:
|
||||
if dict_output:
|
||||
return curves[curve_id].config.model_dump()
|
||||
else:
|
||||
return curves[curve_id].config
|
||||
|
||||
@property
|
||||
def curves(self) -> list[BECCurve]: # TODO discuss if it should be marked as @property for RPC
|
||||
"""
|
||||
Get the curves of the plot widget as a list
|
||||
Returns:
|
||||
list: List of curves.
|
||||
"""
|
||||
return self._curves
|
||||
|
||||
@curves.setter
|
||||
def curves(self, value: list[BECCurve]):
|
||||
self._curves = value
|
||||
|
||||
@property
|
||||
def curves_data(self) -> dict: # TODO discuss if it should be marked as @property for RPC
|
||||
"""
|
||||
Get the curves data of the plot widget as a dictionary
|
||||
Returns:
|
||||
dict: Dictionary of curves data.
|
||||
"""
|
||||
return self._curves_data
|
||||
|
||||
@curves_data.setter
|
||||
def curves_data(self, value: dict):
|
||||
self._curves_data = value
|
||||
|
||||
def get_curve(self, identifier) -> BECCurve:
|
||||
"""
|
||||
Get the curve by its index or ID.
|
||||
Args:
|
||||
identifier(int|str): Identifier of the curve. Can be either an integer (index) or a string (curve_id).
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
if isinstance(identifier, int):
|
||||
return self.curves[identifier]
|
||||
elif isinstance(identifier, str):
|
||||
for source_type, curves in self.curves_data.items():
|
||||
if identifier in curves:
|
||||
return curves[identifier]
|
||||
raise ValueError(f"Curve with ID '{identifier}' not found.")
|
||||
else:
|
||||
raise ValueError("Identifier must be either an integer (index) or a string (curve_id).")
|
||||
|
||||
def add_curve_custom(
|
||||
self,
|
||||
x: list | np.ndarray,
|
||||
y: list | np.ndarray,
|
||||
label: str = None,
|
||||
color: str = None,
|
||||
**kwargs,
|
||||
) -> BECCurve:
|
||||
"""
|
||||
Add a custom data curve to the plot widget.
|
||||
Args:
|
||||
x(list|np.ndarray): X data of the curve.
|
||||
y(list|np.ndarray): Y data of the curve.
|
||||
label(str, optional): Label of the curve. Defaults to None.
|
||||
color(str, optional): Color of the curve. Defaults to None.
|
||||
**kwargs: Additional keyword arguments for the curve configuration.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
curve_source = "custom"
|
||||
curve_id = label or f"Curve {len(self.curves) + 1}"
|
||||
|
||||
curve_exits = self._check_curve_id(curve_id, self.curves_data)
|
||||
if curve_exits:
|
||||
raise ValueError(
|
||||
f"Curve with ID '{curve_id}' already exists in widget '{self.gui_id}'."
|
||||
)
|
||||
|
||||
color = (
|
||||
color
|
||||
or Colors.golden_angle_color(
|
||||
colormap=self.config.color_palette, num=len(self.curves) + 1, format="HEX"
|
||||
)[-1]
|
||||
)
|
||||
|
||||
# Create curve by config
|
||||
curve_config = CurveConfig(
|
||||
widget_class="BECCurve",
|
||||
parent_id=self.gui_id,
|
||||
label=curve_id,
|
||||
color=color,
|
||||
source=curve_source,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
curve = self._add_curve_object(
|
||||
name=curve_id, source=curve_source, config=curve_config, data=(x, y)
|
||||
)
|
||||
return curve
|
||||
|
||||
def _add_curve_object(
|
||||
self,
|
||||
name: str,
|
||||
source: str,
|
||||
config: CurveConfig,
|
||||
data: tuple[list | np.ndarray, list | np.ndarray] = None,
|
||||
) -> BECCurve:
|
||||
"""
|
||||
Add a curve object to the plot widget.
|
||||
Args:
|
||||
name(str): ID of the curve.
|
||||
source(str): Source of the curve.
|
||||
config(CurveConfig): Configuration of the curve.
|
||||
data(tuple[list|np.ndarray,list|np.ndarray], optional): Data (x,y) to be plotted. Defaults to None.
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
curve = BECCurve(config=config, name=name)
|
||||
self.curves_data[source][name] = curve
|
||||
self.addItem(curve)
|
||||
self.config.curves[name] = curve.config
|
||||
if data is not None:
|
||||
curve.setData(data[0], data[1])
|
||||
return curve
|
||||
|
||||
def add_curve_scan(
|
||||
self,
|
||||
x_name: str,
|
||||
y_name: str,
|
||||
x_entry: Optional[str] = None,
|
||||
y_entry: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
validate_bec: bool = True,
|
||||
**kwargs,
|
||||
) -> BECCurve:
|
||||
"""
|
||||
Add a curve to the plot widget from the scan segment.
|
||||
Args:
|
||||
x_name(str): Name of the x signal.
|
||||
x_entry(str): Entry of the x signal.
|
||||
y_name(str): Name of the y signal.
|
||||
y_entry(str): Entry of the y signal.
|
||||
color(str, optional): Color of the curve. Defaults to None.
|
||||
label(str, optional): Label of the curve. Defaults to None.
|
||||
**kwargs: Additional keyword arguments for the curve configuration.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
# Check if curve already exists
|
||||
curve_source = "scan_segment"
|
||||
|
||||
# Get entry if not provided and validate
|
||||
x_entry, y_entry = self._validate_signal_entries(
|
||||
x_name, y_name, x_entry, y_entry, validate_bec
|
||||
)
|
||||
|
||||
label = label or f"{y_name}-{y_entry}"
|
||||
|
||||
curve_exits = self._check_curve_id(label, self.curves_data)
|
||||
if curve_exits:
|
||||
raise ValueError(f"Curve with ID '{label}' already exists in widget '{self.gui_id}'.")
|
||||
|
||||
color = (
|
||||
color
|
||||
or Colors.golden_angle_color(
|
||||
colormap=self.config.color_palette, num=len(self.curves) + 1, format="HEX"
|
||||
)[-1]
|
||||
)
|
||||
|
||||
# Create curve by config
|
||||
curve_config = CurveConfig(
|
||||
widget_class="BECCurve",
|
||||
parent_id=self.gui_id,
|
||||
label=label,
|
||||
color=color,
|
||||
source=curve_source,
|
||||
signals=Signal(
|
||||
source=curve_source,
|
||||
x=SignalData(name=x_name, entry=x_entry),
|
||||
y=SignalData(name=y_name, entry=y_entry),
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
curve = self._add_curve_object(name=label, source=curve_source, config=curve_config)
|
||||
return curve
|
||||
|
||||
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 _check_curve_id(self, val: Any, dict_to_check: dict) -> bool:
|
||||
"""
|
||||
Check if val is in the values of the dict_to_check or in the values of the nested dictionaries.
|
||||
Args:
|
||||
val(Any): Value to check.
|
||||
dict_to_check(dict): Dictionary to check.
|
||||
|
||||
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_curve_id(val, dict_to_check[key]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_curve(self, *identifiers):
|
||||
"""
|
||||
Remove a curve from the plot widget.
|
||||
Args:
|
||||
*identifiers: Identifier of the curve to be removed. Can be either an integer (index) or a string (curve_id).
|
||||
"""
|
||||
for identifier in identifiers:
|
||||
if isinstance(identifier, int):
|
||||
self._remove_curve_by_order(identifier)
|
||||
elif isinstance(identifier, str):
|
||||
self._remove_curve_by_id(identifier)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Each identifier must be either an integer (index) or a string (curve_id)."
|
||||
)
|
||||
|
||||
def _remove_curve_by_id(self, curve_id):
|
||||
"""
|
||||
Remove a curve by its ID from the plot widget.
|
||||
Args:
|
||||
curve_id(str): ID of the curve to be removed.
|
||||
"""
|
||||
for source, curves in self.curves_data.items():
|
||||
if curve_id in curves:
|
||||
curve = curves.pop(curve_id)
|
||||
self.removeItem(curve)
|
||||
del self.config.curves[curve_id]
|
||||
if curve in self.curves:
|
||||
self.curves.remove(curve)
|
||||
return
|
||||
raise KeyError(f"Curve with ID '{curve_id}' not found.")
|
||||
|
||||
def _remove_curve_by_order(self, N):
|
||||
"""
|
||||
Remove a curve by its order from the plot widget.
|
||||
Args:
|
||||
N(int): Order of the curve to be removed.
|
||||
"""
|
||||
if N < len(self.curves):
|
||||
curve = self.curves[N]
|
||||
curve_id = curve.name() # Assuming curve's name is used as its ID
|
||||
self.removeItem(curve)
|
||||
del self.config.curves[curve_id]
|
||||
# Remove from self.curve_data
|
||||
for source, curves in self.curves_data.items():
|
||||
if curve_id in curves:
|
||||
del curves[curve_id]
|
||||
break
|
||||
else:
|
||||
raise IndexError(f"Curve order {N} out of range.")
|
||||
|
||||
@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:
|
||||
self.scanID = current_scanID
|
||||
self.scan_segment_data = self.queue.scan_storage.find_scan_by_ID(
|
||||
self.scanID
|
||||
) # TODO do scan access through BECFigure
|
||||
|
||||
self.scan_signal_update.emit()
|
||||
|
||||
def _update_scan_segment_plot(self):
|
||||
"""Update the plot with the data from the scan segment."""
|
||||
data = self.scan_segment_data.data
|
||||
self._update_scan_curves(data)
|
||||
|
||||
def _update_scan_curves(self, data: ScanData):
|
||||
"""
|
||||
Update the scan curves with the data from the scan segment.
|
||||
Args:
|
||||
data(ScanData): Data from the scan segment.
|
||||
"""
|
||||
for curve_id, curve in self.curves_data["scan_segment"].items():
|
||||
x_name = curve.config.signals.x.name
|
||||
x_entry = curve.config.signals.x.entry
|
||||
y_name = curve.config.signals.y.name
|
||||
y_entry = curve.config.signals.y.entry
|
||||
|
||||
try:
|
||||
data_x = data[x_name][x_entry].val
|
||||
data_y = data[y_name][y_entry].val
|
||||
except TypeError:
|
||||
continue
|
||||
|
||||
curve.setData(data_x, data_y)
|
||||
|
||||
def scan_history(self, scan_index: int = None, scanID: str = None):
|
||||
"""
|
||||
Update the scan curves with the data from the scan storage.
|
||||
Provide only one of scanID or scan_index.
|
||||
Args:
|
||||
scanID(str, optional): ScanID of the scan to be updated. Defaults to None.
|
||||
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
|
||||
"""
|
||||
if scan_index is not None and scanID is not None:
|
||||
raise ValueError("Only one of scanID or scan_index can be provided.")
|
||||
|
||||
if scan_index is not None:
|
||||
self.scanID = self.queue.scan_storage.storage[scan_index].scanID
|
||||
data = self.queue.scan_storage.find_scan_by_ID(self.scanID).data
|
||||
elif scanID is not None:
|
||||
self.scanID = scanID
|
||||
data = self.queue.scan_storage.find_scan_by_ID(self.scanID).data
|
||||
|
||||
self._update_scan_curves(data)
|
||||
|
||||
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict | pd.DataFrame:
|
||||
"""
|
||||
Extract all curve data into a dictionary or a pandas DataFrame.
|
||||
Args:
|
||||
output (Literal["dict", "pandas"]): Format of the output data.
|
||||
Returns:
|
||||
dict | pd.DataFrame: Data of all curves in the specified format.
|
||||
"""
|
||||
|
||||
data = {}
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
pd = None
|
||||
if output == "pandas":
|
||||
print(
|
||||
"Pandas is not installed. "
|
||||
"Please install pandas using 'pip install pandas'."
|
||||
"Output will be dictionary instead."
|
||||
)
|
||||
output = "dict"
|
||||
|
||||
for curve in self.curves:
|
||||
x_data, y_data = curve.get_data()
|
||||
if x_data is not None or y_data is not None:
|
||||
if output == "dict":
|
||||
data[curve.name()] = {"x": x_data.tolist(), "y": y_data.tolist()}
|
||||
elif output == "pandas" and pd is not None:
|
||||
data[curve.name()] = pd.DataFrame({"x": x_data, "y": y_data})
|
||||
|
||||
if output == "pandas" and pd is not None:
|
||||
combined_data = pd.concat(
|
||||
[data[curve.name()] for curve in self.curves],
|
||||
axis=1,
|
||||
keys=[curve.name() for curve in self.curves],
|
||||
)
|
||||
return combined_data
|
||||
return data
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget connection from BECDispatcher."""
|
||||
self.bec_dispatcher.disconnect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
@@ -1,5 +1,5 @@
|
||||
import msgpack
|
||||
from PyQt5.QtWidgets import (
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QWidget,
|
||||
QComboBox,
|
||||
@@ -21,7 +21,8 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
from bec_lib import MessageEndpoints
|
||||
from bec_widgets.qt_utils.widget_io import WidgetIO
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
|
||||
class ScanArgType:
|
||||
@@ -45,7 +46,7 @@ class ScanControl(QWidget):
|
||||
super().__init__(parent)
|
||||
|
||||
# Client from BEC + shortcuts to device manager and scans
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.client = BECDispatcher().client if client is None else client
|
||||
self.dev = self.client.device_manager.devices
|
||||
self.scans = self.client.scans
|
||||
|
||||
@@ -118,8 +119,7 @@ class ScanControl(QWidget):
|
||||
|
||||
def populate_scans(self):
|
||||
"""Populates the scan selection combo box with available scans"""
|
||||
msg = self.client.producer.get(MessageEndpoints.available_scans())
|
||||
self.available_scans = msgpack.loads(msg)
|
||||
self.available_scans = self.client.producer.get(MessageEndpoints.available_scans()).resource
|
||||
if self.allowed_scans is None:
|
||||
allowed_scans = self.available_scans.keys()
|
||||
else:
|
||||
@@ -425,10 +425,8 @@ class ScanControl(QWidget):
|
||||
|
||||
# Application example
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
# BECclient global variables
|
||||
client = bec_dispatcher.client
|
||||
client = BECDispatcher().client
|
||||
client.start()
|
||||
|
||||
app = QApplication([])
|
||||
@@ -438,4 +436,4 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
window = scan_control
|
||||
window.show()
|
||||
app.exec_()
|
||||
app.exec()
|
||||
|
||||
0
bec_widgets/widgets/scan_plot/__init__.py
Normal file
0
bec_widgets/widgets/scan_plot/__init__.py
Normal file
@@ -4,9 +4,9 @@ import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSlot
|
||||
from qtpy.QtCore import Property as pyqtProperty, Slot as pyqtSlot
|
||||
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -17,7 +17,7 @@ pg.setConfigOptions(background="w", foreground="k", antialias=True)
|
||||
class BECScanPlot2D(pg.GraphicsView):
|
||||
def __init__(self, parent=None, background="default"):
|
||||
super().__init__(parent, background)
|
||||
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
BECDispatcher().connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
|
||||
self._scanID = None
|
||||
self._scanID_lock = RLock()
|
||||
@@ -139,7 +139,7 @@ class BECScanPlot2D(pg.GraphicsView):
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
@@ -149,4 +149,4 @@ if __name__ == "__main__":
|
||||
|
||||
plot.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
sys.exit(app.exec())
|
||||
@@ -4,9 +4,9 @@ from threading import RLock
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSlot
|
||||
from qtpy.QtCore import Property as pyqtProperty, Slot as pyqtSlot
|
||||
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -18,7 +18,7 @@ COLORS = ["#fd7f6f", "#7eb0d5", "#b2e061", "#bd7ebe", "#ffb55a"]
|
||||
class BECScanPlot(pg.GraphicsView):
|
||||
def __init__(self, parent=None, background="default"):
|
||||
super().__init__(parent, background)
|
||||
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
BECDispatcher().connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
|
||||
self.view = pg.PlotItem()
|
||||
self.setCentralItem(self.view)
|
||||
@@ -94,6 +94,7 @@ class BECScanPlot(pg.GraphicsView):
|
||||
|
||||
@y_channel_list.setter
|
||||
def y_channel_list(self, new_list):
|
||||
bec_dispatcher = BECDispatcher()
|
||||
# TODO: do we want to care about dap/not dap here?
|
||||
chan_removed = [chan for chan in self._y_channel_list if chan not in new_list]
|
||||
if chan_removed and chan_removed[0].startswith("dap."):
|
||||
@@ -138,7 +139,7 @@ class BECScanPlot(pg.GraphicsView):
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
@@ -148,4 +149,4 @@ if __name__ == "__main__":
|
||||
|
||||
plot.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
sys.exit(app.exec())
|
||||
1
bec_widgets/widgets/toolbar/__init__.py
Normal file
1
bec_widgets/widgets/toolbar/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .toolbar import ModularToolBar
|
||||
145
bec_widgets/widgets/toolbar/toolbar.py
Normal file
145
bec_widgets/widgets/toolbar/toolbar.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtWidgets import QToolBar, QStyle, QApplication
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtGui import QAction
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
|
||||
class ToolBarAction(ABC):
|
||||
"""Abstract base class for action creators for the toolbar."""
|
||||
|
||||
@abstractmethod
|
||||
def create(self, target: QWidget):
|
||||
"""Creates and returns an action to be added to a toolbar.
|
||||
|
||||
This method must be implemented by subclasses.
|
||||
|
||||
Args:
|
||||
target (QWidget): The widget that the action will target.
|
||||
|
||||
Returns:
|
||||
QAction: The action created for the toolbar.
|
||||
"""
|
||||
|
||||
|
||||
class OpenFileAction: # (ToolBarAction):
|
||||
"""Action creator for the 'Open File' action in the toolbar."""
|
||||
|
||||
def create(self, target: QWidget):
|
||||
"""Creates an 'Open File' action for the toolbar.
|
||||
|
||||
Args:
|
||||
target (QWidget): The widget that the 'Open File' action will be targeted.
|
||||
|
||||
Returns:
|
||||
QAction: The 'Open File' action created for the toolbar.
|
||||
"""
|
||||
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_DialogOpenButton)
|
||||
action = QAction(icon, "Open File", target)
|
||||
# action = QAction("Open File", target)
|
||||
action.triggered.connect(target.open_file)
|
||||
return action
|
||||
|
||||
|
||||
class SaveFileAction:
|
||||
"""Action creator for the 'Save File' action in the toolbar."""
|
||||
|
||||
def create(self, target):
|
||||
"""Creates a 'Save File' action for the toolbar.
|
||||
|
||||
Args:
|
||||
target (QWidget): The widget that the 'Save File' action will be targeted.
|
||||
|
||||
Returns:
|
||||
QAction: The 'Save File' action created for the toolbar.
|
||||
"""
|
||||
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton)
|
||||
action = QAction(icon, "Save File", target)
|
||||
# action = QAction("Save File", target)
|
||||
action.triggered.connect(target.save_file)
|
||||
return action
|
||||
|
||||
|
||||
class RunScriptAction:
|
||||
"""Action creator for the 'Run Script' action in the toolbar."""
|
||||
|
||||
def create(self, target):
|
||||
"""Creates a 'Run Script' action for the toolbar.
|
||||
|
||||
Args:
|
||||
target (QWidget): The widget that the 'Run Script' action will be targeted.
|
||||
|
||||
Returns:
|
||||
QAction: The 'Run Script' action created for the toolbar.
|
||||
"""
|
||||
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)
|
||||
action = QAction(icon, "Run Script", target)
|
||||
# action = QAction("Run Script", target)
|
||||
action.triggered.connect(target.run_script)
|
||||
return action
|
||||
|
||||
|
||||
class ModularToolBar(QToolBar):
|
||||
"""Modular toolbar with optional automatic initialization.
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
|
||||
auto_init (bool, optional): If True, automatically populates the toolbar based on the parent widget.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, auto_init=True):
|
||||
super().__init__(parent)
|
||||
self.auto_init = auto_init
|
||||
self.handler = {
|
||||
"BECEditor": [OpenFileAction(), SaveFileAction(), RunScriptAction()],
|
||||
# BECMonitor: [SomeOtherAction(), AnotherAction()], # Example for another widget
|
||||
}
|
||||
self.setStyleSheet("QToolBar { background: transparent; }")
|
||||
# Set the icon size for the toolbar
|
||||
self.setIconSize(QSize(20, 20))
|
||||
|
||||
if self.auto_init:
|
||||
QTimer.singleShot(0, self.auto_detect_and_populate)
|
||||
|
||||
def auto_detect_and_populate(self):
|
||||
"""Automatically detects the parent widget and populates the toolbar with relevant actions."""
|
||||
if not self.auto_init:
|
||||
return
|
||||
|
||||
parent_widget = self.parent()
|
||||
if parent_widget is None:
|
||||
return
|
||||
|
||||
parent_widget_class_name = type(parent_widget).__name__
|
||||
for widget_type_name, actions in self.handler.items():
|
||||
if parent_widget_class_name == widget_type_name:
|
||||
self.populate_toolbar(actions, parent_widget)
|
||||
return
|
||||
|
||||
def populate_toolbar(self, actions, target_widget):
|
||||
"""Populates the toolbar with a set of actions.
|
||||
|
||||
Args:
|
||||
actions (list[ToolBarAction]): A list of action creators to populate the toolbar.
|
||||
target_widget (QWidget): The widget that the actions will target.
|
||||
"""
|
||||
self.clear()
|
||||
for action_creator in actions:
|
||||
action = action_creator.create(target_widget)
|
||||
self.addAction(action)
|
||||
|
||||
def set_manual_actions(self, actions, target_widget):
|
||||
"""Manually sets the actions for the toolbar.
|
||||
|
||||
Args:
|
||||
actions (list[QAction or ToolBarAction]): A list of actions or action creators to populate the toolbar.
|
||||
target_widget (QWidget): The widget that the actions will target.
|
||||
"""
|
||||
self.clear()
|
||||
for action in actions:
|
||||
if isinstance(action, QAction):
|
||||
self.addAction(action)
|
||||
elif isinstance(action, ToolBarAction):
|
||||
self.addAction(action.create(target_widget))
|
||||
@@ -1,2 +1,16 @@
|
||||
(developer)=
|
||||
# Developer
|
||||
# Development
|
||||
|
||||
To contribute to the development of BEC Widgets, start by setting up the development environment:
|
||||
|
||||
1. **Clone the Repository**:
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
cd bec-widgets
|
||||
```
|
||||
2. **Install in Editable Mode**:
|
||||
|
||||
Installing the package in editable mode allows you to make changes to the code and test them in real-time.
|
||||
```bash
|
||||
pip install -e .[dev]
|
||||
```
|
||||
@@ -9,29 +9,31 @@
|
||||
:link: introduction
|
||||
:link-type: ref
|
||||
|
||||
General information about BEC Widgets.
|
||||
General information.
|
||||
```
|
||||
|
||||
```{grid-item-card} User
|
||||
:link: user
|
||||
:link-type: ref
|
||||
|
||||
Information for users of BEC Widgets.
|
||||
Information for users.
|
||||
```
|
||||
|
||||
```{grid-item-card} Developer
|
||||
:link: developer
|
||||
:link-type: ref
|
||||
|
||||
Information for developers of BEC Widgets.
|
||||
Information for developers.
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
---
|
||||
numbered: true
|
||||
maxdepth: 1
|
||||
---
|
||||
|
||||
introduction/introduction
|
||||
user/user
|
||||
developer/developer
|
||||
```
|
||||
|
||||
@@ -1,2 +1,18 @@
|
||||
(introduction)=
|
||||
# Introduction
|
||||
|
||||
## Overview
|
||||
|
||||
BEC Widgets is a GUI framework developed with beamline scientists in mind, aiming to provide a modern and modular environment for interacting with experiments. This package offers a suite of widgets specifically designed to enhance the workflow of beamline experiments, including features for running scans and data visualization.
|
||||
|
||||
Targeting the unique needs of beamline scientists, BEC Widgets stands out with its modular approach to widget design and high customizability. This flexibility allows for tailored solutions that meet the specific requirements of each beamline.
|
||||
|
||||
**Key Features**:
|
||||
|
||||
- **Integration:** Seamlessly integrates with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec), ensuring a cohesive and efficient experiment control experience.
|
||||
- **Support for PyQt5 and PyQt6:** Provides compatibility with both PyQt5 and PyQt6, offering versatility in your development environment.
|
||||
- **Widget Modularity:** Features modular widgets that can be easily combined to create customized applications, perfectly aligning with the diverse needs of beamline experiments.
|
||||
|
||||
## Getting Started
|
||||
|
||||
For detailed usage instructions and examples showcasing the practical applications of BEC Widgets, please refer to the [User](#user) section. Developers interested in contributing or customizing BEC Widgets can find more information in the [Developer](#developer) section.
|
||||
39
docs/user/apps.md
Normal file
39
docs/user/apps.md
Normal file
@@ -0,0 +1,39 @@
|
||||
(user.apps)=
|
||||
# Applications
|
||||
|
||||
In the `bec_widgets/examples` directory, you will find practical applications that demonstrate the capabilities of BEC Widgets in real-world scenarios. These applications showcase the adaptability and functionality of the framework for various beamline experiment needs.
|
||||
|
||||
**Motor Alignment Tool**
|
||||
|
||||
This tool assists in aligning motors with samples during experiments. It enables users to move motors, visually track their movement, and record positions for precise alignment.
|
||||
|
||||
- **Location:** `bec_widgets/examples/motor_movement`
|
||||
- **Further Details:** [Motor Alignment Tool Documentation](#user.apps.motor_app)
|
||||
|
||||
**General Plotting Live Acquisition Tool**
|
||||
|
||||
This application is designed for live data visualization. It allows users to view real-time signals from detectors in a multi-grid layout, facilitating immediate analysis during experiments.
|
||||
|
||||
- **Location:** `bec_widgets/examples/plot_app`
|
||||
- **Further Details:** [General Plotting Live Acquisition Tool Documentation](#user.apps.plot_app)
|
||||
|
||||
|
||||
**Modular Application**
|
||||
|
||||
A bespoke application built entirely using BEC Widgets' modular components. This example illustrates the framework's flexibility in creating customized GUIs tailored to specific experimental setups.
|
||||
|
||||
- **Location:** `bec_widgets/examples/modular_app`
|
||||
- **Further Details:** [Modular Application](#user.apps.modular_app)
|
||||
|
||||
---
|
||||
Note: The documentation for these applications is currently under development. The provided links will direct you to their respective pages once the documentation is complete.
|
||||
|
||||
```{toctree}
|
||||
---
|
||||
maxdepth: 1
|
||||
hidden: true
|
||||
---
|
||||
|
||||
apps/motor_app
|
||||
apps/plot_app
|
||||
apps/modular_app
|
||||
6
docs/user/apps/modular_app.md
Normal file
6
docs/user/apps/modular_app.md
Normal file
@@ -0,0 +1,6 @@
|
||||
(user.apps.modular_app)=
|
||||
|
||||
# Modular Application
|
||||
|
||||
|
||||
_to be added..._
|
||||
34
docs/user/apps/motor_app.md
Normal file
34
docs/user/apps/motor_app.md
Normal file
@@ -0,0 +1,34 @@
|
||||
(user.apps.motor_app)=
|
||||
# Motor Alignment
|
||||
|
||||
The Motor Alignment Application is a key component of the BEC Widgets suite, designed to facilitate precise alignment of motors.
|
||||
Users can easily launch this app using the script located at `/bec_widgets/example/motor_movement/motor_example.py` script.
|
||||
The application's primary function is to enable users to align motors to specific positions and to visually track the motor's trajectory.
|
||||
|
||||
## Controlling Motors
|
||||
|
||||
In the top middle panel of the application, users will find combobox dropdown menus for selecting the motors they wish to track on the x and y axes of the motor map.
|
||||
These motors are automatically loaded from the current active BEC instance, ensuring seamless integration and ease of use.
|
||||
|
||||
There are two primary methods to control motor movements:
|
||||
|
||||
|
||||
1. **Manual Control with Arrow Keys:** Users can manually drive the motors using arrow keys. Before doing so, they need to select the step size for each motor, allowing for precise and incremental movements.
|
||||
2. **Direct Position Entry:** Alternatively, users can input a desired position in the text input box and then click the Go button. This action will move the motor directly to the specified coordinates.
|
||||
|
||||
As the motors are moved, their trajectory is plotted in real-time, providing users with a visual representation of the motor's path. This feature is particularly useful for understanding the movement patterns and making necessary adjustments.
|
||||
|
||||
|
||||
## Saving and Exporting Data
|
||||
|
||||
Users have the ability to save the current motor position in a table widget. This functionality is beneficial for recalling and returning to specific positions. By clicking the Go button in the table widget, the motors will automatically move back to the saved position.
|
||||
|
||||
Additionally, users can annotate each saved position with notes and comments directly in the table widget. This feature is invaluable for keeping track of specific alignment settings or observations. The contents of the table, including the notes, can be exported to a .csv file. This exported data can be used for initiating scans or for record-keeping purposes.
|
||||
|
||||
The table widget also supports saving and loading functionalities, allowing users to preserve their motor positions and notes across sessions. The saved files are in a user-friendly format for ease of access and use.
|
||||
|
||||
|
||||
## Example of Use
|
||||
|
||||

|
||||
|
||||
BIN
docs/user/apps/motor_app_10fps.gif
Normal file
BIN
docs/user/apps/motor_app_10fps.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 MiB |
6
docs/user/apps/plot_app.md
Normal file
6
docs/user/apps/plot_app.md
Normal file
@@ -0,0 +1,6 @@
|
||||
(user.apps.plot_app)=
|
||||
|
||||
# General Plotting Tool
|
||||
|
||||
|
||||
_to be added..._
|
||||
13
docs/user/customisation.md
Normal file
13
docs/user/customisation.md
Normal file
@@ -0,0 +1,13 @@
|
||||
(user.customisation)=
|
||||
# Customisation
|
||||
|
||||
BEC Widgets are designed to be used with QtDesigner to quicly design GUI.
|
||||
|
||||
|
||||
## Example of promoting widgets in Qt Designer
|
||||
|
||||
_Work in progress_
|
||||
|
||||
## Implementation of plugins into Qt Designer
|
||||
|
||||
_Work in progress_
|
||||
46
docs/user/installation.md
Normal file
46
docs/user/installation.md
Normal file
@@ -0,0 +1,46 @@
|
||||
(user.installation)=
|
||||
# Installation
|
||||
|
||||
|
||||
**Prerequisites**
|
||||
|
||||
Before installing BEC Widgets, please ensure the following requirements are met:
|
||||
|
||||
1. **Python Version:** BEC Widgets requires Python version 3.9 or higher. Verify your Python version to ensure compatibility.
|
||||
2. **BEC Installation:** BEC Widgets works in conjunction with BEC. While BEC is a dependency and will be installed automatically, you can find more information about BEC and its installation process in the [BEC documentation](https://beamline-experiment-control.readthedocs.io/en/latest/).
|
||||
|
||||
**Standard Installation**
|
||||
|
||||
Install BEC Widgets using the pip package manager. Open your terminal and execute:
|
||||
|
||||
```bash
|
||||
pip install bec-widgets
|
||||
```
|
||||
|
||||
This command installs BEC Widgets along with its dependencies, including the default PyQt6.
|
||||
|
||||
**Selecting a PyQt Version**
|
||||
|
||||
BEC Widgets supports both PyQt5 and PyQt6. To install a specific version, use:
|
||||
|
||||
For PyQt6:
|
||||
|
||||
```bash
|
||||
pip install bec-widgets[pyqt6]
|
||||
```
|
||||
|
||||
For PyQt5:
|
||||
|
||||
```bash
|
||||
pip install bec-widgets[pyqt5]
|
||||
```
|
||||
|
||||
**Troubleshooting**
|
||||
|
||||
If you encounter issues during installation, particularly with PyQt, try purging the pip cache:
|
||||
|
||||
```bash
|
||||
pip cache purge
|
||||
```
|
||||
|
||||
This can resolve conflicts or issues with package installations.
|
||||
@@ -1,3 +1,38 @@
|
||||
(user)=
|
||||
# User
|
||||
|
||||
**Overview**
|
||||
|
||||
Welcome to the User section of the BEC Widgets documentation! BEC Widgets is a versatile GUI framework tailored for beamline scientists, enabling efficient and intuitive interaction with beamline experiments. This section is designed to guide both new and experienced users through the essential aspects of utilizing BEC Widgets.
|
||||
|
||||
**Key Topics**
|
||||
|
||||
- [Installing BEC Widgets](#user.installation): Instructions for installing BEC Widgets on your system.
|
||||
|
||||
- [Example Applications](#user.apps): Overview of bespoke applications and demonstrations of BEC Widgets in action, showcasing its use in real-world beamline scenarios.
|
||||
|
||||
- [Widgets Overview](#user.widgets): Detailed information on the variety of widgets available, their functions, and how to use them effectively.
|
||||
|
||||
- [Customization and Configuration](#user.customisation): Tips on customizing and configuring BEC Widgets to suit your specific experimental needs using Qt Designer.
|
||||
|
||||
**Bug Reports and Feature Requests**
|
||||
|
||||
We value your feedback and contributions to improving BEC Widgets. If you encounter any issues or have ideas for new features, we encourage you to report them.
|
||||
|
||||
- **Bug Reports:** If you find a bug or an issue, please report it on our repository's [Issues page](https://gitlab.psi.ch/bec/bec-widgets/-/issues?sort=created_date&state=opened). We have a template for bug reporting to help you provide all necessary information.
|
||||
- **Feature Requests:** Have an idea for a new feature or an enhancement? Share it with us on the [Issues page](https://gitlab.psi.ch/bec/bec-widgets/-/issues?sort=created_date&state=opened) of our repository. We have a feature request template that you can use to describe your proposal.
|
||||
|
||||
**Development**
|
||||
|
||||
For advanced details about BEC Widgets’ internal architecture, development contributions, or customization techniques, please explore the [Developer](#developer) section.
|
||||
|
||||
```{toctree}
|
||||
---
|
||||
maxdepth: 3
|
||||
hidden: true
|
||||
---
|
||||
|
||||
installation
|
||||
apps
|
||||
widgets
|
||||
customisation
|
||||
41
docs/user/widgets.md
Normal file
41
docs/user/widgets.md
Normal file
@@ -0,0 +1,41 @@
|
||||
(user.widgets)=
|
||||
# Widgets
|
||||
|
||||
## Visualization Widgets
|
||||
|
||||
BEC Widgets includes a variety of visualization widgets designed to cater to diverse data representation needs in beamline experiments. These widgets enhance the user experience by providing intuitive and interactive data visualizations.
|
||||
|
||||
### 1D Waveform Widget
|
||||
|
||||
**Purpose:** This widget provides a straightforward visualization of 1D data. It is particularly useful for plotting positioner movements against detector readings, enabling users to observe correlations and patterns in a simple, linear format.
|
||||
|
||||
**Key Features:**
|
||||
- Real-time plotting of positioner versus detector values.
|
||||
- Interactive controls for zooming and panning through the data.
|
||||
- Customizable visual elements such as line color and style.
|
||||
|
||||
**Example of Use:**
|
||||

|
||||
### 2D Scatter Plot
|
||||
|
||||
**Purpose:** The 2D scatter plot widget is designed for more complex data visualization. It employs a false color map to represent a third dimension (z-axis), making it an ideal tool for visualizing multidimensional data sets.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- 2D scatter plot with color-coded data points based on a third variable (two positioners for x/y vs. one detector for colormap).
|
||||
- Interactive false color map for enhanced data interpretation.
|
||||
- Tools for selecting and inspecting specific data points.
|
||||
|
||||
**Example of Use:**
|
||||

|
||||
### Motor Position Map
|
||||
|
||||
**Purpose:** A specialized component derived from the Motor Alignment Tool. It's focused on tracking and visualizing the position of motors, crucial for precise alignment and movement tracking during scans.
|
||||
|
||||
**Key Features:**
|
||||
- Real-time tracking of motor positions.
|
||||
- Visual representation of motor trajectories, aiding in alignment tasks.
|
||||
- Ability to record and recall specific motor positions for repetitive tasks.
|
||||
|
||||
**Example of Use:**
|
||||

|
||||
BIN
docs/user/widgets/motor.gif
Normal file
BIN
docs/user/widgets/motor.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
BIN
docs/user/widgets/scatter_2D.gif
Normal file
BIN
docs/user/widgets/scatter_2D.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 MiB |
BIN
docs/user/widgets/w1D.gif
Normal file
BIN
docs/user/widgets/w1D.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 608 KiB |
@@ -15,9 +15,7 @@ classifiers =
|
||||
package_dir =
|
||||
= .
|
||||
packages = find:
|
||||
python_requires = >=3.8
|
||||
python_requires = >=3.9
|
||||
|
||||
[options.packages.find]
|
||||
where = .
|
||||
|
||||
|
||||
|
||||
48
setup.py
48
setup.py
@@ -1,10 +1,50 @@
|
||||
from setuptools import setup
|
||||
# pylint: disable= missing-module-docstring
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
__version__ = "0.31.0"
|
||||
__version__ = "0.41.1"
|
||||
|
||||
# Default to PyQt6 if no other Qt binding is installed
|
||||
QT_DEPENDENCY = "PyQt6>=6.0"
|
||||
QSCINTILLA_DEPENDENCY = "PyQt6-QScintilla"
|
||||
|
||||
# pylint: disable=unused-import
|
||||
try:
|
||||
import PyQt5
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
QT_DEPENDENCY = "PyQt5>=5.9"
|
||||
QSCINTILLA_DEPENDENCY = "QScintilla"
|
||||
|
||||
if __name__ == "__main__":
|
||||
setup(
|
||||
install_requires=["pydantic", "pyqt5", "pyqtgraph", "bec_lib", "zmq", "h5py"],
|
||||
extras_require={"dev": ["pytest", "pytest-random-order", "coverage", "pytest-qt", "black"]},
|
||||
install_requires=[
|
||||
"pydantic",
|
||||
"qtconsole",
|
||||
QT_DEPENDENCY,
|
||||
QSCINTILLA_DEPENDENCY,
|
||||
"jedi",
|
||||
"qtpy",
|
||||
"pyqtgraph",
|
||||
"bec_lib",
|
||||
"zmq",
|
||||
"h5py",
|
||||
"pyqtdarktheme",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest",
|
||||
"pytest-random-order",
|
||||
"pytest-timeout",
|
||||
"coverage",
|
||||
"pytest-qt",
|
||||
"black",
|
||||
],
|
||||
"pyqt5": ["PyQt5>=5.9"],
|
||||
"pyqt6": ["PyQt6>=6.0"],
|
||||
},
|
||||
version=__version__,
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
package_data={"": ["*.ui", "*.yaml"]},
|
||||
)
|
||||
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
77
tests/client_mocks.py
Normal file
77
tests/client_mocks.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class FakeDevice:
|
||||
"""Fake minimal positioner class for testing."""
|
||||
|
||||
def __init__(self, name, enabled=True):
|
||||
self.name = name
|
||||
self.enabled = enabled
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name}}
|
||||
|
||||
def __contains__(self, item):
|
||||
return item == self.name
|
||||
|
||||
@property
|
||||
def _hints(self):
|
||||
return [self.name]
|
||||
|
||||
def set_value(self, fake_value: float = 1.0) -> None:
|
||||
"""
|
||||
Setup fake value for device readout
|
||||
Args:
|
||||
fake_value(float): Desired fake value
|
||||
"""
|
||||
self.signals[self.name]["value"] = fake_value
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""
|
||||
Get the description of the device
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
|
||||
|
||||
def get_mocked_device(device_name: str):
|
||||
"""
|
||||
Helper function to mock the devices
|
||||
Args:
|
||||
device_name(str): Name of the device to mock
|
||||
"""
|
||||
return FakeDevice(name=device_name, enabled=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mocked_client():
|
||||
# Create a dictionary of mocked devices
|
||||
device_names = [
|
||||
"samx",
|
||||
"samy",
|
||||
"gauss_bpm",
|
||||
"gauss_adc1",
|
||||
"gauss_adc2",
|
||||
"gauss_adc3",
|
||||
"bpm4i",
|
||||
"bpm3a",
|
||||
"bpm3i",
|
||||
]
|
||||
mocked_devices = {name: get_mocked_device(name) for name in device_names}
|
||||
|
||||
# Create a MagicMock object
|
||||
client = MagicMock()
|
||||
|
||||
# Mock the device_manager.devices attribute
|
||||
client.device_manager.devices = MagicMock()
|
||||
client.device_manager.devices.__getitem__.side_effect = lambda x: mocked_devices.get(x)
|
||||
client.device_manager.devices.__contains__.side_effect = lambda x: x in mocked_devices
|
||||
|
||||
# Set each device as an attribute of the mock
|
||||
for name, device in mocked_devices.items():
|
||||
setattr(client.device_manager.devices, name, device)
|
||||
|
||||
return client
|
||||
35
tests/conftest.py
Normal file
35
tests/conftest.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
import threading
|
||||
|
||||
from bec_lib.bec_service import BECService
|
||||
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def threads_check():
|
||||
current_threads = set(
|
||||
th
|
||||
for th in threading.enumerate()
|
||||
if "loguru" not in th.name and th is not threading.main_thread()
|
||||
)
|
||||
yield
|
||||
threads_after = set(
|
||||
th
|
||||
for th in threading.enumerate()
|
||||
if "loguru" not in th.name and th is not threading.main_thread()
|
||||
)
|
||||
additional_threads = threads_after - current_threads
|
||||
assert (
|
||||
len(additional_threads) == 0
|
||||
), f"Test creates {len(additional_threads)} threads that are not cleaned: {additional_threads}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def bec_dispatcher(threads_check):
|
||||
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
|
||||
yield bec_dispatcher
|
||||
bec_dispatcher.disconnect_all()
|
||||
# clean BEC client
|
||||
BECService.shutdown(bec_dispatcher.client)
|
||||
# reinitialize singleton for next test
|
||||
bec_dispatcher_module._bec_dispatcher = None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user