1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-19 23:05:36 +02:00

Compare commits

..

83 Commits

Author SHA1 Message Date
semantic-release
8a4aeb8dfe 0.39.0
Automatically generated by python-semantic-release
2024-02-12 13:01:47 +00:00
wyzula-jan
4b0542a513 refactor: pylint ignore for tests 2024-02-12 13:53:52 +01:00
wyzula-jan
bf04a4e04a test: motor_control_compilations.py and motor_control.py tests added 2024-02-12 13:53:52 +01:00
wyzula-jan
fa4ca935bb feat: added full app with all motor movement related widgets into motor_control_compilations.py 2024-02-12 13:53:52 +01:00
wyzula-jan
b52e22d81f refactor: motor_control.py clean up 2024-02-12 13:53:52 +01:00
wyzula-jan
2f96e10b9d feat: MotorCoordinateTable mode_switch added for "Individual" and "Start/Stop" modes 2024-02-12 13:53:52 +01:00
wyzula-jan
031cb094e7 feat: motor_control.py MotorCoordinateTable added basic version to store coordinates and show them in motor_map.py 2024-02-12 13:53:52 +01:00
wyzula-jan
8afc5f0c0c refactor: motor_control_compilations.py moved to example part of repository 2024-02-12 13:53:52 +01:00
wyzula-jan
17f14581d7 feat: active motors from motor_map.py can be changed by slot without changing the whole config 2024-02-12 13:53:52 +01:00
wyzula-jan
8361736679 feat: control panels compilations 2024-02-12 13:53:52 +01:00
wyzula-jan
0b9927fcf5 feat: comboboxes of motor selection are changed to orange if the motors are not connected yet 2024-02-12 13:53:52 +01:00
wyzula-jan
8139e271de refactor: base class for motor_control.py widgets 2024-02-12 13:53:52 +01:00
wyzula-jan
6fe08e6b82 feat: motor_control.py MotorControl widgets - Absolute + Relative movement, MotorSelection, ErrorMessage popups 2024-02-12 13:53:52 +01:00
wyzula-jan
968da6f558 build: added all .ui and .yaml files to pypi install; removed gauss_bpm from default config from monitor.py 2024-02-08 10:59:48 +01:00
semantic-release
11ae0b1054 0.38.2
Automatically generated by python-semantic-release
2024-02-07 16:26:49 +00:00
5ebfd2a3c2 test: fixed import in test_validator_errors.py 2024-02-07 17:23:03 +01:00
b36131eed5 fix: adapt code to BEC 1.0 2024-02-07 17:16:43 +01:00
semantic-release
a7bfcc12b9 0.38.1
Automatically generated by python-semantic-release
2024-01-26 15:45:41 +00:00
wyzula-jan
ab275b8e5f fix: monitor.py replots last scan after changing config with new signals; config_dialog.py checks if the new config is valid with BEC 2024-01-26 16:42:08 +01:00
wyzula-jan
d211b47f4c refactor: black v24 formatting 2024-01-26 15:17:59 +01:00
wyzula-jan
812ffaf8ea docs: 2D waveform scatter plot changed to 2D scatter plot 2024-01-24 10:50:51 +01:00
wyzula-jan
f7a496723c docs: documentation for example apps and widgets updated 2024-01-24 10:36:50 +01:00
semantic-release
48847a19c7 0.38.0
Automatically generated by python-semantic-release
2024-01-23 13:48:08 +00:00
wyzula-jan
8d0083c4aa test: fix test_bec_monitor_scatter2D.py database init test change to check defaultdict 2024-01-23 14:42:11 +01:00
wyzula-jan
3c143274c5 refactor: monitor_scatter_2D.py _init_database replaced with defaultdict 2024-01-23 14:31:20 +01:00
wyzula-jan
747e97e0c9 fix: monitor_scatter_2D.py changed to new BECDispatcher definition 2024-01-23 13:51:23 +01:00
wyzula-jan
c6fe9d2026 test: test_bec_monitor_scatter2D.py added 2024-01-23 13:51:23 +01:00
wyzula-jan
75090b8575 feat: BECMonitor2DScatter for plotting x/y/z signal as a mesh of scatter plot 2024-01-23 13:51:23 +01:00
semantic-release
8f76c789cf 0.37.1
Automatically generated by python-semantic-release
2024-01-23 12:42:02 +00:00
4664568672 fix(tests): ensure BEC service is shutdown after bec dispatcher test 2024-01-20 23:04:51 +01:00
3fb6644543 fix(tests): ensure threads started during plot tests are properly stopped 2024-01-20 23:01:41 +01:00
d909673071 refactor(tests): ensure BEC dispatcher singleton object is renewed at each test
and add a check for dangling threads
2024-01-19 19:40:21 +01:00
semantic-release
d281d6576c 0.37.0
Automatically generated by python-semantic-release
2024-01-17 14:09:10 +00:00
wyzula-jan
8bebc4f692 refactor: pylint improvement 2024-01-17 14:59:53 +01:00
wyzula-jan
1cd273c375 test: test_motor_map.py added 2024-01-17 14:59:53 +01:00
wyzula-jan
249170ea30 refactor: motor_map.py clean up 2024-01-17 14:59:53 +01:00
wyzula-jan
1a429b3024 feat: independent motor_map widget 2024-01-17 14:59:53 +01:00
semantic-release
e05cab812a 0.36.2
Automatically generated by python-semantic-release
2024-01-17 13:56:53 +00:00
wyzula-jan
7607d7a3b6 fix: bec_dispatcher.py can partially disconnect topics from slot 2024-01-16 16:02:31 +01:00
wyzula-jan
e51be04b95 fix: bec_dispatcher.py can connect multiple topics to one callback slot 2024-01-16 16:02:31 +01:00
semantic-release
de1f5c968a 0.36.1
Automatically generated by python-semantic-release
2024-01-15 16:04:22 +00:00
wyzula-jan
bf819bcf48 refactor: motor_example.py get coordinates by .readback.get() method 2024-01-15 16:14:15 +01:00
wyzula-jan
6f26e5cc3d refactor: using motor.readback.read() to access motor coordinates 2024-01-12 16:44:55 +01:00
wyzula-jan
f9c5c82381 fix: motor_example.py fix to the new .read() structure from bec_lib 2024-01-12 16:44:55 +01:00
semantic-release
79487dbec2 0.36.0
Automatically generated by python-semantic-release
2024-01-12 13:23:34 +00:00
58721bea1a feat: bec_dispatcher can link multiple endpoints topics for one qt slot 2024-01-12 14:22:29 +01:00
semantic-release
03e96669da 0.35.0
Automatically generated by python-semantic-release
2024-01-12 09:33:49 +00:00
wyzula-jan
eb529d24d2 refactor: review response for MR !31 2024-01-11 16:58:53 +01:00
wyzula-jan
ebd4fccda2 fix: monitor.py clear command from BECPlotter CLI clear now flush database and clear the plots 2024-01-10 17:59:36 +01:00
wyzula-jan
97dcc5ac76 fix: monitor.py crosshair enabled by default 2024-01-09 12:51:22 +01:00
wyzula-jan
9c7a189beb ci: fix cobertura for gitlab/16 2024-01-09 12:45:40 +01:00
wyzula-jan
6061b3150e fix: monitor.py change import of ConfigDialog from relative to absolute in order to make BECPlotter be able to open it 2024-01-09 12:32:45 +01:00
wyzula-jan
3982c5d498 refactor: config_dialog.py refactored to accept new config formatting 2024-01-08 16:31:56 +01:00
wyzula-jan
404ca49821 refactor: modular_app.py configs changed to new format 2024-01-08 16:17:14 +01:00
wyzula-jan
6e4775a124 feat: monitor.py can access custom data send through redis 2024-01-08 15:23:39 +01:00
wyzula-jan
5ab82bc133 fix: monitor_config_validator.py changed to check .describe() instead of signals 2023-12-21 16:59:02 +01:00
wyzula-jan
00ef3ae925 fix: monitor.py fixed not updating config changes after receiving refresh from BECPlotter 2023-12-18 15:42:10 +01:00
wyzula-jan
90d8069cc3 test: test_validator_errors.py fixed 2023-12-15 19:05:38 +01:00
wyzula-jan
457567ef74 test: test_bec_monitor.py fixed 2023-12-15 18:28:29 +01:00
wyzula-jan
1128ca5252 refactor: monitor.py clean up 2023-12-15 13:49:08 +01:00
wyzula-jan
86c5f25205 fix: monitor_config_validator.py valid color is Literal['black','white'] 2023-12-15 13:19:16 +01:00
wyzula-jan
a706da2490 fix: monitor.py fixed scan mode 2023-12-15 13:12:05 +01:00
wyzula-jan
d67bdd2616 fix: motor_config_validation changed to new monitor config structure 2023-12-14 14:01:28 +01:00
wyzula-jan
c3f2ad45c3 refactor: monitor.py data for scan segment are only accessed through queue.scan_storage 2023-12-13 10:30:58 +01:00
wyzula-jan
26c07c3205 feat: monitor.py access data directly from scan storage 2023-12-13 09:37:45 +01:00
wyzula-jan
c995e0d235 refactor: monitor.py config hierarchy refactor for source (can be 'scan_segment','history', 'redis') 2023-12-13 09:37:45 +01:00
wyzula-jan
463a60a99c refactor: monitor.py on_scan_segment old logic separated from on_scan_segment function 2023-12-13 09:37:21 +01:00
semantic-release
98a46a85b2 0.34.1
Automatically generated by python-semantic-release
2023-12-12 19:20:19 +00:00
wyzula-jan
186c42d667 fix: formatter and tests fixed 2023-12-12 18:24:38 +01:00
wyzula-jan
f3a47a5b08 refactor: repo reorganisation 2023-12-12 17:26:28 +01:00
wyzula-jan
af995a74f3 docs: readdocs updated 2023-12-12 17:26:28 +01:00
wyzula-jan
3abd955465 refactor: repo reorganization 2023-12-12 17:26:22 +01:00
wyzula-jan
cba8131367 docs: readme.md updated 2023-12-12 17:25:25 +01:00
wyzula-jan
831eddc136 docs: gitlab templates for issues and merge requests from main bec repo 2023-12-12 17:25:25 +01:00
9e852d1afc refactor: replace deprecated imports from typing
https://peps.python.org/pep-0585/#implementation
2023-12-12 15:22:59 +01:00
3ec9caae09 build: fix python requirement 2023-12-12 15:14:18 +01:00
11281fef53 ci: added rtd update job 2023-12-11 19:22:01 +01:00
semantic-release
9d497b70bf 0.34.0
Automatically generated by python-semantic-release
2023-12-08 09:57:26 +00:00
wyzula-jan
2a334156a8 test: validation errors tests 2023-12-07 19:32:21 +01:00
wyzula-jan
086804780d fix: monitor_config_validator.py - Signal validation changed from field_validator to model_validator to check first name and then entry 2023-12-07 19:32:21 +01:00
wyzula-jan
731fba55ec refactor: monitor.py pylint improvement 2023-12-07 19:32:21 +01:00
wyzula-jan
a3b24f9242 feat: monitor.py error message popup 2023-12-07 19:32:20 +01:00
wyzula-jan
af71e35e73 fix: monitor_config_validator.py fix entry validation executed only if name validator is successful 2023-12-07 19:20:26 +01:00
100 changed files with 7477 additions and 1114 deletions

View File

@@ -87,7 +87,9 @@ 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"
@@ -147,17 +149,15 @@ semver:
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/

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

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

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

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

View File

@@ -2,6 +2,118 @@
<!--next-version-placeholder-->
## 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

View File

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

View File

@@ -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 qtpy.QtCore import QObject, Signal as 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)

View File

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

View File

@@ -1,33 +0,0 @@
import os
import sys
from qtpy 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()

View File

@@ -0,0 +1,9 @@
from .motor_movement import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelRelative,
MotorControlPanelAbsolute,
MotorCoordinateTable,
MotorThread,
)

View File

@@ -12,7 +12,7 @@ from qtpy.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):

View File

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

View File

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

View File

@@ -74,7 +74,7 @@
<x>0</x>
<y>0</y>
<width>1433</width>
<height>24</height>
<height>37</height>
</rect>
</property>
</widget>

View File

@@ -1,45 +1,13 @@
import os
from qtpy import uic
from qtpy.QtWidgets import QMainWindow, QApplication, QVBoxLayout
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([])

View File

@@ -0,0 +1,9 @@
from .motor_control_compilations import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelRelative,
MotorControlPanelAbsolute,
MotorCoordinateTable,
MotorThread,
)

View File

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

View File

@@ -25,7 +25,7 @@ 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
@@ -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"]:

View File

@@ -9,7 +9,9 @@ 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
@@ -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()

View File

@@ -22,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:
@@ -92,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 = {}
@@ -570,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
@@ -692,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",
@@ -715,6 +714,7 @@ if __name__ == "__main__":
exit(1)
# BECclient global variables
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()

View File

@@ -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 bec_lib.redis_connector import RedisConnector
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QCheckBox, QTableWidgetItem
from pyqtgraph import mkBrush, mkColor, mkPen
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
@@ -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)
@@ -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]
data = msgs
if not data:
continue
with np.errstate(divide="ignore", invalid="ignore"):
@@ -316,6 +319,7 @@ if __name__ == "__main__":
print(f"Plotting signals for: {', '.join(value.signals)}")
# Client from dispatcher
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
app = QtWidgets.QApplication([])

View File

@@ -1,7 +1,7 @@
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):

View File

@@ -1,7 +1,7 @@
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):

View File

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

View File

