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 | Date | |
|---|---|---|---|
|
|
8a4aeb8dfe | ||
|
|
4b0542a513 | ||
|
|
bf04a4e04a | ||
|
|
fa4ca935bb | ||
|
|
b52e22d81f | ||
|
|
2f96e10b9d | ||
|
|
031cb094e7 | ||
|
|
8afc5f0c0c | ||
|
|
17f14581d7 | ||
|
|
8361736679 | ||
|
|
0b9927fcf5 | ||
|
|
8139e271de | ||
|
|
6fe08e6b82 | ||
|
|
968da6f558 | ||
|
|
11ae0b1054 | ||
| 5ebfd2a3c2 | |||
| b36131eed5 | |||
|
|
a7bfcc12b9 | ||
|
|
ab275b8e5f | ||
|
|
d211b47f4c | ||
|
|
812ffaf8ea | ||
|
|
f7a496723c | ||
|
|
48847a19c7 | ||
|
|
8d0083c4aa | ||
|
|
3c143274c5 | ||
|
|
747e97e0c9 | ||
|
|
c6fe9d2026 | ||
|
|
75090b8575 | ||
|
|
8f76c789cf | ||
| 4664568672 | |||
| 3fb6644543 | |||
| d909673071 | |||
|
|
d281d6576c | ||
|
|
8bebc4f692 | ||
|
|
1cd273c375 | ||
|
|
249170ea30 | ||
|
|
1a429b3024 | ||
|
|
e05cab812a | ||
|
|
7607d7a3b6 | ||
|
|
e51be04b95 | ||
|
|
de1f5c968a | ||
|
|
bf819bcf48 | ||
|
|
6f26e5cc3d | ||
|
|
f9c5c82381 | ||
|
|
79487dbec2 | ||
| 58721bea1a | |||
|
|
03e96669da | ||
|
|
eb529d24d2 | ||
|
|
ebd4fccda2 | ||
|
|
97dcc5ac76 | ||
|
|
9c7a189beb | ||
|
|
6061b3150e | ||
|
|
3982c5d498 | ||
|
|
404ca49821 | ||
|
|
6e4775a124 | ||
|
|
5ab82bc133 | ||
|
|
00ef3ae925 | ||
|
|
90d8069cc3 | ||
|
|
457567ef74 | ||
|
|
1128ca5252 | ||
|
|
86c5f25205 | ||
|
|
a706da2490 | ||
|
|
d67bdd2616 | ||
|
|
c3f2ad45c3 | ||
|
|
26c07c3205 | ||
|
|
c995e0d235 | ||
|
|
463a60a99c | ||
|
|
98a46a85b2 | ||
|
|
186c42d667 | ||
|
|
f3a47a5b08 | ||
|
|
af995a74f3 | ||
|
|
3abd955465 | ||
|
|
cba8131367 | ||
|
|
831eddc136 | ||
| 9e852d1afc | |||
| 3ec9caae09 | |||
| 11281fef53 | |||
|
|
9d497b70bf | ||
|
|
2a334156a8 | ||
|
|
086804780d | ||
|
|
731fba55ec | ||
|
|
a3b24f9242 | ||
|
|
af71e35e73 |
@@ -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/
|
||||
|
||||
17
.gitlab/issue_templates/bug_report_template.md
Normal file
17
.gitlab/issue_templates/bug_report_template.md
Normal file
@@ -0,0 +1,17 @@
|
||||
## Bug report
|
||||
|
||||
## Summary
|
||||
|
||||
[Provide a brief description of the bug.]
|
||||
|
||||
## Expected Behavior vs Actual Behavior
|
||||
|
||||
[Describe what you expected to happen and what actually happened.]
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
[Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.]
|
||||
|
||||
## Related Issues
|
||||
|
||||
[Paste links to any related issues or feature requests.]
|
||||
27
.gitlab/issue_templates/documentation_update_template.md
Normal file
27
.gitlab/issue_templates/documentation_update_template.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## Documentation Section
|
||||
|
||||
[Specify the section or page of the documentation that needs updating]
|
||||
|
||||
## Current Information
|
||||
|
||||
[Provide the current information in the documentation that needs to be updated]
|
||||
|
||||
## Proposed Update
|
||||
|
||||
[Describe the proposed update or correction. Be specific about the changes that need to be made]
|
||||
|
||||
## Reason for Update
|
||||
|
||||
[Explain the reason for the documentation update. Include any recent changes, new features, or corrections that necessitate the update]
|
||||
|
||||
## Additional Context
|
||||
|
||||
[Include any additional context or information that can help the documentation team understand the update better]
|
||||
|
||||
## Attachments
|
||||
|
||||
[Attach any files, screenshots, or references that can assist in making the documentation update]
|
||||
|
||||
## Priority
|
||||
|
||||
[Assign a priority level to the documentation update based on its urgency. Use a scale such as Low, Medium, High]
|
||||
40
.gitlab/issue_templates/feature_request_template.md
Normal file
40
.gitlab/issue_templates/feature_request_template.md
Normal file
@@ -0,0 +1,40 @@
|
||||
## Feature Summary
|
||||
|
||||
[Provide a brief and clear summary of the new feature you are requesting]
|
||||
|
||||
## Problem Description
|
||||
|
||||
[Explain the problem or need that this feature aims to address. Be specific about the issues or gaps in the current functionality]
|
||||
|
||||
## Use Case
|
||||
|
||||
[Describe a real-world scenario or use case where this feature would be beneficial. Explain how it would improve the user experience or workflow]
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
[If you have a specific solution in mind, describe it here. Explain how it would work and how it would address the problem described above]
|
||||
|
||||
## Benefits
|
||||
|
||||
[Explain the benefits and advantages of implementing this feature. Highlight how it adds value to the product or improves user satisfaction]
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
[If you've considered alternative solutions or workarounds, mention them here. Explain why the proposed feature is the preferred option]
|
||||
|
||||
## Impact on Existing Functionality
|
||||
|
||||
[Discuss how the new feature might impact or interact with existing features. Address any potential conflicts or dependencies]
|
||||
|
||||
## Priority
|
||||
|
||||
[Assign a priority level to the feature request based on its importance. Use a scale such as Low, Medium, High]
|
||||
|
||||
## Attachments
|
||||
|
||||
[Include any relevant attachments, such as sketches, diagrams, or references that can help the development team understand your feature request better]
|
||||
|
||||
## Additional Information
|
||||
|
||||
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]
|
||||
|
||||
28
.gitlab/merge_request_templates/default.md
Normal file
28
.gitlab/merge_request_templates/default.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## Description
|
||||
|
||||
[Provide a brief description of the changes introduced by this merge request.]
|
||||
|
||||
## Related Issues
|
||||
|
||||
[Cite any related issues or feature requests that are addressed or resolved by this merge request. Use the gitlab syntax for linking issues, for example, `fixes #123` or `closes #123`.]
|
||||
|
||||
## Type of Change
|
||||
|
||||
- Change 1
|
||||
- Change 2
|
||||
|
||||
## Potential side effects
|
||||
|
||||
[Describe any potential side effects or risks of merging this MR.]
|
||||
|
||||
## Screenshots / GIFs (if applicable)
|
||||
|
||||
[Include any relevant screenshots or GIFs to showcase the changes made.]
|
||||
|
||||
## Additional Comments
|
||||
|
||||
[Add any additional comments or information that may be helpful for reviewers.]
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Documentation is up-to-date.
|
||||
|
||||
112
CHANGELOG.md
112
CHANGELOG.md
@@ -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
|
||||
|
||||
71
README.md
71
README.md
@@ -1,2 +1,73 @@
|
||||
# BEC Widgets
|
||||
|
||||
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
|
||||
## Installation
|
||||
|
||||
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
|
||||
|
||||
```bash
|
||||
pip install bec-widgets
|
||||
```
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
cd bec-widgets
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
BEC Widgets currently supports both PyQt5 and PyQt6. By default, PyQt6 is installed.
|
||||
|
||||
To select a specific Python Qt distribution, install the package with an additional tag:
|
||||
|
||||
```bash
|
||||
pip install bec-widgets[pyqt6]
|
||||
```
|
||||
or
|
||||
|
||||
```bash
|
||||
pip install bec-widgets[pyqt5]
|
||||
```
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://beamline-experiment-control.readthedocs.io/en/latest/).
|
||||
|
||||
## Contributing
|
||||
|
||||
All commits should use the Angular commit scheme:
|
||||
|
||||
> #### <a name="commit-header"></a>Angular Commit Message Header
|
||||
>
|
||||
> ```
|
||||
> <type>(<scope>): <short summary>
|
||||
> │ │ │
|
||||
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
|
||||
> │ │
|
||||
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
|
||||
> │ elements|forms|http|language-service|localize|platform-browser|
|
||||
> │ platform-browser-dynamic|platform-server|router|service-worker|
|
||||
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
|
||||
> │ devtools
|
||||
> │
|
||||
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
|
||||
> ```
|
||||
>
|
||||
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
|
||||
|
||||
> ##### Type
|
||||
>
|
||||
> Must be one of the following:
|
||||
>
|
||||
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
|
||||
> * **docs**: Documentation only changes
|
||||
> * **feat**: A new feature
|
||||
> * **fix**: A bug fix
|
||||
> * **perf**: A code change that improves performance
|
||||
> * **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
> * **test**: Adding missing tests or correcting existing tests
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
@@ -1,99 +0,0 @@
|
||||
import argparse
|
||||
import itertools
|
||||
import os
|
||||
from typing import Callable
|
||||
|
||||
from bec_lib import BECClient, messages, ServiceConfig
|
||||
from bec_lib.redis_connector import RedisConsumerThreaded
|
||||
from 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)
|
||||
@@ -1,129 +0,0 @@
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import mkPen
|
||||
from pyqtgraph.Qt import QtCore, QtWidgets
|
||||
|
||||
|
||||
class ConfigPlotter(pg.GraphicsWidget):
|
||||
"""
|
||||
ConfigPlotter is a widget that can be used to plot data from multiple channels
|
||||
in a grid layout. The layout is specified by a list of dicts, where each dict
|
||||
specifies the position of the plot in the grid, the channels to plot, and the
|
||||
type of plot to use. The plot type is specified by the name of the pyqtgraph
|
||||
item to use. For example, to plot a single channel in a PlotItem, the config
|
||||
would look like this:
|
||||
|
||||
config = [
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 0,
|
||||
"x": 0,
|
||||
"config": {"channels": ["a"], "label_xy": ["", "a"], "item": "PlotItem"},
|
||||
}
|
||||
]
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, configs: List[dict], parent=None):
|
||||
super().__init__(parent)
|
||||
self.configs = configs
|
||||
self.plots = {}
|
||||
self._init_ui()
|
||||
self._init_plots()
|
||||
|
||||
def _init_ui(self):
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
|
||||
# pylint: disable=no-member
|
||||
self.pen = mkPen(color=(56, 76, 107), width=4, style=QtCore.Qt.SolidLine)
|
||||
|
||||
self.view = pg.GraphicsView()
|
||||
self.view.setAntialiasing(True)
|
||||
self.view.show()
|
||||
|
||||
self.layout = pg.GraphicsLayout()
|
||||
self.view.setCentralWidget(self.layout)
|
||||
|
||||
def _init_plots(self):
|
||||
for config in self.configs:
|
||||
channels = config["config"]["channels"]
|
||||
for channel in channels:
|
||||
item = pg.PlotItem()
|
||||
self.layout.addItem(
|
||||
item,
|
||||
row=config["y"],
|
||||
col=config["x"],
|
||||
rowspan=config["rows"],
|
||||
colspan=config["cols"],
|
||||
)
|
||||
|
||||
# call the corresponding init function, e.g. init_plotitem
|
||||
init_func = getattr(self, f"init_{config['config']['item']}")
|
||||
init_func(channel, config["config"], item)
|
||||
|
||||
# self.init_ImageItem(channel, config["config"], item)
|
||||
|
||||
def init_PlotItem(self, channel: str, config: dict, item: pg.GraphicsItem):
|
||||
"""
|
||||
Initialize a PlotItem
|
||||
|
||||
Args:
|
||||
channel(str): channel to plot
|
||||
config(dict): config dict for the channel
|
||||
item(pg.GraphicsItem): PlotItem to plot the data
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
plot_data = item.plot(np.random.rand(100), pen=self.pen)
|
||||
item.setLabel("left", channel)
|
||||
self.plots[channel] = {"item": item, "plot_data": plot_data}
|
||||
|
||||
def init_ImageItem(self, channel: str, config: dict, item: pg.GraphicsItem):
|
||||
"""
|
||||
Initialize an ImageItem
|
||||
|
||||
Args:
|
||||
channel(str): channel to plot
|
||||
config(dict): config dict for the channel
|
||||
item(pg.GraphicsItem): ImageItem to plot the data
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
img = pg.ImageItem()
|
||||
item.addItem(img)
|
||||
img.setImage(np.random.rand(100, 100))
|
||||
self.plots[channel] = {"item": item, "plot_data": img}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
CONFIG = [
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 0,
|
||||
"x": 0,
|
||||
"config": {"channels": ["a"], "label_xy": ["", "a"], "item": "PlotItem"},
|
||||
},
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 1,
|
||||
"x": 0,
|
||||
"config": {"channels": ["b"], "label_xy": ["", "b"], "item": "PlotItem"},
|
||||
},
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 2,
|
||||
"y": 0,
|
||||
"x": 1,
|
||||
"config": {"channels": ["c"], "label_xy": ["", "c"], "item": "ImageItem"},
|
||||
},
|
||||
]
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
win = ConfigPlotter(CONFIG)
|
||||
pg.exec()
|
||||
@@ -1,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()
|
||||
@@ -0,0 +1,9 @@
|
||||
from .motor_movement import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelRelative,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1433</width>
|
||||
<height>24</height>
|
||||
<height>37</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
|
||||
@@ -1,45 +1,13 @@
|
||||
import os
|
||||
|
||||
from 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([])
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from .motor_control_compilations import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelRelative,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
|
||||
import qdarktheme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QVBoxLayout
|
||||
from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
QSplitter,
|
||||
)
|
||||
from qtpy.QtCore import Qt
|
||||
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets import (
|
||||
MotorControlAbsolute,
|
||||
MotorControlRelative,
|
||||
MotorControlSelection,
|
||||
MotorThread,
|
||||
MotorMap,
|
||||
MotorCoordinateTable,
|
||||
)
|
||||
|
||||
CONFIG_DEFAULT = {
|
||||
"motor_control": {
|
||||
"motor_x": "samx",
|
||||
"motor_y": "samy",
|
||||
"step_size_x": 3,
|
||||
"step_size_y": 3,
|
||||
"precision": 4,
|
||||
"step_x_y_same": False,
|
||||
"move_with_arrows": False,
|
||||
},
|
||||
"plot_settings": {
|
||||
"colormap": "Greys",
|
||||
"scatter_size": 5,
|
||||
"max_points": 1000,
|
||||
"num_dim_points": 100,
|
||||
"precision": 2,
|
||||
"num_columns": 1,
|
||||
"background_value": 25,
|
||||
},
|
||||
"motors": [
|
||||
{
|
||||
"plot_name": "Motor Map",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Motor Y",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "samy", "entry": "samy"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class MotorControlApp(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
# Widgets
|
||||
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
|
||||
# Create MotorMap
|
||||
self.motion_map = MotorMap(client=self.client, config=self.config)
|
||||
# Create MotorCoordinateTable
|
||||
self.motor_table = MotorCoordinateTable(client=self.client, config=self.config)
|
||||
|
||||
# Create the splitter and add MotorMap and MotorControlPanel
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
splitter.addWidget(self.motion_map)
|
||||
splitter.addWidget(self.motor_control_panel)
|
||||
splitter.addWidget(self.motor_table)
|
||||
|
||||
# Set the main layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(splitter)
|
||||
self.setLayout(layout)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.motor_control_panel.selection_widget.selected_motors_signal.connect(
|
||||
lambda x, y: self.motion_map.change_motors(x, y, 0)
|
||||
)
|
||||
self.motor_control_panel.absolute_widget.coordinates_signal.connect(
|
||||
self.motor_table.add_coordinate
|
||||
)
|
||||
self.motor_control_panel.relative_widget.precision_signal.connect(
|
||||
self.motor_table.set_precision
|
||||
)
|
||||
self.motor_control_panel.relative_widget.precision_signal.connect(
|
||||
self.motor_control_panel.absolute_widget.set_precision
|
||||
)
|
||||
|
||||
self.motor_table.plot_coordinates_signal.connect(self.motion_map.plot_saved_coordinates)
|
||||
|
||||
|
||||
class MotorControlMap(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
# Widgets
|
||||
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
|
||||
# Create MotorMap
|
||||
self.motion_map = MotorMap(client=self.client, config=self.config)
|
||||
|
||||
# Create the splitter and add MotorMap and MotorControlPanel
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
splitter.addWidget(self.motion_map)
|
||||
splitter.addWidget(self.motor_control_panel)
|
||||
|
||||
# Set the main layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(splitter)
|
||||
self.setLayout(layout)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.motor_control_panel.selection_widget.selected_motors_signal.connect(
|
||||
lambda x, y: self.motion_map.change_motors(x, y, 0)
|
||||
)
|
||||
|
||||
|
||||
class MotorControlPanel(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.relative_widget = MotorControlRelative(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.absolute_widget = MotorControlAbsolute(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.relative_widget)
|
||||
layout.addWidget(self.absolute_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
|
||||
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
|
||||
|
||||
# Set the window to a fixed size based on its contents
|
||||
self.layout().setSizeConstraint(layout.SetFixedSize)
|
||||
|
||||
|
||||
class MotorControlPanelAbsolute(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.absolute_widget = MotorControlAbsolute(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.absolute_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
|
||||
|
||||
# Set the window to a fixed size based on its contents
|
||||
self.layout().setSizeConstraint(layout.SetFixedSize)
|
||||
|
||||
|
||||
class MotorControlPanelRelative(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.relative_widget = MotorControlRelative(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.relative_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
|
||||
|
||||
# Set the window to a fixed size based on its contents
|
||||
self.layout().setSizeConstraint(layout.SetFixedSize)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run various Motor Control Widgets compositions.")
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--variant",
|
||||
type=str,
|
||||
choices=["app", "map", "panel", "panel_abs", "panel_rel"],
|
||||
help="Select the variant of the motor control to run. "
|
||||
"'app' for the full application, "
|
||||
"'map' for MotorMap, "
|
||||
"'panel' for the MotorControlPanel, "
|
||||
"'panel_abs' for MotorControlPanel with absolute control, "
|
||||
"'panel_rel' for MotorControlPanel with relative control.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
app = QApplication([])
|
||||
qdarktheme.setup_theme("auto")
|
||||
|
||||
if args.variant == "app":
|
||||
window = MotorControlApp(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "map":
|
||||
window = MotorControlMap(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel":
|
||||
window = MotorControlPanel(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_abs":
|
||||
window = MotorControlPanelAbsolute(client=client, config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_rel":
|
||||
window = MotorControlPanelRelative(client=client, config=CONFIG_DEFAULT)
|
||||
else:
|
||||
print("Please specify a valid variant to run. Use -h for help.")
|
||||
print("Running the full application by default.")
|
||||
window = MotorControlApp(client=client, config=CONFIG_DEFAULT)
|
||||
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
152
bec_widgets/utils/bec_dispatcher.py
Normal file
152
bec_widgets/utils/bec_dispatcher.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
<customwidget>
|
||||
<class>BECTable</class>
|
||||
<extends>QTableWidget</extends>
|
||||
<header>bec_widgets.qt_utils.h</header>
|
||||
<header>bec_widgets.utils.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
|
||||
1
bec_widgets/widgets/monitor_scatter_2D/__init__.py
Normal file
1
bec_widgets/widgets/monitor_scatter_2D/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .monitor_scatter_2D import BECMonitor2DScatter
|
||||
382
bec_widgets/widgets/monitor_scatter_2D/monitor_scatter_2D.py
Normal file
382
bec_widgets/widgets/monitor_scatter_2D/monitor_scatter_2D.py
Normal file
@@ -0,0 +1,382 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_lib import MessageEndpoints
|
||||
from bec_widgets.utils import yaml_dialog
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
CONFIG_DEFAULT = {
|
||||
"plot_settings": {
|
||||
"colormap": "CET-L4",
|
||||
"num_columns": 1,
|
||||
},
|
||||
"waveform2D": [
|
||||
{
|
||||
"plot_name": "Waveform 2D Scatter (1)",
|
||||
"x_label": "Sam X",
|
||||
"y_label": "Sam Y",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "samy", "entry": "samy"}],
|
||||
"z": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"plot_name": "Waveform 2D Scatter (2)",
|
||||
"x_label": "Sam Y",
|
||||
"y_label": "Sam X",
|
||||
"signals": {
|
||||
"x": [{"name": "samy", "entry": "samy"}],
|
||||
"y": [{"name": "samx", "entry": "samx"}],
|
||||
"z": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class BECMonitor2DScatter(QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: dict = None,
|
||||
enable_crosshair: bool = True,
|
||||
gui_id=None,
|
||||
skip_validation: bool = True,
|
||||
toolbar_enabled=True,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
# Client and device manager from BEC
|
||||
self.plot_data = None
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.dev = self.client.device_manager.devices
|
||||
self.queue = self.client.queue
|
||||
|
||||
self.validator = None # TODO implement validator when ready
|
||||
self.gui_id = gui_id
|
||||
|
||||
if self.gui_id is None:
|
||||
self.gui_id = self.__class__.__name__ + str(time.time())
|
||||
|
||||
# Connect dispatcher slots #TODO connect endpoints related to CLI
|
||||
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
|
||||
# Config related variables
|
||||
self.plot_data = None
|
||||
self.plot_settings = None
|
||||
self.num_columns = None
|
||||
self.database = {}
|
||||
self.plots = {}
|
||||
self.grid_coordinates = []
|
||||
|
||||
self.curves_data = {}
|
||||
# Current configuration
|
||||
self.config = config
|
||||
self.skip_validation = skip_validation
|
||||
|
||||
# Enable crosshair
|
||||
self.enable_crosshair = enable_crosshair
|
||||
|
||||
# Displayed Data
|
||||
self.database = {}
|
||||
|
||||
self.crosshairs = None
|
||||
self.plots = None
|
||||
self.curves_data = None
|
||||
self.grid_coordinates = None
|
||||
self.scanID = None
|
||||
|
||||
# Connect the update signal to the update plot method
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=10, slot=self.update_plot
|
||||
)
|
||||
|
||||
# Init UI
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.setLayout(self.layout)
|
||||
if toolbar_enabled: # TODO implement toolbar when ready
|
||||
self._init_toolbar()
|
||||
|
||||
self.glw = pg.GraphicsLayoutWidget()
|
||||
self.layout.addWidget(self.glw)
|
||||
|
||||
if self.config is None:
|
||||
print("No initial config found for BECDeviceMonitor")
|
||||
else:
|
||||
self.on_config_update(self.config)
|
||||
|
||||
def _init_toolbar(self):
|
||||
"""Initialize the toolbar."""
|
||||
# TODO implement toolbar when ready
|
||||
# from bec_widgets.widgets import ModularToolBar
|
||||
#
|
||||
# # Create and configure the toolbar
|
||||
# self.toolbar = ModularToolBar(self)
|
||||
#
|
||||
# # Add the toolbar to the layout
|
||||
# self.layout.addWidget(self.toolbar)
|
||||
|
||||
def _init_config(self):
|
||||
"""Initialize the configuration."""
|
||||
# Global widget settings
|
||||
self._get_global_settings()
|
||||
|
||||
# Plot data
|
||||
self.plot_data = self.config.get("waveform2D", [])
|
||||
|
||||
# Initiate database
|
||||
self.database = self._init_database()
|
||||
|
||||
# Initialize the plot UI
|
||||
self._init_ui()
|
||||
|
||||
def _get_global_settings(self):
|
||||
"""Get the global widget settings."""
|
||||
|
||||
self.plot_settings = self.config.get("plot_settings", {})
|
||||
|
||||
self.num_columns = self.plot_settings.get("num_columns", 1)
|
||||
self.colormap = self.plot_settings.get("colormap", "viridis")
|
||||
|
||||
def _init_database(self) -> dict:
|
||||
"""
|
||||
Initialize the database to store the data for each plot.
|
||||
Returns:
|
||||
dict: The database.
|
||||
"""
|
||||
|
||||
database = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
|
||||
|
||||
return database
|
||||
|
||||
def _init_ui(self, num_columns: int = 3) -> None:
|
||||
"""
|
||||
Initialize the UI components, create plots and store their grid positions.
|
||||
|
||||
Args:
|
||||
num_columns (int): Number of columns to wrap the layout.
|
||||
|
||||
This method initializes a dictionary `self.plots` to store the plot objects
|
||||
along with their corresponding x and y signal names. It dynamically arranges
|
||||
the plots in a grid layout based on the given number of columns and dynamically
|
||||
stretches the last plots to fit the remaining space.
|
||||
"""
|
||||
self.glw.clear()
|
||||
self.plots = {}
|
||||
self.imageItems = {}
|
||||
self.grid_coordinates = []
|
||||
self.scatterPlots = {}
|
||||
self.colorBars = {}
|
||||
|
||||
num_plots = len(self.plot_data)
|
||||
# Check if num_columns exceeds the number of plots
|
||||
if num_columns >= num_plots:
|
||||
num_columns = num_plots
|
||||
self.plot_settings["num_columns"] = num_columns # Update the settings
|
||||
print(
|
||||
"Warning: num_columns in the YAML file was greater than the number of plots."
|
||||
f" Resetting num_columns to number of plots:{num_columns}."
|
||||
)
|
||||
else:
|
||||
self.plot_settings["num_columns"] = num_columns # Update the settings
|
||||
|
||||
num_rows = num_plots // num_columns
|
||||
last_row_cols = num_plots % num_columns
|
||||
remaining_space = num_columns - last_row_cols
|
||||
|
||||
for i, plot_config in enumerate(self.plot_data):
|
||||
row, col = i // num_columns, i % num_columns
|
||||
colspan = 1
|
||||
|
||||
if row == num_rows and remaining_space > 0:
|
||||
if last_row_cols == 1:
|
||||
colspan = num_columns
|
||||
else:
|
||||
colspan = remaining_space // last_row_cols + 1
|
||||
remaining_space -= colspan - 1
|
||||
last_row_cols -= 1
|
||||
|
||||
plot_name = plot_config.get("plot_name", "")
|
||||
|
||||
x_label = plot_config.get("x_label", "")
|
||||
y_label = plot_config.get("y_label", "")
|
||||
|
||||
plot = self.glw.addPlot(row=row, col=col, colspan=colspan, title=plot_name)
|
||||
plot.setLabel("bottom", x_label)
|
||||
plot.setLabel("left", y_label)
|
||||
plot.addLegend()
|
||||
|
||||
self.plots[plot_name] = plot
|
||||
|
||||
self.grid_coordinates.append((row, col))
|
||||
|
||||
self._init_curves()
|
||||
|
||||
def _init_curves(self):
|
||||
"""Init scatter plot pg containers"""
|
||||
self.scatterPlots = {}
|
||||
for i, plot_config in enumerate(self.plot_data):
|
||||
plot_name = plot_config.get("plot_name", "")
|
||||
plot = self.plots[plot_name]
|
||||
plot.clear()
|
||||
|
||||
# Create ScatterPlotItem for each plot
|
||||
scatterPlot = pg.ScatterPlotItem(size=10)
|
||||
plot.addItem(scatterPlot)
|
||||
self.scatterPlots[plot_name] = scatterPlot
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def on_config_update(self, config: dict):
|
||||
"""
|
||||
Validate and update the configuration settings.
|
||||
Args:
|
||||
config(dict): Configuration settings
|
||||
"""
|
||||
# TODO implement BEC CLI commands similar to BECPlotter
|
||||
# convert config from BEC CLI to correct formatting
|
||||
config_tag = config.get("config", None)
|
||||
if config_tag is not None:
|
||||
config = config["config"]
|
||||
|
||||
if self.skip_validation is True:
|
||||
self.config = config
|
||||
self._init_config()
|
||||
|
||||
else: # TODO implement validator
|
||||
print("Do validation")
|
||||
|
||||
def flush(self):
|
||||
"""Reset current plot"""
|
||||
|
||||
self.database = self._init_database()
|
||||
self._init_curves()
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, msg, metadata):
|
||||
"""
|
||||
Handle new scan segments and saves data to a dictionary. Linked through bec_dispatcher.
|
||||
|
||||
Args:
|
||||
msg (dict): Message received with scan data.
|
||||
metadata (dict): Metadata of the scan.
|
||||
"""
|
||||
|
||||
# TODO check if this is correct
|
||||
current_scanID = msg.get("scanID", None)
|
||||
if current_scanID is None:
|
||||
return
|
||||
|
||||
if current_scanID != self.scanID:
|
||||
self.scanID = current_scanID
|
||||
self.scan_data = self.queue.scan_storage.find_scan_by_ID(self.scanID)
|
||||
if not self.scan_data:
|
||||
print(f"No data found for scanID: {self.scanID}") # TODO better error
|
||||
return
|
||||
self.flush()
|
||||
|
||||
# Update the database with new data
|
||||
self.update_database_with_scan_data(msg)
|
||||
|
||||
# Emit signal to update plot #TODO could be moved to update_database_with_scan_data just for coresponding plot name
|
||||
self.update_signal.emit()
|
||||
|
||||
def update_database_with_scan_data(self, msg):
|
||||
"""
|
||||
Update the database with data from the new scan segment.
|
||||
|
||||
Args:
|
||||
msg (dict): Message containing the new scan data.
|
||||
"""
|
||||
data = msg.get("data", {})
|
||||
for plot_config in self.plot_data: # Iterate over the list
|
||||
plot_name = plot_config["plot_name"]
|
||||
x_signal = plot_config["signals"]["x"][0]["name"]
|
||||
y_signal = plot_config["signals"]["y"][0]["name"]
|
||||
z_signal = plot_config["signals"]["z"][0]["name"]
|
||||
|
||||
if x_signal in data and y_signal in data and z_signal in data:
|
||||
x_value = data[x_signal][x_signal]["value"]
|
||||
y_value = data[y_signal][y_signal]["value"]
|
||||
z_value = data[z_signal][z_signal]["value"]
|
||||
|
||||
# Update database for the corresponding plot
|
||||
self.database[plot_name]["x"][x_signal].append(x_value)
|
||||
self.database[plot_name]["y"][y_signal].append(y_value)
|
||||
self.database[plot_name]["z"][z_signal].append(z_value)
|
||||
|
||||
def update_plot(self):
|
||||
"""
|
||||
Update the plots with the latest data from the database.
|
||||
"""
|
||||
for plot_name, scatterPlot in self.scatterPlots.items():
|
||||
x_data = self.database[plot_name]["x"]
|
||||
y_data = self.database[plot_name]["y"]
|
||||
z_data = self.database[plot_name]["z"]
|
||||
|
||||
if x_data and y_data and z_data:
|
||||
# Extract values for each axis
|
||||
x_values = next(iter(x_data.values()), [])
|
||||
y_values = next(iter(y_data.values()), [])
|
||||
z_values = next(iter(z_data.values()), [])
|
||||
|
||||
# Check if the data lists are not empty
|
||||
if x_values and y_values and z_values:
|
||||
# Normalize z_values for color mapping
|
||||
z_min, z_max = np.min(z_values), np.max(z_values)
|
||||
if z_max != z_min: # Ensure that there is a range in the z values
|
||||
z_values_norm = (z_values - z_min) / (z_max - z_min)
|
||||
colormap = pg.colormap.get(
|
||||
self.colormap
|
||||
) # using colormap from global settings
|
||||
colors = [colormap.map(z) for z in z_values_norm]
|
||||
|
||||
# Update scatter plot data with colors
|
||||
scatterPlot.setData(x=x_values, y=y_values, brush=colors)
|
||||
else:
|
||||
# Handle case where all z values are the same (e.g., avoid division by zero)
|
||||
scatterPlot.setData(x=x_values, y=y_values) # Default brush can be used
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config_file", help="Path to the config file.")
|
||||
parser.add_argument("--config", help="Path to the config file.")
|
||||
parser.add_argument("--id", help="GUI ID.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.config is not None:
|
||||
# Load config from file
|
||||
config = json.loads(args.config)
|
||||
elif args.config_file is not None:
|
||||
# Load config from file
|
||||
config = yaml_dialog.load_yaml(args.config_file)
|
||||
else:
|
||||
config = CONFIG_DEFAULT
|
||||
|
||||
client = BECDispatcher().client
|
||||
client.start()
|
||||
app = QApplication(sys.argv)
|
||||
monitor = BECMonitor2DScatter(
|
||||
config=config,
|
||||
gui_id=args.id,
|
||||
skip_validation=True,
|
||||
)
|
||||
monitor.show()
|
||||
sys.exit(app.exec())
|
||||
7
bec_widgets/widgets/motor_control/__init__.py
Normal file
7
bec_widgets/widgets/motor_control/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .motor_control import (
|
||||
MotorControlRelative,
|
||||
MotorControlAbsolute,
|
||||
MotorControlSelection,
|
||||
MotorThread,
|
||||
MotorCoordinateTable,
|
||||
)
|
||||
1194
bec_widgets/widgets/motor_control/motor_control.py
Normal file
1194
bec_widgets/widgets/motor_control/motor_control.py
Normal file
File diff suppressed because it is too large
Load Diff
149
bec_widgets/widgets/motor_control/motor_control_absolute.ui
Normal file
149
bec_widgets/widgets/motor_control/motor_control_absolute.ui
Normal file
@@ -0,0 +1,149 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>285</width>
|
||||
<height>220</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>285</width>
|
||||
<height>220</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>285</width>
|
||||
<height>220</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Move Movement Absolute</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl_absolute">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>195</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>195</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Move Movement Absolute</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_save_with_go">
|
||||
<property name="text">
|
||||
<string>Save position with Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_save">
|
||||
<property name="text">
|
||||
<string>Save</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_set">
|
||||
<property name="text">
|
||||
<string>Set</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_go_absolute">
|
||||
<property name="text">
|
||||
<string>Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_stop">
|
||||
<property name="text">
|
||||
<string>Stop Movement</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
298
bec_widgets/widgets/motor_control/motor_control_relative.ui
Normal file
298
bec_widgets/widgets/motor_control/motor_control_relative.ui
Normal file
@@ -0,0 +1,298 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>285</width>
|
||||
<height>405</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>285</width>
|
||||
<height>405</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Motor Control Relative</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>394</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Control Relative</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_5">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_enableArrows">
|
||||
<property name="text">
|
||||
<string>Move with arrow keys</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_same_xy">
|
||||
<property name="text">
|
||||
<string>Step [X] = Step [Y]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="step_grid">
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_step_y">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Step [Y]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Decimal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step_x">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>0.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>99.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_step_x">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Step [X]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step_y">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>0.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>99.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_precision">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>8</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>2</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="direction_grid">
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetDefaultConstraint</enum>
|
||||
</property>
|
||||
<item row="1" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_up">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::UpArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="4">
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_down">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::DownArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QToolButton" name="toolButton_left">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::LeftArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QToolButton" name="toolButton_right">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::RightArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_stop">
|
||||
<property name="text">
|
||||
<string>Stop Movement</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
69
bec_widgets/widgets/motor_control/motor_control_selection.ui
Normal file
69
bec_widgets/widgets/motor_control/motor_control_selection.ui
Normal file
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>285</width>
|
||||
<height>156</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>285</width>
|
||||
<height>156</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Motor Control Selection</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorSelection">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>145</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Selection</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Motor X</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="comboBox_motor_x"/>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_8">
|
||||
<property name="text">
|
||||
<string>Motor Y</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0" colspan="2">
|
||||
<widget class="QPushButton" name="pushButton_connecMotors">
|
||||
<property name="text">
|
||||
<string>Connect Motors</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QComboBox" name="comboBox_motor_y"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
113
bec_widgets/widgets/motor_control/motor_control_table.ui
Normal file
113
bec_widgets/widgets/motor_control/motor_control_table.ui
Normal file
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>676</width>
|
||||
<height>667</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Motor Coordinates Table</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_4">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_resize_auto">
|
||||
<property name="text">
|
||||
<string>Resize Auto</string>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_editColumns">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Edit Custom Column</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Entries Mode:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBox_mode">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Individual</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Start/Stop</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="table">
|
||||
<property name="gridStyle">
|
||||
<enum>Qt::SolidLine</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_importCSV">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Import CSV</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_exportCSV">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Export CSV</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
1
bec_widgets/widgets/motor_map/__init__.py
Normal file
1
bec_widgets/widgets/motor_map/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .motor_map import MotorMap
|
||||
607
bec_widgets/widgets/motor_map/motor_map.py
Normal file
607
bec_widgets/widgets/motor_map/motor_map.py
Normal file
@@ -0,0 +1,607 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Union
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import MessageEndpoints
|
||||
from qtpy import QtCore
|
||||
from qtpy import QtGui
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
CONFIG_DEFAULT = {
|
||||
"plot_settings": {
|
||||
"colormap": "Greys",
|
||||
"scatter_size": 5,
|
||||
"max_points": 1000,
|
||||
"num_dim_points": 100,
|
||||
"precision": 2,
|
||||
"num_columns": 1,
|
||||
"background_value": 25,
|
||||
},
|
||||
"motors": [
|
||||
{
|
||||
"plot_name": "Motor Map",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Motor Y",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "samy", "entry": "samy"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"plot_name": "Motor Map 2 ",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Motor Y",
|
||||
"signals": {
|
||||
"x": [{"name": "aptrx", "entry": "aptrx"}],
|
||||
"y": [{"name": "aptry", "entry": "aptry"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class MotorMap(pg.GraphicsLayoutWidget):
|
||||
update_signal = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: dict = None,
|
||||
gui_id=None,
|
||||
skip_validation: bool = True,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
# Import BEC related stuff
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.dev = self.client.device_manager.devices
|
||||
|
||||
# TODO import validator when prepared
|
||||
self.gui_id = gui_id
|
||||
|
||||
if self.gui_id is None:
|
||||
self.gui_id = self.__class__.__name__ + str(time.time())
|
||||
|
||||
# Current configuration
|
||||
self.config = config
|
||||
self.skip_validation = skip_validation # TODO implement validation when validator is ready
|
||||
|
||||
# Connect the update signal to the update plot method
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self._update_plots
|
||||
)
|
||||
|
||||
# Config related variables
|
||||
self.plot_data = None
|
||||
self.plot_settings = None
|
||||
self.max_points = None
|
||||
self.num_dim_points = None
|
||||
self.scatter_size = None
|
||||
self.precision = None
|
||||
self.background_value = None
|
||||
self.database = {}
|
||||
self.device_mapping = {}
|
||||
self.plots = {}
|
||||
self.grid_coordinates = []
|
||||
self.curves_data = {}
|
||||
|
||||
# Init UI with config
|
||||
if self.config is None:
|
||||
print("No initial config found for MotorMap. Using default config.")
|
||||
else:
|
||||
self.on_config_update(self.config)
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def on_config_update(self, config: dict) -> None:
|
||||
"""
|
||||
Validate and update the configuration settings for the PlotApp.
|
||||
Args:
|
||||
config(dict): Configuration settings
|
||||
"""
|
||||
# TODO implement BEC CLI commands similar to BECPlotter
|
||||
# convert config from BEC CLI to correct formatting
|
||||
config_tag = config.get("config", None)
|
||||
if config_tag is not None:
|
||||
config = config["config"]
|
||||
|
||||
if self.skip_validation is True:
|
||||
self.config = config
|
||||
self._init_config()
|
||||
|
||||
else: # TODO implement validator
|
||||
print("Do validation")
|
||||
|
||||
@pyqtSlot(str, str, int)
|
||||
def change_motors(self, motor_x: str, motor_y: str, subplot: int = 0) -> None:
|
||||
"""
|
||||
Change the active motors for the plot.
|
||||
Args:
|
||||
motor_x(str): Motor name for the X axis.
|
||||
motor_y(str): Motor name for the Y axis.
|
||||
subplot(int): Subplot number.
|
||||
"""
|
||||
if subplot >= len(self.plot_data):
|
||||
print(f"Invalid subplot index: {subplot}. Available subplots: {len(self.plot_data)}")
|
||||
return
|
||||
|
||||
# Update the motor names in the plot configuration
|
||||
self.config["motors"][subplot]["signals"]["x"][0]["name"] = motor_x
|
||||
self.config["motors"][subplot]["signals"]["x"][0]["entry"] = motor_x
|
||||
self.config["motors"][subplot]["signals"]["y"][0]["name"] = motor_y
|
||||
self.config["motors"][subplot]["signals"]["y"][0]["entry"] = motor_y
|
||||
|
||||
# reinitialise the config and UI
|
||||
self._init_config()
|
||||
|
||||
def _init_config(self):
|
||||
"""Initiate the configuration."""
|
||||
|
||||
# Global widget settings
|
||||
self._get_global_settings()
|
||||
|
||||
# Motor settings
|
||||
self.plot_data = self.config.get("motors", {})
|
||||
|
||||
# Include motor limits into the config
|
||||
self._add_limits_to_plot_data()
|
||||
|
||||
# Initialize the database
|
||||
self.database = self._init_database()
|
||||
|
||||
# Create device mapping for x/y motor pairs
|
||||
self.device_mapping = self._create_device_mapping()
|
||||
|
||||
# Initialize the plot UI
|
||||
self._init_ui()
|
||||
|
||||
# Connect motors to slots
|
||||
self._connect_motors_to_slots()
|
||||
|
||||
# Render init position of selected motors
|
||||
self._update_plots()
|
||||
|
||||
def _get_global_settings(self):
|
||||
"""Get global settings from the config."""
|
||||
self.plot_settings = self.config.get("plot_settings", {})
|
||||
|
||||
self.max_points = self.plot_settings.get("max_points", 5000)
|
||||
self.num_dim_points = self.plot_settings.get("num_dim_points", 100)
|
||||
self.scatter_size = self.plot_settings.get("scatter_size", 5)
|
||||
self.precision = self.plot_settings.get("precision", 2)
|
||||
self.background_value = self.plot_settings.get("background_value", 25)
|
||||
|
||||
def _create_device_mapping(self):
|
||||
"""
|
||||
Create a mapping of device names to their corresponding x/y devices.
|
||||
"""
|
||||
mapping = {}
|
||||
for motor in self.config.get("motors", []):
|
||||
for axis in ["x", "y"]:
|
||||
for signal in motor["signals"][axis]:
|
||||
other_axis = "y" if axis == "x" else "x"
|
||||
corresponding_device = motor["signals"][other_axis][0]["name"]
|
||||
mapping[signal["name"]] = corresponding_device
|
||||
return mapping
|
||||
|
||||
def _connect_motors_to_slots(self):
|
||||
"""Connect motors to slots."""
|
||||
|
||||
# Disconnect all slots before connecting a new ones
|
||||
bec_dispatcher = BECDispatcher()
|
||||
bec_dispatcher.disconnect_all()
|
||||
|
||||
# Get list of all unique motors
|
||||
unique_motors = []
|
||||
for motor_config in self.plot_data:
|
||||
for axis in ["x", "y"]:
|
||||
for signal in motor_config["signals"][axis]:
|
||||
unique_motors.append(signal["name"])
|
||||
unique_motors = list(set(unique_motors))
|
||||
|
||||
# Create list of endpoint
|
||||
endpoints = []
|
||||
for motor in unique_motors:
|
||||
endpoints.append(MessageEndpoints.device_readback(motor))
|
||||
|
||||
# Connect all topics to a single slot
|
||||
bec_dispatcher.connect_slot(
|
||||
self.on_device_readback,
|
||||
endpoints,
|
||||
single_callback_for_all_topics=True,
|
||||
)
|
||||
|
||||
def _add_limits_to_plot_data(self):
|
||||
"""
|
||||
Add limits to each motor signal in the plot_data.
|
||||
"""
|
||||
for motor_config in self.plot_data:
|
||||
for axis in ["x", "y"]:
|
||||
for signal in motor_config["signals"][axis]:
|
||||
motor_name = signal["name"]
|
||||
motor_limits = self._get_motor_limit(motor_name)
|
||||
signal["limits"] = motor_limits
|
||||
|
||||
def _get_motor_limit(self, motor: str) -> Union[list | None]:
|
||||
"""
|
||||
Get the motor limit from the config.
|
||||
Args:
|
||||
motor(str): Motor name.
|
||||
|
||||
Returns:
|
||||
float: Motor limit.
|
||||
"""
|
||||
try:
|
||||
limits = self.dev[motor].limits
|
||||
if limits == [0, 0]:
|
||||
return None
|
||||
return limits
|
||||
except AttributeError: # TODO maybe not needed, if no limits it returns [0,0]
|
||||
# If the motor doesn't have a 'limits' attribute, return a default value or raise a custom exception
|
||||
print(f"The device '{motor}' does not have defined limits.")
|
||||
return None
|
||||
|
||||
def _init_database(self):
|
||||
"""Initiate the database according the config."""
|
||||
database = {}
|
||||
|
||||
for plot in self.plot_data:
|
||||
for axis, signals in plot["signals"].items():
|
||||
for signal in signals:
|
||||
name = signal["name"]
|
||||
entry = signal.get("entry", name)
|
||||
if name not in database:
|
||||
database[name] = {}
|
||||
if entry not in database[name]:
|
||||
database[name][entry] = [self.get_coordinate(name, entry)]
|
||||
return database
|
||||
|
||||
def get_coordinate(self, name, entry):
|
||||
"""Get the initial coordinate value for a motor."""
|
||||
try:
|
||||
return self.dev[name].read()[entry]["value"]
|
||||
except Exception as e:
|
||||
print(f"Error getting initial value for {name}: {e}")
|
||||
return None
|
||||
|
||||
def _init_ui(self, num_columns: int = 3) -> None:
|
||||
"""
|
||||
Initialize the UI components, create plots and store their grid positions.
|
||||
|
||||
Args:
|
||||
num_columns (int): Number of columns to wrap the layout.
|
||||
|
||||
This method initializes a dictionary `self.plots` to store the plot objects
|
||||
along with their corresponding x and y signal names. It dynamically arranges
|
||||
the plots in a grid layout based on the given number of columns and dynamically
|
||||
stretches the last plots to fit the remaining space.
|
||||
"""
|
||||
self.clear()
|
||||
self.plots = {}
|
||||
self.grid_coordinates = []
|
||||
self.curves_data = {} # TODO moved from init_curves
|
||||
|
||||
num_plots = len(self.plot_data)
|
||||
|
||||
# Check if num_columns exceeds the number of plots
|
||||
if num_columns >= num_plots:
|
||||
num_columns = num_plots
|
||||
self.plot_settings["num_columns"] = num_columns # Update the settings
|
||||
print(
|
||||
"Warning: num_columns in the YAML file was greater than the number of plots."
|
||||
f" Resetting num_columns to number of plots:{num_columns}."
|
||||
)
|
||||
else:
|
||||
self.plot_settings["num_columns"] = num_columns # Update the settings
|
||||
|
||||
num_rows = num_plots // num_columns
|
||||
last_row_cols = num_plots % num_columns
|
||||
remaining_space = num_columns - last_row_cols
|
||||
|
||||
for i, plot_config in enumerate(self.plot_data):
|
||||
row, col = i // num_columns, i % num_columns
|
||||
colspan = 1
|
||||
|
||||
if row == num_rows and remaining_space > 0:
|
||||
if last_row_cols == 1:
|
||||
colspan = num_columns
|
||||
else:
|
||||
colspan = remaining_space // last_row_cols + 1
|
||||
remaining_space -= colspan - 1
|
||||
last_row_cols -= 1
|
||||
|
||||
if "plot_name" not in plot_config:
|
||||
plot_name = f"Plot ({row}, {col})"
|
||||
plot_config["plot_name"] = plot_name
|
||||
else:
|
||||
plot_name = plot_config["plot_name"]
|
||||
|
||||
x_label = plot_config.get("x_label", "")
|
||||
y_label = plot_config.get("y_label", "")
|
||||
|
||||
plot = self.addPlot(row=row, col=col, colspan=colspan, title="Motor position: (X, Y)")
|
||||
plot.setLabel("bottom", f"{x_label} ({plot_config['signals']['x'][0]['name']})")
|
||||
plot.setLabel("left", f"{y_label} ({plot_config['signals']['y'][0]['name']})")
|
||||
plot.addLegend()
|
||||
# self._set_plot_colors(plot, self.plot_settings) #TODO implement colors
|
||||
|
||||
self.plots[plot_name] = plot
|
||||
self.grid_coordinates.append((row, col))
|
||||
|
||||
self._init_motor_map(plot_config)
|
||||
|
||||
def _init_motor_map(self, plot_config: dict) -> None:
|
||||
"""
|
||||
Initialize the motor map.
|
||||
Args:
|
||||
plot_config(dict): Plot configuration.
|
||||
"""
|
||||
|
||||
# Get plot name to find appropriate plot
|
||||
plot_name = plot_config.get("plot_name", "")
|
||||
|
||||
# Reset the curves data
|
||||
plot = self.plots[plot_name]
|
||||
plot.clear()
|
||||
|
||||
limits_x, limits_y = plot_config["signals"]["x"][0].get("limits", None), plot_config[
|
||||
"signals"
|
||||
]["y"][0].get("limits", None)
|
||||
if limits_x is not None and limits_y is not None:
|
||||
self._make_limit_map(plot, [limits_x, limits_y])
|
||||
|
||||
# Initiate ScatterPlotItem for motor coordinates
|
||||
self.curves_data[plot_name] = {
|
||||
"pos": pg.ScatterPlotItem(
|
||||
size=self.scatter_size, pen=pg.mkPen(None), brush=pg.mkBrush(255, 255, 255, 255)
|
||||
)
|
||||
}
|
||||
|
||||
# Add the scatter plot to the plot
|
||||
plot.addItem(self.curves_data[plot_name]["pos"])
|
||||
# Set the point map to be always on the top
|
||||
self.curves_data[plot_name]["pos"].setZValue(0)
|
||||
|
||||
# Add all layers to the plot
|
||||
plot.showGrid(x=True, y=True)
|
||||
|
||||
# Add the crosshair for motor coordinates
|
||||
init_position_x = self._get_motor_init_position(
|
||||
plot_config["signals"]["x"][0]["name"], plot_config["signals"]["x"][0]["entry"]
|
||||
)
|
||||
init_position_y = self._get_motor_init_position(
|
||||
plot_config["signals"]["y"][0]["name"], plot_config["signals"]["y"][0]["entry"]
|
||||
)
|
||||
self._add_coordinantes_crosshair(plot_name, init_position_x, init_position_y)
|
||||
|
||||
def _add_coordinantes_crosshair(self, plot_name: str, x: float, y: float) -> None:
|
||||
"""
|
||||
Add crosshair to the plot to highlight the current position.
|
||||
Args:
|
||||
plot_name(str): Name of the plot.
|
||||
x(float): X coordinate.
|
||||
y(float): Y coordinate.
|
||||
"""
|
||||
# find the current plot
|
||||
plot = self.plots[plot_name]
|
||||
|
||||
# Crosshair to highlight the current position
|
||||
highlight_H = pg.InfiniteLine(
|
||||
angle=0, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
|
||||
)
|
||||
highlight_V = pg.InfiniteLine(
|
||||
angle=90, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
|
||||
)
|
||||
|
||||
# Add crosshair to the curve list for future referencing
|
||||
self.curves_data[plot_name]["highlight_H"] = highlight_H
|
||||
self.curves_data[plot_name]["highlight_V"] = highlight_V
|
||||
|
||||
# Add crosshair to the plot
|
||||
plot.addItem(highlight_H)
|
||||
plot.addItem(highlight_V)
|
||||
|
||||
highlight_H.setPos(x)
|
||||
highlight_V.setPos(y)
|
||||
|
||||
def _make_limit_map(self, plot: pg.PlotItem, limits: list):
|
||||
"""
|
||||
Make a limit map from the limits list.
|
||||
|
||||
Args:
|
||||
plot(pg.PlotItem): Plot to add the limit map to.
|
||||
limits(list): List of limits.
|
||||
"""
|
||||
# Define the size of the image map based on the motor's limits
|
||||
limit_x_min, limit_x_max = limits[0]
|
||||
limit_y_min, limit_y_max = limits[1]
|
||||
|
||||
map_width = int(limit_x_max - limit_x_min + 1)
|
||||
map_height = int(limit_y_max - limit_y_min + 1)
|
||||
|
||||
limit_map_data = np.full((map_width, map_height), self.background_value, dtype=np.float32)
|
||||
|
||||
# Create the image map
|
||||
limit_map = pg.ImageItem()
|
||||
limit_map.setImage(limit_map_data)
|
||||
plot.addItem(limit_map)
|
||||
|
||||
# Translate and scale the image item to match the motor coordinates
|
||||
tr = QtGui.QTransform()
|
||||
tr.translate(limit_x_min, limit_y_min)
|
||||
limit_map.setTransform(tr)
|
||||
|
||||
def _get_motor_init_position(self, name: str, entry: str) -> float:
|
||||
"""
|
||||
Get the motor initial position from the config.
|
||||
Args:
|
||||
name(str): Motor name.
|
||||
entry(str): Motor entry.
|
||||
Returns:
|
||||
float: Motor initial position.
|
||||
"""
|
||||
init_position = round(self.dev[name].read()[entry]["value"], self.precision)
|
||||
return init_position
|
||||
|
||||
def _update_plots(self):
|
||||
"""Update the motor position on plots."""
|
||||
for plot_name, curve_list in self.curves_data.items():
|
||||
plot_config = next(
|
||||
(pc for pc in self.plot_data if pc.get("plot_name") == plot_name), None
|
||||
)
|
||||
if not plot_config:
|
||||
continue
|
||||
|
||||
# Get the motor coordinates
|
||||
x_motor_name = plot_config["signals"]["x"][0]["name"]
|
||||
x_motor_entry = plot_config["signals"]["x"][0]["entry"]
|
||||
y_motor_name = plot_config["signals"]["y"][0]["name"]
|
||||
y_motor_entry = plot_config["signals"]["y"][0]["entry"]
|
||||
|
||||
# update motor position only if there is data
|
||||
if (
|
||||
len(self.database[x_motor_name][x_motor_entry]) >= 1
|
||||
and len(self.database[y_motor_name][y_motor_entry]) >= 1
|
||||
):
|
||||
# Relevant data for the plot
|
||||
motor_x_data = self.database[x_motor_name][x_motor_entry]
|
||||
motor_y_data = self.database[y_motor_name][y_motor_entry]
|
||||
|
||||
# Setup gradient brush for history
|
||||
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(motor_x_data)
|
||||
|
||||
# Calculate the decrement step based on self.num_dim_points
|
||||
decrement_step = (255 - 50) / self.num_dim_points
|
||||
|
||||
for i in range(1, min(self.num_dim_points + 1, len(motor_x_data) + 1)):
|
||||
brightness = max(60, 255 - decrement_step * (i - 1))
|
||||
brushes[-i] = pg.mkBrush(brightness, brightness, brightness, 255)
|
||||
|
||||
brushes[-1] = pg.mkBrush(
|
||||
255, 255, 255, 255
|
||||
) # Newest point is always full brightness
|
||||
|
||||
# Update the scatter plot
|
||||
self.curves_data[plot_name]["pos"].setData(
|
||||
x=motor_x_data,
|
||||
y=motor_y_data,
|
||||
brush=brushes,
|
||||
pen=None,
|
||||
size=self.scatter_size,
|
||||
)
|
||||
|
||||
# Get last know position for crosshair
|
||||
current_x = motor_x_data[-1]
|
||||
current_y = motor_y_data[-1]
|
||||
|
||||
# Update plot title
|
||||
self.plots[plot_name].setTitle(
|
||||
f"Motor position: ({round(current_x,self.precision)}, {round(current_y,self.precision)})"
|
||||
)
|
||||
|
||||
# Update the crosshair
|
||||
self.curves_data[plot_name]["highlight_V"].setPos(current_x)
|
||||
self.curves_data[plot_name]["highlight_H"].setPos(current_y)
|
||||
|
||||
@pyqtSlot(list, str, str)
|
||||
def plot_saved_coordinates(self, coordinates: list, tag: str, color: str):
|
||||
"""
|
||||
Plot saved coordinates on the map.
|
||||
Args:
|
||||
coordinates(list): List of coordinates to be plotted.
|
||||
tag(str): Tag for the coordinates for future reference.
|
||||
color(str): Color to plot coordinates in.
|
||||
"""
|
||||
for plot_name in self.plots:
|
||||
plot = self.plots[plot_name]
|
||||
|
||||
# Clear previous saved points
|
||||
if tag in self.curves_data[plot_name]:
|
||||
plot.removeItem(self.curves_data[plot_name][tag])
|
||||
|
||||
# Filter coordinates to be shown
|
||||
visible_coords = [coord[:2] for coord in coordinates if coord[2]]
|
||||
|
||||
if visible_coords:
|
||||
saved_points = pg.ScatterPlotItem(
|
||||
pos=np.array(visible_coords), brush=pg.mkBrush(color)
|
||||
)
|
||||
plot.addItem(saved_points)
|
||||
self.curves_data[plot_name][tag] = saved_points
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def on_device_readback(self, msg: dict):
|
||||
"""
|
||||
Update the motor coordinates on the plots.
|
||||
Args:
|
||||
msg (dict): Message received with device readback data.
|
||||
"""
|
||||
|
||||
for device_name, device_info in msg["signals"].items():
|
||||
# Check if the device is relevant to our current context
|
||||
if device_name in self.device_mapping:
|
||||
self._update_device_data(device_name, device_info["value"])
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
def _update_device_data(self, device_name: str, value: float):
|
||||
"""
|
||||
Update the device data.
|
||||
Args:
|
||||
device_name (str): Device name.
|
||||
value (float): Device value.
|
||||
"""
|
||||
if device_name in self.database:
|
||||
self.database[device_name][device_name].append(value)
|
||||
|
||||
corresponding_device = self.device_mapping.get(device_name)
|
||||
if corresponding_device and corresponding_device in self.database:
|
||||
last_value = (
|
||||
self.database[corresponding_device][corresponding_device][-1]
|
||||
if self.database[corresponding_device][corresponding_device]
|
||||
else None
|
||||
)
|
||||
self.database[corresponding_device][corresponding_device].append(last_value)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config_file", help="Path to the config file.")
|
||||
parser.add_argument("--config", help="Path to the config file.")
|
||||
parser.add_argument("--id", help="GUI ID.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.config is not None:
|
||||
# Load config from file
|
||||
config = json.loads(args.config)
|
||||
elif args.config_file is not None:
|
||||
# Load config from file
|
||||
config = load_yaml(args.config_file)
|
||||
else:
|
||||
config = CONFIG_DEFAULT
|
||||
|
||||
client = BECDispatcher().client
|
||||
client.start()
|
||||
app = QApplication(sys.argv)
|
||||
motor_map = MotorMap(
|
||||
config=config,
|
||||
gui_id=args.id,
|
||||
skip_validation=True,
|
||||
)
|
||||
motor_map.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
@@ -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([])
|
||||
|
||||
0
bec_widgets/widgets/scan_plot/__init__.py
Normal file
0
bec_widgets/widgets/scan_plot/__init__.py
Normal 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()
|
||||
@@ -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."):
|
||||
@@ -1,2 +1,16 @@
|
||||
(developer)=
|
||||
# Developer
|
||||
# Development
|
||||
|
||||
To contribute to the development of BEC Widgets, start by setting up the development environment:
|
||||
|
||||
1. **Clone the Repository**:
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
cd bec-widgets
|
||||
```
|
||||
2. **Install in Editable Mode**:
|
||||
|
||||
Installing the package in editable mode allows you to make changes to the code and test them in real-time.
|
||||
```bash
|
||||
pip install -e .[dev]
|
||||
```
|
||||
@@ -9,29 +9,31 @@
|
||||
:link: introduction
|
||||
:link-type: ref
|
||||
|
||||
General information about BEC Widgets.
|
||||
General information.
|
||||
```
|
||||
|
||||
```{grid-item-card} User
|
||||
:link: user
|
||||
:link-type: ref
|
||||
|
||||
Information for users of BEC Widgets.
|
||||
Information for users.
|
||||
```
|
||||
|
||||
```{grid-item-card} Developer
|
||||
:link: developer
|
||||
:link-type: ref
|
||||
|
||||
Information for developers of BEC Widgets.
|
||||
Information for developers.
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
---
|
||||
numbered: true
|
||||
maxdepth: 1
|
||||
---
|
||||
|
||||
introduction/introduction
|
||||
user/user
|
||||
developer/developer
|
||||
```
|
||||
|
||||
@@ -1,2 +1,18 @@
|
||||
(introduction)=
|
||||
# Introduction
|
||||
|
||||
## Overview
|
||||
|
||||
BEC Widgets is a GUI framework developed with beamline scientists in mind, aiming to provide a modern and modular environment for interacting with experiments. This package offers a suite of widgets specifically designed to enhance the workflow of beamline experiments, including features for running scans and data visualization.
|
||||
|
||||
Targeting the unique needs of beamline scientists, BEC Widgets stands out with its modular approach to widget design and high customizability. This flexibility allows for tailored solutions that meet the specific requirements of each beamline.
|
||||
|
||||
**Key Features**:
|
||||
|
||||
- **Integration:** Seamlessly integrates with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec), ensuring a cohesive and efficient experiment control experience.
|
||||
- **Support for PyQt5 and PyQt6:** Provides compatibility with both PyQt5 and PyQt6, offering versatility in your development environment.
|
||||
- **Widget Modularity:** Features modular widgets that can be easily combined to create customized applications, perfectly aligning with the diverse needs of beamline experiments.
|
||||
|
||||
## Getting Started
|
||||
|
||||
For detailed usage instructions and examples showcasing the practical applications of BEC Widgets, please refer to the [User](#user) section. Developers interested in contributing or customizing BEC Widgets can find more information in the [Developer](#developer) section.
|
||||
39
docs/user/apps.md
Normal file
39
docs/user/apps.md
Normal file
@@ -0,0 +1,39 @@
|
||||
(user.apps)=
|
||||
# Applications
|
||||
|
||||
In the `bec_widgets/examples` directory, you will find practical applications that demonstrate the capabilities of BEC Widgets in real-world scenarios. These applications showcase the adaptability and functionality of the framework for various beamline experiment needs.
|
||||
|
||||
**Motor Alignment Tool**
|
||||
|
||||
This tool assists in aligning motors with samples during experiments. It enables users to move motors, visually track their movement, and record positions for precise alignment.
|
||||
|
||||
- **Location:** `bec_widgets/examples/motor_movement`
|
||||
- **Further Details:** [Motor Alignment Tool Documentation](#user.apps.motor_app)
|
||||
|
||||
**General Plotting Live Acquisition Tool**
|
||||
|
||||
This application is designed for live data visualization. It allows users to view real-time signals from detectors in a multi-grid layout, facilitating immediate analysis during experiments.
|
||||
|
||||
- **Location:** `bec_widgets/examples/plot_app`
|
||||
- **Further Details:** [General Plotting Live Acquisition Tool Documentation](#user.apps.plot_app)
|
||||
|
||||
|
||||
**Modular Application**
|
||||
|
||||
A bespoke application built entirely using BEC Widgets' modular components. This example illustrates the framework's flexibility in creating customized GUIs tailored to specific experimental setups.
|
||||
|
||||
- **Location:** `bec_widgets/examples/modular_app`
|
||||
- **Further Details:** [Modular Application](#user.apps.modular_app)
|
||||
|
||||
---
|
||||
Note: The documentation for these applications is currently under development. The provided links will direct you to their respective pages once the documentation is complete.
|
||||
|
||||
```{toctree}
|
||||
---
|
||||
maxdepth: 1
|
||||
hidden: true
|
||||
---
|
||||
|
||||
apps/motor_app
|
||||
apps/plot_app
|
||||
apps/modular_app
|
||||
6
docs/user/apps/modular_app.md
Normal file
6
docs/user/apps/modular_app.md
Normal file
@@ -0,0 +1,6 @@
|
||||
(user.apps.modular_app)=
|
||||
|
||||
# Modular Application
|
||||
|
||||
|
||||
_to be added..._
|
||||
34
docs/user/apps/motor_app.md
Normal file
34
docs/user/apps/motor_app.md
Normal file
@@ -0,0 +1,34 @@
|
||||
(user.apps.motor_app)=
|
||||
# Motor Alignment
|
||||
|
||||
The Motor Alignment Application is a key component of the BEC Widgets suite, designed to facilitate precise alignment of motors.
|
||||
Users can easily launch this app using the script located at `/bec_widgets/example/motor_movement/motor_example.py` script.
|
||||
The application's primary function is to enable users to align motors to specific positions and to visually track the motor's trajectory.
|
||||
|
||||
## Controlling Motors
|
||||
|
||||
In the top middle panel of the application, users will find combobox dropdown menus for selecting the motors they wish to track on the x and y axes of the motor map.
|
||||
These motors are automatically loaded from the current active BEC instance, ensuring seamless integration and ease of use.
|
||||
|
||||
There are two primary methods to control motor movements:
|
||||
|
||||
|
||||
1. **Manual Control with Arrow Keys:** Users can manually drive the motors using arrow keys. Before doing so, they need to select the step size for each motor, allowing for precise and incremental movements.
|
||||
2. **Direct Position Entry:** Alternatively, users can input a desired position in the text input box and then click the Go button. This action will move the motor directly to the specified coordinates.
|
||||
|
||||
As the motors are moved, their trajectory is plotted in real-time, providing users with a visual representation of the motor's path. This feature is particularly useful for understanding the movement patterns and making necessary adjustments.
|
||||
|
||||
|
||||
## Saving and Exporting Data
|
||||
|
||||
Users have the ability to save the current motor position in a table widget. This functionality is beneficial for recalling and returning to specific positions. By clicking the Go button in the table widget, the motors will automatically move back to the saved position.
|
||||
|
||||
Additionally, users can annotate each saved position with notes and comments directly in the table widget. This feature is invaluable for keeping track of specific alignment settings or observations. The contents of the table, including the notes, can be exported to a .csv file. This exported data can be used for initiating scans or for record-keeping purposes.
|
||||
|
||||
The table widget also supports saving and loading functionalities, allowing users to preserve their motor positions and notes across sessions. The saved files are in a user-friendly format for ease of access and use.
|
||||
|
||||
|
||||
## Example of Use
|
||||
|
||||

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

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

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

|
||||
BIN
docs/user/widgets/motor.gif
Normal file
BIN
docs/user/widgets/motor.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
BIN
docs/user/widgets/scatter_2D.gif
Normal file
BIN
docs/user/widgets/scatter_2D.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 MiB |
BIN
docs/user/widgets/w1D.gif
Normal file
BIN
docs/user/widgets/w1D.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 608 KiB |
@@ -15,9 +15,7 @@ classifiers =
|
||||
package_dir =
|
||||
= .
|
||||
packages = find:
|
||||
python_requires = >=3.8
|
||||
python_requires = >=3.9
|
||||
|
||||
[options.packages.find]
|
||||
where = .
|
||||
|
||||
|
||||
|
||||
7
setup.py
7
setup.py
@@ -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
0
tests/__init__.py
Normal file
35
tests/conftest.py
Normal file
35
tests/conftest.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
import threading
|
||||
|
||||
from bec_lib.bec_service import BECService
|
||||
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def threads_check():
|
||||
current_threads = set(
|
||||
th
|
||||
for th in threading.enumerate()
|
||||
if "loguru" not in th.name and th is not threading.main_thread()
|
||||
)
|
||||
yield
|
||||
threads_after = set(
|
||||
th
|
||||
for th in threading.enumerate()
|
||||
if "loguru" not in th.name and th is not threading.main_thread()
|
||||
)
|
||||
additional_threads = threads_after - current_threads
|
||||
assert (
|
||||
len(additional_threads) == 0
|
||||
), f"Test creates {len(additional_threads)} threads that are not cleaned: {additional_threads}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def bec_dispatcher(threads_check):
|
||||
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
|
||||
yield bec_dispatcher
|
||||
bec_dispatcher.disconnect_all()
|
||||
# clean BEC client
|
||||
BECService.shutdown(bec_dispatcher.client)
|
||||
# reinitialize singleton for next test
|
||||
bec_dispatcher_module._bec_dispatcher = None
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
186
tests/test_bec_monitor_scatter2D.py
Normal file
186
tests/test_bec_monitor_scatter2D.py
Normal 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())
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
649
tests/test_motor_control.py
Normal 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
242
tests/test_motor_map.py
Normal 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"
|
||||
0
tests/test_msgs/__init__.py
Normal file
0
tests/test_msgs/__init__.py
Normal file
989
tests/test_msgs/available_scans_message.py
Normal file
989
tests/test_msgs/available_scans_message.py
Normal 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.
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
109
tests/test_validator_errors.py
Normal file
109
tests/test_validator_errors.py
Normal 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"
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user