@@ -0,0 +1,152 @@
from __future__ import annotations
import argparse
import itertools
import os
from collections.abc import Callable
from typing import Union
from bec_lib import BECClient, ServiceConfig
from bec_lib.redis_connector import RedisConsumerThreaded
from qtpy.QtCore import QObject, 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 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):
"""Utility class to keep track of slots connected to a particular redis consumer"""
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, 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
if not isinstance(msg, list):
msg = [msg]
for msg_i in msg:
for connection_key, connection in self._connections.items():
if set(topics).intersection(
connection_key if isinstance(connection_key, tuple) else [connection_key]
):
connection.signal.emit(msg_i.content, msg_i.metadata)
consumer = self.client.connector.consumer(topics=topics, cb=cb)
consumer.start()
return _Connection(consumer)
def _do_disconnect_slot(self, topic, slot):
connection = self._connections[topic]
connection.signal.disconnect(slot)
connection.slots.remove(slot)
if not connection.slots:
print(f"{connection.consumer} is shutting down")
connection.consumer.shutdown()
connection.consumer.join()
del self._connections[topic]
def _disconnect_slot_from_topic(self, slot: Callable, topic: str) -> None:
"""A helper method to disconnect a slot from a specific topic.
Args:
slot (Callable): A slot to be disconnected
topic (str): A corresponding topic that can typically be acquired via
bec_lib.MessageEndpoints
"""
connection = self._connections.get(topic)
if connection and slot in connection.slots:
self._do_disconnect_slot(topic, slot)
def disconnect_slot(self, slot: Callable, topics: Union[str, list]) -> None:
"""Disconnect widget's pyqt slot from pub/sub updates on a topic.
Args:
slot (Callable): A slot to be disconnected
topics (str | list): A corresponding topic or list of topics that can typically be acquired via
bec_lib.MessageEndpoints
"""
if isinstance(topics, str):
topics = [topics]
for key, connection in list(self._connections.items()):
if slot in connection.slots:
common_topics = set(topics).intersection(key)
if common_topics:
remaining_topics = set(key) - set(topics)
# Disconnect slot from common topics
self._do_disconnect_slot(key, slot)
# Reconnect slot to remaining topics if any
if remaining_topics:
self.connect_slot(slot, list(remaining_topics), True)
def disconnect_all(self):
"""Disconnect all slots from all topics."""
for key, connection in list(self._connections.items()):
for slot in list(connection.slots):
self._disconnect_slot_from_topic(slot, key)
# variable holding the Singleton instance of BECDispatcher
_bec_dispatcher = None
def BECDispatcher():
global _bec_dispatcher
if _bec_dispatcher is None:
parser = argparse.ArgumentParser()
parser.add_argument("--bec-config", default=None)
args, _ = parser.parse_known_args()
_bec_dispatcher = _BECDispatcher(args.bec_config)
return _bec_dispatcher

View File

@@ -1,6 +1,6 @@
from typing import Dict, List, Optional, Union
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
@@ -16,75 +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):
@@ -93,45 +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.
axis_width (Optional[int]): Width of the plot axes.
axis_color (Optional[str]): Color of the plot axes.
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
background_color: Literal["black", "white"] = "black"
axis_width: Optional[int] = 2
axis_color: Optional[str] = None
num_columns: int
colormap: str
scan_types: bool
num_columns: Optional[int] = 1
colormap: Optional[str] = "magma"
scan_types: Optional[bool] = False
class DeviceMonitorConfig(BaseModel):
@@ -140,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):
@@ -153,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):
@@ -183,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)

View File

@@ -1,4 +1,13 @@
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,
)

View File

@@ -12,7 +12,12 @@ from qtpy.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()
main_app.load_config(CONFIG_SCAN_MODE)
app.exec()

View File

@@ -1,22 +1,21 @@
import os
# pylint: disable = no-name-in-module,missing-module-docstring
import time
import pyqtgraph as pg
from bec_lib import MessageEndpoints
from pydantic import ValidationError
from pyqtgraph import mkBrush, mkPen
from qtpy import QtCore, uic
from qtpy import QtCore
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QApplication, QMessageBox, QTableWidgetItem, QWidget
from qtpy.QtWidgets import QApplication, QMessageBox
from bec_widgets.bec_dispatcher import bec_dispatcher
from bec_widgets.qt_utils import Colors, Crosshair
from bec_widgets.qt_utils.yaml_dialog import load_yaml
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,
@@ -27,66 +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,
@@ -96,47 +125,118 @@ 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": "white",
"num_columns": 5,
"background_color": "black",
"num_columns": 2,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {"label": "Motor Y", "signals": [{"name": "samx"}]},
"y": {"label": "bpm4i", "signals": [{"name": "bpm4i"}]},
"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"}]},
"y": {"label": "Gauss", "signals": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}]},
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}, {"name": "bpm4i"}],
},
}
],
},
],
}
test_config = {
CONFIG_REDIS = {
"plot_settings": {
"background_color": "white",
"axis_width": 2,
@@ -147,8 +247,20 @@ test_config = {
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {"label": "Motor Y", "signals": [{"name": "samx"}]},
"y": {"label": "bpm4i", "signals": [{"name": "bpm4i"}]},
"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"}]},
},
],
}
],
}
@@ -164,10 +276,13 @@ class BECMonitor(pg.GraphicsLayoutWidget):
config: dict = None,
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
@@ -176,7 +291,7 @@ class BECMonitor(pg.GraphicsLayoutWidget):
self.gui_id = gui_id
if self.gui_id is None:
self.gui_id = self.__class__.__name__ + str(time.time()) # TODO still in discussion
self.gui_id = self.__class__.__name__ + str(time.time())
# Connect slots dispatcher
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
@@ -184,15 +299,17 @@ class BECMonitor(pg.GraphicsLayoutWidget):
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
@@ -203,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
@@ -229,9 +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()))]
# 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.
@@ -278,8 +432,9 @@ 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)
@@ -290,6 +445,7 @@ class BECMonitor(pg.GraphicsLayoutWidget):
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:
@@ -316,7 +472,6 @@ class BECMonitor(pg.GraphicsLayoutWidget):
f"Invalid background color {plot_settings['background_color']}. Allowed values"
" are 'white' or 'black'."
)
print(plot_settings)
pen = pg.mkPen(color=color, width=pen_width)
x_axis = plot.getAxis("bottom") # 'bottom' corresponds to the x-axis
x_axis.setPen(pen)
@@ -330,54 +485,64 @@ class BECMonitor(pg.GraphicsLayoutWidget):
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
@@ -386,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."""
@@ -409,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()
@@ -423,6 +618,11 @@ 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:
"""
@@ -430,6 +630,7 @@ class BECMonitor(pg.GraphicsLayoutWidget):
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.
@@ -439,8 +640,11 @@ class BECMonitor(pg.GraphicsLayoutWidget):
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}")
@@ -451,25 +655,73 @@ class BECMonitor(pg.GraphicsLayoutWidget):
Args:
config(dict): Configuration settings
"""
if "config" in config:
# 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)
def flush(self) -> None:
"""Flush the data dictionary."""
self.data = {}
self.init_curves()
# 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.
@@ -477,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
@@ -486,58 +737,91 @@ 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"
f" config or in bec 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 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.flush()
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
parser = argparse.ArgumentParser()
parser.add_argument("--config_file", help="Path to the config file.")
parser.add_argument("--config", help="Path to the config file.")
@@ -549,13 +833,23 @@ if __name__ == "__main__": # pragma: no cover
config = json.loads(args.config)
elif args.config_file is not None:
# Load config from file
config = load_yaml(args.config_file)
config = yaml_dialog.load_yaml(args.config_file)
else:
config = test_config
config = CONFIG_SIMPLE
client = bec_dispatcher.client
client = BECDispatcher().client
client.start()
app = QApplication(sys.argv)
monitor = BECMonitor(config=config, gui_id=args.id)
monitor = BECMonitor(
config=config,
gui_id=args.id,
skip_validation=False,
)
monitor.show()
# just to test redis data
# redis_data = {
# "x": {"data": [1, 2, 3], "tag": "x_default_tag"},
# "y": {"y_default_tag": {"data": [1, 2, 3]}},
# }
# monitor.on_data_from_redis({"data": redis_data})
sys.exit(app.exec())

View File

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

View File

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

View File

@@ -0,0 +1,382 @@
# pylint: disable = no-name-in-module,missing-module-docstring
import time
from collections import defaultdict
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QApplication
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_lib import MessageEndpoints
from bec_widgets.utils import yaml_dialog
from bec_widgets.utils.bec_dispatcher import BECDispatcher
CONFIG_DEFAULT = {
"plot_settings": {
"colormap": "CET-L4",
"num_columns": 1,
},
"waveform2D": [
{
"plot_name": "Waveform 2D Scatter (1)",
"x_label": "Sam X",
"y_label": "Sam Y",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "samy", "entry": "samy"}],
"z": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
},
},
{
"plot_name": "Waveform 2D Scatter (2)",
"x_label": "Sam Y",
"y_label": "Sam X",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "samx", "entry": "samx"}],
"z": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
},
},
],
}
class BECMonitor2DScatter(QWidget):
update_signal = pyqtSignal()
def __init__(
self,
parent=None,
client=None,
config: dict = None,
enable_crosshair: bool = True,
gui_id=None,
skip_validation: bool = True,
toolbar_enabled=True,
):
super().__init__(parent=parent)
# Client and device manager from BEC
self.plot_data = None
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
self.queue = self.client.queue
self.validator = None # TODO implement validator when ready
self.gui_id = gui_id
if self.gui_id is None:
self.gui_id = self.__class__.__name__ + str(time.time())
# Connect dispatcher slots #TODO connect endpoints related to CLI
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
# Config related variables
self.plot_data = None
self.plot_settings = None
self.num_columns = None
self.database = {}
self.plots = {}
self.grid_coordinates = []
self.curves_data = {}
# Current configuration
self.config = config
self.skip_validation = skip_validation
# Enable crosshair
self.enable_crosshair = enable_crosshair
# Displayed Data
self.database = {}
self.crosshairs = None
self.plots = None
self.curves_data = None
self.grid_coordinates = None
self.scanID = None
# Connect the update signal to the update plot method
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=10, slot=self.update_plot
)
# Init UI
self.layout = QVBoxLayout(self)
self.setLayout(self.layout)
if toolbar_enabled: # TODO implement toolbar when ready
self._init_toolbar()
self.glw = pg.GraphicsLayoutWidget()
self.layout.addWidget(self.glw)
if self.config is None:
print("No initial config found for BECDeviceMonitor")
else:
self.on_config_update(self.config)
def _init_toolbar(self):
"""Initialize the toolbar."""
# TODO implement toolbar when ready
# from bec_widgets.widgets import ModularToolBar
#
# # Create and configure the toolbar
# self.toolbar = ModularToolBar(self)
#
# # Add the toolbar to the layout
# self.layout.addWidget(self.toolbar)
def _init_config(self):
"""Initialize the configuration."""
# Global widget settings
self._get_global_settings()
# Plot data
self.plot_data = self.config.get("waveform2D", [])
# Initiate database
self.database = self._init_database()
# Initialize the plot UI
self._init_ui()
def _get_global_settings(self):
"""Get the global widget settings."""
self.plot_settings = self.config.get("plot_settings", {})
self.num_columns = self.plot_settings.get("num_columns", 1)
self.colormap = self.plot_settings.get("colormap", "viridis")
def _init_database(self) -> dict:
"""
Initialize the database to store the data for each plot.
Returns:
dict: The database.
"""
database = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
return database
def _init_ui(self, num_columns: int = 3) -> None:
"""
Initialize the UI components, create plots and store their grid positions.
Args:
num_columns (int): Number of columns to wrap the layout.
This method initializes a dictionary `self.plots` to store the plot objects
along with their corresponding x and y signal names. It dynamically arranges
the plots in a grid layout based on the given number of columns and dynamically
stretches the last plots to fit the remaining space.
"""
self.glw.clear()
self.plots = {}
self.imageItems = {}
self.grid_coordinates = []
self.scatterPlots = {}
self.colorBars = {}
num_plots = len(self.plot_data)
# Check if num_columns exceeds the number of plots
if num_columns >= num_plots:
num_columns = num_plots
self.plot_settings["num_columns"] = num_columns # Update the settings
print(
"Warning: num_columns in the YAML file was greater than the number of plots."
f" Resetting num_columns to number of plots:{num_columns}."
)
else:
self.plot_settings["num_columns"] = num_columns # Update the settings
num_rows = num_plots // num_columns
last_row_cols = num_plots % num_columns
remaining_space = num_columns - last_row_cols
for i, plot_config in enumerate(self.plot_data):
row, col = i // num_columns, i % num_columns
colspan = 1
if row == num_rows and remaining_space > 0:
if last_row_cols == 1:
colspan = num_columns
else:
colspan = remaining_space // last_row_cols + 1
remaining_space -= colspan - 1
last_row_cols -= 1
plot_name = plot_config.get("plot_name", "")
x_label = plot_config.get("x_label", "")
y_label = plot_config.get("y_label", "")
plot = self.glw.addPlot(row=row, col=col, colspan=colspan, title=plot_name)
plot.setLabel("bottom", x_label)
plot.setLabel("left", y_label)
plot.addLegend()
self.plots[plot_name] = plot
self.grid_coordinates.append((row, col))
self._init_curves()
def _init_curves(self):
"""Init scatter plot pg containers"""
self.scatterPlots = {}
for i, plot_config in enumerate(self.plot_data):
plot_name = plot_config.get("plot_name", "")
plot = self.plots[plot_name]
plot.clear()
# Create ScatterPlotItem for each plot
scatterPlot = pg.ScatterPlotItem(size=10)
plot.addItem(scatterPlot)
self.scatterPlots[plot_name] = scatterPlot
@pyqtSlot(dict)
def on_config_update(self, config: dict):
"""
Validate and update the configuration settings.
Args:
config(dict): Configuration settings
"""
# TODO implement BEC CLI commands similar to BECPlotter
# convert config from BEC CLI to correct formatting
config_tag = config.get("config", None)
if config_tag is not None:
config = config["config"]
if self.skip_validation is True:
self.config = config
self._init_config()
else: # TODO implement validator
print("Do validation")
def flush(self):
"""Reset current plot"""
self.database = self._init_database()
self._init_curves()
@pyqtSlot(dict, dict)
def on_scan_segment(self, msg, metadata):
"""
Handle new scan segments and saves data to a dictionary. Linked through bec_dispatcher.
Args:
msg (dict): Message received with scan data.
metadata (dict): Metadata of the scan.
"""
# TODO check if this is correct
current_scanID = msg.get("scanID", None)
if current_scanID is None:
return
if current_scanID != self.scanID:
self.scanID = current_scanID
self.scan_data = self.queue.scan_storage.find_scan_by_ID(self.scanID)
if not self.scan_data:
print(f"No data found for scanID: {self.scanID}") # TODO better error
return
self.flush()
# Update the database with new data
self.update_database_with_scan_data(msg)
# Emit signal to update plot #TODO could be moved to update_database_with_scan_data just for coresponding plot name
self.update_signal.emit()
def update_database_with_scan_data(self, msg):
"""
Update the database with data from the new scan segment.
Args:
msg (dict): Message containing the new scan data.
"""
data = msg.get("data", {})
for plot_config in self.plot_data: # Iterate over the list
plot_name = plot_config["plot_name"]
x_signal = plot_config["signals"]["x"][0]["name"]
y_signal = plot_config["signals"]["y"][0]["name"]
z_signal = plot_config["signals"]["z"][0]["name"]
if x_signal in data and y_signal in data and z_signal in data:
x_value = data[x_signal][x_signal]["value"]
y_value = data[y_signal][y_signal]["value"]
z_value = data[z_signal][z_signal]["value"]
# Update database for the corresponding plot
self.database[plot_name]["x"][x_signal].append(x_value)
self.database[plot_name]["y"][y_signal].append(y_value)
self.database[plot_name]["z"][z_signal].append(z_value)
def update_plot(self):
"""
Update the plots with the latest data from the database.
"""
for plot_name, scatterPlot in self.scatterPlots.items():
x_data = self.database[plot_name]["x"]
y_data = self.database[plot_name]["y"]
z_data = self.database[plot_name]["z"]
if x_data and y_data and z_data:
# Extract values for each axis
x_values = next(iter(x_data.values()), [])
y_values = next(iter(y_data.values()), [])
z_values = next(iter(z_data.values()), [])
# Check if the data lists are not empty
if x_values and y_values and z_values:
# Normalize z_values for color mapping
z_min, z_max = np.min(z_values), np.max(z_values)
if z_max != z_min: # Ensure that there is a range in the z values
z_values_norm = (z_values - z_min) / (z_max - z_min)
colormap = pg.colormap.get(
self.colormap
) # using colormap from global settings
colors = [colormap.map(z) for z in z_values_norm]
# Update scatter plot data with colors
scatterPlot.setData(x=x_values, y=y_values, brush=colors)
else:
# Handle case where all z values are the same (e.g., avoid division by zero)
scatterPlot.setData(x=x_values, y=y_values) # Default brush can be used
if __name__ == "__main__": # pragma: no cover
import argparse
import json
import sys
parser = argparse.ArgumentParser()
parser.add_argument("--config_file", help="Path to the config file.")
parser.add_argument("--config", help="Path to the config file.")
parser.add_argument("--id", help="GUI ID.")
args = parser.parse_args()
if args.config is not None:
# Load config from file
config = json.loads(args.config)
elif args.config_file is not None:
# Load config from file
config = yaml_dialog.load_yaml(args.config_file)
else:
config = CONFIG_DEFAULT
client = BECDispatcher().client
client.start()
app = QApplication(sys.argv)
monitor = BECMonitor2DScatter(
config=config,
gui_id=args.id,
skip_validation=True,
)
monitor.show()
sys.exit(app.exec())

View File

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

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

@@ -0,0 +1 @@
from .motor_map import MotorMap

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

View File

@@ -21,7 +21,8 @@ from qtpy.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([])

View File

@@ -6,7 +6,7 @@ from bec_lib import MessageEndpoints
from bec_lib.logger import bec_logger
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()

View File

@@ -6,7 +6,7 @@ from bec_lib import MessageEndpoints
from bec_lib.logger import bec_logger
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."):

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
(user.apps.modular_app)=
# Modular Application
_to be added..._

View 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
![Motor app example](motor_app_10fps.gif)

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

View File

@@ -0,0 +1,6 @@
(user.apps.plot_app)=
# General Plotting Tool
_to be added..._

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

View File

@@ -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
View 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:**
![Waveform 1D](./widgets/w1D.gif)
### 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:**
![Waveform 1D](./widgets/scatter_2D.gif)
### 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:**
![Waveform 1D](./widgets/motor.gif)

BIN
docs/user/widgets/motor.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

BIN
docs/user/widgets/w1D.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

View File

@@ -15,9 +15,7 @@ classifiers =
package_dir =
= .
packages = find:
python_requires = >=3.8
python_requires = >=3.9
[options.packages.find]
where = .

View File

@@ -1,7 +1,7 @@
# pylint: disable= missing-module-docstring
from setuptools import setup
from setuptools import setup, find_packages
__version__ = "0.33.0"
__version__ = "0.39.0"
# Default to PyQt6 if no other Qt binding is installed
QT_DEPENDENCY = "PyQt6>=6.0"
@@ -37,4 +37,7 @@ if __name__ == "__main__":
"pyqt6": ["PyQt6>=6.0"],
},
version=__version__,
packages=find_packages(),
include_package_data=True,
package_data={"": ["*.ui", "*.yaml"]},
)

0
tests/__init__.py Normal file
View File

35
tests/conftest.py Normal file
View 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

View File

@@ -1,21 +1,14 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from unittest.mock import Mock
import pytest
from bec_lib.messages import ScanMessage
from bec_lib.connector import MessageObject
# TODO: find a better way to mock singletons
from bec_widgets.bec_dispatcher import _BECDispatcher
msg = MessageObject(topic="", value=ScanMessage(point_id=0, scanID=0, data={}).dumps())
@pytest.fixture(name="bec_dispatcher")
def _bec_dispatcher():
bec_dispatcher = _BECDispatcher()
yield bec_dispatcher
@pytest.fixture(name="consumer")
def _consumer(bec_dispatcher):
bec_dispatcher.client.connector.consumer = Mock()
@@ -26,7 +19,7 @@ def _consumer(bec_dispatcher):
@pytest.mark.filterwarnings("ignore:Failed to connect to redis.")
def test_connect_one_slot(bec_dispatcher, consumer):
slot1 = Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
consumer.assert_called_once()
# trigger consumer callback as if a message was published
consumer.call_args.kwargs["cb"](msg)
@@ -37,8 +30,8 @@ def test_connect_one_slot(bec_dispatcher, consumer):
def test_connect_identical(bec_dispatcher, consumer):
slot1 = Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
consumer.assert_called_once()
consumer.call_args.kwargs["cb"](msg)
@@ -47,9 +40,9 @@ def test_connect_identical(bec_dispatcher, consumer):
def test_connect_many_slots_one_topic(bec_dispatcher, consumer):
slot1, slot2 = Mock(), Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
consumer.assert_called_once()
bec_dispatcher.connect_slot(slot=slot2, topic="topic0")
bec_dispatcher.connect_slot(slot=slot2, topics="topic0")
consumer.assert_called_once()
# trigger consumer callback as if a message was published
consumer.call_args.kwargs["cb"](msg)
@@ -62,9 +55,9 @@ def test_connect_many_slots_one_topic(bec_dispatcher, consumer):
def test_connect_one_slot_many_topics(bec_dispatcher, consumer):
slot1 = Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
assert consumer.call_count == 1
bec_dispatcher.connect_slot(slot=slot1, topic="topic1")
bec_dispatcher.connect_slot(slot=slot1, topics="topic1")
assert consumer.call_count == 2
# trigger consumer callback as if a message was published
consumer.call_args_list[0].kwargs["cb"](msg)
@@ -75,52 +68,63 @@ def test_connect_one_slot_many_topics(bec_dispatcher, consumer):
def test_disconnect_one_slot_one_topic(bec_dispatcher, consumer):
slot1, slot2 = Mock(), Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
# disconnect using a different slot
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic1")
# disconnect using a different topic
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic1")
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 1
# disconnect using a different topic
bec_dispatcher.disconnect_slot(slot=slot2, topic="topic0")
# disconnect using a different slot
bec_dispatcher.disconnect_slot(slot=slot2, topics="topic0")
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
# disconnect using the right slot and topic
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic0")
with pytest.raises(KeyError):
consumer.call_args.kwargs["cb"](msg)
# disconnect using the right slot and topics
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic0")
# reset count to 0 for slot
slot1.reset_mock()
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 0
def test_disconnect_identical(bec_dispatcher, consumer):
slot1 = Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic0")
with pytest.raises(KeyError):
consumer.call_args.kwargs["cb"](msg)
# Try to connect slot twice
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
# Test to call the slot once (slot should be not connected twice)
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 1
# Disconnect the slot
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic0")
# Test to call the slot once (slot should be not connected anymore), count remains 1
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 1
def test_disconnect_many_slots_one_topic(bec_dispatcher, consumer):
slot1, slot2, slot3 = Mock(), Mock(), Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot2, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
bec_dispatcher.connect_slot(slot=slot2, topics="topic0")
# disconnect using a different slot
bec_dispatcher.disconnect_slot(slot3, topic="topic0")
bec_dispatcher.disconnect_slot(slot3, topics="topic0")
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 1
assert slot2.call_count == 1
# disconnect using a different topic
bec_dispatcher.disconnect_slot(slot1, topic="topic1")
# disconnect using a different topics
bec_dispatcher.disconnect_slot(slot1, topics="topic1")
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
assert slot2.call_count == 2
# disconnect using the right slot and topic
bec_dispatcher.disconnect_slot(slot1, topic="topic0")
# disconnect using the right slot and topics
bec_dispatcher.disconnect_slot(slot1, topics="topic0")
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
assert slot2.call_count == 3
@@ -128,33 +132,110 @@ def test_disconnect_many_slots_one_topic(bec_dispatcher, consumer):
def test_disconnect_one_slot_many_topics(bec_dispatcher, consumer):
slot1, slot2 = Mock(), Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topic="topic1")
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
bec_dispatcher.connect_slot(slot=slot1, topics="topic1")
# disconnect using a different slot
bec_dispatcher.disconnect_slot(slot=slot2, topic="topic0")
bec_dispatcher.disconnect_slot(slot=slot2, topics="topic0")
consumer.call_args_list[0].kwargs["cb"](msg)
assert slot1.call_count == 1
consumer.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 2
# disconnect using a different topic
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic3")
# disconnect using a different topics
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic3")
consumer.call_args_list[0].kwargs["cb"](msg)
assert slot1.call_count == 3
consumer.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 4
# disconnect using the right slot and topic
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic0")
with pytest.raises(KeyError):
consumer.call_args_list[0].kwargs["cb"](msg)
# disconnect using the right slot and topics
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic0")
# Calling disconnected topic0 should not call slot1
consumer.call_args_list[0].kwargs["cb"](msg)
assert slot1.call_count == 4
# Calling topic1 should still call slot1
consumer.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 5
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic1")
with pytest.raises(KeyError):
consumer.call_args_list[0].kwargs["cb"](msg)
with pytest.raises(KeyError):
consumer.call_args_list[1].kwargs["cb"](msg)
# disconnect remaining topic1 from slot1, calling any topic should not increase count
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic1")
consumer.call_args_list[0].kwargs["cb"](msg)
consumer.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 5
def test_disconnect_all(bec_dispatcher, consumer):
# Mock slots to connect
slot1, slot2, slot3 = Mock(), Mock(), Mock()
# Connect slots to different topics
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
bec_dispatcher.connect_slot(slot=slot2, topics="topic1")
bec_dispatcher.connect_slot(slot=slot3, topics="topic2")
# Call disconnect_all method
bec_dispatcher.disconnect_all()
# Simulate messages and verify that none of the slots are called
consumer.call_args_list[0].kwargs["cb"](msg)
consumer.call_args_list[1].kwargs["cb"](msg)
consumer.call_args_list[2].kwargs["cb"](msg)
# Ensure that the slots have not been called
assert slot1.call_count == 0
assert slot2.call_count == 0
assert slot3.call_count == 0
# Also, check that the consumer for each topic is shutdown
assert "topic0" not in bec_dispatcher._connections
assert "topic1" not in bec_dispatcher._connections
assert "topic2" not in bec_dispatcher._connections
def test_connect_one_slot_multiple_topics_single_callback(bec_dispatcher, consumer):
slot1 = Mock()
# Connect the slot to multiple topics using a single callback
topics = ["topic1", "topic2"]
bec_dispatcher.connect_slot(slot=slot1, topics=topics, single_callback_for_all_topics=True)
# Verify the initial state
assert len(bec_dispatcher._connections) == 1 # One connection for all topics
assert len(bec_dispatcher._connections[tuple(sorted(topics))].slots) == 1 # One slot connected
# Simulate messages being published on each topic
for topic in topics:
msg_with_topic = MessageObject(
topic=topic, value=ScanMessage(point_id=0, scanID=0, data={}).dumps()
)
consumer.call_args.kwargs["cb"](msg_with_topic)
# Verify that the slot is called once for each topic
assert slot1.call_count == len(topics)
# Verify that a single consumer is created for all topics
consumer.assert_called_once()
def test_disconnect_all_with_single_callback_for_multiple_topics(bec_dispatcher, consumer):
slot1 = Mock()
# Connect the slot to multiple topics using a single callback
topics = ["topic1", "topic2"]
bec_dispatcher.connect_slot(slot=slot1, topics=topics, single_callback_for_all_topics=True)
# Verify the initial state
assert len(bec_dispatcher._connections) == 1 # One connection for all topics
assert len(bec_dispatcher._connections[tuple(sorted(topics))].slots) == 1 # One slot connected
# Call disconnect_all method
bec_dispatcher.disconnect_all()
# Verify that the slot is disconnected
assert len(bec_dispatcher._connections) == 0 # All connections are removed
assert slot1.call_count == 0 # Slot has not been called
# Simulate messages and verify that the slot is not called
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 0 # Slot has not been called

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import os
import yaml
@@ -22,6 +23,7 @@ class FakeDevice:
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
@@ -38,6 +40,14 @@ class FakeDevice:
"""
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):
"""
@@ -160,6 +170,13 @@ def mock_getitem(dev_name):
return mock_instance
def mock_get_scan_storage(scan_id, data):
"""Helper function to mock the __getitem__ method of the 'dev'."""
mock_instance = MagicMock()
mock_instance.get_scan_storage.return_value = data
return mock_instance
# mocked messages and metadata
msg_1 = {
"data": {
@@ -178,17 +195,32 @@ metadata_line = {"scan_name": "line_scan"}
@pytest.mark.parametrize(
"config_name, msg, metadata, expected_data",
[
# case: msg does not have 'scanid'
("config_device", {"data": {}}, {}, {}),
# case: msg does not have 'scanID'
(
"config_device",
{"data": {}},
{},
{
"scan_segment": {
"bpm4i": {"bpm4i": []},
"gauss_adc1": {"gauss_adc1": []},
"gauss_adc2": {"gauss_adc2": []},
"samx": {"samx": []},
}
},
),
# case: scan_types is false, msg contains all valid fields, and entry is present in config
(
"config_device",
msg_1,
{},
{
("samx", "samx", "bpm4i", "bpm4i"): {"x": [10], "y": [5]},
("samx", "samx", "gauss_adc1", "gauss_adc1"): {"x": [10], "y": [8]},
("samx", "samx", "gauss_adc2", "gauss_adc2"): {"x": [10], "y": [9]},
"scan_segment": {
"bpm4i": {"bpm4i": [5]},
"gauss_adc1": {"gauss_adc1": [8]},
"gauss_adc2": {"gauss_adc2": [9]},
"samx": {"samx": [10]},
}
},
),
# case: scan_types is false, msg contains all valid fields and entry is missing in config, should use hints
@@ -197,8 +229,11 @@ metadata_line = {"scan_name": "line_scan"}
msg_1,
{},
{
("samx", "samx", "bpm4i", "bpm4i"): {"x": [10], "y": [5]},
("samx", "samx", "gauss_bpm", "gauss_bpm"): {"x": [10], "y": [6]},
"scan_segment": {
"bpm4i": {"bpm4i": [5]},
"gauss_bpm": {"gauss_bpm": [6]},
"samx": {"samx": [10]},
}
},
),
# case: scan_types is true, msg contains all valid fields, metadata contains scan "line_scan:"
@@ -207,10 +242,13 @@ metadata_line = {"scan_name": "line_scan"}
msg_1,
metadata_line,
{
("samx", "samx", "bpm4i", "bpm4i"): {"x": [10], "y": [5]},
("samx", "samx", "gauss_bpm", "gauss_bpm"): {"x": [10], "y": [6]},
("samx", "samx", "gauss_adc1", "gauss_adc1"): {"x": [10], "y": [8]},
("samx", "samx", "gauss_adc2", "gauss_adc2"): {"x": [10], "y": [9]},
"scan_segment": {
"bpm4i": {"bpm4i": [5]},
"gauss_adc1": {"gauss_adc1": [8]},
"gauss_adc2": {"gauss_adc2": [9]},
"gauss_bpm": {"gauss_bpm": [6]},
"samx": {"samx": [10]},
}
},
),
(
@@ -218,10 +256,13 @@ metadata_line = {"scan_name": "line_scan"}
msg_1,
metadata_grid,
{
("samx", "samx", "bpm4i", "bpm4i"): {"x": [10], "y": [5]},
("samx", "samx", "gauss_adc1", "gauss_adc1"): {"x": [10], "y": [8]},
("samx", "samx", "gauss_adc2", "gauss_adc2"): {"x": [10], "y": [9]},
("samx", "samx", "gauss_bpm", "gauss_bpm"): {"x": [10], "y": [6]},
"scan_segment": {
"bpm4i": {"bpm4i": [5]},
"gauss_adc1": {"gauss_adc1": [8]},
"gauss_adc2": {"gauss_adc2": [9]},
"gauss_bpm": {"gauss_bpm": [6]},
"samx": {"samx": [10]},
}
},
),
],
@@ -229,8 +270,20 @@ metadata_line = {"scan_name": "line_scan"}
def test_on_scan_segment(monitor, config_name, msg, metadata, expected_data):
config = load_test_config(config_name)
monitor.on_config_update(config)
# Get hints
monitor.dev.__getitem__.side_effect = mock_getitem
# Mock scan_storage.find_scan_by_ID
mock_scan_data = MagicMock()
mock_scan_data.data = {
device_name: {
entry: MagicMock(val=[msg["data"][device_name][entry]["value"]])
for entry in msg["data"][device_name]
}
for device_name in msg["data"]
}
monitor.queue.scan_storage.find_scan_by_ID.return_value = mock_scan_data
monitor.on_scan_segment(msg, metadata)
assert monitor.data == expected_data
assert monitor.database == expected_data

View File

@@ -0,0 +1,186 @@
# pylint: disable=missing-module-docstring, missing-function-docstring
from collections import defaultdict
import pytest
from unittest.mock import MagicMock
from qtpy import QtGui
from bec_widgets.widgets import BECMonitor2DScatter
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 X",
"y_label": "Sam Y",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "samx", "entry": "samx"}],
"z": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
},
},
],
}
CONFIG_ONE_PLOT = {
"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": "aptrx", "entry": "aptrx"}],
"y": [{"name": "aptry", "entry": "aptry"}],
"z": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
},
},
],
}
@pytest.fixture(scope="function")
def monitor_2Dscatter(qtbot):
client = MagicMock()
widget = BECMonitor2DScatter(client=client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.mark.parametrize(
"config, number_of_plots",
[
(CONFIG_DEFAULT, 2),
(CONFIG_ONE_PLOT, 1),
],
)
def test_initialization(monitor_2Dscatter, config, number_of_plots):
config_load = config
monitor_2Dscatter.on_config_update(config_load)
assert isinstance(monitor_2Dscatter, BECMonitor2DScatter)
assert monitor_2Dscatter.client is not None
assert monitor_2Dscatter.config == config_load
assert len(monitor_2Dscatter.plot_data) == number_of_plots
@pytest.mark.parametrize(
"config ",
[
(CONFIG_DEFAULT),
(CONFIG_ONE_PLOT),
],
)
def test_database_initialization(monitor_2Dscatter, config):
monitor_2Dscatter.on_config_update(config)
# Check if the database is a defaultdict
assert isinstance(monitor_2Dscatter.database, defaultdict)
for axis_dict in monitor_2Dscatter.database.values():
assert isinstance(axis_dict, defaultdict)
for signal_list in axis_dict.values():
assert isinstance(signal_list, defaultdict)
# Access the elements
for plot_config in config["waveform2D"]:
plot_name = plot_config["plot_name"]
for axis in ["x", "y", "z"]:
for signal in plot_config["signals"][axis]:
signal_name = signal["name"]
assert not monitor_2Dscatter.database[plot_name][axis][signal_name]
assert isinstance(monitor_2Dscatter.database[plot_name][axis][signal_name], list)
@pytest.mark.parametrize(
"config ",
[
(CONFIG_DEFAULT),
(CONFIG_ONE_PLOT),
],
)
def test_ui_initialization(monitor_2Dscatter, config):
monitor_2Dscatter.on_config_update(config)
assert len(monitor_2Dscatter.plots) == len(config["waveform2D"])
for plot_config in config["waveform2D"]:
plot_name = plot_config["plot_name"]
assert plot_name in monitor_2Dscatter.plots
plot = monitor_2Dscatter.plots[plot_name]
assert plot.titleLabel.text == plot_name
def simulate_scan_data(monitor, x_value, y_value, z_value):
"""Helper function to simulate scan data input with three devices."""
msg = {
"data": {
"samx": {"samx": {"value": x_value}},
"samy": {"samy": {"value": y_value}},
"gauss_bpm": {"gauss_bpm": {"value": z_value}},
},
"scanID": 1,
}
monitor.on_scan_segment(msg, {})
def test_data_update_and_plotting(monitor_2Dscatter, qtbot):
monitor_2Dscatter.on_config_update(CONFIG_DEFAULT)
data_sets = [(1, 4, 7), (2, 5, 8), (3, 6, 9)] # (x, y, z) tuples
plot_name = "Waveform 2D Scatter (1)"
for x, y, z in data_sets:
simulate_scan_data(monitor_2Dscatter, x, y, z)
qtbot.wait(100) # Wait for the plot to update
# Retrieve the plot and check if the number of data points matches
scatterPlot = monitor_2Dscatter.scatterPlots[plot_name]
assert len(scatterPlot.data) == len(data_sets)
# Check if the data in the database matches the sent data
x_data = [
point
for points_list in monitor_2Dscatter.database[plot_name]["x"].values()
for point in points_list
]
y_data = [
point
for points_list in monitor_2Dscatter.database[plot_name]["y"].values()
for point in points_list
]
z_data = [
point
for points_list in monitor_2Dscatter.database[plot_name]["z"].values()
for point in points_list
]
assert x_data == [x for x, _, _ in data_sets]
assert y_data == [y for _, y, _ in data_sets]
assert z_data == [z for _, _, z in data_sets]
def test_color_mapping(monitor_2Dscatter, qtbot):
monitor_2Dscatter.on_config_update(CONFIG_DEFAULT)
data_sets = [(1, 4, 7), (2, 5, 8), (3, 6, 9)] # (x, y, z) tuples
for x, y, z in data_sets:
simulate_scan_data(monitor_2Dscatter, x, y, z)
qtbot.wait(100) # Wait for the plot to update
scatterPlot = monitor_2Dscatter.scatterPlots["Waveform 2D Scatter (1)"]
# Check if colors are applied
assert all(isinstance(point.brush().color(), QtGui.QColor) for point in scatterPlot.points())

View File

@@ -1,4 +1,7 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import os
from unittest.mock import MagicMock
import yaml
import pytest
@@ -15,9 +18,73 @@ def load_test_config(config_name):
return config
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 config_dialog(qtbot):
widget = ConfigDialog()
def mocked_client():
# Create a dictionary of mocked devices
device_names = ["samx", "gauss_bpm", "gauss_adc1", "gauss_adc2", "gauss_adc3", "bpm4i"]
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
@pytest.fixture(scope="function")
def config_dialog(qtbot, mocked_client):
client = mocked_client
widget = ConfigDialog(client=client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@@ -121,6 +188,7 @@ def test_add_new_plot_and_modify(config_dialog):
# Ensure the tab count is initially 1 and it is called "Default"
assert config_dialog.tabWidget_scan_types.count() == 1
assert config_dialog.tabWidget_scan_types.tabText(0) == "Default"
# Get the first tab (which should be a scan tab)
scan_tab = config_dialog.tabWidget_scan_types.widget(0)
@@ -149,8 +217,7 @@ def test_add_new_plot_and_modify(config_dialog):
new_plot_tab.ui.lineEdit_x_entry.setText("Modified X Entry")
# Modify the table for signals
# new_plot_tab.ui.pushButton_y_new.click() # Press button to add a new row
config_dialog.add_new_signal(new_plot_tab.ui.tableWidget_y_signals) # TODO change to click?
config_dialog.add_new_signal(new_plot_tab.ui.tableWidget_y_signals)
table = new_plot_tab.ui.tableWidget_y_signals
assert table.rowCount() == 1 # Ensure the new row is added
@@ -160,17 +227,18 @@ def test_add_new_plot_and_modify(config_dialog):
# Modify the first row
table.setItem(row_position, 0, QTableWidgetItem("New Signal Name"))
table.setItem(row_position, 1, QTableWidgetItem("New Signal Entry"))
# Apply the configuration
config = config_dialog.apply_config()
# Check if the modifications are reflected in the configuration
modified_plot_config = config["plot_data"][
1
] # Assuming the new plot is the second item in the plot_data list
modified_plot_config = config["plot_data"][1] # Access the second plot in the plot_data list
sources = modified_plot_config["sources"][0] # Access the first source in the sources list
assert modified_plot_config["plot_name"] == "Modified Plot Title"
assert modified_plot_config["x"]["label"] == "Modified X Label"
assert modified_plot_config["y"]["label"] == "Modified Y Label"
assert modified_plot_config["x"]["signals"][0]["name"] == "Modified X Name"
assert modified_plot_config["x"]["signals"][0]["entry"] == "Modified X Entry"
assert modified_plot_config["y"]["signals"][0]["name"] == "New Signal Name"
assert modified_plot_config["y"]["signals"][0]["entry"] == "New Signal Entry"
assert modified_plot_config["x_label"] == "Modified X Label"
assert modified_plot_config["y_label"] == "Modified Y Label"
assert sources["signals"]["x"][0]["name"] == "Modified X Name"
assert sources["signals"]["x"][0]["entry"] == "Modified X Entry"
assert sources["signals"]["y"][0]["name"] == "New Signal Name"
assert sources["signals"]["y"][0]["entry"] == "New Signal Entry"

View File

@@ -1,45 +0,0 @@
import pyqtgraph as pg
from pytestqt import qtbot
from bec_widgets import config_plotter
def test_config_plotter(qtbot):
"""Test ConfigPlotter"""
config = [
{
"cols": 1,
"rows": 1,
"y": 0,
"x": 0,
"config": {"channels": ["a"], "label_xy": ["", "a"], "item": "PlotItem"},
}
]
plotter = config_plotter.ConfigPlotter(config)
assert isinstance(plotter.plots["a"]["item"], pg.PlotItem)
def test_config_plotter_image(qtbot):
"""Test ConfigPlotter"""
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": "ImageItem"},
},
]
plotter = config_plotter.ConfigPlotter(config)
assert isinstance(plotter.plots["a"]["item"], pg.PlotItem)

View File

@@ -5,26 +5,29 @@ plot_settings:
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"
x_label: "Motor X"
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_adc1"
entry: "gauss_adc1"
- name: "gauss_adc2"
entry: "gauss_adc2"
x_label: "Motor X"
y_label: "Gauss"
sources:
- type: "scan_segment"
signals:
x:
- name: "samx"
entry: "samx"
y:
- name: "gauss_adc1"
entry: "gauss_adc1"
- name: "gauss_adc2"
entry: "gauss_adc2"

View File

@@ -1,24 +1,27 @@
plot_settings:
background_color: "white"
num_columns: 5
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"
y:
label: "bpm4i"
signals:
- name: "bpm4i"
x_label: "Motor X"
y_label: "bpm4i"
sources:
- type: "scan_segment"
signals:
x:
- name : "samx"
y:
- name : "bpm4i"
- plot_name: "Gauss plots vs samx"
x:
label: "Motor X"
signals:
- name: "samx"
y:
label: "Gauss"
signals:
- name: "gauss_bpm"
x_label: "Motor X"
y_label: "Gauss"
sources:
- type: "scan_segment"
signals:
x:
- name: "samx"
y:
- name: "gauss_bpm"

View File

@@ -6,72 +6,77 @@ plot_settings:
plot_data:
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"
x_label: "Motor X"
y_label: "BPM"
sources:
- type: "scan_segment"
signals:
x:
- name: "samx"
entry: "samx"
y:
- name: "gauss_bpm"
entry: "gauss_bpm"
- plot_name: "Grid plot 2"
x:
label: "Motor X"
signals:
- name: "samx"
entry: "samx"
y:
label: "BPM"
signals:
- 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"
entry: "gauss_adc1"
- plot_name: "Grid plot 3"
x:
label: "Motor Y"
signals:
- name: "samx"
entry: "samx"
y:
label: "BPM"
signals:
- name: "gauss_adc2"
entry: "gauss_adc2"
x_label: "Motor X"
y_label: "BPM"
sources:
- type: "scan_segment"
signals:
x:
- name: "samx"
entry: "samx"
y:
- name: "gauss_adc2"
entry: "gauss_adc2"
- plot_name: "Grid plot 4"
x:
label: "Motor Y"
signals:
- name: "samx"
entry: "samx"
y:
label: "BPM"
signals:
- name: "bpm4i"
entry: "bpm4i"
x_label: "Motor X"
y_label: "BPM"
sources:
- type: "scan_segment"
signals:
x:
- name: "samx"
entry: "samx"
y:
- name: "bpm4i"
entry: "bpm4i"
line_scan:
- plot_name: "Multiple Gauss 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 Plot"
x:
label: "Motor X"
signals:
- name: "samx"
entry: "samx"
y:
label: "Multi"
signals:
- name: "bpm4i"
entry: "bpm4i"
- plot_name: "Multiple Gauss Plot"
x_label: "Motor X"
y_label: "BPM"
sources:
- type: "scan_segment"
signals:
x:
- name: "samx"
y:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "gauss_adc1"
entry: "gauss_adc1"
- name: "gauss_adc2"
entry: "gauss_adc2"
- plot_name: "BPM Plot"
x_label: "Motor X"
y_label: "BPM"
sources:
- type: "scan_segment"
signals:
x:
- name: "samx"
y:
- name: "bpm4i"
entry: "bpm4i"

View File

@@ -1,8 +1,9 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import QPointF
from bec_widgets.qt_utils import Crosshair
from bec_widgets.utils import Crosshair
def test_mouse_moved_lines(qtbot):

View File

@@ -1,4 +1,4 @@
# pylint: disable=no-name-in-module
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import os
import tempfile

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import json
from unittest.mock import MagicMock, patch
import numpy as np
@@ -14,6 +15,7 @@ def eiger_plot_instance(qtbot):
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
@pytest.mark.parametrize(
@@ -80,27 +82,24 @@ def test_zmq_consumer(eiger_plot_instance, qtbot):
fake_meta = json.dumps({"type": "int32", "shape": (2, 2)}).encode("utf-8")
fake_data = np.array([[1, 2], [3, 4]], dtype="int32").tobytes()
with patch("zmq.Context") as MockContext:
MockContext.reset_mock() # Reset the mock here
# Mocking zmq socket and its methods
with patch("zmq.Context", autospec=True) as MockContext:
mock_socket = MagicMock()
MockContext().socket.return_value = mock_socket
mock_socket.recv_multipart.side_effect = [[fake_meta, fake_data], Exception("Break loop")]
mock_socket.recv_multipart.side_effect = ((fake_meta, fake_data),)
MockContext.return_value.socket.return_value = mock_socket
# Mocking the update_signal to check if it gets emitted
eiger_plot_instance.update_signal = MagicMock()
try:
with patch("zmq.Poller"):
# will do only 1 iteration of the loop in the thread
eiger_plot_instance._zmq_consumer_exit_event.set()
# Run the method under test
eiger_plot_instance.zmq_consumer()
except Exception as e:
# Ensure the loop was broken by our mocked exception
assert str(e) == "Break loop"
consumer_thread = eiger_plot_instance.start_zmq_consumer()
consumer_thread.join()
# Check if zmq methods are called
# MockContext.assert_called_once()
assert MockContext.call_count == 2 # TODO why 2?
assert MockContext.call_count == 1
mock_socket.connect.assert_called_with("tcp://129.129.95.38:20000")
mock_socket.setsockopt_string.assert_called_with(zmq.SUBSCRIBE, "")
mock_socket.recv_multipart.assert_called()

649
tests/test_motor_control.py Normal file
View File

@@ -0,0 +1,649 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from unittest.mock import patch
from bec_lib.device import Positioner
import pytest
from unittest.mock import MagicMock
from bec_widgets.widgets import (
MotorControlSelection,
MotorControlAbsolute,
MotorControlRelative,
MotorThread,
MotorCoordinateTable,
)
from bec_widgets.examples import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
)
from bec_widgets.widgets.motor_control.motor_control import MotorActions
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"}],
},
},
],
}
#######################################################
# Client and devices fixture
#######################################################
class FakeDevice:
"""Fake minimal positioner class for testing."""
def __init__(self, name, enabled=True, limits=None, read_value=1.0):
super().__init__()
self.name = name
self.enabled = enabled
self.read_value = read_value
self.limits = limits or (-100, 100) # Default limits if not provided
def read(self):
"""Simulates reading the current position of the device."""
return {self.name: {"value": self.read_value}}
def move(self, value, relative=False):
"""Simulates moving the device to a new position."""
if relative:
self.read_value += value
else:
self.read_value = value
# Respect the limits
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
@property
def readback(self):
return MagicMock(get=MagicMock(return_value=self.read_value))
def describe(self):
"""Describes the device."""
return {self.name: {"source": self.name, "dtype": "number", "shape": []}}
@pytest.fixture
def mocked_client():
client = MagicMock()
# Setup the fake devices
motors = {
"samx": FakeDevice("samx", limits=[-10, 10], read_value=2.0),
"samy": FakeDevice("samy", limits=[-5, 5], read_value=3.0),
"aptrx": FakeDevice("aptrx", read_value=4.0),
"aptry": FakeDevice("aptry", read_value=5.0),
}
client.device_manager.devices = MagicMock()
client.device_manager.devices.__getitem__.side_effect = lambda x: motors.get(x, FakeDevice(x))
client.device_manager.devices.enabled_devices = list(motors.values())
# Mock the scans.mv method
def mock_mv(*args, relative=False):
# Extracting motor and value pairs
for i in range(0, len(args), 2):
motor = args[i]
value = args[i + 1]
motor.move(value, relative=relative)
return MagicMock(wait=MagicMock()) # Simulate wait method of the move status object
client.scans = MagicMock(mv=mock_mv)
# Ensure isinstance check for Positioner passes
original_isinstance = isinstance
def isinstance_mock(obj, class_info):
if class_info == Positioner:
return True
return original_isinstance(obj, class_info)
with patch("builtins.isinstance", new=isinstance_mock):
yield client
#######################################################
# Motor Thread
#######################################################
@pytest.fixture
def motor_thread(mocked_client):
"""Fixture for MotorThread with a mocked client."""
return MotorThread(client=mocked_client)
def test_motor_thread_initialization(mocked_client):
motor_thread = MotorThread(client=mocked_client)
assert motor_thread.client == mocked_client
assert isinstance(motor_thread.dev, MagicMock)
def test_get_all_motors_names(mocked_client):
motor_thread = MotorThread(client=mocked_client)
motor_names = motor_thread.get_all_motors_names()
expected_names = ["samx", "samy", "aptrx", "aptry"]
assert sorted(motor_names) == sorted(expected_names)
assert all(name in motor_names for name in expected_names)
assert len(motor_names) == len(expected_names) # Ensure only these motors are returned
def test_get_coordinates(mocked_client):
motor_thread = MotorThread(client=mocked_client)
motor_x, motor_y = "samx", "samy"
x, y = motor_thread.get_coordinates(motor_x, motor_y)
assert x == mocked_client.device_manager.devices[motor_x].readback.get()
assert y == mocked_client.device_manager.devices[motor_y].readback.get()
def test_move_motor_absolute_by_run(mocked_client):
motor_thread = MotorThread(client=mocked_client)
motor_thread.motor_x = "samx"
motor_thread.motor_y = "samy"
motor_thread.target_coordinates = (5.0, -3.0)
motor_thread.action = MotorActions.MOVE_ABSOLUTE
motor_thread.run()
assert mocked_client.device_manager.devices["samx"].read_value == 5.0
assert mocked_client.device_manager.devices["samy"].read_value == -3.0
def test_move_motor_relative_by_run(mocked_client):
motor_thread = MotorThread(client=mocked_client)
motor_thread.motor = "samx"
motor_thread.value = 2.0
motor_thread.action = MotorActions.MOVE_RELATIVE
motor_thread.run()
assert mocked_client.device_manager.devices["samx"].read_value == 4.0
def test_motor_thread_move_absolute(motor_thread):
motor_x = "samx"
motor_y = "samy"
target_x = 5.0
target_y = -3.0
motor_thread.move_absolute(motor_x, motor_y, (target_x, target_y))
motor_thread.wait()
assert motor_thread.dev[motor_x].read()["samx"]["value"] == target_x
assert motor_thread.dev[motor_y].read()["samy"]["value"] == target_y
def test_motor_thread_move_relative(motor_thread):
motor_name = "samx"
move_value = 2.0
initial_value = motor_thread.dev[motor_name].read()["samx"]["value"]
motor_thread.move_relative(motor_name, move_value)
motor_thread.wait()
expected_value = initial_value + move_value
assert motor_thread.dev[motor_name].read()["samx"]["value"] == expected_value
#######################################################
# Motor Control Widgets - MotorControlSelection
#######################################################
@pytest.fixture(scope="function")
def motor_selection_widget(qtbot, mocked_client, motor_thread):
"""Fixture for creating a MotorControlSelection widget with a mocked client."""
widget = MotorControlSelection(
client=mocked_client, config=CONFIG_DEFAULT, motor_thread=motor_thread
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def test_initialization_and_population(motor_selection_widget):
assert motor_selection_widget.comboBox_motor_x.count() == 4
assert motor_selection_widget.comboBox_motor_x.itemText(0) == "samx"
assert motor_selection_widget.comboBox_motor_y.itemText(1) == "samy"
assert motor_selection_widget.comboBox_motor_x.itemText(2) == "aptrx"
assert motor_selection_widget.comboBox_motor_y.itemText(3) == "aptry"
def test_selection_and_signal_emission(motor_selection_widget):
# Connect signal to a custom slot to capture the emitted values
emitted_values = []
def capture_emitted_values(motor_x, motor_y):
emitted_values.append((motor_x, motor_y))
motor_selection_widget.selected_motors_signal.connect(capture_emitted_values)
# Select motors
motor_selection_widget.comboBox_motor_x.setCurrentIndex(0) # Select 'samx'
motor_selection_widget.comboBox_motor_y.setCurrentIndex(1) # Select 'samy'
motor_selection_widget.pushButton_connecMotors.click() # Emit the signal
# Verify the emitted signal
assert emitted_values == [
("samx", "samy")
], "The emitted signal did not match the expected values"
def test_configuration_update(motor_selection_widget):
new_config = {"motor_control": {"motor_x": "samy", "motor_y": "samx"}}
motor_selection_widget.on_config_update(new_config)
assert motor_selection_widget.comboBox_motor_x.currentText() == "samy"
assert motor_selection_widget.comboBox_motor_y.currentText() == "samx"
def test_enable_motor_controls(motor_selection_widget):
motor_selection_widget.enable_motor_controls(False)
assert not motor_selection_widget.comboBox_motor_x.isEnabled()
assert not motor_selection_widget.comboBox_motor_y.isEnabled()
motor_selection_widget.enable_motor_controls(True)
assert motor_selection_widget.comboBox_motor_x.isEnabled()
assert motor_selection_widget.comboBox_motor_y.isEnabled()
#######################################################
# Motor Control Widgets - MotorControlAbsolute
#######################################################
@pytest.fixture(scope="function")
def motor_absolute_widget(qtbot, mocked_client, motor_thread):
widget = MotorControlAbsolute(
client=mocked_client, config=CONFIG_DEFAULT, motor_thread=motor_thread
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def test_absolute_initialization(motor_absolute_widget):
motor_absolute_widget.change_motors("samx", "samy")
motor_absolute_widget.on_config_update(CONFIG_DEFAULT)
assert motor_absolute_widget.motor_x == "samx", "Motor X not initialized correctly"
assert motor_absolute_widget.motor_y == "samy", "Motor Y not initialized correctly"
assert motor_absolute_widget.precision == CONFIG_DEFAULT["motor_control"]["precision"]
def test_absolute_save_current_coordinates(motor_absolute_widget):
motor_absolute_widget.client.device_manager["samx"].set_value(2.0)
motor_absolute_widget.client.device_manager["samy"].set_value(3.0)
motor_absolute_widget.change_motors("samx", "samy")
emitted_coordinates = []
def capture_emit(x_y):
emitted_coordinates.append(x_y)
motor_absolute_widget.coordinates_signal.connect(capture_emit)
# Trigger saving current coordinates
motor_absolute_widget.pushButton_save.click()
# Default position of samx and samy are 2.0 and 3.0 respectively
assert emitted_coordinates == [(2.0, 3.0)]
def test_absolute_set_absolute_coordinates(motor_absolute_widget):
motor_absolute_widget.spinBox_absolute_x.setValue(5)
motor_absolute_widget.spinBox_absolute_y.setValue(10)
# Connect to the coordinates_signal to capture emitted values
emitted_values = []
def capture_coordinates(x_y):
emitted_values.append(x_y)
motor_absolute_widget.coordinates_signal.connect(capture_coordinates)
# Simulate button click for absolute movement
motor_absolute_widget.pushButton_set.click()
assert emitted_values == [(5, 10)]
def test_absolute_go_absolute_coordinates(motor_absolute_widget):
motor_absolute_widget.change_motors("samx", "samy")
motor_absolute_widget.spinBox_absolute_x.setValue(5)
motor_absolute_widget.spinBox_absolute_y.setValue(10)
with patch(
"bec_widgets.widgets.motor_control.motor_control.MotorThread.move_absolute",
new_callable=MagicMock,
) as mock_move_absolute:
motor_absolute_widget.pushButton_go_absolute.click()
mock_move_absolute.assert_called_once_with("samx", "samy", (5, 10))
def test_change_motor_absolute(motor_absolute_widget):
motor_absolute_widget.change_motors("aptrx", "aptry")
assert motor_absolute_widget.motor_x == "aptrx"
assert motor_absolute_widget.motor_y == "aptry"
motor_absolute_widget.change_motors("samx", "samy")
assert motor_absolute_widget.motor_x == "samx"
assert motor_absolute_widget.motor_y == "samy"
def test_set_precision(motor_absolute_widget):
motor_absolute_widget.on_config_update(CONFIG_DEFAULT)
motor_absolute_widget.set_precision(2)
assert motor_absolute_widget.spinBox_absolute_x.decimals() == 2
assert motor_absolute_widget.spinBox_absolute_y.decimals() == 2
#######################################################
# Motor Control Widgets - MotorControlRelative
#######################################################
@pytest.fixture(scope="function")
def motor_relative_widget(qtbot, mocked_client, motor_thread):
widget = MotorControlRelative(
client=mocked_client, config=CONFIG_DEFAULT, motor_thread=motor_thread
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def test_initialization_and_config_update(motor_relative_widget):
motor_relative_widget.on_config_update(CONFIG_DEFAULT)
assert motor_relative_widget.motor_x == CONFIG_DEFAULT["motor_control"]["motor_x"]
assert motor_relative_widget.motor_y == CONFIG_DEFAULT["motor_control"]["motor_y"]
assert motor_relative_widget.precision == CONFIG_DEFAULT["motor_control"]["precision"]
# Simulate a configuration update
new_config = {
"motor_control": {
"motor_x": "new_motor_x",
"motor_y": "new_motor_y",
"precision": 2,
"step_size_x": 5,
"step_size_y": 5,
"step_x_y_same": True,
"move_with_arrows": True,
}
}
motor_relative_widget.on_config_update(new_config)
assert motor_relative_widget.motor_x == "new_motor_x"
assert motor_relative_widget.motor_y == "new_motor_y"
assert motor_relative_widget.precision == 2
def test_move_motor_relative(motor_relative_widget):
motor_relative_widget.on_config_update(CONFIG_DEFAULT)
# Set step sizes
motor_relative_widget.spinBox_step_x.setValue(1)
motor_relative_widget.spinBox_step_y.setValue(1)
# Mock the move_relative method
motor_relative_widget.motor_thread.move_relative = MagicMock()
# Simulate button clicks
motor_relative_widget.toolButton_right.click()
motor_relative_widget.motor_thread.move_relative.assert_called_with(
motor_relative_widget.motor_x, 1
)
motor_relative_widget.toolButton_left.click()
motor_relative_widget.motor_thread.move_relative.assert_called_with(
motor_relative_widget.motor_x, -1
)
motor_relative_widget.toolButton_up.click()
motor_relative_widget.motor_thread.move_relative.assert_called_with(
motor_relative_widget.motor_y, 1
)
motor_relative_widget.toolButton_down.click()
motor_relative_widget.motor_thread.move_relative.assert_called_with(
motor_relative_widget.motor_y, -1
)
def test_precision_update(motor_relative_widget):
# Capture emitted precision values
emitted_values = []
def capture_precision(precision):
emitted_values.append(precision)
motor_relative_widget.precision_signal.connect(capture_precision)
# Update precision
motor_relative_widget.spinBox_precision.setValue(1)
assert emitted_values == [1]
assert motor_relative_widget.spinBox_step_x.decimals() == 1
assert motor_relative_widget.spinBox_step_y.decimals() == 1
def test_sync_step_sizes(motor_relative_widget):
motor_relative_widget.on_config_update(CONFIG_DEFAULT)
motor_relative_widget.checkBox_same_xy.setChecked(True)
# Change step size for X
motor_relative_widget.spinBox_step_x.setValue(2)
assert motor_relative_widget.spinBox_step_y.value() == 2
def test_change_motor_relative(motor_relative_widget):
motor_relative_widget.on_config_update(CONFIG_DEFAULT)
motor_relative_widget.change_motors("aptrx", "aptry")
assert motor_relative_widget.motor_x == "aptrx"
assert motor_relative_widget.motor_y == "aptry"
#######################################################
# Motor Control Widgets - MotorCoordinateTable
#######################################################
@pytest.fixture(scope="function")
def motor_coordinate_table(qtbot, mocked_client, motor_thread):
widget = MotorCoordinateTable(
client=mocked_client, config=CONFIG_DEFAULT, motor_thread=motor_thread
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def test_delete_selected_row(motor_coordinate_table):
# Add a coordinate
motor_coordinate_table.add_coordinate((1.0, 2.0))
motor_coordinate_table.add_coordinate((3.0, 4.0))
# Select the row
motor_coordinate_table.table.selectRow(0)
# Delete the selected row
motor_coordinate_table.delete_selected_row()
assert motor_coordinate_table.table.rowCount() == 1
def test_add_coordinate_and_table_update(motor_coordinate_table):
# Disable Warning message popups for test
motor_coordinate_table.warning_message = False
# Add coordinate in Individual mode
motor_coordinate_table.add_coordinate((1.0, 2.0))
assert motor_coordinate_table.table.rowCount() == 1
# Check if the coordinates match
x_item_individual = motor_coordinate_table.table.cellWidget(0, 3) # Assuming X is in column 3
y_item_individual = motor_coordinate_table.table.cellWidget(0, 4) # Assuming Y is in column 4
assert float(x_item_individual.text()) == 1.0
assert float(y_item_individual.text()) == 2.0
# Switch to Start/Stop and add coordinates
motor_coordinate_table.comboBox_mode.setCurrentIndex(1) # Switch mode
motor_coordinate_table.add_coordinate((3.0, 4.0))
motor_coordinate_table.add_coordinate((5.0, 6.0))
assert motor_coordinate_table.table.rowCount() == 1
def test_plot_coordinates_signal(motor_coordinate_table):
# Connect to the signal
def signal_emitted(coordinates, reference_tag, color):
nonlocal received
received = True
assert len(coordinates) == 1 # Assuming one coordinate was added
assert reference_tag in ["Individual", "Start", "Stop"]
assert color in ["green", "blue", "red"]
received = False
motor_coordinate_table.plot_coordinates_signal.connect(signal_emitted)
# Add a coordinate and check signal
motor_coordinate_table.add_coordinate((1.0, 2.0))
assert received
def test_move_motor_action(motor_coordinate_table):
# Add a coordinate
motor_coordinate_table.add_coordinate((1.0, 2.0))
# Mock the motor thread move_absolute function
motor_coordinate_table.motor_thread.move_absolute = MagicMock()
# Trigger the move action
move_button = motor_coordinate_table.table.cellWidget(0, 1)
move_button.click()
motor_coordinate_table.motor_thread.move_absolute.assert_called_with(
motor_coordinate_table.motor_x, motor_coordinate_table.motor_y, (1.0, 2.0)
)
def test_plot_coordinates_signal_individual(motor_coordinate_table, qtbot):
motor_coordinate_table.warning_message = False
motor_coordinate_table.set_precision(3)
motor_coordinate_table.comboBox_mode.setCurrentIndex(0)
# This list will store the signals emitted during the test
emitted_signals = []
def signal_emitted(coordinates, reference_tag, color):
emitted_signals.append((coordinates, reference_tag, color))
motor_coordinate_table.plot_coordinates_signal.connect(signal_emitted)
# Add new coordinates
motor_coordinate_table.add_coordinate((1.0, 2.0))
qtbot.wait(100)
# Verify the signals
assert len(emitted_signals) > 0, "No signals were emitted."
for coordinates, reference_tag, color in emitted_signals:
assert len(coordinates) > 0, "Coordinates list is empty."
assert reference_tag == "Individual"
assert color == "green"
assert motor_coordinate_table.table.cellWidget(0, 3).text() == "1.000"
assert motor_coordinate_table.table.cellWidget(0, 4).text() == "2.000"
#######################################################
# MotorControl examples compilations
#######################################################
@pytest.fixture(scope="function")
def motor_app(qtbot, mocked_client):
widget = MotorControlApp(config=CONFIG_DEFAULT, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_motor_app_initialization(motor_app):
assert isinstance(motor_app, MotorControlApp)
assert motor_app.client is not None
assert motor_app.config == CONFIG_DEFAULT
@pytest.fixture(scope="function")
def motor_control_map(qtbot, mocked_client):
widget = MotorControlMap(config=CONFIG_DEFAULT, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_motor_control_map_initialization(motor_control_map):
assert isinstance(motor_control_map, MotorControlMap)
assert motor_control_map.client is not None
assert motor_control_map.config == CONFIG_DEFAULT
@pytest.fixture(scope="function")
def motor_control_panel(qtbot, mocked_client):
widget = MotorControlPanel(config=CONFIG_DEFAULT, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_motor_control_panel_initialization(motor_control_panel):
assert isinstance(motor_control_panel, MotorControlPanel)
assert motor_control_panel.client is not None
assert motor_control_panel.config == CONFIG_DEFAULT
@pytest.fixture(scope="function")
def motor_control_panel_absolute(qtbot, mocked_client):
widget = MotorControlPanelAbsolute(config=CONFIG_DEFAULT, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_motor_control_panel_absolute_initialization(motor_control_panel_absolute):
assert isinstance(motor_control_panel_absolute, MotorControlPanelAbsolute)
assert motor_control_panel_absolute.client is not None
assert motor_control_panel_absolute.config == CONFIG_DEFAULT
@pytest.fixture(scope="function")
def motor_control_panel_relative(qtbot, mocked_client):
widget = MotorControlPanelRelative(config=CONFIG_DEFAULT, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_motor_control_panel_relative_initialization(motor_control_panel_relative):
assert isinstance(motor_control_panel_relative, MotorControlPanelRelative)
assert motor_control_panel_relative.client is not None
assert motor_control_panel_relative.config == CONFIG_DEFAULT

242
tests/test_motor_map.py Normal file
View File

@@ -0,0 +1,242 @@
# pylint: disable = no-name-in-module,missing-module-docstring, missing-function-docstring
from unittest.mock import MagicMock
import pytest
from bec_widgets.widgets import MotorMap
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"}],
},
},
],
}
CONFIG_ONE_DEVICE = {
"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 FakeDevice:
"""Fake minimal positioner class for testing."""
def __init__(self, name, enabled=True, limits=None, read_value=1.0):
self.name = name
self.enabled = enabled
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name}}
self.limits = limits if limits is not None else [0, 0]
self.read_value = read_value
def set_read_value(self, value):
self.read_value = value
def read(self):
return {self.name: {"value": self.read_value}}
def set_limits(self, limits):
self.limits = limits
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
@pytest.fixture
def mocked_client():
client = MagicMock()
# Mocking specific motors with their limits
motors = {
"samx": FakeDevice("samx", limits=[-10, 10], read_value=2.0),
"samy": FakeDevice("samy", limits=[-5, 5], read_value=3.0),
"aptrx": FakeDevice("aptrx", read_value=4.0),
"aptry": FakeDevice("aptry", read_value=5.0),
}
client.device_manager.devices = MagicMock()
client.device_manager.devices.__getitem__.side_effect = lambda x: motors.get(x, FakeDevice(x))
return client
@pytest.fixture(scope="function")
def motor_map(qtbot, mocked_client):
widget = MotorMap(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_motor_limits_initialization(motor_map):
# Example test to check if motor limits are correctly initialized
expected_limits = {
"samx": [-10, 10],
"samy": [-5, 5],
}
for motor_name, expected_limit in expected_limits.items():
actual_limit = motor_map._get_motor_limit(motor_name)
assert actual_limit == expected_limit
def test_motor_initial_position(motor_map):
motor_map.precision = 2
# Example test to check if motor initial positions are correctly initialized
expected_positions = {
("samx", "samx"): 2.0,
("samy", "samy"): 3.0,
("aptrx", "aptrx"): 4.0,
("aptry", "aptry"): 5.0,
}
for (motor_name, entry), expected_position in expected_positions.items():
actual_position = motor_map._get_motor_init_position(motor_name, entry)
assert actual_position == expected_position
@pytest.mark.parametrize(
"config, number_of_plots",
[
(CONFIG_DEFAULT, 2),
(CONFIG_ONE_DEVICE, 1),
],
)
def test_initialization(motor_map, config, number_of_plots):
config_load = config
motor_map.on_config_update(config_load)
assert isinstance(motor_map, MotorMap)
assert motor_map.client is not None
assert motor_map.config == config_load
assert len(motor_map.plot_data) == number_of_plots
def test_motor_movement_updates_position_and_database(motor_map):
motor_map.on_config_update(CONFIG_DEFAULT)
# Initial positions
initial_position_samx = 2.0
initial_position_samy = 3.0
# Set initial positions in the mocked database
motor_map.database["samx"]["samx"] = [initial_position_samx]
motor_map.database["samy"]["samy"] = [initial_position_samy]
# Simulate motor movement for 'samx' only
new_position_samx = 4.0
motor_map.on_device_readback({"signals": {"samx": {"value": new_position_samx}}})
# Verify database update for 'samx'
assert motor_map.database["samx"]["samx"] == [
initial_position_samx,
new_position_samx,
]
# Verify 'samy' retains its last known position
assert motor_map.database["samy"]["samy"] == [
initial_position_samy,
initial_position_samy,
]
def test_scatter_plot_rendering(motor_map):
motor_map.on_config_update(CONFIG_DEFAULT)
# Set initial positions
initial_position_samx = 2.0
initial_position_samy = 3.0
motor_map.database["samx"]["samx"] = [initial_position_samx]
motor_map.database["samy"]["samy"] = [initial_position_samy]
# Simulate motor movement for 'samx' only
new_position_samx = 4.0
motor_map.on_device_readback({"signals": {"samx": {"value": new_position_samx}}})
motor_map._update_plots()
# Get the scatter plot item
plot_name = "Motor Map" # Update as per your actual plot name
scatter_plot_item = motor_map.curves_data[plot_name]["pos"]
# Check the scatter plot item properties
assert len(scatter_plot_item.data) > 0, "Scatter plot data is empty"
x_data = scatter_plot_item.data["x"]
y_data = scatter_plot_item.data["y"]
assert x_data[-1] == new_position_samx, "Scatter plot X data not updated correctly"
assert (
y_data[-1] == initial_position_samy
), "Scatter plot Y data should retain last known position"
def test_plot_visualization_consistency(motor_map):
motor_map.on_config_update(CONFIG_DEFAULT)
# Simulate updating the plot with new data
motor_map.on_device_readback({"signals": {"samx": {"value": 5}}})
motor_map.on_device_readback({"signals": {"samy": {"value": 9}}})
motor_map._update_plots()
plot_name = "Motor Map"
scatter_plot_item = motor_map.curves_data[plot_name]["pos"]
# Check if the scatter plot reflects the new data correctly
assert (
scatter_plot_item.data["x"][-1] == 5 and scatter_plot_item.data["y"][-1] == 9
), "Plot not updated correctly with new data"

View File

View File

@@ -0,0 +1,989 @@
from bec_lib.messages import AvailableResourceMessage
available_scans_message = AvailableResourceMessage(
resource={
"acquire": {
"class": "Acquire",
"base_class": "ScanBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "table",
"doc": "\n A simple acquisition at the current position.\n\n Args:\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.acquire(exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"interactive_scan_trigger": {
"class": "AddInteractiveScanPoint",
"base_class": "ScanComponent",
"arg_input": {"device": "device"},
"required_kwargs": ["required"],
"arg_bundle_size": {"bundle": 1, "min": 1, "max": None},
"scan_report_hint": "",
"doc": "\n An interactive scan for one or more motors.\n\n Args:\n *args: devices\n exp_time: exposure time in s\n steps: number of steps (please note: 5 steps == 6 positions)\n relative: Start from an absolute or relative position\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.interactive_scan_trigger()\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"close_interactive_scan": {
"class": "CloseInteractiveScan",
"base_class": "ScanComponent",
"arg_input": {},
"required_kwargs": ["required"],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "",
"doc": "\n An interactive scan for one or more motors.\n\n Args:\n *args: devices\n exp_time: exposure time in s\n steps: number of steps (please note: 5 steps == 6 positions)\n relative: Start from an absolute or relative position\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.close_interactive_scan(dev.motor1, dev.motor2, exp_time=0.1)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"close_scan_def": {
"class": "CloseScanDef",
"base_class": "RequestBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": 0, "max": 0},
"scan_report_hint": "table",
"doc": None,
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "device_manager",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceManagerBase",
},
{
"name": "monitored",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "list",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{"name": "metadata", "kind": "KEYWORD_ONLY", "default": None, "annotation": "dict"},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"close_scan_group": {
"class": "CloseScanGroup",
"base_class": "RequestBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": 0, "max": 0},
"scan_report_hint": None,
"doc": None,
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "device_manager",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceManagerBase",
},
{
"name": "monitored",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "list",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{"name": "metadata", "kind": "KEYWORD_ONLY", "default": None, "annotation": "dict"},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"cont_line_scan": {
"class": "ContLineScan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float"},
"required_kwargs": ["steps", "relative"],
"arg_bundle_size": {"bundle": 3, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n A line scan for one or more motors.\n\n Args:\n *args (Device, float, float): pairs of device / start position / end position\n exp_time (float): exposure time in seconds. Default is 0.\n steps (int): number of steps. Default is 10.\n relative (bool): if True, the motors will be moved relative to their current position. Default is False.\n burst_at_each_point (int): number of exposures at each point. Default is 1.\n offset (float): offset in motor units. Default is 100.\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.cont_line_scan(dev.motor1, -5, 5, steps=10, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{"name": "steps", "kind": "KEYWORD_ONLY", "default": 10, "annotation": "int"},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{"name": "offset", "kind": "KEYWORD_ONLY", "default": 100, "annotation": "float"},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"device_rpc": {
"class": "DeviceRPC",
"base_class": "RequestBase",
"arg_input": ["device", "str", "list", "dict"],
"required_kwargs": [],
"arg_bundle_size": {"bundle": 4, "min": 1, "max": 1},
"scan_report_hint": None,
"doc": None,
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "device_manager",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceManagerBase",
},
{
"name": "monitored",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "list",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{"name": "metadata", "kind": "KEYWORD_ONLY", "default": None, "annotation": "dict"},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"fermat_scan": {
"class": "FermatSpiralScan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float"},
"required_kwargs": ["step", "relative"],
"arg_bundle_size": {"bundle": 3, "min": 2, "max": 2},
"scan_report_hint": "table",
"doc": '\n A scan following Fermat\'s spiral.\n\n Args:\n *args: pairs of device / start position / end position arguments\n step (float): step size in motor units. Default is 0.1.\n exp_time (float): exposure time in seconds. Default is 0.\n settling_time (float): settling time in seconds. Default is 0.\n relative (bool): if True, the motors will be moved relative to their current position. Default is False.\n burst_at_each_point (int): number of exposures at each point. Default is 1.\n spiral_type (float): type of spiral to use. Default is 0.\n optim_trajectory (str): trajectory optimization method. Default is None. Options are "corridor" and "none".\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.fermat_scan(dev.motor1, -5, 5, dev.motor2, -5, 5, step=0.5, exp_time=0.1, relative=True, optim_trajectory="corridor")\n\n ',
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "step", "kind": "KEYWORD_ONLY", "default": 0.1, "annotation": "float"},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{
"name": "settling_time",
"kind": "KEYWORD_ONLY",
"default": 0,
"annotation": "float",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "spiral_type",
"kind": "KEYWORD_ONLY",
"default": 0,
"annotation": "float",
},
{
"name": "optim_trajectory",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": {"Literal": ["corridor", None]},
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"line_scan": {
"class": "LineScan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float"},
"required_kwargs": ["steps", "relative"],
"arg_bundle_size": {"bundle": 3, "min": 1, "max": None},
"scan_report_hint": "table",
"doc": "\n A line scan for one or more motors.\n\n Args:\n *args (Device, float, float): pairs of device / start position / end position\n exp_time (float): exposure time in s. Default: 0\n steps (int): number of steps. Default: 10\n relative (bool): if True, the start and end positions are relative to the current position. Default: False\n burst_at_each_point (int): number of acquisition per point. Default: 1\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.line_scan(dev.motor1, -5, 5, dev.motor2, -5, 5, steps=10, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{"name": "steps", "kind": "KEYWORD_ONLY", "default": None, "annotation": "int"},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"list_scan": {
"class": "ListScan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "positions": "list"},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 2, "min": 1, "max": None},
"scan_report_hint": "table",
"doc": "\n A scan following the positions specified in a list.\n Please note that all lists must be of equal length.\n\n Args:\n *args: pairs of motors and position lists\n relative: Start from an absolute or relative position\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.list_scan(dev.motor1, [0,1,2,3,4], dev.motor2, [4,3,2,1,0], exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"monitor_scan": {
"class": "MonitorScan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float"},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 3, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n Readout all primary devices at each update of the monitored device.\n\n Args:\n device (Device): monitored device\n start (float): start position of the monitored device\n stop (float): stop position of the monitored device\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.monitor_scan(dev.motor1, -5, 5, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "device",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "start",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "stop",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"mv": {
"class": "Move",
"base_class": "RequestBase",
"arg_input": {"device": "device", "target": "float"},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 2, "min": 1, "max": None},
"scan_report_hint": None,
"doc": "\n Move device(s) to an absolute position\n Args:\n *args (Device, float): pairs of device / position arguments\n relative (bool): if True, move relative to current position\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.mv(dev.samx, 1, dev.samy,2)\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"open_interactive_scan": {
"class": "OpenInteractiveScan",
"base_class": "ScanComponent",
"arg_input": {"device": "device"},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 1, "min": 1, "max": None},
"scan_report_hint": "",
"doc": "\n An interactive scan for one or more motors.\n\n Args:\n *args: devices\n exp_time: exposure time in s\n steps: number of steps (please note: 5 steps == 6 positions)\n relative: Start from an absolute or relative position\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.open_interactive_scan(dev.motor1, dev.motor2, exp_time=0.1)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"open_scan_def": {
"class": "OpenScanDef",
"base_class": "RequestBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": 0, "max": 0},
"scan_report_hint": None,
"doc": None,
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "device_manager",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceManagerBase",
},
{
"name": "monitored",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "list",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{"name": "metadata", "kind": "KEYWORD_ONLY", "default": None, "annotation": "dict"},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"round_roi_scan": {
"class": "RoundROIScan",
"base_class": "ScanBase",
"arg_input": {
"motor_1": "device",
"motor_2": "device",
"width_1": "float",
"width_2": "float",
},
"required_kwargs": ["dr", "nth", "relative"],
"arg_bundle_size": {"bundle": 4, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n A scan following a round-roi-like pattern.\n\n Args:\n *args: motor1, width for motor1, motor2, width for motor2,\n dr (float): shell width. Default is 1.\n nth (int): number of points in the first shell. Default is 5.\n exp_time (float): exposure time in seconds. Default is 0.\n relative (bool): Start from an absolute or relative position. Default is False.\n burst_at_each_point (int): number of acquisition per point. Default is 1.\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.round_roi_scan(dev.motor1, 20, dev.motor2, 20, dr=2, nth=3, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "dr", "kind": "KEYWORD_ONLY", "default": 1, "annotation": "float"},
{"name": "nth", "kind": "KEYWORD_ONLY", "default": 5, "annotation": "int"},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"round_scan": {
"class": "RoundScan",
"base_class": "ScanBase",
"arg_input": {
"motor_1": "device",
"motor_2": "device",
"inner_ring": "float",
"outer_ring": "float",
"number_of_rings": "int",
"number_of_positions_in_first_ring": "int",
},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 6, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n A scan following a round shell-like pattern.\n\n Args:\n *args: motor1, motor2, inner ring, outer ring, number of rings, number of positions in the first ring\n relative (bool): if True, the motors will be moved relative to their current position. Default is False.\n burst_at_each_point (int): number of exposures at each point. Default is 1.\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.round_scan(dev.motor1, dev.motor2, 0, 25, 5, 3, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"round_scan_fly": {
"class": "RoundScanFlySim",
"base_class": "ScanBase",
"arg_input": {
"flyer": "device",
"inner_ring": "float",
"outer_ring": "float",
"number_of_rings": "int",
"number_of_positions_in_first_ring": "int",
},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 5, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n A fly scan following a round shell-like pattern.\n\n Args:\n *args: motor1, motor2, inner ring, outer ring, number of rings, number of positions in the first ring\n relative: Start from an absolute or relative position\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.round_scan_fly(dev.flyer_sim, 0, 50, 5, 3, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"grid_scan": {
"class": "Scan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float", "steps": "int"},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 4, "min": 2, "max": None},
"scan_report_hint": "table",
"doc": "\n Scan two motors in a grid.\n\n Args:\n *args (Device, float, float, int): pairs of device / start / stop / steps arguments\n exp_time (float): exposure time in seconds. Default is 0.\n settling_time (float): settling time in seconds. Default is 0.\n relative (bool): if True, the motors will be moved relative to their current position. Default is False.\n burst_at_each_point (int): number of exposures at each point. Default is 1.\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.grid_scan(dev.motor1, -5, 5, 10, dev.motor2, -5, 5, 10, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{
"name": "settling_time",
"kind": "KEYWORD_ONLY",
"default": 0,
"annotation": "float",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"time_scan": {
"class": "TimeScan",
"base_class": "ScanBase",
"arg_input": {},
"required_kwargs": ["points", "interval"],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "table",
"doc": '\n Trigger and readout devices at a fixed interval.\n Note that the interval time cannot be less than the exposure time.\n The effective "sleep" time between points is\n sleep_time = interval - exp_time\n\n Args:\n points: number of points\n interval: time interval between points\n exp_time: exposure time in s\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.time_scan(points=10, interval=1.5, exp_time=0.1, relative=True)\n\n ',
"signature": [
{
"name": "points",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "int",
},
{
"name": "interval",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "exp_time",
"kind": "POSITIONAL_OR_KEYWORD",
"default": 0,
"annotation": "float",
},
{
"name": "burst_at_each_point",
"kind": "POSITIONAL_OR_KEYWORD",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"umv": {
"class": "UpdatedMove",
"base_class": "RequestBase",
"arg_input": {"device": "device", "target": "float"},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 2, "min": 1, "max": None},
"scan_report_hint": "readback",
"doc": "\n Move device(s) to an absolute position and show live updates. This is a blocking call. For non-blocking use Move.\n Args:\n *args (Device, float): pairs of device / position arguments\n relative (bool): if True, move relative to current position\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.umv(dev.samx, 1, dev.samy,2)\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"lamni_fermat_scan": {
"class": "LamNIFermatScan",
"base_class": "ScanBase",
"arg_input": {},
"required_kwargs": ["fov_size", "exp_time", "step", "angle"],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "table",
"doc": "\n A LamNI scan following Fermat's spiral.\n\n Kwargs:\n fov_size [um]: Fov in the piezo plane (i.e. piezo range). Max 80 um\n step [um]: stepsize\n shift_x/y [mm]: extra shift in x/y. The shift is directly applied to the scan. It will not be auto rotated. (default 0).\n center_x/center_y [mm]: center position in x/y at 0 deg. This shift is rotated\n using the geometry of LamNI\n It is determined by the first 'click' in the x-ray eye alignemnt procedure\n angle [deg]: rotation angle (will rotate first)\n scan_type: fly (i.e. HW triggered step in case of LamNI) or step\n stitch_x/y: shift scan to adjacent stitch region\n fov_circular [um]: generate a circular field of view in the sample plane. This is an additional cropping to fov_size.\n stitch_overlap [um]: overlap of the stitched regions\n Returns:\n\n Examples:\n >>> scans.lamni_fermat_scan(fov_size=[20], step=0.5, exp_time=0.1)\n >>> scans.lamni_fermat_scan(fov_size=[20, 25], center_x=0.02, center_y=0, shift_x=0, shift_y=0, angle=0, step=0.5, fov_circular=0, exp_time=0.1)\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"lamni_move_to_scan_center": {
"class": "LamNIMoveToScanCenter",
"base_class": "RequestBase",
"arg_input": {"shift_x": "float", "shift_y": "float", "angle": "float"},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 3, "min": 1, "max": 1},
"scan_report_hint": None,
"doc": "\n Move LamNI to a new scan center.\n\n Args:\n *args: shift x, shift y, tomo angle in deg\n\n Examples:\n >>> scans.lamni_move_to_scan_center(1.2, 2.8, 12.5)\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"owis_grid": {
"class": "OwisGrid",
"base_class": "FlyScanBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "scan_progress",
"doc": "Owis-based grid scan.",
"signature": [
{
"name": "start_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "end_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "interval_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "int",
},
{
"name": "start_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "end_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "interval_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "int",
},
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0.1, "annotation": "float"},
{
"name": "readout_time",
"kind": "KEYWORD_ONLY",
"default": 0.003,
"annotation": "float",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"sgalil_grid": {
"class": "SgalilGrid",
"base_class": "FlyScanBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "scan_progress",
"doc": "\n SGalil-based grid scan.\n\n Args:\n start_y (float): start position of y axis (fast axis)\n end_y (float): end position of y axis (fast axis)\n interval_y (int): number of points in y axis\n start_x (float): start position of x axis (slow axis)\n end_x (float): end position of x axis (slow axis)\n interval_x (int): number of points in x axis\n exp_time (float): exposure time in seconds. Default is 0.1s\n readout_time (float): readout time in seconds, minimum of 3e-3s (3ms)\n\n Exp:\n scans.sgalil_grid(start_y = val1, end_y= val1, interval_y = val1, start_x = val1, end_x = val1, interval_x = val1, exp_time = 0.02, readout_time = 3e-3)\n\n\n ",
"signature": [
{
"name": "start_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "end_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "interval_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "int",
},
{
"name": "start_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "end_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "interval_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "int",
},
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0.1, "annotation": "float"},
{
"name": "readout_time",
"kind": "KEYWORD_ONLY",
"default": 0.1,
"annotation": "float",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"hyst_scan": {
"class": "HystScan",
"base_class": "ScanBase",
"arg_input": {
"field_motor": "device",
"start_field": "float",
"end_field": "float",
"mono": "device",
"energy1": "float",
"energy2": "float",
},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 3, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n A hysteresis scan.\n\n scans.hyst_scan(field_motor, start_field, end_field, mono, energy1, energy2)\n\n Examples:\n >>> scans.hyst_scan(dev.field_x, 0, 0.5, dev.mono, 600, 640, ramp_rate=2)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"otf_scan": {
"class": "OTFScan",
"base_class": "FlyScanBase",
"arg_input": {},
"required_kwargs": ["e1", "e2", "time"],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "table",
"doc": "Scans the energy from e1 to e2 in <time> minutes.\n\n Examples:\n >>> scans.otf_scan(e1=700, e2=740, time=4)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
}
)

Binary file not shown.

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import unittest
from unittest.mock import MagicMock, patch
@@ -5,7 +6,7 @@ import pyqtgraph as pg
import pytest
from qtpy.QtWidgets import QMessageBox
from bec_widgets.examples.extreme.extreme import PlotApp, ErrorHandler
from bec_widgets.examples.plot_app.plot_app import PlotApp, ErrorHandler
def setup_plot_app(qtbot, config):
@@ -424,7 +425,9 @@ def test_initialization(error_handler):
assert error_handler.retry_action is None
@patch("bec_widgets.examples.extreme.extreme.QMessageBox.critical", return_value=QMessageBox.Retry)
@patch(
"bec_widgets.examples.plot_app.plot_app.QMessageBox.critical", return_value=QMessageBox.Retry
)
def test_handle_error_retry(mocked_critical, error_handler):
retry_action = MagicMock()
error_handler.set_retry_action(retry_action)
@@ -432,7 +435,9 @@ def test_handle_error_retry(mocked_critical, error_handler):
retry_action.assert_called_once()
@patch("bec_widgets.examples.extreme.extreme.QMessageBox.critical", return_value=QMessageBox.Cancel)
@patch(
"bec_widgets.examples.plot_app.plot_app.QMessageBox.critical", return_value=QMessageBox.Cancel
)
def test_handle_error_cancel(mocked_critical, error_handler):
retry_action = MagicMock()
with pytest.raises(SystemExit) as excinfo:
@@ -458,7 +463,7 @@ def test_error_handler(error_handler, config, expected_errors):
error_handler.handle_error = MagicMock()
# Mock logging
with unittest.mock.patch("bec_widgets.examples.extreme.extreme.logging") as mocked_logging:
with unittest.mock.patch("bec_widgets.examples.plot_app.plot_app.logging") as mocked_logging:
error_handler.validate_config_file(config)
# Assert

View File

@@ -1,25 +1,15 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import os
import pickle
from unittest.mock import MagicMock
import msgpack
import pytest
from qtpy.QtWidgets import QLineEdit
from bec_widgets.widgets import ScanControl
from bec_widgets.qt_utils.widget_io import WidgetIO
from bec_widgets.utils.widget_io import WidgetIO
# TODO there has to be a better way to mock messages than this, in this case I just took the msg from bec
def load_test_msg(msg_name):
"""Helper function to load msg from pickle file."""
msg_path = os.path.join(os.path.dirname(__file__), "test_msgs", f"{msg_name}.pkl")
with open(msg_path, "rb") as f:
msg = pickle.load(f)
return msg
packed_message = load_test_msg("msg_dict")["available_scans"]
from .test_msgs.available_scans_message import available_scans_message
class FakePositioner:
@@ -45,7 +35,7 @@ def mocked_client():
client = MagicMock()
# Mock the producer.get method to return the packed message
client.producer.get.return_value = packed_message
client.producer.get.return_value = available_scans_message
# # Mock the device_manager.devices attribute to return a mock object for samx
client.device_manager.devices = MagicMock()
@@ -66,7 +56,7 @@ def scan_control(qtbot, mocked_client): # , mock_dev):
def test_populate_scans(scan_control, mocked_client):
# The comboBox should be populated with all scan from the message right after initialization
expected_scans = msgpack.loads(packed_message).keys()
expected_scans = available_scans_message.resource.keys()
assert scan_control.comboBox_scan_selection.count() == len(expected_scans)
for scan in expected_scans: # Each scan should be in the comboBox
assert scan_control.comboBox_scan_selection.findText(scan) != -1
@@ -77,7 +67,7 @@ def test_populate_scans(scan_control, mocked_client):
) # TODO now only for line_scan and grid_scan, later for all loaded scans
def test_on_scan_selected(scan_control, scan_name):
# Expected scan info from the message signature
expected_scan_info = msgpack.loads(packed_message)[scan_name]
expected_scan_info = available_scans_message.resource[scan_name]
# Select a scan from the comboBox
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
@@ -110,7 +100,7 @@ def test_on_scan_selected(scan_control, scan_name):
@pytest.mark.parametrize("scan_name", ["line_scan", "grid_scan"])
def test_add_remove_bundle(scan_control, scan_name):
# Expected scan info from the message signature
expected_scan_info = msgpack.loads(packed_message)[scan_name]
expected_scan_info = available_scans_message.resource[scan_name]
# Select a scan from the comboBox
scan_control.comboBox_scan_selection.setCurrentText(scan_name)

View File

@@ -1,6 +1,5 @@
from pytestqt import qtbot
from bec_widgets import scan_plot
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from bec_widgets.widgets.scan_plot import scan_plot
def test_scan_plot(qtbot):

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from unittest import mock
import numpy as np
@@ -17,6 +18,7 @@ def stream_app(qtbot):
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_roi_signals_emitted(qtbot, stream_app):

View File

@@ -0,0 +1,109 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import pytest
from pydantic import ValidationError
from bec_widgets.validation.monitor_config_validator import (
MonitorConfigValidator,
Signal,
AxisSignal,
PlotConfig,
)
from .test_bec_monitor import mocked_client
@pytest.fixture(scope="function")
def setup_devices(mocked_client):
MonitorConfigValidator.devices = mocked_client.device_manager.devices
def test_signal_validation_name_missing(setup_devices):
with pytest.raises(ValidationError) as excinfo:
Signal(name=None)
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "no_device_name"
assert "Device name must be provided" in str(excinfo.value)
def test_signal_validation_name_not_in_bec(setup_devices):
with pytest.raises(ValidationError) as excinfo:
Signal(name="non_existent_device")
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "no_device_bec"
assert 'Device "non_existent_device" not found in current BEC session' in str(excinfo.value)
def test_signal_validation_entry_not_in_device(setup_devices):
with pytest.raises(ValidationError) as excinfo:
Signal(name="samx", entry="non_existent_entry")
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "no_entry_for_device"
assert 'Entry "non_existent_entry" not found in device "samx" signals' in errors[0]["msg"]
def test_signal_validation_success(setup_devices):
signal = Signal(name="samx")
assert signal.name == "samx"
def test_plot_config_x_axis_signal_validation(setup_devices):
# Setup a valid signal
valid_signal = Signal(name="samx")
with pytest.raises(ValidationError) as excinfo:
AxisSignal(x=[valid_signal, valid_signal], y=[valid_signal, valid_signal])
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "x_axis_multiple_signals"
assert "There must be exactly one signal for x axis" in errors[0]["msg"]
def test_plot_config_unsupported_source_type(setup_devices):
with pytest.raises(ValidationError) as excinfo:
PlotConfig(sources=[{"type": "unsupported_type", "signals": {}}])
errors = excinfo.value.errors()
print(errors)
assert len(errors) == 1
assert errors[0]["type"] == "literal_error"
def test_plot_config_no_source_type_provided(setup_devices):
with pytest.raises(ValidationError) as excinfo:
PlotConfig(sources=[{"signals": {}}])
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "missing"
def test_plot_config_history_source_type(setup_devices):
history_source = {
"type": "history",
"scanID": "valid_scan_id",
"signals": {"x": [{"name": "samx"}], "y": [{"name": "samx"}]},
}
plot_config = PlotConfig(sources=[history_source])
assert len(plot_config.sources) == 1
assert plot_config.sources[0].type == "history"
assert plot_config.sources[0].scanID == "valid_scan_id"
def test_plot_config_redis_source_type(setup_devices):
history_source = {
"type": "redis",
"endpoint": "valid_endpoint",
"update": "append",
"signals": {"x": [{"name": "samx"}], "y": [{"name": "samx"}]},
}
plot_config = PlotConfig(sources=[history_source])
assert len(plot_config.sources) == 1
assert plot_config.sources[0].type == "redis"

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import pytest
from qtpy.QtWidgets import (
QWidget,
@@ -8,7 +9,7 @@ from qtpy.QtWidgets import (
QSpinBox,
)
from bec_widgets.qt_utils.widget_io import WidgetHierarchy
from bec_widgets.utils.widget_io import WidgetHierarchy
@pytest.fixture(scope="function")

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import os
import tempfile
from unittest.mock import patch
@@ -5,7 +6,7 @@ import pytest
import yaml
from qtpy.QtWidgets import QWidget, QVBoxLayout, QPushButton
from bec_widgets.qt_utils.yaml_dialog import load_yaml, save_yaml
from bec_widgets.utils.yaml_dialog import load_yaml, save_yaml
@pytest.fixture(scope="function")