Compare commits
100 Commits
v0.70.0
...
feature/as
| Author | SHA1 | Date | |
|---|---|---|---|
| 70fd1b7be4 | |||
|
|
fc3a69bbb0 | ||
| 9594be2606 | |||
|
|
96fd239608 | ||
| 61de7e9e22 | |||
|
|
24c4cdc39f | ||
| fadbf77866 | |||
| 03819a3d90 | |||
| d6d0777113 | |||
| 1aa83e0ef1 | |||
|
|
b09c644e01 | ||
| e403870874 | |||
| 1586ce2d6c | |||
| 576353cfe8 | |||
|
|
cefc415c98 | ||
| bc0ef7893e | |||
|
|
0e802d8194 | ||
| d7718d4dcb | |||
|
|
4c2e02e912 | ||
| b8774e0b0b | |||
| 6e75642090 | |||
| aaa0d1003d | |||
| 5960918137 | |||
| 3dc0532df0 | |||
| 96863adf53 | |||
|
|
08425a623e | ||
| b787759f44 | |||
|
|
25ef7c05e6 | ||
| c36bb80d6a | |||
|
|
c069f3e1b3 | ||
| 215d59c8bf | |||
| 008a33a9b1 | |||
| 3e787234c7 | |||
| 1173510105 | |||
| a391f3018c | |||
| b6e1e20b7c | |||
| 572f2fb811 | |||
| 2e2d422910 | |||
| f0556e4411 | |||
| 4a97105e4b | |||
| 797f73c39a | |||
| b8f796fd3f | |||
| 78673ea11a | |||
| c6a14c0768 | |||
|
|
70a966d8dc | ||
| c42511dd44 | |||
|
|
db62f9e998 | ||
| 0610d2f9f0 | |||
| c1dd0ee190 | |||
| a45c407568 | |||
|
|
813f57861c | ||
| 3faee98ec8 | |||
| ca02132c8d | |||
|
|
cb4ef25b73 | ||
| c8b7367815 | |||
| a268caaa30 | |||
| 6b25abff70 | |||
| 21c807f358 | |||
| 56fdae4275 | |||
| e6a06c9f43 | |||
| f979a63d3d | |||
|
|
327bc54e22 | ||
| a51b15da3f | |||
| 7271b422f9 | |||
| 1866ba66c8 | |||
|
|
6175a04a90 | ||
| 7120f3e93b | |||
| acc13183e2 | |||
| f75fc19c5b | |||
|
|
2650c8b8cf | ||
| 1de3cbf65a | |||
|
|
4a9d0c9e44 | ||
| 88ecd05b95 | |||
| df812eaad5 | |||
|
|
d62da494c8 | ||
| e631fc15d8 | |||
|
|
ecbf1ce0c8 | ||
| e5c0087c9a | |||
| 4348ed1bb2 | |||
|
|
5c11fde0a9 | ||
| 4ca1efeeb8 | |||
| aa7ce2ea27 | |||
|
|
174f0cdcb6 | ||
| 860517a321 | |||
|
|
66daae6d9e | ||
| 83001a0d82 | |||
| 1b7921a7f2 | |||
| 8badb6adc1 | |||
| 37682e7b8a | |||
| 56e74a0e7d | |||
| ec4574ed5c | |||
| 21d20e0fc7 | |||
| 7ce3a83c58 | |||
| 6dff1879c4 | |||
| c09644b29d | |||
| d8cf44134c | |||
| ca856384f3 | |||
| 4e2c9df6a4 | |||
| 8b822e0fa8 | |||
| 67d398caf7 |
@@ -154,7 +154,6 @@ test-matrix:
|
||||
- "3.12"
|
||||
QT_PCKG:
|
||||
- "pyside6"
|
||||
- "pyqt5"
|
||||
- "pyqt6"
|
||||
|
||||
stage: AdditionalTests
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code.
|
||||
extension-pkg-allow-list=PyQt5, pyqtgraph
|
||||
extension-pkg-allow-list=PyQt6, PySide6, pyqtgraph
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
|
||||
226
CHANGELOG.md
@@ -1,167 +1,143 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.70.0 (2024-06-21)
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs: fix typo in link ([`fdf11d8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fdf11d8147750e379af9b17792761a267b49ae53))
|
||||
## v0.81.0 (2024-07-06)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(bec-designer): automatic plugin discovery ([`4639eee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4639eee0b975ebd7a946e0e290449f5b88c372eb))
|
||||
* feat(color_button): can get colors in RGBA or HEX ([`9594be2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9594be260680d11c8550ff74ffb8d679e5a5b8f6))
|
||||
|
||||
* feat(device_line_edit): plugin added to bec-designer ([`b4b27ae`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b4b27aea3d8c08fa3d5d5514c69dbde32721d1dc))
|
||||
|
||||
* feat(device_combobox): plugin added to bec-designer ([`e483b28`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e483b282db20a81182b87938ea172654092419b5))
|
||||
|
||||
* feat: added entry point for bec-designer ([`36391db`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/36391db60735d57b371211791ddf8d3d00cebcf1))
|
||||
|
||||
* feat(utils/bec-designer): added startup script to launched QtDesigner compatible with conda environments ([`5362334`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5362334ff3b07fc83653323a084a4b6946bade96))
|
||||
## v0.80.1 (2024-07-06)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(bec-desiger+plugins): imports fixed, PYSIDE6 check to not enable run plugins with pyqt6 ([`50b3422`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/50b3422528d46d74317e8c903b6286e868ab7fe0))
|
||||
* fix(entry_validator): check for entry == "" ([`61de7e9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/61de7e9e221c766b9fb3ec23246da6a11c96a986))
|
||||
|
||||
## v0.69.0 (2024-06-21)
|
||||
## v0.80.0 (2024-07-06)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(widgets): added vscode widget ([`48ae950`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/48ae950d57b454307ce409e2511f7b7adf3cfc6b))
|
||||
* feat(qt5): dropped support for qt5; pyside2 and pyqt5 ([`fadbf77`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fadbf77866903beff6580802bc203d53367fc7e7))
|
||||
|
||||
* feat(plugins): moved plugin dict to dataclass and container ([`03819a3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/03819a3d902b4a51f3e882d52aedd971b2a8e127))
|
||||
|
||||
* feat(plugins): added support for pyqt6 ui files ([`d6d0777`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d6d07771135335cb78dc648508ce573b8970261a))
|
||||
|
||||
* feat(plugins): added bec widgets base class ([`1aa83e0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1aa83e0ef1ffe45b01677b0b4590535cb0ca1cff))
|
||||
|
||||
## v0.79.3 (2024-07-05)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(generate_cli): fixed rpc generate for classes without user access; closes #226 ([`925c893`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/925c893f3ff4337fc8b4d237c8ffc19a597b0996))
|
||||
* fix: changed inheritance to adress qt designer bug in rendering ([`e403870`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e403870874bd5e45840a034d6f1b3dd576d9c846))
|
||||
|
||||
## v0.68.0 (2024-06-21)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat: properly handle SIGINT (ctrl-c) in BEC GUI server -> calls qapplication.quit() ([`3644f34`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3644f344da2df674bc0d5740c376a86b9d0dfe95))
|
||||
|
||||
* feat: bec-gui-server: redirect stdout and stderr (if any) as proper debug and error log entries ([`d1266a1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d1266a1ce148ff89557a039e3a182a87a3948f49))
|
||||
|
||||
* feat: add logger for BEC GUI server ([`630616e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/630616ec729f60aa0b4d17a9e0379f9c6198eb96))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: ignore GUI server output (any output will go to log file)
|
||||
|
||||
If a logger is given to log `_start_log_process`, the server stdout and
|
||||
stderr streams will be redirected as log entries with levels DEBUG or ERROR
|
||||
in their parent process ([`ce37416`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ce374163cab87a92847409051739777bc505a77b))
|
||||
|
||||
* fix: do not create 'BECClient' logger when instantiating BECDispatcher ([`f7d0b07`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f7d0b0768ace42a33e2556bb33611d4f02e5a6d9))
|
||||
|
||||
## v0.67.0 (2024-06-21)
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs: add widget to documentation ([`6fa1c06`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6fa1c06053131dabd084bb3cf13c853b5d3ce833))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat: introduce BECStatusBox Widget ([`443b6c1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/443b6c1d7b02c772fda02e2d1eefd5bd40249e0c))
|
||||
* fix: add designer plugin classes ([`1586ce2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1586ce2d6cba2bb086b2ef596e724bb9e40ab4f2))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor: Change inheritance to QTreeWidget from QWidget ([`d2f2b20`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d2f2b206bb0eab60b8a9b0d0ac60a6b7887fa6fb))
|
||||
* refactor: simplify logic in bec_status_box ([`576353c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/576353cfe8c6fd64db561f0b6e2bc951300643d3))
|
||||
|
||||
### Test
|
||||
## v0.79.2 (2024-07-04)
|
||||
|
||||
* test: add test suite for bec_status_box and status_item ([`5d4ca81`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d4ca816cdedec4c88aba9eb326f85392504ea1c))
|
||||
### Fix
|
||||
|
||||
* fix: overwrite closeEvent and call super class ([`bc0ef78`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bc0ef7893ef100b71b62101c459655509b534a56))
|
||||
|
||||
## v0.79.1 (2024-07-03)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: use libdir env var to preload Python library, also for Linux platform ([`d7718d4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d7718d4dcb9728c050b6421388af4d484f3741f2))
|
||||
|
||||
## v0.79.0 (2024-07-03)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(motor_map_widget): standalone MotorMap Widget with toolbar + plugin ([`6e75642`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6e756420907d7093557e945bc92bc4cfc0138d07))
|
||||
|
||||
* feat(motor_map): method to reset history trace ([`5960918`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5960918137dd41cdeb94e50f8abc4f169cf45c11))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(toolbar): change default color to black to match BECFigure theme ([`b8774e0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b8774e0b0bc43dcd00f94f42539a778e507ca27d))
|
||||
|
||||
* fix(motor_map): fixed bug with residual trace after changing motors ([`aaa0d10`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/aaa0d1003d2e94b45bafe4f700852c2c05288aea))
|
||||
|
||||
* fix(widget_io): widget handler adjusted for spinboxes and comboboxes ([`3dc0532`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3dc0532df05b6ec0a2522107fa0b1e210ce7d91b))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor(toolbar): cleanup and adjusted colors ([`96863ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/96863adf53c15112645d20eb6200733617801c6d))
|
||||
|
||||
## v0.78.1 (2024-07-02)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(ui_loader): ui loader is compatible with bec plugins ([`b787759`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b787759f44486dc7af2c03811efb156041e4b6cb))
|
||||
|
||||
## v0.78.0 (2024-07-02)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(color_button): patched ColorButton from pyqtgraph to be able to be opened in another QDialog ([`c36bb80`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c36bb80d6a4939802a4a1c8e5452c7b94bac185e))
|
||||
|
||||
## v0.77.0 (2024-07-02)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(bec_connector): export config to yaml ([`a391f30`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a391f3018c50fee6a4a06884491b957df80c3cd3))
|
||||
|
||||
* feat(utils): colors added convertor for rgba to hex ([`572f2fb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/572f2fb8110d5cb0e80f3ca45ce57ef405572456))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(waveform): scatter 2D brush error ([`215d59c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/215d59c8bfe7fda9aff8cec8353bef9e1ce2eca1))
|
||||
|
||||
* fix(figure): API cleanup ([`008a33a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/008a33a9b192473cc58e90cd6d98c5bcb5f7b8c0))
|
||||
|
||||
* fix(figure): if/else logic corrected in subplot_factory ([`3e78723`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3e787234c7274b0698423d7bf9a4c54ec46bad5f))
|
||||
|
||||
* fix(image): processing of already displayed data; closes #106 ([`1173510`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1173510105d2d70d7e498c2ac1e122cea3a16597))
|
||||
|
||||
* fix(bec_figure): full reconstruction with config from other bec figure ([`b6e1e20`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b6e1e20b7c8549bb092e981062329e601411dda6))
|
||||
|
||||
* fix(motor_map): API changes updates current visualisation; motor_map can be initialised from config ([`2e2d422`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2e2d422910685a2527a3d961a468c787f771ca44))
|
||||
|
||||
* fix(image): image add_custom_image fixed, closes #225 ([`f0556e4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0556e44113ffee66cf735aa2dd758c62cb634f4))
|
||||
|
||||
* fix(figure): subplot methods consolidated; added subplot factory ([`4a97105`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4a97105e4bd2ce77d72dfe5f8307dd9ee65b21b0))
|
||||
|
||||
* fix(image): image can be fully reconstructed from config ([`797f73c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/797f73c39aa73e07d6311f3de4baea53f6c380e0))
|
||||
|
||||
* fix(image_item): vrange added int for pydantic model check ([`b8f796f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b8f796fd3fcc15641e8fc6a3ca75c344ce90fc45))
|
||||
|
||||
* fix(bec_figure): waveforms can be initialised from the config; widgets are deleteLater after removal ([`78673ea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/78673ea11a47aad878128197ae6213925228ed59))
|
||||
|
||||
### Unknown
|
||||
|
||||
* Update file requirements.txt ([`505a5ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/505a5ec8334ff4422913b3a7b79d39bcb42ad535))
|
||||
* Resolve "add VT100 console executing BEC as a widget" ([`c6a14c0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c6a14c0768a90695567a83a7895247ed0c64f3ce))
|
||||
|
||||
## v0.66.1 (2024-06-20)
|
||||
## v0.76.1 (2024-06-29)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: fixed shutdown for pyside ([`2718bc6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2718bc624731301756df524d0d5beef6cb1c1430))
|
||||
* fix(plugins): fixes and tests for auto-gen plugins ([`c42511d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c42511dd44cc13577e108a6cef3166376e594f54))
|
||||
|
||||
## v0.66.0 (2024-06-20)
|
||||
## v0.76.0 (2024-06-28)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(rpc): discover widgets automatically ([`ef25f56`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ef25f5638032f931ceb292540ada618508bb2aed))
|
||||
|
||||
## v0.65.2 (2024-06-20)
|
||||
* feat(designer): added support for creating designer plugins automatically ([`c1dd0ee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c1dd0ee1906dba1f2e2ae9ce40a84d55c26a1cce))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(pyqt): webengine must be imported before qcoreapplication ([`cbbd23a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cbbd23aa33095141e4c265719d176c4aa8c25996))
|
||||
* fix: fixed qwidget inheritance for ring progress bar ([`0610d2f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0610d2f9f027f8659e7149f2dfbb316ff30e337d))
|
||||
|
||||
## v0.65.1 (2024-06-20)
|
||||
### Unknown
|
||||
|
||||
### Fix
|
||||
* fix:parent set as first kwarg TextBox and WebsiteWidget ([`a45c407`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a45c4075684b93bfdcee03e5a416b84f61d3bc6f))
|
||||
|
||||
* fix: prevent segfault by closing the QCoreApplication, if any ([`fa344a5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fa344a5799b07a2d8ace63cc7010b69bc4ed6f1d))
|
||||
|
||||
## v0.65.0 (2024-06-20)
|
||||
## v0.75.0 (2024-06-26)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(device_input): DeviceLineEdit with QCompleter added ([`50e41ff`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/50e41ff26160ec26d77feb6d519e4dad902a9b9b))
|
||||
|
||||
* feat(device_combobox): DeviceInputBase and DeviceComboBox added ([`430b282`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/430b282039806e3fbc6cf98e958861a065760620))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(device_input_base): bug with setting config and overwriting default device and filter ([`d79f7e9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d79f7e9ccde03dc77819ca556c79736d30f7821a))
|
||||
|
||||
### Test
|
||||
|
||||
* test(device_input): tests added ([`1a0a98a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1a0a98a45367db414bed813bbd346b3e1ae8d550))
|
||||
|
||||
## v0.64.2 (2024-06-19)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(client_utils): added close rpc command to shutdown of gui from bec_ipython_client ([`e5a7d47`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e5a7d47b21cbf066f740f1d11d7c9ea7c70f3080))
|
||||
|
||||
## v0.64.1 (2024-06-19)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(widgets): removed widget module import of sub widgets ([`216511b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/216511b951ff0e15b6d7c70133095f3ac45c23f4))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor(utils): moved get_rpc_widgets to plugin_utils ([`6dabbf8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6dabbf874fbbdde89c34a7885bf95aa9c895a28b))
|
||||
|
||||
### Test
|
||||
|
||||
* test: moved rpc_classes test ([`b3575eb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b3575eb06852b456cde915dfda281a3e778e3aeb))
|
||||
|
||||
## v0.64.0 (2024-06-19)
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: add job optional dependency check ([`27426ce`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/27426ce7a52b4cbad7f3bef114d6efe6ad73bd7f))
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs: fix links in developer section ([`9e16f2f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9e16f2faf9c59a5d36ae878512c5a910cca31e69))
|
||||
|
||||
* docs: refactor developer section, add widget tutorial ([`2a36d93`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2a36d9364f242bf42e4cda4b50e6f46aa3833bbd))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat: add option to change size of the fonts ([`ea805d1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ea805d1362fc084d3b703b6f81b0180072f0825d))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(plot_base): font size is set with setScale which is scaling the whole legend window ([`5d66720`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d6672069ea1cbceb62104f66c127e4e3c23e4a4))
|
||||
|
||||
### Test
|
||||
|
||||
* test: add tests ([`140ad83`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/140ad83380808928edf7953e23c762ab72a0a1e9))
|
||||
|
||||
## v0.63.2 (2024-06-14)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: do not import "server" in client, prevents from having trouble with QApplication creation order
|
||||
|
||||
Like with QtWebEngine ([`6f96498`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6f96498de66358b89f3a2035627eed2e02dde5a1))
|
||||
* feat(widgets): added simple bec queue widget ([`3faee98`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3faee98ec80041a27e4c1f1156178de6f9dcdc63))
|
||||
|
||||
@@ -17,7 +17,7 @@ cd bec_widgets
|
||||
pip install -e .[dev,pyqt6]
|
||||
```
|
||||
|
||||
BEC Widgets currently supports both PyQt5 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
|
||||
BEC Widgets currently supports both Pyside6 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
|
||||
Python Qt distributions manually.
|
||||
|
||||
To select a specific Python Qt distribution, install the package with an additional tag:
|
||||
@@ -28,7 +28,7 @@ pip install bec_widgets[pyqt6]
|
||||
or
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyqt5]
|
||||
pip install bec_widgets[pyside6]
|
||||
```
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -13,11 +13,14 @@ class Widgets(str, enum.Enum):
|
||||
Enum for the available widgets.
|
||||
"""
|
||||
|
||||
BECStatusBox = "BECStatusBox"
|
||||
BECDock = "BECDock"
|
||||
BECDockArea = "BECDockArea"
|
||||
BECFigure = "BECFigure"
|
||||
SpiralProgressBar = "SpiralProgressBar"
|
||||
BECMotorMapWidget = "BECMotorMapWidget"
|
||||
BECQueue = "BECQueue"
|
||||
BECStatusBox = "BECStatusBox"
|
||||
RingProgressBar = "RingProgressBar"
|
||||
ScanControl = "ScanControl"
|
||||
TextBox = "TextBox"
|
||||
VSCodeEditor = "VSCodeEditor"
|
||||
WebsiteWidget = "WebsiteWidget"
|
||||
@@ -32,14 +35,21 @@ class BECCurve(RPCBase):
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def rpc_id(self) -> "str":
|
||||
def dap_params(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def _rpc_id(self) -> "str":
|
||||
"""
|
||||
Get the RPC ID of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -142,11 +152,18 @@ class BECCurve(RPCBase):
|
||||
tuple[np.ndarray,np.ndarray]: X and Y data of the curve.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def dap_params(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
|
||||
class BECDock(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -156,7 +173,7 @@ class BECDock(RPCBase):
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def rpc_id(self) -> "str":
|
||||
def _rpc_id(self) -> "str":
|
||||
"""
|
||||
Get the RPC ID of the widget.
|
||||
"""
|
||||
@@ -274,7 +291,7 @@ class BECDock(RPCBase):
|
||||
class BECDockArea(RPCBase, BECGuiClientMixin):
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -382,7 +399,7 @@ class BECDockArea(RPCBase, BECGuiClientMixin):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_all_rpc(self) -> "dict":
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
@@ -401,14 +418,14 @@ class BECDockArea(RPCBase, BECGuiClientMixin):
|
||||
class BECFigure(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def rpc_id(self) -> "str":
|
||||
def _rpc_id(self) -> "str":
|
||||
"""
|
||||
Get the RPC ID of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -416,6 +433,12 @@ class BECFigure(RPCBase):
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def axes(self, row: "int", col: "int") -> "BECPlotBase":
|
||||
"""
|
||||
@@ -438,102 +461,6 @@ class BECFigure(RPCBase):
|
||||
dict: All widgets within the figure.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_plot(
|
||||
self,
|
||||
x: "list | np.ndarray" = None,
|
||||
y: "list | np.ndarray" = None,
|
||||
x_name: "str" = None,
|
||||
y_name: "str" = None,
|
||||
z_name: "str" = None,
|
||||
x_entry: "str" = None,
|
||||
y_entry: "str" = None,
|
||||
z_entry: "str" = None,
|
||||
color: "Optional[str]" = None,
|
||||
color_map_z: "Optional[str]" = "plasma",
|
||||
label: "Optional[str]" = None,
|
||||
validate: "bool" = True,
|
||||
row: "int" = None,
|
||||
col: "int" = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> "BECWaveform":
|
||||
"""
|
||||
Add a Waveform1D plot to the figure at the specified position.
|
||||
|
||||
Args:
|
||||
x(list | np.ndarray): Custom x data to plot.
|
||||
y(list | np.ndarray): Custom y data to plot.
|
||||
x_name(str): The name of the device for the x-axis.
|
||||
y_name(str): The name of the device for the y-axis.
|
||||
z_name(str): The name of the device for the z-axis.
|
||||
x_entry(str): The name of the entry for the x-axis.
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
z_entry(str): The name of the entry for the z-axis.
|
||||
color(str): The color of the curve.
|
||||
color_map_z(str): The color map to use for the z-axis.
|
||||
label(str): The label of the curve.
|
||||
validate(bool): If True, validate the device names and entries.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_image(
|
||||
self,
|
||||
monitor: "str" = None,
|
||||
color_bar: "Literal['simple', 'full']" = "full",
|
||||
color_map: "str" = "magma",
|
||||
data: "np.ndarray" = None,
|
||||
vrange: "tuple[float, float]" = None,
|
||||
row: "int" = None,
|
||||
col: "int" = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> "BECImageShow":
|
||||
"""
|
||||
Add an image to the figure at the specified position.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to display.
|
||||
color_bar(Literal["simple","full"]): The type of color bar to display.
|
||||
color_map(str): The color map to use for the image.
|
||||
data(np.ndarray): Custom data to display.
|
||||
vrange(tuple[float, float]): The range of values to display.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECImageShow: The image widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_motor_map(
|
||||
self,
|
||||
motor_x: "str" = None,
|
||||
motor_y: "str" = None,
|
||||
row: "int" = None,
|
||||
col: "int" = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> "BECMotorMap":
|
||||
"""
|
||||
Args:
|
||||
motor_x(str): The name of the motor for the X axis.
|
||||
motor_y(str): The name of the motor for the Y axis.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs:
|
||||
|
||||
Returns:
|
||||
BECMotorMap: The motor map widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def plot(
|
||||
self,
|
||||
@@ -549,6 +476,11 @@ class BECFigure(RPCBase):
|
||||
color_map_z: "str | None" = "plasma",
|
||||
label: "str | None" = None,
|
||||
validate: "bool" = True,
|
||||
new: "bool" = False,
|
||||
row: "int | None" = None,
|
||||
col: "int | None" = None,
|
||||
dap: "str | None" = None,
|
||||
config: "dict | None" = None,
|
||||
**axis_kwargs,
|
||||
) -> "BECWaveform":
|
||||
"""
|
||||
@@ -567,6 +499,11 @@ class BECFigure(RPCBase):
|
||||
color_map_z(str): The color map to use for the z-axis.
|
||||
label(str): The label of the curve.
|
||||
validate(bool): If True, validate the device names and entries.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
dap(str): The DAP model to use for the curve.
|
||||
config(dict): Recreates the whole BECWaveform widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
@@ -581,6 +518,10 @@ class BECFigure(RPCBase):
|
||||
color_map: "str" = "magma",
|
||||
data: "np.ndarray" = None,
|
||||
vrange: "tuple[float, float]" = None,
|
||||
new: "bool" = False,
|
||||
row: "int | None" = None,
|
||||
col: "int | None" = None,
|
||||
config: "dict | None" = None,
|
||||
**axis_kwargs,
|
||||
) -> "BECImageShow":
|
||||
"""
|
||||
@@ -592,6 +533,10 @@ class BECFigure(RPCBase):
|
||||
color_map(str): The color map to use for the image.
|
||||
data(np.ndarray): Custom data to display.
|
||||
vrange(tuple[float, float]): The range of values to display.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Recreates the whole BECImageShow widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
@@ -600,7 +545,14 @@ class BECFigure(RPCBase):
|
||||
|
||||
@rpc_call
|
||||
def motor_map(
|
||||
self, motor_x: "str" = None, motor_y: "str" = None, **axis_kwargs
|
||||
self,
|
||||
motor_x: "str" = None,
|
||||
motor_y: "str" = None,
|
||||
new: "bool" = False,
|
||||
row: "int | None" = None,
|
||||
col: "int | None" = None,
|
||||
config: "dict | None" = None,
|
||||
**axis_kwargs,
|
||||
) -> "BECMotorMap":
|
||||
"""
|
||||
Add a motor map to the figure. Always access the first motor map widget in the figure.
|
||||
@@ -608,6 +560,10 @@ class BECFigure(RPCBase):
|
||||
Args:
|
||||
motor_x(str): The name of the motor for the X axis.
|
||||
motor_y(str): The name of the motor for the Y axis.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Recreates the whole BECImageShow widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
@@ -658,12 +614,6 @@ class BECFigure(RPCBase):
|
||||
Clear all widgets from the figure and reset to default state
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def widget_list(self) -> "list[BECPlotBase]":
|
||||
@@ -677,14 +627,14 @@ class BECFigure(RPCBase):
|
||||
class BECImageItem(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def rpc_id(self) -> "str":
|
||||
def _rpc_id(self) -> "str":
|
||||
"""
|
||||
Get the RPC ID of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -710,6 +660,7 @@ class BECImageItem(RPCBase):
|
||||
- log
|
||||
- rot
|
||||
- transpose
|
||||
- autorange_mode
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
@@ -766,6 +717,15 @@ class BECImageItem(RPCBase):
|
||||
autorange(bool): Whether to autorange the color bar.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_autorange_mode(self, mode: "Literal['max', 'mean']" = "mean"):
|
||||
"""
|
||||
Set the autorange mode to scale the vrange of the color bar. Choose between min/max or mean +/- std.
|
||||
|
||||
Args:
|
||||
mode(Literal["max","mean"]): Max for min/max or mean for mean +/- std.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_color_map(self, cmap: "str" = "magma"):
|
||||
"""
|
||||
@@ -795,7 +755,11 @@ class BECImageItem(RPCBase):
|
||||
|
||||
@rpc_call
|
||||
def set_vrange(
|
||||
self, vmin: "float" = None, vmax: "float" = None, vrange: "tuple[int, int]" = None
|
||||
self,
|
||||
vmin: "float" = None,
|
||||
vmax: "float" = None,
|
||||
vrange: "tuple[float, float]" = None,
|
||||
change_autorange: "bool" = True,
|
||||
):
|
||||
"""
|
||||
Set the range of the color bar.
|
||||
@@ -817,14 +781,14 @@ class BECImageItem(RPCBase):
|
||||
class BECImageShow(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def rpc_id(self) -> "str":
|
||||
def _rpc_id(self) -> "str":
|
||||
"""
|
||||
Get the RPC ID of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -844,34 +808,12 @@ class BECImageShow(RPCBase):
|
||||
BECImageItem: The image object.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_image_config(self, image_id, dict_output: "bool" = True) -> "ImageItemConfig | dict":
|
||||
"""
|
||||
Get the configuration of the image.
|
||||
|
||||
Args:
|
||||
image_id(str): The ID of the image.
|
||||
dict_output(bool): Whether to return the configuration as a dictionary. Defaults to True.
|
||||
|
||||
Returns:
|
||||
ImageItemConfig|dict: The configuration of the image.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_image_dict(self) -> "dict[str, dict[str, BECImageItem]]":
|
||||
"""
|
||||
Get all images.
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, BECImageItem]]: The dictionary of images.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_monitor_image(
|
||||
self,
|
||||
monitor: "str",
|
||||
color_map: "Optional[str]" = "magma",
|
||||
color_bar: "Optional[Literal['simple', 'full']]" = "simple",
|
||||
color_bar: "Optional[Literal['simple', 'full']]" = "full",
|
||||
downsample: "Optional[bool]" = True,
|
||||
opacity: "Optional[float]" = 1.0,
|
||||
vrange: "Optional[tuple[int, int]]" = None,
|
||||
@@ -887,7 +829,7 @@ class BECImageShow(RPCBase):
|
||||
name: "str",
|
||||
data: "Optional[np.ndarray]" = None,
|
||||
color_map: "Optional[str]" = "magma",
|
||||
color_bar: "Optional[Literal['simple', 'full']]" = "simple",
|
||||
color_bar: "Optional[Literal['simple', 'full']]" = "full",
|
||||
downsample: "Optional[bool]" = True,
|
||||
opacity: "Optional[float]" = 1.0,
|
||||
vrange: "Optional[tuple[int, int]]" = None,
|
||||
@@ -930,6 +872,17 @@ class BECImageShow(RPCBase):
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_autorange_mode(self, mode: "Literal['max', 'mean']", name: "str" = None):
|
||||
"""
|
||||
Set the autoscale mode of the image, that decides how the vrange of the color bar is scaled.
|
||||
Choose betwen 'max' -> min/max of the data, 'mean' -> mean +/- fudge_factor*std of the data (fudge_factor~2).
|
||||
|
||||
Args:
|
||||
mode(str): The autoscale mode of the image.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_monitor(self, monitor: "str", name: "str" = None):
|
||||
"""
|
||||
@@ -1021,15 +974,6 @@ class BECImageShow(RPCBase):
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def toggle_threading(self, use_threading: "bool"):
|
||||
"""
|
||||
Toggle threading for the widgets postprocessing and updating.
|
||||
|
||||
Args:
|
||||
use_threading(bool): Whether to use threading.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set(self, **kwargs) -> "None":
|
||||
"""
|
||||
@@ -1165,7 +1109,14 @@ class BECImageShow(RPCBase):
|
||||
class BECMotorMap(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _rpc_id(self) -> "str":
|
||||
"""
|
||||
Get the RPC ID of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -1242,15 +1193,110 @@ class BECMotorMap(RPCBase):
|
||||
def get_data(self) -> "dict":
|
||||
"""
|
||||
Get the data of the motor map.
|
||||
|
||||
Returns:
|
||||
dict: Data of the motor map.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the plot widget from the figure.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def reset_history(self):
|
||||
"""
|
||||
Reset the history of the motor map.
|
||||
"""
|
||||
|
||||
|
||||
class BECMotorMapWidget(RPCBase):
|
||||
@rpc_call
|
||||
def change_motors(
|
||||
self,
|
||||
motor_x: "str",
|
||||
motor_y: "str",
|
||||
motor_x_entry: "str" = None,
|
||||
motor_y_entry: "str" = None,
|
||||
validate_bec: "bool" = True,
|
||||
) -> "None":
|
||||
"""
|
||||
Change the active motors for the plot.
|
||||
|
||||
Args:
|
||||
motor_x(str): Motor name for the X axis.
|
||||
motor_y(str): Motor name for the Y axis.
|
||||
motor_x_entry(str): Motor entry for the X axis.
|
||||
motor_y_entry(str): Motor entry for the Y axis.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_max_points(self, max_points: "int") -> "None":
|
||||
"""
|
||||
Set the maximum number of points to display on the motor map.
|
||||
|
||||
Args:
|
||||
max_points(int): Maximum number of points to display.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_precision(self, precision: "int") -> "None":
|
||||
"""
|
||||
Set the precision of the motor map.
|
||||
|
||||
Args:
|
||||
precision(int): Precision to set.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_num_dim_points(self, num_dim_points: "int") -> "None":
|
||||
"""
|
||||
Set the number of points to display on the motor map.
|
||||
|
||||
Args:
|
||||
num_dim_points(int): Number of points to display.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_background_value(self, background_value: "int") -> "None":
|
||||
"""
|
||||
Set the background value of the motor map.
|
||||
|
||||
Args:
|
||||
background_value(int): Background value of the motor map.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_scatter_size(self, scatter_size: "int") -> "None":
|
||||
"""
|
||||
Set the scatter size of the motor map.
|
||||
|
||||
Args:
|
||||
scatter_size(int): Scatter size of the motor map.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_data(self) -> "dict":
|
||||
"""
|
||||
Get the data of the motor map.
|
||||
|
||||
Returns:
|
||||
dict: Data of the motor map.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def reset_history(self) -> "None":
|
||||
"""
|
||||
Reset the history of the motor map.
|
||||
"""
|
||||
|
||||
|
||||
class BECPlotBase(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -1390,10 +1436,10 @@ class BECPlotBase(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class BECStatusBox(RPCBase):
|
||||
class BECQueue(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -1402,7 +1448,25 @@ class BECStatusBox(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_all_rpc(self) -> "dict":
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
|
||||
|
||||
class BECStatusBox(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
@@ -1411,14 +1475,14 @@ class BECStatusBox(RPCBase):
|
||||
class BECWaveform(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def rpc_id(self) -> "str":
|
||||
def _rpc_id(self) -> "str":
|
||||
"""
|
||||
Get the RPC ID of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -1441,6 +1505,7 @@ class BECWaveform(RPCBase):
|
||||
color_map_z: "str | None" = "plasma",
|
||||
label: "str | None" = None,
|
||||
validate: "bool" = True,
|
||||
dap: "str | None" = None,
|
||||
) -> "BECCurve":
|
||||
"""
|
||||
Plot a curve to the plot widget.
|
||||
@@ -1457,11 +1522,50 @@ class BECWaveform(RPCBase):
|
||||
color_map_z(str): The color map to use for the z-axis.
|
||||
label(str): The label of the curve.
|
||||
validate(bool): If True, validate the device names and entries.
|
||||
dap(str): The dap model to use for the curve. If not specified, none will be added.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_dap(
|
||||
self,
|
||||
x_name: "str",
|
||||
y_name: "str",
|
||||
x_entry: "Optional[str]" = None,
|
||||
y_entry: "Optional[str]" = None,
|
||||
color: "Optional[str]" = None,
|
||||
dap: "str" = "GaussianModel",
|
||||
**kwargs,
|
||||
) -> "BECCurve":
|
||||
"""
|
||||
Add LMFIT dap model curve to the plot widget.
|
||||
|
||||
Args:
|
||||
x_name(str): Name of the x signal.
|
||||
x_entry(str): Entry of the x signal.
|
||||
y_name(str): Name of the y signal.
|
||||
y_entry(str): Entry of the y signal.
|
||||
color(str, optional): Color of the curve. Defaults to None.
|
||||
color_map_z(str): The color map to use for the z-axis.
|
||||
label(str, optional): Label of the curve. Defaults to None.
|
||||
dap(str): The dap model to use for the curve.
|
||||
**kwargs: Additional keyword arguments for the curve configuration.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_dap_params(self) -> "dict":
|
||||
"""
|
||||
Get the DAP parameters of all DAP curves.
|
||||
|
||||
Returns:
|
||||
dict: DAP parameters of all DAP curves.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove_curve(self, *identifiers):
|
||||
"""
|
||||
@@ -1503,28 +1607,6 @@ class BECWaveform(RPCBase):
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_curve_config(self, curve_id: "str", dict_output: "bool" = True) -> "CurveConfig | dict":
|
||||
"""
|
||||
Get the configuration of a curve by its ID.
|
||||
|
||||
Args:
|
||||
curve_id(str): ID of the curve.
|
||||
|
||||
Returns:
|
||||
CurveConfig|dict: Configuration of the curve.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def apply_config(self, config: "dict | SubplotConfig", replot_last_scan: "bool" = False):
|
||||
"""
|
||||
Apply the configuration to the 1D waveform widget.
|
||||
|
||||
Args:
|
||||
config(dict|SubplotConfig): Configuration settings.
|
||||
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_all_data(self, output: "Literal['dict', 'pandas']" = "dict") -> "dict | pd.DataFrame":
|
||||
"""
|
||||
@@ -1672,7 +1754,7 @@ class BECWaveform(RPCBase):
|
||||
class DeviceComboBox(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -1681,7 +1763,7 @@ class DeviceComboBox(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_all_rpc(self) -> "dict":
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
@@ -1690,7 +1772,7 @@ class DeviceComboBox(RPCBase):
|
||||
class DeviceInputBase(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -1699,7 +1781,7 @@ class DeviceInputBase(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_all_rpc(self) -> "dict":
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
@@ -1708,7 +1790,7 @@ class DeviceInputBase(RPCBase):
|
||||
class DeviceLineEdit(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -1717,7 +1799,7 @@ class DeviceLineEdit(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_all_rpc(self) -> "dict":
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
@@ -1725,21 +1807,21 @@ class DeviceLineEdit(RPCBase):
|
||||
|
||||
class Ring(RPCBase):
|
||||
@rpc_call
|
||||
def get_all_rpc(self) -> "dict":
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def rpc_id(self) -> "str":
|
||||
def _rpc_id(self) -> "str":
|
||||
"""
|
||||
Get the RPC ID of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -1823,23 +1905,23 @@ class Ring(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class SpiralProgressBar(RPCBase):
|
||||
class RingProgressBar(RPCBase):
|
||||
@rpc_call
|
||||
def get_all_rpc(self) -> "dict":
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def rpc_id(self) -> "str":
|
||||
def _rpc_id(self) -> "str":
|
||||
"""
|
||||
Get the RPC ID of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -1855,7 +1937,7 @@ class SpiralProgressBar(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def update_config(self, config: "SpiralProgressBarConfig | dict"):
|
||||
def update_config(self, config: "RingProgressBarConfig | dict"):
|
||||
"""
|
||||
Update the configuration of the widget.
|
||||
|
||||
@@ -2002,10 +2084,10 @@ class SpiralProgressBar(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class StopButton(RPCBase):
|
||||
class ScanControl(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def config_dict(self) -> "dict":
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -2014,7 +2096,25 @@ class StopButton(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_all_rpc(self) -> "dict":
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
|
||||
|
||||
class StopButton(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def _get_all_rpc(self) -> "dict":
|
||||
"""
|
||||
Get all registered RPC objects.
|
||||
"""
|
||||
|
||||
@@ -141,6 +141,10 @@ class BECGuiClientMixin:
|
||||
for ep in eps:
|
||||
if ep.name == "plugin_widgets_update":
|
||||
try:
|
||||
spec = importlib.util.find_spec(ep.module)
|
||||
# if the module is not found, we skip it
|
||||
if spec is None:
|
||||
continue
|
||||
return ep.load()(gui=self)
|
||||
except Exception as e:
|
||||
print(f"Error loading auto update script from plugin: {str(e)}")
|
||||
@@ -199,15 +203,12 @@ class BECGuiClientMixin:
|
||||
if self._process is None:
|
||||
return
|
||||
|
||||
self._run_rpc("close", (), wait_for_rpc_response=False)
|
||||
while self.gui_is_alive():
|
||||
time.sleep(0.2)
|
||||
|
||||
self._client.shutdown()
|
||||
if self._process:
|
||||
self._process.terminate()
|
||||
if self._process_output_processing_thread:
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import argparse
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from typing import Literal
|
||||
|
||||
import black
|
||||
import isort
|
||||
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_classes
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, get_rpc_classes
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import get_overloads
|
||||
@@ -39,17 +39,20 @@ from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
|
||||
|
||||
self.content = ""
|
||||
|
||||
def generate_client(
|
||||
self, published_classes: dict[Literal["connector_classes", "top_level_classes"], list[type]]
|
||||
):
|
||||
def generate_client(self, class_container: BECClassContainer):
|
||||
"""
|
||||
Generate the client for the published classes.
|
||||
|
||||
Args:
|
||||
published_classes(dict): A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
|
||||
class_container: The class container with the classes to generate the client for.
|
||||
"""
|
||||
self.write_client_enum(published_classes["top_level_classes"])
|
||||
for cls in published_classes["connector_classes"]:
|
||||
rpc_top_level_classes = class_container.rpc_top_level_classes
|
||||
rpc_top_level_classes.sort(key=lambda x: x.__name__)
|
||||
connector_classes = class_container.connector_classes
|
||||
connector_classes.sort(key=lambda x: x.__name__)
|
||||
|
||||
self.write_client_enum(rpc_top_level_classes)
|
||||
for cls in connector_classes:
|
||||
self.content += "\n\n"
|
||||
self.generate_content_for_class(cls)
|
||||
|
||||
@@ -155,12 +158,31 @@ def main():
|
||||
client_path = os.path.join(current_path, "client.py")
|
||||
|
||||
rpc_classes = get_rpc_classes("bec_widgets")
|
||||
rpc_classes["connector_classes"].sort(key=lambda x: x.__name__)
|
||||
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(client_path)
|
||||
|
||||
for cls in rpc_classes.plugins:
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
continue
|
||||
|
||||
# if the class directory already has a register, plugin and pyproject file, skip
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"register_{plugin.info.plugin_name_snake}.py")
|
||||
):
|
||||
continue
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}_plugin.py")
|
||||
):
|
||||
continue
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}.pyproject")
|
||||
):
|
||||
continue
|
||||
plugin.run()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.argv = ["generate_cli.py", "--core"]
|
||||
|
||||
@@ -29,7 +29,7 @@ class RPCWidgetHandler:
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_classes
|
||||
|
||||
clss = get_rpc_classes("bec_widgets")
|
||||
self._widget_classes = {cls.__name__: cls for cls in clss["top_level_classes"]}
|
||||
self._widget_classes = {cls.__name__: cls for cls in clss.top_level_classes}
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECConnector:
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import signal
|
||||
import sys
|
||||
@@ -29,7 +31,7 @@ class BECWidgetsCLIServer:
|
||||
dispatcher: BECDispatcher = None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_class: Union["BECFigure", "BECDockArea"] = BECFigure,
|
||||
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
|
||||
) -> None:
|
||||
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
|
||||
self.client = self.dispatcher.client if client is None else client
|
||||
@@ -118,6 +120,7 @@ class BECWidgetsCLIServer:
|
||||
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
|
||||
self._shutdown_event = True
|
||||
self._heartbeat_timer.stop()
|
||||
self.gui.close()
|
||||
self.client.shutdown()
|
||||
|
||||
|
||||
@@ -207,6 +210,7 @@ def main():
|
||||
app.quit()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
@@ -14,21 +14,6 @@ from bec_widgets.widgets.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
|
||||
# class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
|
||||
# def __init__(self):
|
||||
# super().__init__()
|
||||
#
|
||||
# self.kernel_manager = QtInProcessKernelManager()
|
||||
# self.kernel_manager.start_kernel(show_banner=False)
|
||||
# self.kernel_client = self.kernel_manager.client()
|
||||
# self.kernel_client.start_channels()
|
||||
#
|
||||
# self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
|
||||
#
|
||||
# def shutdown_kernel(self):
|
||||
# self.kernel_client.stop_channels()
|
||||
# self.kernel_manager.shutdown_kernel()
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API)."""
|
||||
@@ -55,12 +40,14 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"w1": self.w1,
|
||||
"w2": self.w2,
|
||||
"w3": self.w3,
|
||||
"w1_c": self.w1_c,
|
||||
"w2_c": self.w2_c,
|
||||
"w3_c": self.w3_c,
|
||||
"w4": self.w4,
|
||||
"d0": self.d0,
|
||||
"d1": self.d1,
|
||||
"d2": self.d2,
|
||||
"fig0": self.fig0,
|
||||
"fig1": self.fig1,
|
||||
"fig2": self.fig2,
|
||||
"plt": self.plt,
|
||||
"bar": self.bar,
|
||||
}
|
||||
)
|
||||
@@ -89,17 +76,37 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
self.figure.plot(x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="cividis")
|
||||
self.figure.motor_map("samx", "samy")
|
||||
self.figure.image("eiger", color_map="viridis", vrange=(0, 100))
|
||||
self.figure.add_plot(x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="magma")
|
||||
self.figure.plot(
|
||||
x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="magma", new=True
|
||||
)
|
||||
|
||||
self.figure.change_layout(2, 2)
|
||||
|
||||
self.w1 = self.figure[0, 0]
|
||||
self.w2 = self.figure[0, 1]
|
||||
self.w3 = self.figure[1, 0]
|
||||
self.w4 = self.figure[1, 1]
|
||||
|
||||
# Plot Customisation
|
||||
self.w1.set_title("Waveform 1")
|
||||
self.w1.set_x_label("Motor Position (samx)")
|
||||
self.w1.set_y_label("Intensity A.U.")
|
||||
|
||||
# Image Customisation
|
||||
self.w3.set_title("Eiger Image")
|
||||
self.w3.set_x_label("X")
|
||||
self.w3.set_y_label("Y")
|
||||
|
||||
# Configs to try to pass
|
||||
self.w1_c = self.w1._config_dict
|
||||
self.w2_c = self.w2._config_dict
|
||||
self.w3_c = self.w3._config_dict
|
||||
|
||||
# curves for w1
|
||||
self.c1 = self.w1.get_config()
|
||||
|
||||
self.fig_c = self.figure._config_dict
|
||||
|
||||
def _init_dock(self):
|
||||
|
||||
self.d0 = self.dock.add_dock(name="dock_0")
|
||||
@@ -115,8 +122,9 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
|
||||
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
|
||||
self.fig2 = self.d2.add_widget("BECFigure", row=0, col=0)
|
||||
self.fig2.plot(x_name="samx", y_name="bpm4i")
|
||||
self.bar = self.d2.add_widget("SpiralProgressBar", row=0, col=1)
|
||||
self.plt = self.fig2.plot(x_name="samx", y_name="bpm3a")
|
||||
self.plt.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
|
||||
self.bar.set_diameter(200)
|
||||
|
||||
self.dock.save_state()
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, Type
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
import yaml
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
@@ -33,10 +39,35 @@ class ConnectionConfig(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class BECConnector:
|
||||
class WorkerSignals(QObject):
|
||||
progress = Signal(dict)
|
||||
completed = Signal()
|
||||
|
||||
|
||||
class Worker(QRunnable):
|
||||
"""
|
||||
Worker class to run a function in a separate thread.
|
||||
"""
|
||||
|
||||
def __init__(self, func, *args, **kwargs):
|
||||
super().__init__()
|
||||
self.signals = WorkerSignals()
|
||||
self.func = func
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Run the specified function in the thread.
|
||||
"""
|
||||
self.func(*self.args, **self.kwargs)
|
||||
self.signals.completed.emit()
|
||||
|
||||
|
||||
class BECConnector(BECWidget):
|
||||
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
|
||||
|
||||
USER_ACCESS = ["config_dict", "get_all_rpc"]
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc"]
|
||||
|
||||
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
|
||||
# BEC related connections
|
||||
@@ -63,23 +94,60 @@ class BECConnector:
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self)
|
||||
|
||||
def get_all_rpc(self) -> dict:
|
||||
self._thread_pool = QThreadPool.globalInstance()
|
||||
|
||||
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
|
||||
"""
|
||||
Submit a task to run in a separate thread. The task will run the specified
|
||||
function with the provided arguments and emit the completed signal when done.
|
||||
|
||||
Use this method if you want to wait for a task to complete without blocking the
|
||||
main thread.
|
||||
|
||||
Args:
|
||||
fn: Function to run in a separate thread.
|
||||
*args: Arguments for the function.
|
||||
on_complete: Slot to run when the task is complete.
|
||||
**kwargs: Keyword arguments for the function.
|
||||
|
||||
Returns:
|
||||
worker: The worker object that will run the task.
|
||||
|
||||
Examples:
|
||||
>>> def my_function(a, b):
|
||||
>>> print(a + b)
|
||||
>>> self.submit_task(my_function, 1, 2)
|
||||
|
||||
>>> def my_function(a, b):
|
||||
>>> print(a + b)
|
||||
>>> def on_complete():
|
||||
>>> print("Task complete")
|
||||
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
|
||||
|
||||
"""
|
||||
worker = Worker(fn, *args, **kwargs)
|
||||
if on_complete:
|
||||
worker.signals.completed.connect(on_complete)
|
||||
self._thread_pool.start(worker)
|
||||
return worker
|
||||
|
||||
def _get_all_rpc(self) -> dict:
|
||||
"""Get all registered RPC objects."""
|
||||
all_connections = self.rpc_register.list_all_connections()
|
||||
return dict(all_connections)
|
||||
|
||||
@property
|
||||
def rpc_id(self) -> str:
|
||||
def _rpc_id(self) -> str:
|
||||
"""Get the RPC ID of the widget."""
|
||||
return self.gui_id
|
||||
|
||||
@rpc_id.setter
|
||||
def rpc_id(self, rpc_id: str) -> None:
|
||||
@_rpc_id.setter
|
||||
def _rpc_id(self, rpc_id: str) -> None:
|
||||
"""Set the RPC ID of the widget."""
|
||||
self.gui_id = rpc_id
|
||||
|
||||
@property
|
||||
def config_dict(self) -> dict:
|
||||
def _config_dict(self) -> dict:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -88,8 +156,8 @@ class BECConnector:
|
||||
"""
|
||||
return self.config.model_dump()
|
||||
|
||||
@config_dict.setter
|
||||
def config_dict(self, config: BaseModel) -> None:
|
||||
@_config_dict.setter
|
||||
def _config_dict(self, config: BaseModel) -> None:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -98,6 +166,60 @@ class BECConnector:
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
||||
"""
|
||||
Apply the configuration to the widget.
|
||||
|
||||
Args:
|
||||
config(dict): Configuration settings.
|
||||
generate_new_id(bool): If True, generate a new GUI ID for the widget.
|
||||
"""
|
||||
self.config = ConnectionConfig(**config)
|
||||
if generate_new_id is True:
|
||||
gui_id = str(uuid.uuid4())
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.set_gui_id(gui_id)
|
||||
self.rpc_register.add_rpc(self)
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
def load_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Load the configuration of the widget from YAML.
|
||||
|
||||
Args:
|
||||
path(str): Path to the configuration file for non-GUI dialog mode.
|
||||
gui(bool): If True, use the GUI dialog to load the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
config = load_yaml_gui(self)
|
||||
else:
|
||||
config = load_yaml(path)
|
||||
|
||||
if config is not None:
|
||||
if config.get("widget_class") != self.__class__.__name__:
|
||||
raise ValueError(
|
||||
f"Configuration file is not for {self.__class__.__name__}. Got configuration for {config.get('widget_class')}."
|
||||
)
|
||||
self.apply_config(config)
|
||||
|
||||
def save_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Save the configuration of the widget to YAML.
|
||||
|
||||
Args:
|
||||
path(str): Path to save the configuration file for non-GUI dialog mode.
|
||||
gui(bool): If True, use the GUI dialog to save the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
save_yaml_gui(self, self._config_dict)
|
||||
else:
|
||||
if path is None:
|
||||
path = os.getcwd()
|
||||
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
|
||||
|
||||
save_yaml(file_path, self._config_dict)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def set_gui_id(self, gui_id: str) -> None:
|
||||
"""
|
||||
@@ -165,6 +287,7 @@ class BECConnector:
|
||||
all_connections = self.rpc_register.list_all_connections()
|
||||
if len(all_connections) == 0:
|
||||
print("No more connections. Shutting down GUI BEC client.")
|
||||
self.bec_dispatcher.disconnect_all()
|
||||
self.client.shutdown()
|
||||
|
||||
# def closeEvent(self, event):
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import site
|
||||
import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
@@ -9,15 +12,55 @@ if PYSIDE6:
|
||||
from PySide6.scripts.pyside_tool import (
|
||||
_extend_path_var,
|
||||
init_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
is_pyenv_python,
|
||||
is_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
ui_tool_binary,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
|
||||
|
||||
def list_editable_packages() -> set[str]:
|
||||
"""
|
||||
List all editable packages in the environment.
|
||||
|
||||
Returns:
|
||||
set: A set of paths to editable packages.
|
||||
"""
|
||||
|
||||
editable_packages = set()
|
||||
|
||||
# Get site-packages directories
|
||||
site_packages = site.getsitepackages()
|
||||
if hasattr(site, "getusersitepackages"):
|
||||
site_packages.append(site.getusersitepackages())
|
||||
|
||||
for dist in importlib.metadata.distributions():
|
||||
location = dist.locate_file("").resolve()
|
||||
is_editable = all(not str(location).startswith(site_pkg) for site_pkg in site_packages)
|
||||
|
||||
if is_editable:
|
||||
editable_packages.add(str(location))
|
||||
|
||||
for packages in site_packages:
|
||||
# all dist-info directories in site-packages that contain a direct_url.json file
|
||||
dist_info_dirs = Path(packages).rglob("*.dist-info")
|
||||
for dist_info_dir in dist_info_dirs:
|
||||
direct_url = dist_info_dir / "direct_url.json"
|
||||
if not direct_url.exists():
|
||||
continue
|
||||
# load the json file and get the path to the package
|
||||
with open(direct_url, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
path = data.get("url", "")
|
||||
if path.startswith("file://"):
|
||||
path = path[7:]
|
||||
editable_packages.add(path)
|
||||
|
||||
return editable_packages
|
||||
|
||||
|
||||
def patch_designer(): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
@@ -30,20 +73,28 @@ def patch_designer(): # pragma: no cover
|
||||
os.environ["PY_MAJOR_VERSION"] = str(major_version)
|
||||
os.environ["PY_MINOR_VERSION"] = str(minor_version)
|
||||
|
||||
if sys.platform == "linux":
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{sys.abiflags}.so"
|
||||
if is_pyenv_python():
|
||||
library_name = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ["LD_PRELOAD"] = library_name
|
||||
elif sys.platform == "darwin":
|
||||
library_name = f"libpython{major_version}.{minor_version}.dylib"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ["DYLD_INSERT_LIBRARIES"] = lib_path
|
||||
elif sys.platform == "win32":
|
||||
if sys.platform == "win32":
|
||||
if is_virtual_env():
|
||||
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
|
||||
else:
|
||||
if sys.platform == "linux":
|
||||
suffix = f"{sys.abiflags}.so"
|
||||
env_var = "LD_PRELOAD"
|
||||
elif sys.platform == "darwin":
|
||||
suffix = ".dylib"
|
||||
env_var = "DYLD_INSERT_LIBRARIES"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{suffix}"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ[env_var] = lib_path
|
||||
|
||||
if is_pyenv_python() or is_virtual_env():
|
||||
# append all editable packages to the PYTHONPATH
|
||||
editable_packages = list_editable_packages()
|
||||
for pckg in editable_packages:
|
||||
_extend_path_var("PYTHONPATH", pckg, True)
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Union
|
||||
@@ -9,7 +8,7 @@ import redis
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.redis_connector import MessageObject, RedisConnector
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import PYQT5, PYQT6, PYSIDE2, PYSIDE6, QCoreApplication, QObject
|
||||
from qtpy.QtCore import PYQT6, PYSIDE6, QCoreApplication, QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -128,14 +127,17 @@ class BECDispatcher:
|
||||
return
|
||||
|
||||
# shutdown QCoreApp if it exists
|
||||
if PYQT5 or PYQT6:
|
||||
if PYQT6:
|
||||
cls.qapp.exit()
|
||||
elif PYSIDE2 or PYSIDE6:
|
||||
elif PYSIDE6:
|
||||
cls.qapp.shutdown()
|
||||
cls.qapp = None
|
||||
|
||||
def connect_slot(
|
||||
self, slot: Callable, topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]]
|
||||
self,
|
||||
slot: Callable,
|
||||
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message.
|
||||
|
||||
@@ -145,7 +147,7 @@ class BECDispatcher:
|
||||
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
"""
|
||||
slot = QtThreadSafeCallback(slot)
|
||||
self.client.connector.register(topics, cb=slot)
|
||||
self.client.connector.register(topics, cb=slot, **kwargs)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[slot].update(set(topics_str))
|
||||
|
||||
|
||||
2
bec_widgets/utils/bec_widget.py
Normal file
@@ -0,0 +1,2 @@
|
||||
class BECWidget:
|
||||
"""Base class for all BEC widgets."""
|
||||
@@ -67,6 +67,44 @@ class Colors:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return colors
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
|
||||
"""
|
||||
Convert HEX color to RGBA.
|
||||
|
||||
Args:
|
||||
hex_color(str): HEX color string.
|
||||
alpha(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
tuple: RGBA color tuple (r, g, b, a).
|
||||
"""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) == 6:
|
||||
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
||||
elif len(hex_color) == 8:
|
||||
r, g, b, a = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6))
|
||||
return (r, g, b, a)
|
||||
else:
|
||||
raise ValueError("HEX color must be 6 or 8 characters long.")
|
||||
return (r, g, b, alpha)
|
||||
|
||||
@staticmethod
|
||||
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
|
||||
"""
|
||||
Convert RGBA color to HEX.
|
||||
|
||||
Args:
|
||||
r(int): Red value (0-255).
|
||||
g(int): Green value (0-255).
|
||||
b(int): Blue value (0-255).
|
||||
a(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
hec_color(str): HEX color string.
|
||||
"""
|
||||
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
|
||||
|
||||
@staticmethod
|
||||
def validate_color(color: tuple | str) -> tuple | str:
|
||||
"""
|
||||
|
||||
@@ -19,7 +19,7 @@ class EntryValidator:
|
||||
device = self.devices[name]
|
||||
description = device.describe()
|
||||
|
||||
if entry is None:
|
||||
if entry is None or entry == "":
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in description:
|
||||
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
|
||||
|
||||
148
bec_widgets/utils/generate_designer_plugin.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock"]
|
||||
|
||||
|
||||
class DesignerPluginInfo:
|
||||
def __init__(self, plugin_class):
|
||||
self.plugin_class = plugin_class
|
||||
self.plugin_name_pascal = plugin_class.__name__
|
||||
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
|
||||
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
|
||||
plugin_module = (
|
||||
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
|
||||
)
|
||||
self.plugin_import = f"from {plugin_module} import {self.plugin_name_pascal}Plugin"
|
||||
|
||||
# first sentence / line of the docstring is used as tooltip
|
||||
self.plugin_tooltip = (
|
||||
plugin_class.__doc__.split("\n")[0].strip().replace('"', "'")
|
||||
if plugin_class.__doc__
|
||||
else self.plugin_name_pascal
|
||||
)
|
||||
|
||||
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
|
||||
|
||||
@staticmethod
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""
|
||||
Convert PascalCase to snake_case.
|
||||
|
||||
Args:
|
||||
name (str): The name to be converted.
|
||||
|
||||
Returns:
|
||||
str: The converted name.
|
||||
"""
|
||||
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
|
||||
return s2.lower()
|
||||
|
||||
|
||||
class DesignerPluginGenerator:
|
||||
def __init__(self, widget: type):
|
||||
self._excluded = False
|
||||
self.widget = widget
|
||||
self.info = DesignerPluginInfo(widget)
|
||||
if widget.__name__ in EXCLUDED_PLUGINS:
|
||||
|
||||
self._excluded = True
|
||||
return
|
||||
|
||||
self.templates = {}
|
||||
self.template_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
if self._excluded:
|
||||
print(f"Plugin {self.widget.__name__} is excluded from generation.")
|
||||
return
|
||||
self._check_class_validity()
|
||||
self._load_templates()
|
||||
self._write_templates()
|
||||
|
||||
def _check_class_validity(self):
|
||||
|
||||
# Check if the widget is a QWidget subclass
|
||||
if not issubclass(self.widget, QObject):
|
||||
return
|
||||
|
||||
# Check if the widget class has parent as the first argument. This is a strict requirement of Qt!
|
||||
signature = list(inspect.signature(self.widget.__init__).parameters.values())
|
||||
if len(signature) == 1 or signature[1].name != "parent":
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must have parent as the first argument."
|
||||
)
|
||||
|
||||
base_cls = [val for val in self.widget.__bases__ if issubclass(val, QObject)]
|
||||
if not base_cls:
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must inherit from a QObject subclass."
|
||||
)
|
||||
|
||||
# Check if the widget class calls the super constructor with parent argument
|
||||
init_source = inspect.getsource(self.widget.__init__)
|
||||
cls_init_found = (
|
||||
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
|
||||
)
|
||||
super_init_found = (
|
||||
bool(
|
||||
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
|
||||
)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
|
||||
)
|
||||
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
|
||||
super_init_found = (
|
||||
bool(init_source.find("super().__init__(parent=parent") > 0)
|
||||
or bool(init_source.find("super().__init__(parent,") > 0)
|
||||
or bool(init_source.find("super().__init__(parent)") > 0)
|
||||
)
|
||||
|
||||
if not cls_init_found and not super_init_found:
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must call the super constructor with parent."
|
||||
)
|
||||
|
||||
def _write_templates(self):
|
||||
self._write_register()
|
||||
self._write_plugin()
|
||||
self._write_pyproject()
|
||||
|
||||
def _write_register(self):
|
||||
file_path = os.path.join(self.info.base_path, f"register_{self.info.plugin_name_snake}.py")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.templates["register"].format(**self.info.__dict__))
|
||||
|
||||
def _write_plugin(self):
|
||||
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}_plugin.py")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.templates["plugin"].format(**self.info.__dict__))
|
||||
|
||||
def _write_pyproject(self):
|
||||
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}.pyproject")
|
||||
out = {"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]}
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(str(out))
|
||||
|
||||
def _load_templates(self):
|
||||
for file in os.listdir(self.template_path):
|
||||
if not file.endswith(".template"):
|
||||
continue
|
||||
with open(os.path.join(self.template_path, file), "r", encoding="utf-8") as f:
|
||||
self.templates[file.split(".")[0]] = f.read()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.dock import BECDockArea
|
||||
|
||||
generator = DesignerPluginGenerator(BECDockArea)
|
||||
generator.run()
|
||||
54
bec_widgets/utils/plugin_templates/plugin.template
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
{widget_import}
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='{plugin_name_pascal}' name='{plugin_name_snake}'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = {plugin_name_pascal}(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def includeFile(self):
|
||||
return "{plugin_name_snake}"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "{plugin_name_pascal}"
|
||||
|
||||
def toolTip(self):
|
||||
return "{plugin_tooltip}"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
15
bec_widgets/utils/plugin_templates/register.template
Normal file
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
{plugin_import}
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget({plugin_name_pascal}Plugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,12 +1,13 @@
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
from typing import Literal
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
def get_plugin_widgets() -> dict[str, BECConnector]:
|
||||
@@ -44,9 +45,74 @@ def _filter_plugins(obj):
|
||||
return inspect.isclass(obj) and issubclass(obj, BECConnector)
|
||||
|
||||
|
||||
def get_rpc_classes(
|
||||
repo_name: str,
|
||||
) -> dict[Literal["connector_classes", "top_level_classes"], list[type]]:
|
||||
@dataclass
|
||||
class BECClassInfo:
|
||||
name: str
|
||||
module: str
|
||||
file: str
|
||||
obj: type
|
||||
is_connector: bool = False
|
||||
is_widget: bool = False
|
||||
is_top_level: bool = False
|
||||
|
||||
|
||||
class BECClassContainer:
|
||||
def __init__(self):
|
||||
self._collection = []
|
||||
|
||||
def add_class(self, class_info: BECClassInfo):
|
||||
"""
|
||||
Add a class to the collection.
|
||||
|
||||
Args:
|
||||
class_info(BECClassInfo): The class information
|
||||
"""
|
||||
self.collection.append(class_info)
|
||||
|
||||
@property
|
||||
def collection(self):
|
||||
"""
|
||||
Get the collection of classes.
|
||||
"""
|
||||
return self._collection
|
||||
|
||||
@property
|
||||
def connector_classes(self):
|
||||
"""
|
||||
Get all connector classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_connector]
|
||||
|
||||
@property
|
||||
def top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_top_level]
|
||||
|
||||
@property
|
||||
def plugins(self):
|
||||
"""
|
||||
Get all plugins. These are all classes that are on the top level and are widgets.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget and info.is_top_level]
|
||||
|
||||
@property
|
||||
def widgets(self):
|
||||
"""
|
||||
Get all widgets. These are all classes inheriting from BECWidget.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget]
|
||||
|
||||
@property
|
||||
def rpc_top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_top_level and info.is_connector]
|
||||
|
||||
|
||||
def get_rpc_classes(repo_name: str) -> BECClassContainer:
|
||||
"""
|
||||
Get all RPC-enabled classes in the specified repository.
|
||||
|
||||
@@ -56,8 +122,7 @@ def get_rpc_classes(
|
||||
Returns:
|
||||
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
|
||||
"""
|
||||
connector_classes = []
|
||||
top_level_classes = []
|
||||
collection = BECClassContainer()
|
||||
anchor_module = importlib.import_module(f"{repo_name}.widgets")
|
||||
directory = os.path.dirname(anchor_module.__file__)
|
||||
for root, _, files in sorted(os.walk(directory)):
|
||||
@@ -78,11 +143,16 @@ def get_rpc_classes(
|
||||
obj = getattr(module, name)
|
||||
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
|
||||
continue
|
||||
if isinstance(obj, type) and issubclass(obj, BECConnector):
|
||||
connector_classes.append(obj)
|
||||
if isinstance(obj, type):
|
||||
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
|
||||
if issubclass(obj, BECConnector):
|
||||
class_info.is_connector = True
|
||||
if issubclass(obj, BECWidget):
|
||||
class_info.is_widget = True
|
||||
if len(subs) == 1 and (
|
||||
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
|
||||
):
|
||||
top_level_classes.append(obj)
|
||||
class_info.is_top_level = True
|
||||
collection.add_class(class_info)
|
||||
|
||||
return {"connector_classes": connector_classes, "top_level_classes": top_level_classes}
|
||||
return collection
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
from qtpy import QT_VERSION
|
||||
import os
|
||||
|
||||
from qtpy import PYQT6, PYSIDE6, QT_VERSION
|
||||
from qtpy.QtCore import QFile, QIODevice
|
||||
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_classes
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
|
||||
class CustomUiLoader(QUiLoader):
|
||||
def __init__(self, baseinstance, custom_widgets: dict = None):
|
||||
super().__init__(baseinstance)
|
||||
self.custom_widgets = custom_widgets or {}
|
||||
|
||||
self.baseinstance = baseinstance
|
||||
|
||||
def createWidget(self, class_name, parent=None, name=""):
|
||||
if class_name in self.custom_widgets:
|
||||
widget = self.custom_widgets[class_name](parent)
|
||||
widget.setObjectName(name)
|
||||
return widget
|
||||
return super().createWidget(class_name, parent, name)
|
||||
|
||||
|
||||
class UILoader:
|
||||
"""Universal UI loader for PyQt5, PyQt6, PySide2, and PySide6."""
|
||||
"""Universal UI loader for PyQt6 and PySide6."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
self.parent = parent
|
||||
if QT_VERSION.startswith("5"):
|
||||
# PyQt5 or PySide2
|
||||
from qtpy import uic
|
||||
|
||||
self.loader = uic.loadUi
|
||||
elif QT_VERSION.startswith("6"):
|
||||
# PyQt6 or PySide6
|
||||
try:
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
widgets = get_rpc_classes("bec_widgets").top_level_classes
|
||||
|
||||
self.loader = self.load_ui_pyside6
|
||||
except ImportError:
|
||||
from PyQt6.uic import loadUi
|
||||
self.custom_widgets = {widget.__name__: widget for widget in widgets}
|
||||
|
||||
self.loader = loadUi
|
||||
if PYSIDE6:
|
||||
self.loader = self.load_ui_pyside6
|
||||
elif PYQT6:
|
||||
self.loader = self.load_ui_pyqt6
|
||||
else:
|
||||
raise ImportError("No compatible Qt bindings found.")
|
||||
|
||||
def load_ui_pyside6(self, ui_file, parent=None):
|
||||
"""
|
||||
@@ -33,9 +51,8 @@ class UILoader:
|
||||
Returns:
|
||||
QWidget: The loaded widget.
|
||||
"""
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
|
||||
loader = QUiLoader(parent)
|
||||
loader = CustomUiLoader(parent, self.custom_widgets)
|
||||
file = QFile(ui_file)
|
||||
if not file.open(QIODevice.ReadOnly):
|
||||
raise IOError(f"Cannot open file: {ui_file}")
|
||||
@@ -43,6 +60,71 @@ class UILoader:
|
||||
file.close()
|
||||
return widget
|
||||
|
||||
def load_ui_pyqt6(self, ui_file, parent=None):
|
||||
"""
|
||||
Specific loader for PyQt6 using loadUi.
|
||||
Args:
|
||||
ui_file(str): Path to the .ui file.
|
||||
parent(QWidget): Parent widget.
|
||||
|
||||
Returns:
|
||||
QWidget: The loaded widget.
|
||||
"""
|
||||
from PyQt6.uic.Loader.loader import DynamicUILoader
|
||||
|
||||
class CustomDynamicUILoader(DynamicUILoader):
|
||||
def __init__(self, package, custom_widgets: dict = None):
|
||||
super().__init__(package)
|
||||
self.custom_widgets = custom_widgets or {}
|
||||
|
||||
def _handle_custom_widgets(self, el):
|
||||
"""Handle the <customwidgets> element."""
|
||||
|
||||
def header2module(header):
|
||||
"""header2module(header) -> string
|
||||
|
||||
Convert paths to C++ header files to according Python modules
|
||||
>>> header2module("foo/bar/baz.h")
|
||||
'foo.bar.baz'
|
||||
"""
|
||||
|
||||
if header.endswith(".h"):
|
||||
header = header[:-2]
|
||||
|
||||
mpath = []
|
||||
for part in header.split("/"):
|
||||
# Ignore any empty parts or those that refer to the current
|
||||
# directory.
|
||||
if part not in ("", "."):
|
||||
if part == "..":
|
||||
# We should allow this for Python3.
|
||||
raise SyntaxError(
|
||||
"custom widget header file name may not contain '..'."
|
||||
)
|
||||
|
||||
mpath.append(part)
|
||||
|
||||
return ".".join(mpath)
|
||||
|
||||
for custom_widget in el:
|
||||
classname = custom_widget.findtext("class")
|
||||
header = custom_widget.findtext("header")
|
||||
if header:
|
||||
header = self._translate_bec_widgets_header(header)
|
||||
self.factory.addCustomWidget(
|
||||
classname,
|
||||
custom_widget.findtext("extends") or "QWidget",
|
||||
header2module(header),
|
||||
)
|
||||
|
||||
def _translate_bec_widgets_header(self, header):
|
||||
for name, value in self.custom_widgets.items():
|
||||
if header == DesignerPluginInfo.pascal_to_snake(name):
|
||||
return value.__module__
|
||||
return header
|
||||
|
||||
return CustomDynamicUILoader("", self.custom_widgets).loadUi(ui_file, parent)
|
||||
|
||||
def load_ui(self, ui_file, parent=None):
|
||||
"""
|
||||
Universal UI loader method.
|
||||
|
||||
@@ -44,8 +44,11 @@ class ComboBoxHandler(WidgetHandler):
|
||||
def get_value(self, widget: QComboBox) -> int:
|
||||
return widget.currentIndex()
|
||||
|
||||
def set_value(self, widget: QComboBox, value: int) -> None:
|
||||
widget.setCurrentIndex(value)
|
||||
def set_value(self, widget: QComboBox, value: int | str) -> None:
|
||||
if isinstance(value, str):
|
||||
value = widget.findText(value)
|
||||
if isinstance(value, int):
|
||||
widget.setCurrentIndex(value)
|
||||
|
||||
|
||||
class TableWidgetHandler(WidgetHandler):
|
||||
@@ -119,7 +122,7 @@ class WidgetIO:
|
||||
widget: Widget instance.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = WidgetIO._handlers.get(type(widget))
|
||||
handler_class = WidgetIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().get_value(widget) # Instantiate the handler
|
||||
if not ignore_errors:
|
||||
@@ -136,12 +139,48 @@ class WidgetIO:
|
||||
value: Value to set.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = WidgetIO._handlers.get(type(widget))
|
||||
handler_class = WidgetIO._find_handler(widget)
|
||||
if handler_class:
|
||||
handler_class().set_value(widget, value) # Instantiate the handler
|
||||
elif not ignore_errors:
|
||||
raise ValueError(f"No handler for widget type: {type(widget)}")
|
||||
|
||||
@staticmethod
|
||||
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
|
||||
"""
|
||||
Check if the new limits are within the current limits, if not adjust the limits.
|
||||
|
||||
Args:
|
||||
number(float): The new value to check against the limits.
|
||||
"""
|
||||
|
||||
min_value = spin_box.minimum()
|
||||
max_value = spin_box.maximum()
|
||||
|
||||
# Calculate the new limits
|
||||
new_limit = number + 5 * number
|
||||
|
||||
if number < min_value:
|
||||
spin_box.setMinimum(new_limit)
|
||||
elif number > max_value:
|
||||
spin_box.setMaximum(new_limit)
|
||||
|
||||
@staticmethod
|
||||
def _find_handler(widget):
|
||||
"""
|
||||
Find the appropriate handler for the widget by checking its base classes.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
|
||||
Returns:
|
||||
handler_class: The handler class if found, otherwise None.
|
||||
"""
|
||||
for base in type(widget).__mro__:
|
||||
if base in WidgetIO._handlers:
|
||||
return WidgetIO._handlers[base]
|
||||
return None
|
||||
|
||||
|
||||
################## for exporting and importing widget hierarchies ##################
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import yaml
|
||||
from qtpy.QtWidgets import QFileDialog
|
||||
|
||||
|
||||
def load_yaml(instance) -> Union[dict, None]:
|
||||
def load_yaml_gui(instance) -> Union[dict, None]:
|
||||
"""
|
||||
Load YAML file from disk.
|
||||
|
||||
@@ -20,12 +20,25 @@ def load_yaml(instance) -> Union[dict, None]:
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
instance, "Load Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
|
||||
)
|
||||
config = load_yaml(file_path)
|
||||
return config
|
||||
|
||||
|
||||
def load_yaml(file_path: str) -> Union[dict, None]:
|
||||
"""
|
||||
Load YAML file from disk.
|
||||
|
||||
Args:
|
||||
file_path(str): Path to the YAML file.
|
||||
|
||||
Returns:
|
||||
dict: Configuration data loaded from the YAML file.
|
||||
"""
|
||||
if not file_path:
|
||||
return None
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
config = yaml.load(file, Loader=yaml.FullLoader)
|
||||
return config
|
||||
|
||||
except FileNotFoundError:
|
||||
@@ -38,7 +51,7 @@ def load_yaml(instance) -> Union[dict, None]:
|
||||
print(f"An error occurred while loading the settings from {file_path}: {e}")
|
||||
|
||||
|
||||
def save_yaml(instance, config: dict) -> None:
|
||||
def save_yaml_gui(instance, config: dict) -> None:
|
||||
"""
|
||||
Save YAML file to disk.
|
||||
|
||||
@@ -51,6 +64,17 @@ def save_yaml(instance, config: dict) -> None:
|
||||
instance, "Save Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
save_yaml(file_path, config)
|
||||
|
||||
|
||||
def save_yaml(file_path: str, config: dict) -> None:
|
||||
"""
|
||||
Save YAML file to disk.
|
||||
|
||||
Args:
|
||||
file_path(str): Path to the YAML file.
|
||||
config(dict): Configuration data to be saved.
|
||||
"""
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
# from .buttons import StopButton
|
||||
# from .dock import BECDock, BECDockArea
|
||||
# from .figure import BECFigure, FigureConfig
|
||||
# from .scan_control import ScanControl
|
||||
# from .spiral_progress_bar import SpiralProgressBar
|
||||
|
||||
|
||||
0
bec_widgets/widgets/bec_queue/__init__.py
Normal file
111
bec_widgets/widgets/bec_queue/bec_queue.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import Qt, Slot
|
||||
from qtpy.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
|
||||
|
||||
class BECQueue(BECConnector, QTableWidget):
|
||||
"""
|
||||
Widget to display the BEC queue.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str = None,
|
||||
):
|
||||
super().__init__(client, config, gui_id)
|
||||
QTableWidget.__init__(self, parent=parent)
|
||||
self.setColumnCount(3)
|
||||
self.setHorizontalHeaderLabels(["Scan Number", "Type", "Status"])
|
||||
header = self.horizontalHeader()
|
||||
header.setSectionResizeMode(QHeaderView.Stretch)
|
||||
self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status())
|
||||
self.reset_content()
|
||||
|
||||
@Slot(dict, dict)
|
||||
def update_queue(self, content, _metadata):
|
||||
"""
|
||||
Update the queue table with the latest queue information.
|
||||
|
||||
Args:
|
||||
content (dict): The queue content.
|
||||
_metadata (dict): The metadata.
|
||||
"""
|
||||
# only show the primary queue for now
|
||||
queue_info = content.get("queue", {}).get("primary", {}).get("info", [])
|
||||
self.setRowCount(len(queue_info))
|
||||
self.clearContents()
|
||||
|
||||
if not queue_info:
|
||||
self.reset_content()
|
||||
return
|
||||
|
||||
for index, item in enumerate(queue_info):
|
||||
blocks = item.get("request_blocks", [])
|
||||
scan_types = []
|
||||
scan_numbers = []
|
||||
status = item.get("status", "")
|
||||
for request_block in blocks:
|
||||
scan_type = request_block.get("content", {}).get("scan_type", "")
|
||||
if scan_type:
|
||||
scan_types.append(scan_type)
|
||||
scan_number = request_block.get("scan_number", "")
|
||||
if scan_number:
|
||||
scan_numbers.append(str(scan_number))
|
||||
if scan_types:
|
||||
scan_types = ", ".join(scan_types)
|
||||
if scan_numbers:
|
||||
scan_numbers = ", ".join(scan_numbers)
|
||||
self.set_row(index, scan_numbers, scan_types, status)
|
||||
|
||||
def format_item(self, content: str) -> QTableWidgetItem:
|
||||
"""
|
||||
Format the content of the table item.
|
||||
|
||||
Args:
|
||||
content (str): The content to be formatted.
|
||||
|
||||
Returns:
|
||||
QTableWidgetItem: The formatted item.
|
||||
"""
|
||||
item = QTableWidgetItem(content)
|
||||
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
return item
|
||||
|
||||
def set_row(self, index: int, scan_number: str, scan_type: str, status: str):
|
||||
"""
|
||||
Set the row of the table.
|
||||
|
||||
Args:
|
||||
index (int): The index of the row.
|
||||
scan_number (str): The scan number.
|
||||
scan_type (str): The scan type.
|
||||
status (str): The status.
|
||||
"""
|
||||
|
||||
self.setItem(index, 0, self.format_item(scan_number))
|
||||
self.setItem(index, 1, self.format_item(scan_type))
|
||||
self.setItem(index, 2, self.format_item(status))
|
||||
|
||||
def reset_content(self):
|
||||
"""
|
||||
Reset the content of the table.
|
||||
"""
|
||||
|
||||
self.setRowCount(1)
|
||||
self.set_row(0, "", "", "")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = BECQueue()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
1
bec_widgets/widgets/bec_queue/bec_queue.pyproject
Normal file
@@ -0,0 +1 @@
|
||||
{'files': ['bec_queue.py']}
|
||||
54
bec_widgets/widgets/bec_queue/bec_queue_plugin.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECQueue' name='bec_queue'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class BECQueuePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECQueue(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_queue"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECQueue"
|
||||
|
||||
def toolTip(self):
|
||||
return "Widget to display the BEC queue."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
15
bec_widgets/widgets/bec_queue/register_bec_queue.py
Normal file
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.bec_queue.bec_queue_plugin import BECQueuePlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECQueuePlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -5,15 +5,16 @@ The widget automatically updates the status of all running BEC services, and dis
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import qdarktheme
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QObject, QTimer, Signal, Slot
|
||||
from qtpy.QtWidgets import QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QHBoxLayout, QTreeWidget, QTreeWidgetItem, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.widgets.bec_status_box.status_item import StatusItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -23,45 +24,18 @@ if TYPE_CHECKING:
|
||||
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
|
||||
|
||||
|
||||
class BECStatusBoxConfig(ConnectionConfig):
|
||||
pass
|
||||
|
||||
|
||||
class BECServiceInfoContainer(BaseModel):
|
||||
@dataclass
|
||||
class BECServiceInfoContainer:
|
||||
"""Container to store information about the BEC services."""
|
||||
|
||||
service_name: str
|
||||
status: BECStatus | str = Field(
|
||||
default="NOTCONNECTED",
|
||||
description="The status of the service. Can be any of the BECStatus names, or NOTCONNECTED.",
|
||||
)
|
||||
status: str
|
||||
info: dict
|
||||
metrics: dict | None
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, v):
|
||||
"""Validate input for status. Accept BECStatus and NOTCONNECTED.
|
||||
|
||||
Args:
|
||||
v (BECStatus | str): The input value.
|
||||
|
||||
Returns:
|
||||
str: The validated status.
|
||||
"""
|
||||
if v in list(BECStatus.__members__.values()):
|
||||
return v.name
|
||||
if v in list(BECStatus.__members__.keys()) or v == "NOTCONNECTED":
|
||||
return v
|
||||
raise ValueError(
|
||||
f"Status must be one of {BECStatus.__members__.values()} or 'NOTCONNECTED'. Input {v}"
|
||||
)
|
||||
|
||||
|
||||
class BECServiceStatusMixin(QObject):
|
||||
"""A mixin class to update the service status, and metrics.
|
||||
It emits a signal 'services_update' when the service status is updated.
|
||||
"""Mixin to receive the latest service status from the BEC server and emit it via services_update signal.
|
||||
|
||||
Args:
|
||||
client (BECClient): The client object to connect to the BEC server.
|
||||
@@ -69,29 +43,26 @@ class BECServiceStatusMixin(QObject):
|
||||
|
||||
services_update = Signal(dict, dict)
|
||||
|
||||
def __init__(self, client: BECClient):
|
||||
super().__init__()
|
||||
def __init__(self, parent, client: BECClient):
|
||||
super().__init__(parent)
|
||||
self.client = client
|
||||
self._service_update_timer = QTimer()
|
||||
self._service_update_timer.timeout.connect(self._get_service_status)
|
||||
self._service_update_timer.start(1000)
|
||||
|
||||
def _get_service_status(self):
|
||||
"""Pull latest service and metrics updates from REDIS for all services, and emit both via 'services_update' signal."""
|
||||
"""Get the latest service status from the BEC server."""
|
||||
# pylint: disable=protected-access
|
||||
self.client._update_existing_services()
|
||||
self.services_update.emit(self.client._services_info, self.client._services_metric)
|
||||
|
||||
|
||||
class BECStatusBox(BECConnector, QTreeWidget):
|
||||
"""A widget to display the status of different BEC services.
|
||||
This widget automatically updates the status of all running BEC services, and displays their status.
|
||||
Information about the individual services is collapsible, and double clicking on
|
||||
the individual service will display the metrics about the service.
|
||||
class BECStatusBox(BECConnector, QWidget):
|
||||
"""An autonomous widget to display the status of BEC services.
|
||||
|
||||
Args:
|
||||
parent Optional : The parent widget for the BECStatusBox. Defaults to None.
|
||||
service_name Optional(str): The name of the top service label. Defaults to "BEC Server".
|
||||
box_name Optional(str): The name of the top service label. Defaults to "BEC Server".
|
||||
client Optional(BECClient): The client object to connect to the BEC server. Defaults to None
|
||||
config Optional(BECStatusBoxConfig | dict): The configuration for the status box. Defaults to None.
|
||||
gui_id Optional(str): The unique id for the widget. Defaults to None.
|
||||
@@ -99,54 +70,67 @@ class BECStatusBox(BECConnector, QTreeWidget):
|
||||
|
||||
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
|
||||
|
||||
service_update = Signal(dict)
|
||||
service_update = Signal(BECServiceInfoContainer)
|
||||
bec_core_state = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
service_name: str = "BEC Server",
|
||||
box_name: str = "BEC Server",
|
||||
client: BECClient = None,
|
||||
config: BECStatusBoxConfig | dict = None,
|
||||
bec_service_status_mixin: BECServiceStatusMixin = None,
|
||||
gui_id: str = None,
|
||||
):
|
||||
if config is None:
|
||||
config = BECStatusBoxConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = BECStatusBoxConfig(**config)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QTreeWidget.__init__(self, parent=parent)
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self.tree = QTreeWidget(self)
|
||||
self.layout = QHBoxLayout(self)
|
||||
|
||||
self.service_name = service_name
|
||||
self.config = config
|
||||
self.box_name = box_name
|
||||
self.status_container = defaultdict(lambda: {"info": None, "item": None, "widget": None})
|
||||
|
||||
self.bec_service_info_container = {}
|
||||
self.tree_items = {}
|
||||
self.tree_top_item = None
|
||||
self.bec_service_status = BECServiceStatusMixin(client=self.client)
|
||||
if not bec_service_status_mixin:
|
||||
bec_service_status_mixin = BECServiceStatusMixin(self, client=self.client)
|
||||
self.bec_service_status = bec_service_status_mixin
|
||||
|
||||
self.init_ui()
|
||||
self.bec_service_status.services_update.connect(self.update_service_status)
|
||||
self.bec_core_state.connect(self.update_top_item_status)
|
||||
self.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
|
||||
self.tree.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
|
||||
self.layout.addWidget(self.tree)
|
||||
|
||||
def init_ui(self) -> None:
|
||||
"""Initialize the UI for the status box, and add QTreeWidget as the basis for the status box."""
|
||||
"""Init the UI for the BECStatusBox widget, should only take place once."""
|
||||
self.init_ui_tree_widget()
|
||||
top_label = self._create_status_widget(self.service_name, status=BECStatus.IDLE)
|
||||
self.tree_top_item = QTreeWidgetItem()
|
||||
self.tree_top_item.setExpanded(True)
|
||||
self.tree_top_item.setDisabled(True)
|
||||
self.addTopLevelItem(self.tree_top_item)
|
||||
self.setItemWidget(self.tree_top_item, 0, top_label)
|
||||
top_label = self._create_status_widget(self.box_name, status=BECStatus.IDLE)
|
||||
tree_item = QTreeWidgetItem()
|
||||
tree_item.setExpanded(True)
|
||||
tree_item.setDisabled(True)
|
||||
self.status_container[self.box_name].update({"item": tree_item, "widget": top_label})
|
||||
self.tree.addTopLevelItem(tree_item)
|
||||
self.tree.setItemWidget(tree_item, 0, top_label)
|
||||
self.service_update.connect(top_label.update_config)
|
||||
self._initialized = True
|
||||
|
||||
def init_ui_tree_widget(self) -> None:
|
||||
"""Initialise the tree widget for the status box."""
|
||||
self.tree.setHeaderHidden(True)
|
||||
# TODO probably here is a problem still with setting the stylesheet
|
||||
self.tree.setStyleSheet(
|
||||
"QTreeWidget::item:!selected "
|
||||
"{ "
|
||||
"border: 1px solid gainsboro; "
|
||||
"border-left: none; "
|
||||
"border-top: none; "
|
||||
"}"
|
||||
"QTreeWidget::item:selected {}"
|
||||
)
|
||||
|
||||
def _create_status_widget(
|
||||
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
|
||||
) -> StatusItem:
|
||||
"""Creates a StatusItem (QWidget) for the given service, and stores all relevant
|
||||
information about the service in the bec_service_info_container.
|
||||
information about the service in the status_container.
|
||||
|
||||
Args:
|
||||
service_name (str): The name of the service.
|
||||
@@ -159,16 +143,8 @@ class BECStatusBox(BECConnector, QTreeWidget):
|
||||
"""
|
||||
if info is None:
|
||||
info = {}
|
||||
self._update_bec_service_container(service_name, status, info, metrics)
|
||||
item = StatusItem(
|
||||
parent=self,
|
||||
config={
|
||||
"service_name": service_name,
|
||||
"status": status.name,
|
||||
"info": info,
|
||||
"metrics": metrics,
|
||||
},
|
||||
)
|
||||
self._update_status_container(service_name, status, info, metrics)
|
||||
item = StatusItem(parent=self, config=self.status_container[service_name]["info"])
|
||||
return item
|
||||
|
||||
@Slot(str)
|
||||
@@ -179,30 +155,35 @@ class BECStatusBox(BECConnector, QTreeWidget):
|
||||
Args:
|
||||
status (BECStatus): The state of the core services.
|
||||
"""
|
||||
self.bec_service_info_container[self.service_name].status = status
|
||||
self.service_update.emit(self.bec_service_info_container[self.service_name].model_dump())
|
||||
self.status_container[self.box_name]["info"].status = status
|
||||
self.service_update.emit(self.status_container[self.box_name]["info"])
|
||||
|
||||
def _update_bec_service_container(
|
||||
def _update_status_container(
|
||||
self, service_name: str, status: BECStatus, info: dict, metrics: dict = None
|
||||
) -> None:
|
||||
"""Update the bec_service_info_container with the newest status and metrics for the BEC service.
|
||||
"""Update the status_container with the newest status and metrics for the BEC service.
|
||||
If information about the service already exists, it will create a new entry.
|
||||
|
||||
Args:
|
||||
service_name (str): The name of the service.
|
||||
service_info (StatusMessage): A class containing the service status.
|
||||
service_metric (ServiceMetricMessage): A class containing the service metrics.
|
||||
status (BECStatus): The status of the service.
|
||||
info (dict): The information about the service.
|
||||
metrics (dict): The metrics of the service.
|
||||
"""
|
||||
container = self.bec_service_info_container.get(service_name, None)
|
||||
container = self.status_container[service_name].get("info", None)
|
||||
|
||||
if container:
|
||||
container.status = status
|
||||
container.status = status.name
|
||||
container.info = info
|
||||
container.metrics = metrics
|
||||
return
|
||||
service_info_item = BECServiceInfoContainer(
|
||||
service_name=service_name, status=status, info=info, metrics=metrics
|
||||
service_name=service_name,
|
||||
status=status.name if isinstance(status, BECStatus) else status,
|
||||
info=info,
|
||||
metrics=metrics,
|
||||
)
|
||||
self.bec_service_info_container.update({service_name: service_info_item})
|
||||
self.status_container[service_name].update({"info": service_info_item})
|
||||
|
||||
@Slot(dict, dict)
|
||||
def update_service_status(self, services_info: dict, services_metric: dict) -> None:
|
||||
@@ -213,7 +194,7 @@ class BECStatusBox(BECConnector, QTreeWidget):
|
||||
services_info (dict): A dictionary containing the service status for all running BEC services.
|
||||
services_metric (dict): A dictionary containing the service metrics for all running BEC services.
|
||||
"""
|
||||
checked = []
|
||||
checked = [self.box_name]
|
||||
services_info = self.update_core_services(services_info, services_metric)
|
||||
checked.extend(self.CORE_SERVICES)
|
||||
|
||||
@@ -221,28 +202,19 @@ class BECStatusBox(BECConnector, QTreeWidget):
|
||||
checked.append(service_name)
|
||||
metric_msg = services_metric.get(service_name, None)
|
||||
metrics = metric_msg.metrics if metric_msg else None
|
||||
if service_name in self.tree_items:
|
||||
self._update_bec_service_container(
|
||||
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
|
||||
)
|
||||
self.service_update.emit(self.bec_service_info_container[service_name].model_dump())
|
||||
if service_name in self.status_container:
|
||||
if not msg:
|
||||
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
|
||||
continue
|
||||
self._update_status_container(service_name, msg.status, msg.info, metrics)
|
||||
self.service_update.emit(self.status_container[service_name]["info"])
|
||||
continue
|
||||
|
||||
item_widget = self._create_status_widget(
|
||||
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
|
||||
)
|
||||
item = QTreeWidgetItem()
|
||||
item.setDisabled(True)
|
||||
self.service_update.connect(item_widget.update_config)
|
||||
self.tree_top_item.addChild(item)
|
||||
self.setItemWidget(item, 0, item_widget)
|
||||
self.tree_items.update({service_name: (item, item_widget)})
|
||||
|
||||
self.add_tree_item(service_name, msg.status, msg.info, metrics)
|
||||
self.check_redundant_tree_items(checked)
|
||||
|
||||
def update_core_services(self, services_info: dict, services_metric: dict) -> dict:
|
||||
"""Method to process status and metrics updates of core services (stored in CORE_SERVICES).
|
||||
If a core services is not connected, it should not be removed from the status widget
|
||||
"""Update the core services of BEC, and emit the updated status to the BECStatusBox.
|
||||
|
||||
Args:
|
||||
services_info (dict): A dictionary containing the service status of different services.
|
||||
@@ -251,28 +223,28 @@ class BECStatusBox(BECConnector, QTreeWidget):
|
||||
Returns:
|
||||
dict: The services_info dictionary after removing the info updates related to the CORE_SERVICES
|
||||
"""
|
||||
bec_core_state = "RUNNING"
|
||||
core_state = BECStatus.RUNNING
|
||||
for service_name in sorted(self.CORE_SERVICES):
|
||||
metric_msg = services_metric.get(service_name, None)
|
||||
metrics = metric_msg.metrics if metric_msg else None
|
||||
if service_name not in services_info:
|
||||
self.bec_service_info_container[service_name].status = "NOTCONNECTED"
|
||||
bec_core_state = "ERROR"
|
||||
else:
|
||||
msg = services_info.pop(service_name)
|
||||
self._update_bec_service_container(
|
||||
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
|
||||
)
|
||||
bec_core_state = (
|
||||
"RUNNING" if (msg.status.value > 1 and bec_core_state == "RUNNING") else "ERROR"
|
||||
)
|
||||
|
||||
if service_name in self.tree_items:
|
||||
self.service_update.emit(self.bec_service_info_container[service_name].model_dump())
|
||||
msg = services_info.pop(service_name, None)
|
||||
if service_name not in self.status_container:
|
||||
if not msg:
|
||||
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
|
||||
continue
|
||||
self.add_tree_item(service_name, msg.status, msg.info, metrics)
|
||||
continue
|
||||
self.add_tree_item(service_name, msg.status, msg.info, metrics)
|
||||
if not msg:
|
||||
self.status_container[service_name]["info"].status = "NOTCONNECTED"
|
||||
core_state = None
|
||||
else:
|
||||
self._update_status_container(service_name, msg.status, msg.info, metrics)
|
||||
if core_state:
|
||||
core_state = msg.status if msg.status.value < core_state.value else core_state
|
||||
|
||||
self.bec_core_state.emit(bec_core_state)
|
||||
self.service_update.emit(self.status_container[service_name]["info"])
|
||||
|
||||
self.bec_core_state.emit(core_state.name if core_state else "NOTCONNECTED")
|
||||
return services_info
|
||||
|
||||
def check_redundant_tree_items(self, checked: list) -> None:
|
||||
@@ -281,11 +253,12 @@ class BECStatusBox(BECConnector, QTreeWidget):
|
||||
Args:
|
||||
checked (list): A list of services that are currently running.
|
||||
"""
|
||||
to_be_deleted = [key for key in self.tree_items if key not in checked]
|
||||
to_be_deleted = [key for key in self.status_container if key not in checked]
|
||||
|
||||
for key in to_be_deleted:
|
||||
item, _ = self.tree_items.pop(key)
|
||||
self.tree_top_item.removeChild(item)
|
||||
obj = self.status_container.pop(key)
|
||||
item = obj["item"]
|
||||
self.status_container[self.box_name]["item"].removeChild(item)
|
||||
|
||||
def add_tree_item(
|
||||
self, service_name: str, status: BECStatus, info: dict = None, metrics: dict = None
|
||||
@@ -294,30 +267,16 @@ class BECStatusBox(BECConnector, QTreeWidget):
|
||||
|
||||
Args:
|
||||
service_name (str): The name of the service.
|
||||
service_status_msg (StatusMessage): The status of the service.
|
||||
status (BECStatus): The status of the service.
|
||||
info (dict): The information about the service.
|
||||
metrics (dict): The metrics of the service.
|
||||
"""
|
||||
item_widget = self._create_status_widget(
|
||||
service_name=service_name, status=status, info=info, metrics=metrics
|
||||
)
|
||||
item_widget = self._create_status_widget(service_name, status, info, metrics)
|
||||
item = QTreeWidgetItem()
|
||||
self.service_update.connect(item_widget.update_config)
|
||||
self.tree_top_item.addChild(item)
|
||||
self.setItemWidget(item, 0, item_widget)
|
||||
self.tree_items.update({service_name: (item, item_widget)})
|
||||
|
||||
def init_ui_tree_widget(self) -> None:
|
||||
"""Initialise the tree widget for the status box."""
|
||||
self.setHeaderHidden(True)
|
||||
self.setStyleSheet(
|
||||
"QTreeWidget::item:!selected "
|
||||
"{ "
|
||||
"border: 1px solid gainsboro; "
|
||||
"border-left: none; "
|
||||
"border-top: none; "
|
||||
"}"
|
||||
"QTreeWidget::item:selected {}"
|
||||
)
|
||||
self.status_container[self.box_name]["item"].addChild(item)
|
||||
self.tree.setItemWidget(item, 0, item_widget)
|
||||
self.status_container[service_name].update({"item": item, "widget": item_widget})
|
||||
|
||||
@Slot(QTreeWidgetItem, int)
|
||||
def on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
|
||||
@@ -327,13 +286,18 @@ class BECStatusBox(BECConnector, QTreeWidget):
|
||||
item (QTreeWidgetItem): The item that was double clicked.
|
||||
column (int): The column that was double clicked.
|
||||
"""
|
||||
for _, (tree_item, status_widget) in self.tree_items.items():
|
||||
if tree_item == item:
|
||||
status_widget.show_popup()
|
||||
for _, objects in self.status_container.items():
|
||||
if objects["item"] == item:
|
||||
objects["widget"].show_popup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Upon closing the widget, clean up the BECStatusBox and the QWidget.
|
||||
|
||||
Args:
|
||||
event: The close event.
|
||||
"""
|
||||
super().cleanup()
|
||||
QTreeWidget().closeEvent(event)
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['bec_status_box.py']}
|
||||
54
bec_widgets/widgets/bec_status_box/bec_status_box_plugin.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.bec_status_box.bec_status_box import BECStatusBox
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECStatusBox' name='bec_status_box'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class BECStatusBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECStatusBox(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_status_box"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECStatusBox"
|
||||
|
||||
def toolTip(self):
|
||||
return "An autonomous widget to display the status of BEC services."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.bec_status_box.bec_status_box_plugin import BECStatusBoxPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECStatusBoxPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -2,17 +2,12 @@
|
||||
The widget is bound to be used with the BECStatusBox widget."""
|
||||
|
||||
import enum
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import qdarktheme
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from pydantic import Field
|
||||
from qtpy.QtCore import Qt, Slot
|
||||
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QStyle, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
|
||||
# TODO : Put normal imports back when Pydantic gets faster
|
||||
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
|
||||
|
||||
@@ -27,39 +22,29 @@ class IconsEnum(enum.Enum):
|
||||
NOTCONNECTED = "SP_TitleBarContextHelpButton"
|
||||
|
||||
|
||||
class StatusWidgetConfig(ConnectionConfig):
|
||||
"""Configuration class for the status item widget."""
|
||||
|
||||
service_name: str
|
||||
status: str
|
||||
info: dict
|
||||
metrics: dict | None
|
||||
icon_size: tuple = Field(default=(24, 24), description="The size of the icon in the widget.")
|
||||
font_size: int = Field(16, description="The font size of the text in the widget.")
|
||||
|
||||
|
||||
class StatusItem(QWidget):
|
||||
"""A widget to display the status of a service.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
config (dict): The configuration for the service.
|
||||
config (dict): The configuration for the service, must be a BECServiceInfoContainer.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, config: dict = None):
|
||||
if config is None:
|
||||
config = StatusWidgetConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = StatusWidgetConfig(**config)
|
||||
self.config = config
|
||||
def __init__(self, parent: QWidget = None, config=None):
|
||||
QWidget.__init__(self, parent=parent)
|
||||
if config is None:
|
||||
# needed because we need parent to be the first argument for QT Designer
|
||||
raise ValueError(
|
||||
"Please initialize the StatusItem with a BECServiceInfoContainer for config, received None."
|
||||
)
|
||||
self.config = config
|
||||
self.parent = parent
|
||||
self.layout = None
|
||||
self.config = config
|
||||
self._popup_label_ref = {}
|
||||
self._label = None
|
||||
self._icon = None
|
||||
self.icon_size = (24, 24)
|
||||
|
||||
self._popup_label_ref = {}
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self) -> None:
|
||||
@@ -74,23 +59,21 @@ class StatusItem(QWidget):
|
||||
self.update_ui()
|
||||
|
||||
@Slot(dict)
|
||||
def update_config(self, config: dict) -> None:
|
||||
"""Update the configuration of the status item widget.
|
||||
This method is invoked from the parent widget.
|
||||
The UI values are later updated based on the new configuration.
|
||||
def update_config(self, config) -> None:
|
||||
"""Update the config of the status item widget.
|
||||
|
||||
Args:
|
||||
config (dict): Config updates from parent widget.
|
||||
config (dict): Config updates from parent widget, must be a BECServiceInfoContainer.
|
||||
"""
|
||||
if config["service_name"] != self.config.service_name:
|
||||
if self.config is None or config.service_name != self.config.service_name:
|
||||
return
|
||||
self.config.status = config["status"]
|
||||
self.config.info = config["info"]
|
||||
self.config.metrics = config["metrics"]
|
||||
self.config = config
|
||||
self.update_ui()
|
||||
|
||||
def update_ui(self) -> None:
|
||||
"""Update the UI of the labels, and popup dialog."""
|
||||
if self.config is None:
|
||||
return
|
||||
self.set_text()
|
||||
self.set_status()
|
||||
self._set_popup_text()
|
||||
@@ -99,8 +82,8 @@ class StatusItem(QWidget):
|
||||
"""Set the text of the QLabel basae on the config."""
|
||||
service = self.config.service_name
|
||||
status = self.config.status
|
||||
if "BECClient" in service.split("/"):
|
||||
service = service.split("/")[0] + "/..." + service.split("/")[1][-4:]
|
||||
if len(service.split("/")) > 1 and service.split("/")[0].startswith("BEC"):
|
||||
service = service.split("/", maxsplit=1)[0] + "/..." + service.split("/")[1][-4:]
|
||||
if status == "NOTCONNECTED":
|
||||
status = "NOT CONNECTED"
|
||||
text = f"{service} is {status}"
|
||||
@@ -110,7 +93,7 @@ class StatusItem(QWidget):
|
||||
"""Set the status icon for the status item widget."""
|
||||
icon_name = IconsEnum[self.config.status].value
|
||||
icon = self.style().standardIcon(getattr(QStyle.StandardPixmap, icon_name))
|
||||
self._icon.setPixmap(icon.pixmap(*self.config.icon_size))
|
||||
self._icon.setPixmap(icon.pixmap(*self.icon_size))
|
||||
self._icon.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
def show_popup(self) -> None:
|
||||
@@ -153,19 +136,3 @@ class StatusItem(QWidget):
|
||||
def _cleanup_popup_label(self) -> None:
|
||||
"""Cleanup the popup label."""
|
||||
self._popup_label_ref.clear()
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the status item widget."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
qdarktheme.setup_theme("auto")
|
||||
main_window = StatusItem()
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
BIN
bec_widgets/widgets/buttons/color_button/assets/color_button.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
36
bec_widgets/widgets/buttons/color_button/color_button.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import pyqtgraph as pg
|
||||
|
||||
|
||||
class ColorButton(pg.ColorButton):
|
||||
"""
|
||||
A ColorButton that opens a dialog to select a color. Inherits from pyqtgraph.ColorButton.
|
||||
Patches event loop of the ColorDialog, if opened in another QDialog.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def selectColor(self):
|
||||
self.origColor = self.color()
|
||||
self.colorDialog.setCurrentColor(self.color())
|
||||
self.colorDialog.open()
|
||||
self.colorDialog.exec()
|
||||
|
||||
def get_color(self, format: Literal["RGBA", "HEX"] = "RGBA") -> tuple | str:
|
||||
"""
|
||||
Get the color of the button in the specified format.
|
||||
|
||||
Args:
|
||||
format(Literal["RGBA", "HEX"]): The format of the returned color.
|
||||
|
||||
Returns:
|
||||
tuple|str: The color in the specified format.
|
||||
"""
|
||||
if format == "RGBA":
|
||||
return self.color().getRgb()
|
||||
if format == "HEX":
|
||||
return self.color().name()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['color_button.py']}
|
||||
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.buttons.color_button.color_button import ColorButton
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='ColorButton' name='color_button'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class ColorButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = ColorButton(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Buttons"
|
||||
|
||||
def icon(self):
|
||||
current_path = os.path.dirname(__file__)
|
||||
icon_path = os.path.join(current_path, "assets", "color_button.png")
|
||||
return QIcon(icon_path)
|
||||
|
||||
def includeFile(self):
|
||||
return "color_button"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "ColorButton"
|
||||
|
||||
def toolTip(self):
|
||||
return "ColorButton which opens a color dialog."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.buttons.color_button.color_button_plugin import ColorButtonPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ColorButtonPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
496
bec_widgets/widgets/console/console.py
Normal file
@@ -0,0 +1,496 @@
|
||||
"""
|
||||
BECConsole is a Qt widget that runs a Bash shell. The widget can be used and
|
||||
embedded like any other Qt widget.
|
||||
|
||||
BECConsole is powered by Pyte, a Python based terminal emulator
|
||||
(https://github.com/selectel/pyte).
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import html
|
||||
import os
|
||||
import pty
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import pyte
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from qtpy.QtCore import QSize, QSocketNotifier, Qt
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtGui import QClipboard, QTextCursor
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
|
||||
|
||||
ansi_colors = {
|
||||
"black": "#000000",
|
||||
"red": "#CD0000",
|
||||
"green": "#00CD00",
|
||||
"brown": "#996633", # Brown, replacing the yellow
|
||||
"blue": "#0000EE",
|
||||
"magenta": "#CD00CD",
|
||||
"cyan": "#00CDCD",
|
||||
"white": "#E5E5E5",
|
||||
"brightblack": "#7F7F7F",
|
||||
"brightred": "#FF0000",
|
||||
"brightgreen": "#00FF00",
|
||||
"brightyellow": "#FFFF00",
|
||||
"brightblue": "#5C5CFF",
|
||||
"brightmagenta": "#FF00FF",
|
||||
"brightcyan": "#00FFFF",
|
||||
"brightwhite": "#FFFFFF",
|
||||
}
|
||||
|
||||
control_keys_mapping = {
|
||||
QtCore.Qt.Key_A: b"\x01", # Ctrl-A
|
||||
QtCore.Qt.Key_B: b"\x02", # Ctrl-B
|
||||
QtCore.Qt.Key_C: b"\x03", # Ctrl-C
|
||||
QtCore.Qt.Key_D: b"\x04", # Ctrl-D
|
||||
QtCore.Qt.Key_E: b"\x05", # Ctrl-E
|
||||
QtCore.Qt.Key_F: b"\x06", # Ctrl-F
|
||||
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
|
||||
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
|
||||
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
|
||||
QtCore.Qt.Key_J: b"\x0A", # Ctrl-J (Line Feed)
|
||||
QtCore.Qt.Key_K: b"\x0B", # Ctrl-K (Vertical Tab)
|
||||
QtCore.Qt.Key_L: b"\x0C", # Ctrl-L (Form Feed)
|
||||
QtCore.Qt.Key_M: b"\x0D", # Ctrl-M (Carriage Return)
|
||||
QtCore.Qt.Key_N: b"\x0E", # Ctrl-N
|
||||
QtCore.Qt.Key_O: b"\x0F", # Ctrl-O
|
||||
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
|
||||
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
|
||||
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
|
||||
QtCore.Qt.Key_S: b"\x13", # Ctrl-S
|
||||
QtCore.Qt.Key_T: b"\x14", # Ctrl-T
|
||||
QtCore.Qt.Key_U: b"\x15", # Ctrl-U
|
||||
QtCore.Qt.Key_V: b"\x16", # Ctrl-V
|
||||
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
|
||||
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
|
||||
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
|
||||
QtCore.Qt.Key_Z: b"\x1A", # Ctrl-Z
|
||||
QtCore.Qt.Key_Escape: b"\x1B", # Ctrl-Escape
|
||||
QtCore.Qt.Key_Backslash: b"\x1C", # Ctrl-\
|
||||
QtCore.Qt.Key_Underscore: b"\x1F", # Ctrl-_
|
||||
}
|
||||
|
||||
normal_keys_mapping = {
|
||||
QtCore.Qt.Key_Return: b"\n",
|
||||
QtCore.Qt.Key_Space: b" ",
|
||||
QtCore.Qt.Key_Enter: b"\n",
|
||||
QtCore.Qt.Key_Tab: b"\t",
|
||||
QtCore.Qt.Key_Backspace: b"\x08",
|
||||
QtCore.Qt.Key_Home: b"\x47",
|
||||
QtCore.Qt.Key_End: b"\x4f",
|
||||
QtCore.Qt.Key_Left: b"\x02",
|
||||
QtCore.Qt.Key_Up: b"\x10",
|
||||
QtCore.Qt.Key_Right: b"\x06",
|
||||
QtCore.Qt.Key_Down: b"\x0E",
|
||||
QtCore.Qt.Key_PageUp: b"\x49",
|
||||
QtCore.Qt.Key_PageDown: b"\x51",
|
||||
QtCore.Qt.Key_F1: b"\x1b\x31",
|
||||
QtCore.Qt.Key_F2: b"\x1b\x32",
|
||||
QtCore.Qt.Key_F3: b"\x1b\x33",
|
||||
QtCore.Qt.Key_F4: b"\x1b\x34",
|
||||
QtCore.Qt.Key_F5: b"\x1b\x35",
|
||||
QtCore.Qt.Key_F6: b"\x1b\x36",
|
||||
QtCore.Qt.Key_F7: b"\x1b\x37",
|
||||
QtCore.Qt.Key_F8: b"\x1b\x38",
|
||||
QtCore.Qt.Key_F9: b"\x1b\x39",
|
||||
QtCore.Qt.Key_F10: b"\x1b\x30",
|
||||
QtCore.Qt.Key_F11: b"\x45",
|
||||
QtCore.Qt.Key_F12: b"\x46",
|
||||
}
|
||||
|
||||
|
||||
def QtKeyToAscii(event):
|
||||
"""
|
||||
Convert the Qt key event to the corresponding ASCII sequence for
|
||||
the terminal. This works fine for standard alphanumerical characters, but
|
||||
most other characters require terminal specific control sequences.
|
||||
|
||||
The conversion below works for TERM="linux" terminals.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
# special case for MacOS
|
||||
# /!\ Qt maps ControlModifier to CMD
|
||||
# CMD-C, CMD-V for copy/paste
|
||||
# CTRL-C and other modifiers -> key mapping
|
||||
if event.modifiers() == QtCore.Qt.MetaModifier:
|
||||
if event.key() == Qt.Key_Backspace:
|
||||
return control_keys_mapping.get(Qt.Key_W)
|
||||
return control_keys_mapping.get(event.key())
|
||||
elif event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
if event.key() == Qt.Key_C:
|
||||
# copy
|
||||
return "copy"
|
||||
elif event.key() == Qt.Key_V:
|
||||
# paste
|
||||
return "paste"
|
||||
return None
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
if event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
return control_keys_mapping.get(event.key())
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
|
||||
|
||||
class Screen(pyte.HistoryScreen):
|
||||
def __init__(self, stdin_fd, numColumns, numLines, historyLength):
|
||||
super().__init__(numColumns, numLines, historyLength, ratio=1 / numLines)
|
||||
self._fd = stdin_fd
|
||||
|
||||
def write_process_input(self, data):
|
||||
"""Response to CPR request for example"""
|
||||
os.write(self._fd, data.encode("utf-8"))
|
||||
|
||||
|
||||
class Backend(QtCore.QObject):
|
||||
"""
|
||||
Poll Bash.
|
||||
|
||||
This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
|
||||
file descriptor of the Bash terminal.
|
||||
"""
|
||||
|
||||
# Signals to communicate with ``_TerminalWidget``.
|
||||
startWork = pyqtSignal()
|
||||
dataReady = pyqtSignal(object)
|
||||
|
||||
def __init__(self, fd, numColumns, numLines):
|
||||
super().__init__()
|
||||
|
||||
# File descriptor that connects to Bash process.
|
||||
self.fd = fd
|
||||
|
||||
# Setup Pyte (hard coded display size for now).
|
||||
self.screen = Screen(self.fd, numColumns, numLines, 10000)
|
||||
self.stream = pyte.ByteStream()
|
||||
self.stream.attach(self.screen)
|
||||
|
||||
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
|
||||
self.notifier.activated.connect(self._fd_readable)
|
||||
|
||||
def _fd_readable(self):
|
||||
"""
|
||||
Poll the Bash output, run it through Pyte, and notify the main applet.
|
||||
"""
|
||||
# Read the shell output until the file descriptor is closed.
|
||||
try:
|
||||
out = os.read(self.fd, 2**16)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
# Feed output into Pyte's state machine and send the new screen
|
||||
# output to the GUI
|
||||
self.stream.feed(out)
|
||||
self.dataReady.emit(self.screen)
|
||||
|
||||
|
||||
class BECConsole(QtWidgets.QScrollArea):
|
||||
"""Container widget for the terminal text area"""
|
||||
|
||||
def __init__(self, parent=None, numLines=50, numColumns=125):
|
||||
super().__init__(parent)
|
||||
|
||||
self.innerWidget = QtWidgets.QWidget(self)
|
||||
QHBoxLayout(self.innerWidget)
|
||||
self.innerWidget.layout().setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.term = _TerminalWidget(self.innerWidget, numLines, numColumns)
|
||||
self.term.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.innerWidget.layout().addWidget(self.term)
|
||||
|
||||
self.scroll_bar = QScrollBar(Qt.Vertical, self.term)
|
||||
self.innerWidget.layout().addWidget(self.scroll_bar)
|
||||
|
||||
self.term.set_scroll(self.scroll_bar)
|
||||
|
||||
self.setWidget(self.innerWidget)
|
||||
|
||||
def start(self, cmd=["bec", "--nogui"], deactivate_ctrl_d=True):
|
||||
self.term._cmd = cmd
|
||||
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
|
||||
|
||||
def push(self, text):
|
||||
"""Push some text to the terminal"""
|
||||
return self.term.push(text)
|
||||
|
||||
|
||||
class _TerminalWidget(QtWidgets.QPlainTextEdit):
|
||||
"""
|
||||
Start ``Backend`` process and render Pyte output as text.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, numColumns, numLines, **kwargs):
|
||||
super().__init__(parent)
|
||||
|
||||
# file descriptor to communicate with the subprocess
|
||||
self.fd = None
|
||||
self.backend = None
|
||||
self.lock = threading.Lock()
|
||||
# command to execute
|
||||
self._cmd = None
|
||||
# should ctrl-d be deactivated ? (prevent Python exit)
|
||||
self._deactivate_ctrl_d = False
|
||||
|
||||
# Specify the terminal size in terms of lines and columns.
|
||||
self.numLines = numLines
|
||||
self.numColumns = numColumns
|
||||
self.output = [""] * numLines
|
||||
|
||||
# Use Monospace fonts and disable line wrapping.
|
||||
self.setFont(QtGui.QFont("Courier", 9))
|
||||
self.setFont(QtGui.QFont("Monospace"))
|
||||
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
|
||||
|
||||
# Disable vertical scrollbar (we use our own, to be set via .set_scroll())
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
|
||||
fmt = QtGui.QFontMetrics(self.font())
|
||||
self._char_width = fmt.width("w")
|
||||
self._char_height = fmt.height()
|
||||
self.setCursorWidth(self._char_width)
|
||||
# self.setStyleSheet("QPlainTextEdit { color: #ffff00; background-color: #303030; } ");
|
||||
|
||||
def start(self, deactivate_ctrl_d=False):
|
||||
self._deactivate_ctrl_d = deactivate_ctrl_d
|
||||
|
||||
# Start the Bash process
|
||||
self.fd = self.forkShell()
|
||||
|
||||
# Create the ``Backend`` object
|
||||
self.backend = Backend(self.fd, self.numColumns, self.numLines)
|
||||
self.backend.dataReady.connect(self.dataReady)
|
||||
|
||||
def minimumSizeHint(self):
|
||||
width = self._char_width * self.numColumns
|
||||
height = self._char_height * self.numLines
|
||||
return QSize(width, height + 20)
|
||||
|
||||
def set_scroll(self, scroll):
|
||||
self.scroll = scroll
|
||||
self.scroll.setMinimum(0)
|
||||
self.scroll.valueChanged.connect(self.scroll_value_change)
|
||||
|
||||
def scroll_value_change(self, value, old={"value": 0}):
|
||||
if value <= old["value"]:
|
||||
# scroll up
|
||||
# value is number of lines from the start
|
||||
nlines = old["value"] - value
|
||||
# history ratio gives prev_page == 1 line
|
||||
for i in range(nlines):
|
||||
self.backend.screen.prev_page()
|
||||
else:
|
||||
# scroll down
|
||||
nlines = value - old["value"]
|
||||
for i in range(nlines):
|
||||
self.backend.screen.next_page()
|
||||
old["value"] = value
|
||||
self.dataReady(self.backend.screen, reset_scroll=False)
|
||||
|
||||
@pyqtSlot(object)
|
||||
def keyPressEvent(self, event):
|
||||
"""
|
||||
Redirect all keystrokes to the terminal process.
|
||||
"""
|
||||
# Convert the Qt key to the correct ASCII code.
|
||||
if (
|
||||
self._deactivate_ctrl_d
|
||||
and event.modifiers() == QtCore.Qt.ControlModifier
|
||||
and event.key() == QtCore.Qt.Key_D
|
||||
):
|
||||
return None
|
||||
|
||||
code = QtKeyToAscii(event)
|
||||
if code == "copy":
|
||||
# MacOS only: CMD-C handling
|
||||
self.copy()
|
||||
elif code == "paste":
|
||||
# MacOS only: CMD-V handling
|
||||
self._push_clipboard()
|
||||
elif code is not None:
|
||||
os.write(self.fd, code)
|
||||
|
||||
def push(self, text):
|
||||
"""
|
||||
Write 'text' to terminal
|
||||
"""
|
||||
os.write(self.fd, text.encode("utf-8"))
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
menu = self.createStandardContextMenu()
|
||||
for action in menu.actions():
|
||||
# remove all actions except copy and paste
|
||||
if "opy" in action.text():
|
||||
# redefine text without shortcut
|
||||
# since it probably clashes with control codes (like CTRL-C etc)
|
||||
action.setText("Copy")
|
||||
continue
|
||||
if "aste" in action.text():
|
||||
# redefine text without shortcut
|
||||
action.setText("Paste")
|
||||
# paste -> have to insert with self.push
|
||||
action.triggered.connect(self._push_clipboard)
|
||||
continue
|
||||
menu.removeAction(action)
|
||||
menu.exec_(event.globalPos())
|
||||
|
||||
def _push_clipboard(self):
|
||||
clipboard = QApplication.instance().clipboard()
|
||||
self.push(clipboard.text())
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.MiddleButton:
|
||||
# push primary selection buffer ("mouse clipboard") to terminal
|
||||
clipboard = QApplication.instance().clipboard()
|
||||
if clipboard.supportsSelection():
|
||||
self.push(clipboard.text(QClipboard.Selection))
|
||||
return None
|
||||
elif event.button() == Qt.LeftButton:
|
||||
# left button click
|
||||
textCursor = self.textCursor()
|
||||
if textCursor.selectedText():
|
||||
# mouse was used to select text -> nothing to do
|
||||
pass
|
||||
else:
|
||||
# a simple 'click', make cursor going to end
|
||||
textCursor.setPosition(0)
|
||||
textCursor.movePosition(
|
||||
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
|
||||
)
|
||||
textCursor.movePosition(
|
||||
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
|
||||
)
|
||||
self.setTextCursor(textCursor)
|
||||
self.ensureCursorVisible()
|
||||
return None
|
||||
return super().mouseReleaseEvent(event)
|
||||
|
||||
def dataReady(self, screenData, reset_scroll=True):
|
||||
"""
|
||||
Render the new screen as text into the widget.
|
||||
|
||||
This method is triggered via a signal from ``Backend``.
|
||||
"""
|
||||
with self.lock:
|
||||
# Clear the widget
|
||||
self.clear()
|
||||
|
||||
# Prepare the HTML output
|
||||
for line_no in screenData.dirty:
|
||||
line = text = ""
|
||||
style = old_style = ""
|
||||
for ch in screenData.buffer[line_no].values():
|
||||
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
|
||||
if style != old_style:
|
||||
if old_style:
|
||||
line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
|
||||
else:
|
||||
line += html.escape(text, quote=True)
|
||||
text = ""
|
||||
old_style = style
|
||||
text += ch.data
|
||||
if style:
|
||||
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
|
||||
else:
|
||||
line += html.escape(text, quote=True)
|
||||
self.output[line_no] = line
|
||||
# fill the text area with HTML contents in one go
|
||||
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
|
||||
# done updates, all clean
|
||||
screenData.dirty.clear()
|
||||
|
||||
# Activate cursor
|
||||
textCursor = self.textCursor()
|
||||
textCursor.setPosition(0)
|
||||
textCursor.movePosition(QTextCursor.Down, QTextCursor.MoveAnchor, screenData.cursor.y)
|
||||
textCursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, screenData.cursor.x)
|
||||
self.setTextCursor(textCursor)
|
||||
self.ensureCursorVisible()
|
||||
|
||||
# manage scroll
|
||||
if reset_scroll:
|
||||
self.scroll.valueChanged.disconnect(self.scroll_value_change)
|
||||
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
|
||||
self.scroll.setMaximum(tmp if tmp > 0 else 0)
|
||||
self.scroll.setSliderPosition(len(self.backend.screen.history.top))
|
||||
self.scroll.valueChanged.connect(self.scroll_value_change)
|
||||
|
||||
# def resizeEvent(self, event):
|
||||
# with self.lock:
|
||||
# self.numColumns = int(self.width() / self._char_width)
|
||||
# self.numLines = int(self.height() / self._char_height)
|
||||
# self.output = [""] * self.numLines
|
||||
# print("RESIZING TO", self.numColumns, "x", self.numLines)
|
||||
# self.backend.screen.resize(self.numLines, self.numColumns)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
y = event.angleDelta().y()
|
||||
if y > 0:
|
||||
self.backend.screen.prev_page()
|
||||
else:
|
||||
self.backend.screen.next_page()
|
||||
self.dataReady(self.backend.screen, reset_scroll=False)
|
||||
|
||||
def forkShell(self):
|
||||
"""
|
||||
Fork the current process and execute bec in shell.
|
||||
"""
|
||||
try:
|
||||
pid, fd = pty.fork()
|
||||
except (IOError, OSError):
|
||||
return False
|
||||
if pid == 0:
|
||||
# Safe way to make it work under BSD and Linux
|
||||
try:
|
||||
ls = os.environ["LANG"].split(".")
|
||||
except KeyError:
|
||||
ls = []
|
||||
if len(ls) < 2:
|
||||
ls = ["en_US", "UTF-8"]
|
||||
try:
|
||||
os.putenv("COLUMNS", str(self.numColumns))
|
||||
os.putenv("LINES", str(self.numLines))
|
||||
os.putenv("TERM", "linux")
|
||||
os.putenv("LANG", ls[0] + ".UTF-8")
|
||||
if isinstance(self._cmd, str):
|
||||
os.execvp(self._cmd, self._cmd)
|
||||
else:
|
||||
os.execvp(self._cmd[0], self._cmd)
|
||||
# print "child_pid", child_pid, sts
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
# self.proc_finish(sid)
|
||||
os._exit(0)
|
||||
else:
|
||||
# We are in the parent process.
|
||||
# Set file control
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
|
||||
print("Spawned Bash shell (PID {})".format(pid))
|
||||
return fd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy import QtGui, QtWidgets
|
||||
|
||||
# Terminal size in characters.
|
||||
numLines = 25
|
||||
numColumns = 100
|
||||
|
||||
# Create the Qt application and QBash instance.
|
||||
app = QtWidgets.QApplication([])
|
||||
mainwin = QtWidgets.QMainWindow()
|
||||
title = "BECConsole ({}x{})".format(numColumns, numLines)
|
||||
mainwin.setWindowTitle(title)
|
||||
|
||||
console = BECConsole(mainwin, numColumns, numLines)
|
||||
mainwin.setCentralWidget(console)
|
||||
console.start()
|
||||
|
||||
# Show widget and launch Qt's event loop.
|
||||
mainwin.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -18,7 +18,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class.
|
||||
default_device: Default device name.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
@@ -29,7 +29,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
config: DeviceInputConfig = None,
|
||||
gui_id: str | None = None,
|
||||
device_filter: str | None = None,
|
||||
default_device: str | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
@@ -41,8 +41,8 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
self.config.arg_name = arg_name
|
||||
if device_filter is not None:
|
||||
self.set_device_filter(device_filter)
|
||||
if default_device is not None:
|
||||
self.set_default_device(default_device)
|
||||
if default is not None:
|
||||
self.set_default_device(default)
|
||||
|
||||
def set_device_filter(self, device_filter: str):
|
||||
"""
|
||||
@@ -82,3 +82,11 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
if device_obj is None:
|
||||
raise ValueError(f"Device {device_name} is not found.")
|
||||
return device_obj
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
super().cleanup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
super().cleanup()
|
||||
return QComboBox.closeEvent(self, event)
|
||||
|
||||
@@ -5,7 +5,7 @@ from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
|
||||
class DeviceInputConfig(ConnectionConfig):
|
||||
device_filter: str | list[str] | None = None
|
||||
default_device: str | None = None
|
||||
default: str | None = None
|
||||
arg_name: str | None = None
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ class DeviceInputBase(BECConnector):
|
||||
default_device(str): Default device name.
|
||||
"""
|
||||
self.validate_device(default_device)
|
||||
self.config.default_device = default_device
|
||||
self.config.default = default_device
|
||||
|
||||
def get_device_list(self, filter: str | list[str] | None = None) -> list[str]:
|
||||
"""
|
||||
@@ -118,3 +118,6 @@ class DeviceInputBase(BECConnector):
|
||||
"""
|
||||
if device not in self.get_device_list(self.config.device_filter):
|
||||
raise ValueError(f"Device {device} is not valid.")
|
||||
|
||||
def cleanup(self):
|
||||
super().cleanup()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtWidgets import QCompleter, QLineEdit
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtWidgets import QCompleter, QLineEdit, QSizePolicy
|
||||
|
||||
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase, DeviceInputConfig
|
||||
|
||||
@@ -18,7 +19,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class.
|
||||
default_device: Default device name.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
@@ -29,7 +30,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
config: DeviceInputConfig = None,
|
||||
gui_id: str | None = None,
|
||||
device_filter: str | list[str] | None = None,
|
||||
default_device: str | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
):
|
||||
QLineEdit.__init__(self, parent=parent)
|
||||
@@ -41,10 +42,14 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
self.arg_name = arg_name
|
||||
if device_filter is not None:
|
||||
self.set_device_filter(device_filter)
|
||||
if default_device is not None:
|
||||
self.set_default_device(default_device)
|
||||
if default is not None:
|
||||
self.set_default_device(default)
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.setMinimumSize(QSize(100, 0))
|
||||
|
||||
def set_device_filter(self, device_filter: str | list[str]):
|
||||
"""
|
||||
@@ -89,3 +94,11 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
if device_obj is None:
|
||||
raise ValueError(f"Device {device_name} is not found.")
|
||||
return device_obj
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
super().cleanup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
super().cleanup()
|
||||
return QLineEdit.closeEvent(self, event)
|
||||
|
||||
@@ -26,8 +26,8 @@ class DockConfig(ConnectionConfig):
|
||||
|
||||
class BECDock(BECConnector, Dock):
|
||||
USER_ACCESS = [
|
||||
"config_dict",
|
||||
"rpc_id",
|
||||
"_config_dict",
|
||||
"_rpc_id",
|
||||
"widget_list",
|
||||
"show_title_bar",
|
||||
"hide_title_bar",
|
||||
|
||||
@@ -23,7 +23,7 @@ class DockAreaConfig(ConnectionConfig):
|
||||
|
||||
class BECDockArea(BECConnector, DockArea):
|
||||
USER_ACCESS = [
|
||||
"config_dict",
|
||||
"_config_dict",
|
||||
"panels",
|
||||
"save_state",
|
||||
"remove_dock",
|
||||
@@ -32,7 +32,7 @@ class BECDockArea(BECConnector, DockArea):
|
||||
"clear_all",
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"get_all_rpc",
|
||||
"_get_all_rpc",
|
||||
"temp_areas",
|
||||
]
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Literal, Optional
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import qdarktheme
|
||||
from pydantic import Field
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from typeguard import typechecked
|
||||
@@ -30,16 +30,36 @@ class FigureConfig(ConnectionConfig):
|
||||
{}, description="The list of widgets to be added to the figure widget."
|
||||
)
|
||||
|
||||
@field_validator("widgets", mode="before")
|
||||
@classmethod
|
||||
def validate_widgets(cls, v):
|
||||
"""Validate the widgets configuration."""
|
||||
widget_class_map = {
|
||||
"BECWaveform": Waveform1DConfig,
|
||||
"BECImageShow": ImageConfig,
|
||||
"BECMotorMap": MotorMapConfig,
|
||||
}
|
||||
validated_widgets = {}
|
||||
for key, widget_config in v.items():
|
||||
if "widget_class" not in widget_config:
|
||||
raise ValueError(f"Widget config for {key} does not contain 'widget_class'.")
|
||||
widget_class = widget_config["widget_class"]
|
||||
if widget_class not in widget_class_map:
|
||||
raise ValueError(f"Unknown widget_class '{widget_class}' for widget '{key}'.")
|
||||
config_class = widget_class_map[widget_class]
|
||||
validated_widgets[key] = config_class(**widget_config)
|
||||
return validated_widgets
|
||||
|
||||
|
||||
class WidgetHandler:
|
||||
"""Factory for creating and configuring BEC widgets for BECFigure."""
|
||||
|
||||
def __init__(self):
|
||||
self.widget_factory = {
|
||||
"PlotBase": (BECPlotBase, SubplotConfig),
|
||||
"Waveform1D": (BECWaveform, Waveform1DConfig),
|
||||
"ImShow": (BECImageShow, ImageConfig),
|
||||
"MotorMap": (BECMotorMap, MotorMapConfig),
|
||||
"BECPlotBase": (BECPlotBase, SubplotConfig),
|
||||
"BECWaveform": (BECWaveform, Waveform1DConfig),
|
||||
"BECImageShow": (BECImageShow, ImageConfig),
|
||||
"BECMotorMap": (BECMotorMap, MotorMapConfig),
|
||||
}
|
||||
|
||||
def create_widget(
|
||||
@@ -90,13 +110,11 @@ class WidgetHandler:
|
||||
|
||||
class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
USER_ACCESS = [
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"_get_all_rpc",
|
||||
"axes",
|
||||
"widgets",
|
||||
"add_plot",
|
||||
"add_image",
|
||||
"add_motor_map",
|
||||
"plot",
|
||||
"image",
|
||||
"motor_map",
|
||||
@@ -104,9 +122,15 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
"change_layout",
|
||||
"change_theme",
|
||||
"clear_all",
|
||||
"get_all_rpc",
|
||||
"widget_list",
|
||||
]
|
||||
subplot_map = {
|
||||
"PlotBase": BECPlotBase,
|
||||
"BECWaveform": BECWaveform,
|
||||
"BECImageShow": BECImageShow,
|
||||
"BECMotorMap": BECMotorMap,
|
||||
}
|
||||
widget_method_map = {"BECWaveform": "plot", "BECImageShow": "image", "BECMotorMap": "motor_map"}
|
||||
|
||||
clean_signal = pyqtSignal()
|
||||
|
||||
@@ -122,8 +146,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = FigureConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
pg.GraphicsLayoutWidget.__init__(self, parent)
|
||||
|
||||
self.widget_handler = WidgetHandler()
|
||||
@@ -133,6 +156,8 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
|
||||
# Container to keep track of the grid
|
||||
self.grid = []
|
||||
# Create config and apply it
|
||||
self.apply_config(config)
|
||||
|
||||
def __getitem__(self, key: tuple | str):
|
||||
if isinstance(key, tuple) and len(key) == 2:
|
||||
@@ -147,6 +172,24 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
|
||||
)
|
||||
|
||||
def apply_config(self, config: dict | FigureConfig): # ,generate_new_id: bool = False):
|
||||
if isinstance(config, dict):
|
||||
try:
|
||||
config = FigureConfig(**config)
|
||||
except ValidationError as e:
|
||||
print(f"Error in applying config: {e}")
|
||||
return
|
||||
self.config = config
|
||||
self.change_theme(self.config.theme)
|
||||
|
||||
# widget_config has to be reset for not have each widget config twice when added to the figure
|
||||
widget_configs = [config for config in self.config.widgets.values()]
|
||||
self.config.widgets = {}
|
||||
for widget_config in widget_configs:
|
||||
getattr(self, self.widget_method_map[widget_config.widget_class])(
|
||||
config=widget_config.model_dump(), row=widget_config.row, col=widget_config.col
|
||||
)
|
||||
|
||||
@property
|
||||
def widget_list(self) -> list[BECPlotBase]:
|
||||
"""
|
||||
@@ -195,11 +238,12 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
z_entry: str = None,
|
||||
x: list | np.ndarray = None,
|
||||
y: list | np.ndarray = None,
|
||||
color: Optional[str] = None,
|
||||
color_map_z: Optional[str] = "plasma",
|
||||
label: Optional[str] = None,
|
||||
color: str | None = None,
|
||||
color_map_z: str | None = "plasma",
|
||||
label: str | None = None,
|
||||
validate: bool = True,
|
||||
):
|
||||
dap: str | None = None,
|
||||
) -> BECWaveform:
|
||||
"""
|
||||
Configure the waveform based on the provided parameters.
|
||||
|
||||
@@ -217,6 +261,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
color_map_z (str): The color map to use for the z-axis.
|
||||
label (str): The label of the curve.
|
||||
validate (bool): If True, validate the device names and entries.
|
||||
dap (str): The DAP model to use for the curve.
|
||||
"""
|
||||
if x is not None and y is None:
|
||||
if isinstance(x, np.ndarray):
|
||||
@@ -240,7 +285,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
return waveform
|
||||
# User wants to add scan curve -> 1D Waveform
|
||||
if x_name is not None and y_name is not None and z_name is None and x is None and y is None:
|
||||
waveform.add_curve_scan(
|
||||
waveform.plot(
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
x_entry=x_entry,
|
||||
@@ -248,6 +293,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
validate=validate,
|
||||
color=color,
|
||||
label=label,
|
||||
dap=dap,
|
||||
)
|
||||
# User wants to add scan curve -> 2D Waveform Scatter
|
||||
if (
|
||||
@@ -257,7 +303,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
and x is None
|
||||
and y is None
|
||||
):
|
||||
waveform.add_curve_scan(
|
||||
waveform.plot(
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
@@ -268,6 +314,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
color_map_z=color_map_z,
|
||||
label=label,
|
||||
validate=validate,
|
||||
dap=dap,
|
||||
)
|
||||
# User wants to add custom curve
|
||||
elif x is not None and y is not None and x_name is None and y_name is None:
|
||||
@@ -275,73 +322,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
|
||||
return waveform
|
||||
|
||||
def add_plot(
|
||||
self,
|
||||
x: list | np.ndarray = None,
|
||||
y: list | np.ndarray = None,
|
||||
x_name: str = None,
|
||||
y_name: str = None,
|
||||
z_name: str = None,
|
||||
x_entry: str = None,
|
||||
y_entry: str = None,
|
||||
z_entry: str = None,
|
||||
color: Optional[str] = None,
|
||||
color_map_z: Optional[str] = "plasma",
|
||||
label: Optional[str] = None,
|
||||
validate: bool = True,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> BECWaveform:
|
||||
"""
|
||||
Add a Waveform1D plot to the figure at the specified position.
|
||||
|
||||
Args:
|
||||
x(list | np.ndarray): Custom x data to plot.
|
||||
y(list | np.ndarray): Custom y data to plot.
|
||||
x_name(str): The name of the device for the x-axis.
|
||||
y_name(str): The name of the device for the y-axis.
|
||||
z_name(str): The name of the device for the z-axis.
|
||||
x_entry(str): The name of the entry for the x-axis.
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
z_entry(str): The name of the entry for the z-axis.
|
||||
color(str): The color of the curve.
|
||||
color_map_z(str): The color map to use for the z-axis.
|
||||
label(str): The label of the curve.
|
||||
validate(bool): If True, validate the device names and entries.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
|
||||
"""
|
||||
widget_id = str(uuid.uuid4())
|
||||
waveform = self.add_widget(
|
||||
widget_type="Waveform1D",
|
||||
widget_id=widget_id,
|
||||
row=row,
|
||||
col=col,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
|
||||
waveform = self._init_waveform(
|
||||
waveform=waveform,
|
||||
x=x,
|
||||
y=y,
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color=color,
|
||||
color_map_z=color_map_z,
|
||||
label=label,
|
||||
validate=validate,
|
||||
)
|
||||
return waveform
|
||||
|
||||
@typechecked
|
||||
def plot(
|
||||
self,
|
||||
@@ -357,6 +337,11 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
color_map_z: str | None = "plasma",
|
||||
label: str | None = None,
|
||||
validate: bool = True,
|
||||
new: bool = False,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
dap: str | None = None,
|
||||
config: dict | None = None, # TODO make logic more transparent
|
||||
**axis_kwargs,
|
||||
) -> BECWaveform:
|
||||
"""
|
||||
@@ -375,20 +360,23 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
color_map_z(str): The color map to use for the z-axis.
|
||||
label(str): The label of the curve.
|
||||
validate(bool): If True, validate the device names and entries.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
dap(str): The DAP model to use for the curve.
|
||||
config(dict): Recreates the whole BECWaveform widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECWaveform: The waveform plot widget.
|
||||
"""
|
||||
waveform = WidgetContainerUtils.find_first_widget_by_class(
|
||||
self._widgets, BECWaveform, can_fail=True
|
||||
waveform = self.subplot_factory(
|
||||
widget_type="BECWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
|
||||
)
|
||||
if waveform is not None:
|
||||
if axis_kwargs:
|
||||
waveform.set(**axis_kwargs)
|
||||
else:
|
||||
waveform = self.add_plot(**axis_kwargs)
|
||||
if config is not None:
|
||||
return waveform
|
||||
|
||||
# Passing args to init_waveform
|
||||
waveform = self._init_waveform(
|
||||
waveform=waveform,
|
||||
x=x,
|
||||
@@ -403,8 +391,8 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
color_map_z=color_map_z,
|
||||
label=label,
|
||||
validate=validate,
|
||||
dap=dap,
|
||||
)
|
||||
# TODO remove repetition from .plot method
|
||||
return waveform
|
||||
|
||||
def _init_image(
|
||||
@@ -451,6 +439,10 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
color_map: str = "magma",
|
||||
data: np.ndarray = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
new: bool = False,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
config: dict | None = None,
|
||||
**axis_kwargs,
|
||||
) -> BECImageShow:
|
||||
"""
|
||||
@@ -462,78 +454,22 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
color_map(str): The color map to use for the image.
|
||||
data(np.ndarray): Custom data to display.
|
||||
vrange(tuple[float, float]): The range of values to display.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECImageShow: The image widget.
|
||||
"""
|
||||
image = WidgetContainerUtils.find_first_widget_by_class(
|
||||
self._widgets, BECImageShow, can_fail=True
|
||||
)
|
||||
if image is not None:
|
||||
if axis_kwargs:
|
||||
image.set(**axis_kwargs)
|
||||
else:
|
||||
image = self.add_image(color_bar=color_bar, **axis_kwargs)
|
||||
|
||||
image = self._init_image(
|
||||
image=image,
|
||||
monitor=monitor,
|
||||
color_bar=color_bar,
|
||||
color_map=color_map,
|
||||
data=data,
|
||||
vrange=vrange,
|
||||
)
|
||||
return image
|
||||
|
||||
def add_image(
|
||||
self,
|
||||
monitor: str = None,
|
||||
color_bar: Literal["simple", "full"] = "full",
|
||||
color_map: str = "magma",
|
||||
data: np.ndarray = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> BECImageShow:
|
||||
"""
|
||||
Add an image to the figure at the specified position.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to display.
|
||||
color_bar(Literal["simple","full"]): The type of color bar to display.
|
||||
color_map(str): The color map to use for the image.
|
||||
data(np.ndarray): Custom data to display.
|
||||
vrange(tuple[float, float]): The range of values to display.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
config(dict): Recreates the whole BECImageShow widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECImageShow: The image widget.
|
||||
"""
|
||||
|
||||
widget_id = str(uuid.uuid4())
|
||||
if config is None:
|
||||
config = ImageConfig(
|
||||
widget_class="BECImageShow",
|
||||
gui_id=widget_id,
|
||||
parent_id=self.gui_id,
|
||||
color_map=color_map,
|
||||
color_bar=color_bar,
|
||||
vrange=vrange,
|
||||
)
|
||||
image = self.add_widget(
|
||||
widget_type="ImShow",
|
||||
widget_id=widget_id,
|
||||
row=row,
|
||||
col=col,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
image = self.subplot_factory(
|
||||
widget_type="BECImageShow", config=config, row=row, col=col, new=new, **axis_kwargs
|
||||
)
|
||||
if config is not None:
|
||||
return image
|
||||
|
||||
image = self._init_image(
|
||||
image=image,
|
||||
monitor=monitor,
|
||||
@@ -544,76 +480,99 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
)
|
||||
return image
|
||||
|
||||
def motor_map(self, motor_x: str = None, motor_y: str = None, **axis_kwargs) -> BECMotorMap:
|
||||
def motor_map(
|
||||
self,
|
||||
motor_x: str = None,
|
||||
motor_y: str = None,
|
||||
new: bool = False,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
config: dict | None = None,
|
||||
**axis_kwargs,
|
||||
) -> BECMotorMap:
|
||||
"""
|
||||
Add a motor map to the figure. Always access the first motor map widget in the figure.
|
||||
|
||||
Args:
|
||||
motor_x(str): The name of the motor for the X axis.
|
||||
motor_y(str): The name of the motor for the Y axis.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Recreates the whole BECImageShow widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECMotorMap: The motor map widget.
|
||||
"""
|
||||
motor_map = WidgetContainerUtils.find_first_widget_by_class(
|
||||
self._widgets, BECMotorMap, can_fail=True
|
||||
motor_map = self.subplot_factory(
|
||||
widget_type="BECMotorMap", config=config, row=row, col=col, new=new, **axis_kwargs
|
||||
)
|
||||
if motor_map is not None:
|
||||
if axis_kwargs:
|
||||
motor_map.set(**axis_kwargs)
|
||||
else:
|
||||
motor_map = self.add_motor_map(**axis_kwargs)
|
||||
if config is not None:
|
||||
return motor_map
|
||||
|
||||
if motor_x is not None and motor_y is not None:
|
||||
motor_map.change_motors(motor_x, motor_y)
|
||||
|
||||
return motor_map
|
||||
|
||||
def add_motor_map(
|
||||
def subplot_factory(
|
||||
self,
|
||||
motor_x: str = None,
|
||||
motor_y: str = None,
|
||||
widget_type: Literal[
|
||||
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap"
|
||||
] = "BECPlotBase",
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
new: bool = False,
|
||||
**axis_kwargs,
|
||||
) -> BECMotorMap:
|
||||
"""
|
||||
|
||||
Args:
|
||||
motor_x(str): The name of the motor for the X axis.
|
||||
motor_y(str): The name of the motor for the Y axis.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs:
|
||||
|
||||
Returns:
|
||||
BECMotorMap: The motor map widget.
|
||||
"""
|
||||
widget_id = str(uuid.uuid4())
|
||||
if config is None:
|
||||
config = MotorMapConfig(
|
||||
widget_class="BECMotorMap", gui_id=widget_id, parent_id=self.gui_id
|
||||
) -> BECPlotBase:
|
||||
# Case 1 - config provided, new plot, possible to define coordinates
|
||||
if config is not None:
|
||||
widget_cls = config["widget_class"]
|
||||
if widget_cls != widget_type:
|
||||
raise ValueError(
|
||||
f"Widget type '{widget_type}' does not match the provided configuration ({widget_cls})."
|
||||
)
|
||||
widget = self.add_widget(
|
||||
widget_type=widget_type, config=config, row=row, col=col, **axis_kwargs
|
||||
)
|
||||
motor_map = self.add_widget(
|
||||
widget_type="MotorMap",
|
||||
widget_id=widget_id,
|
||||
row=row,
|
||||
col=col,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
return widget
|
||||
|
||||
if motor_x is not None and motor_y is not None:
|
||||
motor_map.change_motors(motor_x, motor_y)
|
||||
# Case 2 - find first plot or create first plot if no plot available, no config provided, no coordinates
|
||||
if new is False and (row is None or col is None):
|
||||
widget = WidgetContainerUtils.find_first_widget_by_class(
|
||||
self._widgets, self.subplot_map[widget_type], can_fail=True
|
||||
)
|
||||
if widget is not None:
|
||||
if axis_kwargs:
|
||||
widget.set(**axis_kwargs)
|
||||
else:
|
||||
widget = self.add_widget(widget_type=widget_type, **axis_kwargs)
|
||||
return widget
|
||||
|
||||
return motor_map
|
||||
# Case 3 - modifying existing plot wit coordinates provided
|
||||
if new is False and (row is not None and col is not None):
|
||||
try:
|
||||
widget = self.axes(row, col)
|
||||
except ValueError:
|
||||
widget = None
|
||||
if widget is not None:
|
||||
if axis_kwargs:
|
||||
widget.set(**axis_kwargs)
|
||||
else:
|
||||
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
|
||||
return widget
|
||||
|
||||
# Case 4 - no previous plot or new plot, no config provided, possible to define coordinates
|
||||
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
|
||||
return widget
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
widget_type: Literal["PlotBase", "Waveform1D", "ImShow"] = "PlotBase",
|
||||
widget_type: Literal[
|
||||
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap"
|
||||
] = "BECPlotBase",
|
||||
widget_id: str = None,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
@@ -644,6 +603,9 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
# has to be changed manually to ensure unique id, if config is copied from existing widget, the id could be
|
||||
# used otherwise multiple times
|
||||
widget.set_gui_id(widget_id)
|
||||
|
||||
# Check if position is occupied
|
||||
if row is not None and col is not None:
|
||||
@@ -747,6 +709,7 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
|
||||
self._reindex_grid()
|
||||
if widget_id in self.config.widgets:
|
||||
self.config.widgets.pop(widget_id)
|
||||
widget.deleteLater()
|
||||
else:
|
||||
raise ValueError(f"Widget with ID '{widget_id}' does not exist.")
|
||||
|
||||
|
||||
61
bec_widgets/widgets/figure/plots/axis_settings.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
|
||||
import qdarktheme
|
||||
from qtpy.QtCore import Slot
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
class AxisSettings(QWidget):
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader().load_ui(os.path.join(current_path, "axis_settings.ui"), self)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.addWidget(self.ui)
|
||||
|
||||
# Hardcoded values for best appearance
|
||||
self.setMinimumHeight(280)
|
||||
self.setMaximumHeight(280)
|
||||
self.resize(380, 280)
|
||||
|
||||
@Slot(dict)
|
||||
def display_current_settings(self, axis_config: dict):
|
||||
# Top Box
|
||||
WidgetIO.set_value(self.ui.plot_title, axis_config["title"])
|
||||
|
||||
# X Axis Box
|
||||
WidgetIO.set_value(self.ui.x_label, axis_config["x_label"])
|
||||
WidgetIO.set_value(self.ui.x_scale, axis_config["x_scale"])
|
||||
WidgetIO.set_value(self.ui.x_grid, axis_config["x_grid"])
|
||||
if axis_config["x_lim"] is not None:
|
||||
WidgetIO.check_and_adjust_limits(self.ui.x_min, axis_config["x_lim"][0])
|
||||
WidgetIO.check_and_adjust_limits(self.ui.x_max, axis_config["x_lim"][1])
|
||||
WidgetIO.set_value(self.ui.x_min, axis_config["x_lim"][0])
|
||||
WidgetIO.set_value(self.ui.x_max, axis_config["x_lim"][1])
|
||||
|
||||
# Y Axis Box
|
||||
WidgetIO.set_value(self.ui.y_label, axis_config["y_label"])
|
||||
WidgetIO.set_value(self.ui.y_scale, axis_config["y_scale"])
|
||||
WidgetIO.set_value(self.ui.y_grid, axis_config["y_grid"])
|
||||
if axis_config["y_lim"] is not None:
|
||||
WidgetIO.check_and_adjust_limits(self.ui.y_min, axis_config["y_lim"][0])
|
||||
WidgetIO.check_and_adjust_limits(self.ui.y_max, axis_config["y_lim"][1])
|
||||
WidgetIO.set_value(self.ui.y_min, axis_config["y_lim"][0])
|
||||
WidgetIO.set_value(self.ui.y_max, axis_config["y_lim"][1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
qdarktheme.setup_theme("dark")
|
||||
window = AxisSettings()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
249
bec_widgets/widgets/figure/plots/axis_settings.ui
Normal file
@@ -0,0 +1,249 @@
|
||||
<?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>417</width>
|
||||
<height>250</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>250</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>278</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0" colspan="3">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="plot_title_label">
|
||||
<property name="text">
|
||||
<string>Plot Title</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="plot_title"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="3">
|
||||
<widget class="Line" name="line_H">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QGroupBox" name="x_axis_box">
|
||||
<property name="title">
|
||||
<string>X Axis</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="x_scale_label">
|
||||
<property name="text">
|
||||
<string>Scale</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QDoubleSpinBox" name="x_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
<widget class="QLabel" name="x_min_label">
|
||||
<property name="text">
|
||||
<string>Min</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QDoubleSpinBox" name="x_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QComboBox" name="x_scale">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>linear</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>log</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="x_max_label">
|
||||
<property name="text">
|
||||
<string>Max</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLineEdit" name="x_label"/>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="x_label_label">
|
||||
<property name="text">
|
||||
<string>Label</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QCheckBox" name="x_grid">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="x_grid_label">
|
||||
<property name="text">
|
||||
<string>Grid</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="Line" name="line_V">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Vertical</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QGroupBox" name="y_axis_box">
|
||||
<property name="title">
|
||||
<string>Y Axis</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5">
|
||||
<item row="3" column="2">
|
||||
<widget class="QComboBox" name="y_scale">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>linear</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>log</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QDoubleSpinBox" name="y_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
<widget class="QLabel" name="y_min_label">
|
||||
<property name="text">
|
||||
<string>Min</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QDoubleSpinBox" name="y_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLineEdit" name="y_label"/>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="y_scale_label">
|
||||
<property name="text">
|
||||
<string>Scale</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="y_label_label">
|
||||
<property name="text">
|
||||
<string>Label</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="y_max_label">
|
||||
<property name="text">
|
||||
<string>Max</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QCheckBox" name="y_grid">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="y_grid_label">
|
||||
<property name="text">
|
||||
<string>Grid</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -5,14 +5,18 @@ from typing import Any, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field, ValidationError
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from qtpy.QtCore import QThread
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import EntryValidator
|
||||
from bec_widgets.widgets.figure.plots.image.image_item import BECImageItem, ImageItemConfig
|
||||
from bec_widgets.widgets.figure.plots.image.image_processor import ImageProcessor, ProcessorWorker
|
||||
from bec_widgets.widgets.figure.plots.image.image_processor import (
|
||||
ImageProcessor,
|
||||
ImageStats,
|
||||
ProcessorWorker,
|
||||
)
|
||||
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
|
||||
|
||||
|
||||
@@ -25,16 +29,15 @@ class ImageConfig(SubplotConfig):
|
||||
|
||||
class BECImageShow(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"add_image_by_config",
|
||||
"get_image_config",
|
||||
"get_image_dict",
|
||||
"add_monitor_image",
|
||||
"add_custom_image",
|
||||
"set_vrange",
|
||||
"set_color_map",
|
||||
"set_autorange",
|
||||
"set_autorange_mode",
|
||||
"set_monitor",
|
||||
"set_processing",
|
||||
"set_image_properties",
|
||||
@@ -42,7 +45,6 @@ class BECImageShow(BECPlotBase):
|
||||
"set_log",
|
||||
"set_rotation",
|
||||
"set_transpose",
|
||||
"toggle_threading",
|
||||
"set",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
@@ -86,6 +88,7 @@ class BECImageShow(BECPlotBase):
|
||||
# Connect signals and slots
|
||||
thread.started.connect(lambda: worker.process_image(device, image))
|
||||
worker.processed.connect(self.update_image)
|
||||
worker.stats.connect(self.update_vrange)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.finished.connect(thread.wait)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
@@ -132,7 +135,8 @@ class BECImageShow(BECPlotBase):
|
||||
self.apply_axis_config()
|
||||
self._images = defaultdict(dict)
|
||||
|
||||
# TODO extend by adding image by config
|
||||
for image_id, image_config in config.images.items():
|
||||
self.add_image_by_config(image_config)
|
||||
|
||||
def change_gui_id(self, new_gui_id: str):
|
||||
"""
|
||||
@@ -220,7 +224,7 @@ class BECImageShow(BECPlotBase):
|
||||
self,
|
||||
monitor: str,
|
||||
color_map: Optional[str] = "magma",
|
||||
color_bar: Optional[Literal["simple", "full"]] = "simple",
|
||||
color_bar: Optional[Literal["simple", "full"]] = "full",
|
||||
downsample: Optional[bool] = True,
|
||||
opacity: Optional[float] = 1.0,
|
||||
vrange: Optional[tuple[int, int]] = None,
|
||||
@@ -235,7 +239,7 @@ class BECImageShow(BECPlotBase):
|
||||
f"Monitor with ID '{monitor}' already exists in widget '{self.gui_id}'."
|
||||
)
|
||||
|
||||
monitor = self.entry_validator.validate_monitor(monitor)
|
||||
# monitor = self.entry_validator.validate_monitor(monitor)
|
||||
|
||||
image_config = ImageItemConfig(
|
||||
widget_class="BECImageItem",
|
||||
@@ -245,12 +249,13 @@ class BECImageShow(BECPlotBase):
|
||||
downsample=downsample,
|
||||
opacity=opacity,
|
||||
vrange=vrange,
|
||||
source=image_source,
|
||||
monitor=monitor,
|
||||
# post_processing=post_processing,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
image = self._add_image_object(source=image_source, name=monitor, config=image_config)
|
||||
self._connect_device_monitor(monitor)
|
||||
return image
|
||||
|
||||
def add_custom_image(
|
||||
@@ -258,16 +263,17 @@ class BECImageShow(BECPlotBase):
|
||||
name: str,
|
||||
data: Optional[np.ndarray] = None,
|
||||
color_map: Optional[str] = "magma",
|
||||
color_bar: Optional[Literal["simple", "full"]] = "simple",
|
||||
color_bar: Optional[Literal["simple", "full"]] = "full",
|
||||
downsample: Optional[bool] = True,
|
||||
opacity: Optional[float] = 1.0,
|
||||
vrange: Optional[tuple[int, int]] = None,
|
||||
# post_processing: Optional[PostProcessingConfig] = None,
|
||||
**kwargs,
|
||||
):
|
||||
image_source = "device_monitor"
|
||||
image_source = "custom"
|
||||
# image_source = "device_monitor"
|
||||
|
||||
image_exits = self._check_curve_id(name, self._images)
|
||||
image_exits = self._check_image_id(name, self._images)
|
||||
if image_exits:
|
||||
raise ValueError(f"Monitor with ID '{name}' already exists in widget '{self.gui_id}'.")
|
||||
|
||||
@@ -284,7 +290,9 @@ class BECImageShow(BECPlotBase):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
image = self._add_image_object(source=image_source, config=image_config, data=data)
|
||||
image = self._add_image_object(
|
||||
source=image_source, name=name, config=image_config, data=data
|
||||
)
|
||||
return image
|
||||
|
||||
def apply_setting_to_images(
|
||||
@@ -307,6 +315,7 @@ class BECImageShow(BECPlotBase):
|
||||
for source, images in self._images.items():
|
||||
for _, image in images.items():
|
||||
getattr(image, setting_method_name)(*args, **kwargs)
|
||||
self.refresh_image()
|
||||
|
||||
def set_vrange(self, vmin: float, vmax: float, name: str = None):
|
||||
"""
|
||||
@@ -341,6 +350,17 @@ class BECImageShow(BECPlotBase):
|
||||
"""
|
||||
self.apply_setting_to_images("set_autorange", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def set_autorange_mode(self, mode: Literal["max", "mean"], name: str = None):
|
||||
"""
|
||||
Set the autoscale mode of the image, that decides how the vrange of the color bar is scaled.
|
||||
Choose betwen 'max' -> min/max of the data, 'mean' -> mean +/- fudge_factor*std of the data (fudge_factor~2).
|
||||
|
||||
Args:
|
||||
mode(str): The autoscale mode of the image.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_autorange_mode", args=[mode], kwargs={}, image_id=name)
|
||||
|
||||
def set_monitor(self, monitor: str, name: str = None):
|
||||
"""
|
||||
Set the monitor of the image.
|
||||
@@ -443,6 +463,27 @@ class BECImageShow(BECPlotBase):
|
||||
if self.use_threading is False and self.thread.isRunning():
|
||||
self.cleanup()
|
||||
|
||||
def process_image(self, device: str, image: BECImageItem, data: np.ndarray):
|
||||
"""
|
||||
Process the image data.
|
||||
|
||||
Args:
|
||||
device(str): The name of the device - image_id of image.
|
||||
image(np.ndarray): The image data to be processed.
|
||||
data(np.ndarray): The image data to be processed.
|
||||
|
||||
Returns:
|
||||
np.ndarray: The processed image data.
|
||||
"""
|
||||
processing_config = image.config.processing
|
||||
self.processor.set_config(processing_config)
|
||||
if self.use_threading:
|
||||
self._create_thread_worker(device, data)
|
||||
else:
|
||||
data = self.processor.process_image(data)
|
||||
self.update_image(device, data)
|
||||
self.update_vrange(device, self.processor.config.stats)
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def on_image_update(self, msg: dict):
|
||||
"""
|
||||
@@ -453,14 +494,8 @@ class BECImageShow(BECPlotBase):
|
||||
"""
|
||||
data = msg["data"]
|
||||
device = msg["device"]
|
||||
image_to_update = self._images["device_monitor"][device]
|
||||
processing_config = image_to_update.config.processing
|
||||
self.processor.set_config(processing_config)
|
||||
if self.use_threading:
|
||||
self._create_thread_worker(device, data)
|
||||
else:
|
||||
data = self.processor.process_image(data)
|
||||
self.update_image(device, data)
|
||||
image = self._images["device_monitor"][device]
|
||||
self.process_image(device, image, data)
|
||||
|
||||
@pyqtSlot(str, np.ndarray)
|
||||
def update_image(self, device: str, data: np.ndarray):
|
||||
@@ -474,6 +509,27 @@ class BECImageShow(BECPlotBase):
|
||||
image_to_update = self._images["device_monitor"][device]
|
||||
image_to_update.updateImage(data, autoLevels=image_to_update.config.autorange)
|
||||
|
||||
@pyqtSlot(str, ImageStats)
|
||||
def update_vrange(self, device: str, stats: ImageStats):
|
||||
"""
|
||||
Update the scaling of the image.
|
||||
|
||||
Args:
|
||||
stats(ImageStats): The statistics of the image.
|
||||
"""
|
||||
image_to_update = self._images["device_monitor"][device]
|
||||
if image_to_update.config.autorange:
|
||||
image_to_update.auto_update_vrange(stats)
|
||||
|
||||
def refresh_image(self):
|
||||
"""
|
||||
Refresh the image.
|
||||
"""
|
||||
for source, images in self._images.items():
|
||||
for image_id, image in images.items():
|
||||
data = image.get_data()
|
||||
self.process_image(image_id, image, data)
|
||||
|
||||
def _connect_device_monitor(self, monitor: str):
|
||||
"""
|
||||
Connect to the device monitor.
|
||||
@@ -486,16 +542,18 @@ class BECImageShow(BECPlotBase):
|
||||
previous_monitor = image_item.config.monitor
|
||||
except AttributeError:
|
||||
previous_monitor = None
|
||||
if previous_monitor != monitor:
|
||||
if previous_monitor:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor(previous_monitor)
|
||||
)
|
||||
if monitor:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor(monitor)
|
||||
)
|
||||
image_item.set_monitor(monitor)
|
||||
if previous_monitor and image_item.connected is True:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor(previous_monitor)
|
||||
)
|
||||
image_item.connected = False
|
||||
if monitor and image_item.connected is False:
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor(monitor)
|
||||
)
|
||||
image_item.set_monitor(monitor)
|
||||
image_item.connected = True
|
||||
|
||||
def _add_image_object(
|
||||
self, source: str, name: str, config: ImageItemConfig, data=None
|
||||
@@ -504,6 +562,8 @@ class BECImageShow(BECPlotBase):
|
||||
image = BECImageItem(config=config, parent_image=self)
|
||||
self.plot_item.addItem(image)
|
||||
self._images[source][name] = image
|
||||
if source == "device_monitor":
|
||||
self._connect_device_monitor(config.monitor)
|
||||
self.config.images[name] = config
|
||||
if data is not None:
|
||||
image.setImage(data)
|
||||
@@ -528,23 +588,6 @@ class BECImageShow(BECPlotBase):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _validate_monitor(self, monitor: str, validate_bec: bool = True):
|
||||
"""
|
||||
Validate the monitor name.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor.
|
||||
validate_bec(bool): Whether to validate the monitor name with BEC.
|
||||
|
||||
Returns:
|
||||
bool: True if the monitor name is valid, False otherwise.
|
||||
"""
|
||||
if not monitor or monitor == "":
|
||||
return False
|
||||
if validate_bec:
|
||||
return monitor in self.dev
|
||||
return True
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the widget.
|
||||
|
||||
@@ -7,7 +7,7 @@ import pyqtgraph as pg
|
||||
from pydantic import Field
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
from bec_widgets.widgets.figure.plots.image.image_processor import ProcessingConfig
|
||||
from bec_widgets.widgets.figure.plots.image.image_processor import ImageStats, ProcessingConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.figure.plots.image.image import BECImageShow
|
||||
@@ -20,13 +20,16 @@ class ImageItemConfig(ConnectionConfig):
|
||||
color_map: Optional[str] = Field("magma", description="The color map of the image.")
|
||||
downsample: Optional[bool] = Field(True, description="Whether to downsample the image.")
|
||||
opacity: Optional[float] = Field(1.0, description="The opacity of the image.")
|
||||
vrange: Optional[tuple[int, int]] = Field(
|
||||
vrange: Optional[tuple[float | int, float | int]] = Field(
|
||||
None, description="The range of the color bar. If None, the range is automatically set."
|
||||
)
|
||||
color_bar: Optional[Literal["simple", "full"]] = Field(
|
||||
"simple", description="The type of the color bar."
|
||||
)
|
||||
autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.")
|
||||
autorange_mode: Optional[Literal["max", "mean"]] = Field(
|
||||
"mean", description="Whether to use the mean of the image for autoscaling."
|
||||
)
|
||||
processing: ProcessingConfig = Field(
|
||||
default_factory=ProcessingConfig, description="The post processing of the image."
|
||||
)
|
||||
@@ -34,8 +37,8 @@ class ImageItemConfig(ConnectionConfig):
|
||||
|
||||
class BECImageItem(BECConnector, pg.ImageItem):
|
||||
USER_ACCESS = [
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"set",
|
||||
"set_fft",
|
||||
"set_log",
|
||||
@@ -43,6 +46,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
|
||||
"set_transpose",
|
||||
"set_opacity",
|
||||
"set_autorange",
|
||||
"set_autorange_mode",
|
||||
"set_color_map",
|
||||
"set_auto_downsample",
|
||||
"set_monitor",
|
||||
@@ -74,6 +78,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
|
||||
self.apply_config()
|
||||
if kwargs:
|
||||
self.set(**kwargs)
|
||||
self.connected = False
|
||||
|
||||
def apply_config(self):
|
||||
"""
|
||||
@@ -101,6 +106,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
|
||||
- log
|
||||
- rot
|
||||
- transpose
|
||||
- autorange_mode
|
||||
"""
|
||||
method_map = {
|
||||
"downsample": self.set_auto_downsample,
|
||||
@@ -112,6 +118,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
|
||||
"log": self.set_log,
|
||||
"rot": self.set_rotation,
|
||||
"transpose": self.set_transpose,
|
||||
"autorange_mode": self.set_autorange_mode,
|
||||
}
|
||||
for key, value in kwargs.items():
|
||||
if key in method_map:
|
||||
@@ -175,9 +182,18 @@ class BECImageItem(BECConnector, pg.ImageItem):
|
||||
autorange(bool): Whether to autorange the color bar.
|
||||
"""
|
||||
self.config.autorange = autorange
|
||||
if self.color_bar is not None:
|
||||
if self.color_bar and autorange:
|
||||
self.color_bar.autoHistogramRange()
|
||||
|
||||
def set_autorange_mode(self, mode: Literal["max", "mean"] = "mean"):
|
||||
"""
|
||||
Set the autorange mode to scale the vrange of the color bar. Choose between min/max or mean +/- std.
|
||||
|
||||
Args:
|
||||
mode(Literal["max","mean"]): Max for min/max or mean for mean +/- std.
|
||||
"""
|
||||
self.config.autorange_mode = mode
|
||||
|
||||
def set_color_map(self, cmap: str = "magma"):
|
||||
"""
|
||||
Set the color map of the image.
|
||||
@@ -212,7 +228,29 @@ class BECImageItem(BECConnector, pg.ImageItem):
|
||||
"""
|
||||
self.config.monitor = monitor
|
||||
|
||||
def set_vrange(self, vmin: float = None, vmax: float = None, vrange: tuple[int, int] = None):
|
||||
def auto_update_vrange(self, stats: ImageStats) -> None:
|
||||
"""Auto update of the vrange base on the stats of the image.
|
||||
|
||||
Args:
|
||||
stats(ImageStats): The stats of the image.
|
||||
"""
|
||||
fumble_factor = 2
|
||||
if self.config.autorange_mode == "mean":
|
||||
vmin = max(stats.mean - fumble_factor * stats.std, 0)
|
||||
vmax = stats.mean + fumble_factor * stats.std
|
||||
self.set_vrange(vmin, vmax, change_autorange=False)
|
||||
return
|
||||
if self.config.autorange_mode == "max":
|
||||
self.set_vrange(max(stats.minimum, 0), stats.maximum, change_autorange=False)
|
||||
return
|
||||
|
||||
def set_vrange(
|
||||
self,
|
||||
vmin: float = None,
|
||||
vmax: float = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
change_autorange: bool = True,
|
||||
):
|
||||
"""
|
||||
Set the range of the color bar.
|
||||
|
||||
@@ -224,11 +262,13 @@ class BECImageItem(BECConnector, pg.ImageItem):
|
||||
vmin, vmax = vrange
|
||||
self.setLevels([vmin, vmax])
|
||||
self.config.vrange = (vmin, vmax)
|
||||
self.config.autorange = False
|
||||
if change_autorange:
|
||||
self.config.autorange = False
|
||||
if self.color_bar is not None:
|
||||
if self.config.color_bar == "simple":
|
||||
self.color_bar.setLevels(low=vmin, high=vmax)
|
||||
elif self.config.color_bar == "full":
|
||||
# pylint: disable=unexpected-keyword-arg
|
||||
self.color_bar.setLevels(min=vmin, max=vmax)
|
||||
self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -7,6 +8,16 @@ from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import QObject, Signal, Slot
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageStats:
|
||||
"""Container to store stats of an image."""
|
||||
|
||||
maximum: float
|
||||
minimum: float
|
||||
mean: float
|
||||
std: float
|
||||
|
||||
|
||||
class ProcessingConfig(BaseModel):
|
||||
fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.")
|
||||
log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.")
|
||||
@@ -20,6 +31,10 @@ class ProcessingConfig(BaseModel):
|
||||
None, description="The rotation angle of the monitor data before displaying."
|
||||
)
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
stats: ImageStats = Field(
|
||||
ImageStats(maximum=0, minimum=0, mean=0, std=0),
|
||||
description="The statistics of the image data.",
|
||||
)
|
||||
|
||||
|
||||
class ImageProcessor:
|
||||
@@ -97,6 +112,18 @@ class ImageProcessor:
|
||||
# def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality
|
||||
# return np.unravel_index(np.argmax(data), data.shape)
|
||||
|
||||
def update_image_stats(self, data: np.ndarray) -> None:
|
||||
"""Get the statistics of the image data.
|
||||
|
||||
Args:
|
||||
data(np.ndarray): The image data.
|
||||
|
||||
"""
|
||||
self.config.stats.maximum = np.max(data)
|
||||
self.config.stats.minimum = np.min(data)
|
||||
self.config.stats.mean = np.mean(data)
|
||||
self.config.stats.std = np.std(data)
|
||||
|
||||
def process_image(self, data: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Process the data according to the configuration.
|
||||
@@ -115,6 +142,7 @@ class ImageProcessor:
|
||||
data = self.transpose(data)
|
||||
if self.config.log:
|
||||
data = self.log(data)
|
||||
self.update_image_stats(data)
|
||||
return data
|
||||
|
||||
|
||||
@@ -124,6 +152,7 @@ class ProcessorWorker(QObject):
|
||||
"""
|
||||
|
||||
processed = Signal(str, np.ndarray)
|
||||
stats = Signal(str, ImageStats)
|
||||
stopRequested = Signal()
|
||||
finished = Signal()
|
||||
|
||||
@@ -147,6 +176,7 @@ class ProcessorWorker(QObject):
|
||||
self._isRunning = False
|
||||
if not self._isRunning:
|
||||
self.processed.emit(device, processed_image)
|
||||
self.stats.emit(self.processor.config.stats)
|
||||
self.finished.emit()
|
||||
|
||||
def stop(self):
|
||||
|
||||
@@ -6,37 +6,51 @@ from typing import Optional, Union
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy import QtCore, QtGui
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtCore import Slot
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import EntryValidator
|
||||
from bec_widgets.utils import Colors, EntryValidator
|
||||
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
|
||||
from bec_widgets.widgets.figure.plots.waveform.waveform import Signal, SignalData
|
||||
|
||||
|
||||
class MotorMapConfig(SubplotConfig):
|
||||
signals: Optional[Signal] = Field(None, description="Signals of the motor map")
|
||||
color_map: Optional[str] = Field(
|
||||
"Greys", description="Color scheme of the motor position gradient."
|
||||
) # TODO decide if useful for anything, or just keep GREYS always
|
||||
color: Optional[str | tuple] = Field(
|
||||
(255, 255, 255, 255), description="The color of the last point of current position."
|
||||
)
|
||||
scatter_size: Optional[int] = Field(5, description="Size of the scatter points.")
|
||||
max_points: Optional[int] = Field(1000, description="Maximum number of points to display.")
|
||||
max_points: Optional[int] = Field(5000, description="Maximum number of points to display.")
|
||||
num_dim_points: Optional[int] = Field(
|
||||
100,
|
||||
description="Number of points to dim before the color remains same for older recorded position.",
|
||||
)
|
||||
precision: Optional[int] = Field(2, description="Decimal precision of the motor position.")
|
||||
background_value: Optional[int] = Field(
|
||||
25, description="Background value of the motor map."
|
||||
) # TODO can be percentage from 255 calculated
|
||||
25, description="Background value of the motor map. Has to be between 0 and 255."
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
_validate_color = field_validator("color")(Colors.validate_color)
|
||||
|
||||
@field_validator("background_value")
|
||||
def validate_background_value(cls, value):
|
||||
if not 0 <= value <= 255:
|
||||
raise PydanticCustomError(
|
||||
"wrong_value", f"'{value}' hs to be between 0 and 255.", {"wrong_value": value}
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class BECMotorMap(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"config_dict",
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"change_motors",
|
||||
"set_max_points",
|
||||
"set_precision",
|
||||
@@ -44,6 +58,8 @@ class BECMotorMap(BECPlotBase):
|
||||
"set_background_value",
|
||||
"set_scatter_size",
|
||||
"get_data",
|
||||
"remove",
|
||||
"reset_history",
|
||||
]
|
||||
|
||||
# QT Signals
|
||||
@@ -67,31 +83,45 @@ class BECMotorMap(BECPlotBase):
|
||||
self.get_bec_shortcuts()
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
|
||||
# connect update signal to update plot
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self._update_plot
|
||||
)
|
||||
self.apply_config(self.config)
|
||||
|
||||
def apply_config(self, config: dict | MotorMapConfig):
|
||||
"""
|
||||
Apply the config to the motor map.
|
||||
|
||||
Args:
|
||||
config(dict|MotorMapConfig): Config to be applied.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
try:
|
||||
config = MotorMapConfig(**config)
|
||||
except ValidationError as e:
|
||||
print(f"Error in applying config: {e}")
|
||||
return
|
||||
|
||||
self.config = config
|
||||
self.plot_item.clear()
|
||||
|
||||
self.motor_x = None
|
||||
self.motor_y = None
|
||||
self.database_buffer = {"x": [], "y": []}
|
||||
self.plot_components = defaultdict(dict) # container for plot components
|
||||
|
||||
# connect update signal to update plot
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self._update_plot
|
||||
)
|
||||
self.apply_axis_config()
|
||||
|
||||
# TODO decide if needed to implement, maybe there will be no children widgets for motormap for now...
|
||||
# def find_widget_by_id(self, item_id: str) -> BECCurve:
|
||||
# """
|
||||
# Find the curve by its ID.
|
||||
# Args:
|
||||
# item_id(str): ID of the curve.
|
||||
#
|
||||
# Returns:
|
||||
# BECCurve: The curve object.
|
||||
# """
|
||||
# for curve in self.plot_item.curves:
|
||||
# if curve.gui_id == item_id:
|
||||
# return curve
|
||||
if self.config.signals is not None:
|
||||
self.change_motors(
|
||||
motor_x=self.config.signals.x.name,
|
||||
motor_y=self.config.signals.y.name,
|
||||
motor_x_entry=self.config.signals.x.entry,
|
||||
motor_y_entry=self.config.signals.y.entry,
|
||||
)
|
||||
|
||||
@pyqtSlot(str, str, str, str, bool)
|
||||
@Slot(str, str, str, str, bool)
|
||||
def change_motors(
|
||||
self,
|
||||
motor_x: str,
|
||||
@@ -110,6 +140,8 @@ class BECMotorMap(BECPlotBase):
|
||||
motor_y_entry(str): Motor entry for the Y axis.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
"""
|
||||
self.plot_item.clear()
|
||||
|
||||
motor_x_entry, motor_y_entry = self._validate_signal_entries(
|
||||
motor_x, motor_y, motor_x_entry, motor_y_entry, validate_bec
|
||||
)
|
||||
@@ -127,19 +159,42 @@ class BECMotorMap(BECPlotBase):
|
||||
# reconnect the signals
|
||||
self._connect_motor_to_slots()
|
||||
|
||||
self.database_buffer = {"x": [], "y": []}
|
||||
|
||||
# Redraw the motor map
|
||||
self._make_motor_map()
|
||||
|
||||
def get_data(self) -> dict:
|
||||
"""
|
||||
Get the data of the motor map.
|
||||
|
||||
Returns:
|
||||
dict: Data of the motor map.
|
||||
"""
|
||||
data = {"x": self.database_buffer["x"], "y": self.database_buffer["y"]}
|
||||
return data
|
||||
|
||||
# TODO setup all visual properties
|
||||
def reset_history(self):
|
||||
"""
|
||||
Reset the history of the motor map.
|
||||
"""
|
||||
self.database_buffer["x"] = [self.database_buffer["x"][-1]]
|
||||
self.database_buffer["y"] = [self.database_buffer["y"][-1]]
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_color(self, color: str | tuple):
|
||||
"""
|
||||
Set color of the motor trace.
|
||||
|
||||
Args:
|
||||
color(str|tuple): Color of the motor trace. Can be HEX(str) or RGBA(tuple).
|
||||
"""
|
||||
if isinstance(color, str):
|
||||
color = Colors.validate_color(color)
|
||||
color = Colors.hex_to_rgba(color, 255)
|
||||
self.config.color = color
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_max_points(self, max_points: int) -> None:
|
||||
"""
|
||||
Set the maximum number of points to display.
|
||||
@@ -148,6 +203,7 @@ class BECMotorMap(BECPlotBase):
|
||||
max_points(int): Maximum number of points to display.
|
||||
"""
|
||||
self.config.max_points = max_points
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_precision(self, precision: int) -> None:
|
||||
"""
|
||||
@@ -157,6 +213,7 @@ class BECMotorMap(BECPlotBase):
|
||||
precision(int): Decimal precision of the motor position.
|
||||
"""
|
||||
self.config.precision = precision
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_num_dim_points(self, num_dim_points: int) -> None:
|
||||
"""
|
||||
@@ -166,6 +223,7 @@ class BECMotorMap(BECPlotBase):
|
||||
num_dim_points(int): Number of dim points.
|
||||
"""
|
||||
self.config.num_dim_points = num_dim_points
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_background_value(self, background_value: int) -> None:
|
||||
"""
|
||||
@@ -175,6 +233,7 @@ class BECMotorMap(BECPlotBase):
|
||||
background_value(int): Background value of the motor map.
|
||||
"""
|
||||
self.config.background_value = background_value
|
||||
self._swap_limit_map()
|
||||
|
||||
def set_scatter_size(self, scatter_size: int) -> None:
|
||||
"""
|
||||
@@ -184,6 +243,7 @@ class BECMotorMap(BECPlotBase):
|
||||
scatter_size(int): Size of the scatter points.
|
||||
"""
|
||||
self.config.scatter_size = scatter_size
|
||||
self.update_signal.emit()
|
||||
|
||||
def _disconnect_current_motors(self):
|
||||
"""Disconnect the current motors from the slots."""
|
||||
@@ -208,6 +268,15 @@ class BECMotorMap(BECPlotBase):
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints)
|
||||
|
||||
def _swap_limit_map(self):
|
||||
"""Swap the limit map."""
|
||||
self.plot_item.removeItem(self.plot_components["limit_map"])
|
||||
self.plot_components["limit_map"] = self._make_limit_map(
|
||||
self.config.signals.x.limits, self.config.signals.y.limits
|
||||
)
|
||||
self.plot_components["limit_map"].setZValue(-1)
|
||||
self.plot_item.addItem(self.plot_components["limit_map"])
|
||||
|
||||
def _make_motor_map(self):
|
||||
"""
|
||||
Create the motor map plot.
|
||||
@@ -247,6 +316,8 @@ class BECMotorMap(BECPlotBase):
|
||||
# Set default labels for the plot
|
||||
self.set(x_label=f"Motor X ({self.motor_x})", y_label=f"Motor Y ({self.motor_y})")
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
def _add_coordinantes_crosshair(self, x: float, y: float) -> None:
|
||||
"""
|
||||
Add crosshair to the plot to highlight the current position.
|
||||
@@ -369,21 +440,34 @@ class BECMotorMap(BECPlotBase):
|
||||
print(f"The device '{motor}' does not have defined limits.")
|
||||
return None
|
||||
|
||||
@Slot()
|
||||
def _update_plot(self):
|
||||
"""Update the motor map plot."""
|
||||
# If the number of points exceeds max_points, delete the oldest points
|
||||
if len(self.database_buffer["x"]) > self.config.max_points:
|
||||
self.database_buffer["x"] = self.database_buffer["x"][-self.config.max_points :]
|
||||
self.database_buffer["y"] = self.database_buffer["y"][-self.config.max_points :]
|
||||
|
||||
x = self.database_buffer["x"]
|
||||
y = self.database_buffer["y"]
|
||||
|
||||
# Setup gradient brush for history
|
||||
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(x)
|
||||
|
||||
# RGB color
|
||||
r, g, b, a = self.config.color
|
||||
|
||||
# Calculate the decrement step based on self.num_dim_points
|
||||
num_dim_points = self.config.num_dim_points
|
||||
decrement_step = (255 - 50) / num_dim_points
|
||||
|
||||
for i in range(1, min(num_dim_points + 1, len(x) + 1)):
|
||||
brightness = max(60, 255 - decrement_step * (i - 1))
|
||||
brushes[-i] = pg.mkBrush(brightness, brightness, brightness, 255)
|
||||
brushes[-1] = pg.mkBrush(255, 255, 255, 255) # Newest point is always full brightness
|
||||
dim_r = int(r * (brightness / 255))
|
||||
dim_g = int(g * (brightness / 255))
|
||||
dim_b = int(b * (brightness / 255))
|
||||
brushes[-i] = pg.mkBrush(dim_r, dim_g, dim_b, a)
|
||||
brushes[-1] = pg.mkBrush(r, g, b, a) # Newest point is always full brightness
|
||||
scatter_size = self.config.scatter_size
|
||||
|
||||
# Update the scatter plot
|
||||
@@ -406,7 +490,7 @@ class BECMotorMap(BECPlotBase):
|
||||
f"Motor position: ({round(float(current_x),precision)}, {round(float(current_y),precision)})"
|
||||
)
|
||||
|
||||
@pyqtSlot(dict)
|
||||
@Slot(dict)
|
||||
def on_device_readback(self, msg: dict) -> None:
|
||||
"""
|
||||
Update the motor map plot with the new motor position.
|
||||
|
||||
@@ -46,7 +46,7 @@ class SubplotConfig(ConnectionConfig):
|
||||
|
||||
class BECPlotBase(BECConnector, pg.GraphicsLayout):
|
||||
USER_ACCESS = [
|
||||
"config_dict",
|
||||
"_config_dict",
|
||||
"set",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import messages
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.scan_data import ScanData
|
||||
from pydantic import Field, ValidationError
|
||||
@@ -33,15 +36,15 @@ class Waveform1DConfig(SubplotConfig):
|
||||
|
||||
class BECWaveform(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"plot",
|
||||
"add_dap",
|
||||
"get_dap_params",
|
||||
"remove_curve",
|
||||
"scan_history",
|
||||
"curves",
|
||||
"get_curve",
|
||||
"get_curve_config",
|
||||
"apply_config",
|
||||
"get_all_data",
|
||||
"set",
|
||||
"set_title",
|
||||
@@ -57,6 +60,7 @@ class BECWaveform(BECPlotBase):
|
||||
"set_legend_label_size",
|
||||
]
|
||||
scan_signal_update = pyqtSignal()
|
||||
dap_params_update = pyqtSignal(dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -73,6 +77,7 @@ class BECWaveform(BECPlotBase):
|
||||
)
|
||||
|
||||
self._curves_data = defaultdict(dict)
|
||||
self.old_scan_id = None
|
||||
self.scan_id = None
|
||||
|
||||
# Scan segment update proxy
|
||||
@@ -80,6 +85,9 @@ class BECWaveform(BECPlotBase):
|
||||
self.scan_signal_update, rateLimit=25, slot=self._update_scan_segment_plot
|
||||
)
|
||||
|
||||
self.proxy_update_dap = pg.SignalProxy(
|
||||
self.scan_signal_update, rateLimit=25, slot=self.refresh_dap
|
||||
)
|
||||
# Get bec shortcuts dev, scans, queue, scan_storage, dap
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@@ -213,6 +221,7 @@ class BECWaveform(BECPlotBase):
|
||||
color_map_z: str | None = "plasma",
|
||||
label: str | None = None,
|
||||
validate: bool = True,
|
||||
dap: str | None = None, # TODO add dap custom curve wrapper
|
||||
) -> BECCurve:
|
||||
"""
|
||||
Plot a curve to the plot widget.
|
||||
@@ -229,6 +238,7 @@ class BECWaveform(BECPlotBase):
|
||||
color_map_z(str): The color map to use for the z-axis.
|
||||
label(str): The label of the curve.
|
||||
validate(bool): If True, validate the device names and entries.
|
||||
dap(str): The dap model to use for the curve. If not specified, none will be added.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
@@ -237,6 +247,8 @@ class BECWaveform(BECPlotBase):
|
||||
if x is not None and y is not None:
|
||||
return self.add_curve_custom(x=x, y=y, label=label, color=color)
|
||||
else:
|
||||
if dap:
|
||||
self.add_dap(x_name=x_name, y_name=y_name, dap=dap)
|
||||
return self.add_curve_scan(
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
@@ -256,6 +268,7 @@ class BECWaveform(BECPlotBase):
|
||||
y: list | np.ndarray,
|
||||
label: str = None,
|
||||
color: str = None,
|
||||
curve_source: str = "custom",
|
||||
**kwargs,
|
||||
) -> BECCurve:
|
||||
"""
|
||||
@@ -266,12 +279,13 @@ class BECWaveform(BECPlotBase):
|
||||
y(list|np.ndarray): Y data of the curve.
|
||||
label(str, optional): Label of the curve. Defaults to None.
|
||||
color(str, optional): Color of the curve. Defaults to None.
|
||||
curve_source(str, optional): Tag for source of the curve. Defaults to "custom".
|
||||
**kwargs: Additional keyword arguments for the curve configuration.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
curve_source = "custom"
|
||||
curve_source = curve_source
|
||||
curve_id = label or f"Curve {len(self.plot_item.curves) + 1}"
|
||||
|
||||
curve_exits = self._check_curve_id(curve_id, self._curves_data)
|
||||
@@ -314,10 +328,12 @@ class BECWaveform(BECPlotBase):
|
||||
color_map_z: Optional[str] = "plasma",
|
||||
label: Optional[str] = None,
|
||||
validate_bec: bool = True,
|
||||
source: Optional[str] = None,
|
||||
dap: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> BECCurve:
|
||||
"""
|
||||
Add a curve to the plot widget from the scan segment.
|
||||
Add a curve to the plot widget from the scan segment. #TODO adapt docs to DAP
|
||||
|
||||
Args:
|
||||
x_name(str): Name of the x signal.
|
||||
@@ -334,8 +350,6 @@ class BECWaveform(BECPlotBase):
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
# Check if curve already exists
|
||||
curve_source = "scan_segment"
|
||||
|
||||
# Get entry if not provided and validate
|
||||
x_entry, y_entry, z_entry = self._validate_signal_entries(
|
||||
@@ -347,6 +361,7 @@ class BECWaveform(BECPlotBase):
|
||||
else:
|
||||
label = label or f"{y_name}-{y_entry}"
|
||||
|
||||
# Check if curve already exists
|
||||
curve_exits = self._check_curve_id(label, self._curves_data)
|
||||
if curve_exits:
|
||||
raise ValueError(f"Curve with ID '{label}' already exists in widget '{self.gui_id}'.")
|
||||
@@ -358,6 +373,18 @@ class BECWaveform(BECPlotBase):
|
||||
)[-1]
|
||||
)
|
||||
|
||||
if source is None:
|
||||
if validate_bec:
|
||||
curve_source = (
|
||||
"async_readback"
|
||||
if self.dev[y_name].readout_priority == ReadoutPriority.ASYNC
|
||||
else "scan_segment"
|
||||
)
|
||||
else:
|
||||
curve_source = "scan_segment"
|
||||
else:
|
||||
curve_source = source
|
||||
|
||||
# Create curve by config
|
||||
curve_config = CurveConfig(
|
||||
widget_class="BECCurve",
|
||||
@@ -371,12 +398,74 @@ class BECWaveform(BECPlotBase):
|
||||
x=SignalData(name=x_name, entry=x_entry),
|
||||
y=SignalData(name=y_name, entry=y_entry),
|
||||
z=SignalData(name=z_name, entry=z_entry) if z_name else None,
|
||||
dap=dap,
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
curve = self._add_curve_object(name=label, source=curve_source, config=curve_config)
|
||||
return curve
|
||||
|
||||
def add_dap(
|
||||
self,
|
||||
x_name: str,
|
||||
y_name: str,
|
||||
x_entry: Optional[str] = None,
|
||||
y_entry: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
dap: str = "GaussianModel",
|
||||
**kwargs,
|
||||
) -> BECCurve:
|
||||
"""
|
||||
Add LMFIT dap model curve to the plot widget.
|
||||
|
||||
Args:
|
||||
x_name(str): Name of the x signal.
|
||||
x_entry(str): Entry of the x signal.
|
||||
y_name(str): Name of the y signal.
|
||||
y_entry(str): Entry of the y signal.
|
||||
color(str, optional): Color of the curve. Defaults to None.
|
||||
color_map_z(str): The color map to use for the z-axis.
|
||||
label(str, optional): Label of the curve. Defaults to None.
|
||||
dap(str): The dap model to use for the curve.
|
||||
**kwargs: Additional keyword arguments for the curve configuration.
|
||||
|
||||
Returns:
|
||||
BECCurve: The curve object.
|
||||
"""
|
||||
x_entry, y_entry, _ = self._validate_signal_entries(
|
||||
x_name, y_name, None, x_entry, y_entry, None
|
||||
)
|
||||
label = f"{y_name}-{y_entry}-{dap}"
|
||||
curve = self.add_curve_scan(
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
color=color,
|
||||
label=label,
|
||||
source="DAP",
|
||||
dap=dap,
|
||||
pen_style="dash",
|
||||
symbol="star",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.setup_dap(self.old_scan_id, self.scan_id)
|
||||
self.refresh_dap()
|
||||
return curve
|
||||
|
||||
def get_dap_params(self) -> dict:
|
||||
"""
|
||||
Get the DAP parameters of all DAP curves.
|
||||
|
||||
Returns:
|
||||
dict: DAP parameters of all DAP curves.
|
||||
"""
|
||||
params = {}
|
||||
for curve_id, curve in self._curves_data["DAP"].items():
|
||||
params[curve_id] = curve.dap_params
|
||||
return params
|
||||
|
||||
def _add_curve_object(
|
||||
self,
|
||||
name: str,
|
||||
@@ -528,13 +617,75 @@ class BECWaveform(BECPlotBase):
|
||||
return
|
||||
|
||||
if current_scan_id != self.scan_id:
|
||||
self.old_scan_id = self.scan_id
|
||||
self.scan_id = current_scan_id
|
||||
self.scan_segment_data = self.queue.scan_storage.find_scan_by_ID(
|
||||
self.scan_id
|
||||
) # TODO do scan access through BECFigure
|
||||
self.setup_dap(self.old_scan_id, self.scan_id)
|
||||
|
||||
self.scan_signal_update.emit()
|
||||
|
||||
def setup_dap(self, old_scan_id, new_scan_id):
|
||||
"""
|
||||
Setup DAP for the new scan.
|
||||
|
||||
Args:
|
||||
old_scan_id(str): old_scan_id, used to disconnect the previous dispatcher connection.
|
||||
new_scan_id(str): new_scan_id, used to connect the new dispatcher connection.
|
||||
|
||||
"""
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.update_dap, MessageEndpoints.dap_response(old_scan_id)
|
||||
)
|
||||
if len(self._curves_data["DAP"]) > 0:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.update_dap, MessageEndpoints.dap_response(new_scan_id)
|
||||
)
|
||||
|
||||
def refresh_dap(self):
|
||||
"""
|
||||
Refresh the DAP curves with the latest data from the DAP model MessageEndpoints.dap_response().
|
||||
"""
|
||||
for curve_id, curve in self._curves_data["DAP"].items():
|
||||
x_name = curve.config.signals.x.name
|
||||
y_name = curve.config.signals.y.name
|
||||
x_entry = curve.config.signals.x.entry
|
||||
y_entry = curve.config.signals.y.entry
|
||||
model_name = curve.config.signals.dap
|
||||
model = getattr(self.dap, model_name)
|
||||
|
||||
msg = messages.DAPRequestMessage(
|
||||
dap_cls="LmfitService1D",
|
||||
dap_type="on_demand",
|
||||
config={
|
||||
"args": [self.scan_id, x_name, x_entry, y_name, y_entry],
|
||||
"kwargs": {},
|
||||
"class_args": model._plugin_info["class_args"],
|
||||
"class_kwargs": model._plugin_info["class_kwargs"],
|
||||
},
|
||||
metadata={"RID": self.scan_id},
|
||||
)
|
||||
self.client.connector.set_and_publish(MessageEndpoints.dap_request(), msg)
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def update_dap(self, msg, metadata):
|
||||
self.msg = msg
|
||||
scan_id, x_name, x_entry, y_name, y_entry = msg["dap_request"].content["config"]["args"]
|
||||
model = msg["dap_request"].content["config"]["class_kwargs"]["model"]
|
||||
|
||||
curve_id_request = f"{y_name}-{y_entry}-{model}"
|
||||
|
||||
for curve_id, curve in self._curves_data["DAP"].items():
|
||||
if curve_id == curve_id_request:
|
||||
if msg["data"] is not None:
|
||||
x = msg["data"][0]["x"]
|
||||
y = msg["data"][0]["y"]
|
||||
curve.setData(x, y)
|
||||
curve.dap_params = msg["data"][1]["fit_parameters"]
|
||||
self.dap_params_update.emit(curve.dap_params)
|
||||
break
|
||||
|
||||
def _update_scan_segment_plot(self):
|
||||
"""Update the plot with the data from the scan segment."""
|
||||
data = self.scan_segment_data.data
|
||||
@@ -564,14 +715,15 @@ class BECWaveform(BECPlotBase):
|
||||
data_y = data[y_name][y_entry].val
|
||||
if curve.config.signals.z:
|
||||
data_z = data[z_name][z_entry].val
|
||||
color_z = self._make_z_gradient(
|
||||
data_z, curve.config.color_map_z
|
||||
) # TODO decide how to implement custom gradient
|
||||
color_z = self._make_z_gradient(data_z, curve.config.color_map_z)
|
||||
except TypeError:
|
||||
continue
|
||||
|
||||
if data_z is not None and color_z is not None:
|
||||
curve.setData(x=data_x, y=data_y, symbolBrush=color_z)
|
||||
try:
|
||||
curve.setData(x=data_x, y=data_y, symbolBrush=color_z)
|
||||
except:
|
||||
return
|
||||
else:
|
||||
curve.setData(data_x, data_y)
|
||||
|
||||
@@ -609,13 +761,17 @@ class BECWaveform(BECPlotBase):
|
||||
if scan_index is not None and scan_id is not None:
|
||||
raise ValueError("Only one of scan_id or scan_index can be provided.")
|
||||
|
||||
# Reset DAP connector
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.update_dap, MessageEndpoints.dap_response(self.scan_id)
|
||||
)
|
||||
if scan_index is not None:
|
||||
self.scan_id = self.queue.scan_storage.storage[scan_index].scan_id
|
||||
data = self.queue.scan_storage.find_scan_by_ID(self.scan_id).data
|
||||
elif scan_id is not None:
|
||||
self.scan_id = scan_id
|
||||
data = self.queue.scan_storage.find_scan_by_ID(self.scan_id).data
|
||||
|
||||
self.setup_dap(self.old_scan_id, self.scan_id)
|
||||
data = self.queue.scan_storage.find_scan_by_ID(self.scan_id).data
|
||||
self._update_scan_curves(data)
|
||||
|
||||
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict | pd.DataFrame:
|
||||
@@ -661,6 +817,9 @@ class BECWaveform(BECPlotBase):
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget connection from BECDispatcher."""
|
||||
self.bec_dispatcher.disconnect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.update_dap, MessageEndpoints.dap_response(self.scan_id)
|
||||
)
|
||||
for curve in self.curves:
|
||||
curve.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
from typing import TYPE_CHECKING, Literal, Optional
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy import QtCore
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy as np
|
||||
|
||||
from bec_widgets.widgets.figure.plots.waveform import BECWaveform1D
|
||||
|
||||
|
||||
@@ -31,6 +33,7 @@ class Signal(BaseModel):
|
||||
x: SignalData # TODO maybe add metadata for config gui later
|
||||
y: SignalData
|
||||
z: Optional[SignalData] = None
|
||||
dap: Optional[str] = None
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
@@ -63,8 +66,9 @@ class CurveConfig(ConnectionConfig):
|
||||
class BECCurve(BECConnector, pg.PlotDataItem):
|
||||
USER_ACCESS = [
|
||||
"remove",
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"dap_params",
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"set",
|
||||
"set_data",
|
||||
"set_color",
|
||||
@@ -75,6 +79,7 @@ class BECCurve(BECConnector, pg.PlotDataItem):
|
||||
"set_pen_width",
|
||||
"set_pen_style",
|
||||
"get_data",
|
||||
"dap_params",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -96,9 +101,18 @@ class BECCurve(BECConnector, pg.PlotDataItem):
|
||||
|
||||
self.parent_item = parent_item
|
||||
self.apply_config()
|
||||
self.dap_params = None
|
||||
if kwargs:
|
||||
self.set(**kwargs)
|
||||
|
||||
self._async_info = {}
|
||||
|
||||
if self.config.source == "async_readback":
|
||||
# get updates on new scans in order to change the subscription to the correct async readback
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_scan_status_update, MessageEndpoints.scan_status()
|
||||
)
|
||||
|
||||
def apply_config(self):
|
||||
pen_style_map = {
|
||||
"solid": QtCore.Qt.SolidLine,
|
||||
@@ -119,12 +133,63 @@ class BECCurve(BECConnector, pg.PlotDataItem):
|
||||
self.setSymbolSize(self.config.symbol_size)
|
||||
self.setSymbol(self.config.symbol)
|
||||
|
||||
@property
|
||||
def dap_params(self):
|
||||
return self._dap_params
|
||||
|
||||
@dap_params.setter
|
||||
def dap_params(self, value):
|
||||
self._dap_params = value
|
||||
|
||||
def set_data(self, x, y):
|
||||
if self.config.source == "custom":
|
||||
self.setData(x, y)
|
||||
else:
|
||||
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
|
||||
|
||||
def on_scan_status_update(self, content: dict, metadata: dict):
|
||||
"""
|
||||
Update the async info with the latest scan status.
|
||||
"""
|
||||
scan_id = content.get("scan_id")
|
||||
if scan_id == self._async_info.get("scan_id"):
|
||||
return
|
||||
active_subscription = self._async_info.get("active_subscription")
|
||||
if active_subscription:
|
||||
self.bec_dispatcher.disconnect_slot(self.on_async_update, active_subscription)
|
||||
self._async_info["scan_id"] = scan_id
|
||||
self._async_info["active_subscription"] = MessageEndpoints.device_async_readback(
|
||||
scan_id, self.config.signals.y.name
|
||||
)
|
||||
self._async_info["data"] = []
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_async_update,
|
||||
MessageEndpoints.device_async_readback(scan_id, self.config.signals.y.name),
|
||||
from_start=True,
|
||||
)
|
||||
|
||||
def on_async_update(self, content: dict, metadata: dict):
|
||||
"""
|
||||
Update the curve with the latest async readback data.
|
||||
"""
|
||||
async_update = metadata.get("async_update")
|
||||
if not async_update:
|
||||
return
|
||||
signals = content.get("signals")
|
||||
if not signals:
|
||||
return
|
||||
y_data = signals.get(self.config.signals.y.name, {}).get("value", [])
|
||||
if y_data is None:
|
||||
return
|
||||
if async_update == "extend":
|
||||
self._async_info["data"].extend(y_data)
|
||||
elif async_update == "replace":
|
||||
self._async_info["data"] = y_data
|
||||
else:
|
||||
print(f"Warning: Unsupported async update type {async_update}.")
|
||||
|
||||
self.setData(self._async_info["data"])
|
||||
|
||||
def set(self, **kwargs):
|
||||
"""
|
||||
Set the properties of the curve.
|
||||
@@ -241,5 +306,6 @@ class BECCurve(BECConnector, pg.PlotDataItem):
|
||||
|
||||
def remove(self):
|
||||
"""Remove the curve from the plot."""
|
||||
self.parent_item.removeItem(self)
|
||||
# self.parent_item.removeItem(self)
|
||||
self.parent_item.remove_curve(self.name())
|
||||
self.cleanup()
|
||||
|
||||
0
bec_widgets/widgets/motor_map/__init__.py
Normal file
4
bec_widgets/widgets/motor_map/assets/connection.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M17 7h-4v2h4c1.65 0 3 1.35 3 3s-1.35 3-3 3h-4v2h4c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-6 8H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-2zm-3-4h8v2H8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 341 B |
4
bec_widgets/widgets/motor_map/assets/history.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.25 2.52.77-1.28-3.52-2.09V8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 392 B |
BIN
bec_widgets/widgets/motor_map/assets/motor_map.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
4
bec_widgets/widgets/motor_map/assets/settings.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M19.43 12.98c.04-.32.07-.64.07-.98 0-.34-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.09-.16-.26-.25-.44-.25-.06 0-.12.01-.17.03l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.06-.02-.12-.03-.18-.03-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98 0 .33.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.09.16.26.25.44.25.06 0 .12-.01.17-.03l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.06.02.12.03.18.03.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zm-1.98-1.71c.04.31.05.52.05.73 0 .21-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7.14 1.13zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
{'files': ['motor_map_widget.py','motor_map_widget_plugin.py']}
|
||||
55
bec_widgets/widgets/motor_map/bec_motor_map_widget_plugin.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.motor_map.motor_map_widget import BECMotorMapWidget
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECMotorMapWidget' name='bec_motor_map_widget'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class BECMotorMapWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECMotorMapWidget(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Visualization Widgets"
|
||||
|
||||
def icon(self):
|
||||
current_path = os.path.dirname(__file__)
|
||||
icon_path = os.path.join(current_path, "assets", "motor_map.png")
|
||||
return QIcon(icon_path)
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_motor_map_widget"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECMotorMapWidget"
|
||||
|
||||
def toolTip(self):
|
||||
return "BECMotorMapWidget"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,73 @@
|
||||
import os
|
||||
|
||||
from qtpy.QtCore import Slot
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
class MotorMapSettings(QWidget):
|
||||
def __init__(self, parent=None, target_widget: QWidget = None, *args, **kwargs):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
current_path = os.path.dirname(__file__)
|
||||
|
||||
self.ui = UILoader(self).loader(os.path.join(current_path, "motor_map_settings.ui"))
|
||||
self.target_widget = target_widget
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.addWidget(self.ui)
|
||||
|
||||
@Slot(dict)
|
||||
def display_current_settings(self, config: dict):
|
||||
WidgetIO.set_value(self.ui.max_points, config["max_points"])
|
||||
WidgetIO.set_value(self.ui.trace_dim, config["num_dim_points"])
|
||||
WidgetIO.set_value(self.ui.precision, config["precision"])
|
||||
WidgetIO.set_value(self.ui.scatter_size, config["scatter_size"])
|
||||
background_intensity = int((config["background_value"] / 255) * 100)
|
||||
WidgetIO.set_value(self.ui.background_value, background_intensity)
|
||||
color = config["color"]
|
||||
self.ui.color.setColor(color)
|
||||
|
||||
@Slot()
|
||||
def accept_changes(self):
|
||||
max_points = WidgetIO.get_value(self.ui.max_points)
|
||||
num_dim_points = WidgetIO.get_value(self.ui.trace_dim)
|
||||
precision = WidgetIO.get_value(self.ui.precision)
|
||||
scatter_size = WidgetIO.get_value(self.ui.scatter_size)
|
||||
background_intensity = int(WidgetIO.get_value(self.ui.background_value) * 0.01 * 255)
|
||||
color = self.ui.color.color().toTuple()
|
||||
|
||||
if self.target_widget is not None:
|
||||
self.target_widget.set_max_points(max_points)
|
||||
self.target_widget.set_num_dim_points(num_dim_points)
|
||||
self.target_widget.set_precision(precision)
|
||||
self.target_widget.set_scatter_size(scatter_size)
|
||||
self.target_widget.set_background_value(background_intensity)
|
||||
self.target_widget.set_color(color)
|
||||
|
||||
|
||||
class MotorMapDialog(QDialog):
|
||||
def __init__(self, parent=None, target_widget: QWidget = None, *args, **kwargs):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
|
||||
self.setModal(False)
|
||||
|
||||
self.setWindowTitle("Motor Map Settings")
|
||||
|
||||
self.target_widget = target_widget
|
||||
self.widget = MotorMapSettings(target_widget=self.target_widget)
|
||||
self.widget.display_current_settings(self.target_widget._config_dict)
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.addWidget(self.widget)
|
||||
self.layout.addWidget(self.button_box)
|
||||
|
||||
@Slot()
|
||||
def accept(self):
|
||||
self.widget.accept_changes()
|
||||
super().accept()
|
||||
@@ -0,0 +1,108 @@
|
||||
<?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>243</width>
|
||||
<height>233</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="3" column="1">
|
||||
<widget class="QSpinBox" name="scatter_size">
|
||||
<property name="maximum">
|
||||
<number>20</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="trace_label">
|
||||
<property name="text">
|
||||
<string>Trace Dim</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="precision_label">
|
||||
<property name="text">
|
||||
<string>Precision</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="background_label">
|
||||
<property name="text">
|
||||
<string>Background Intensity</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QSpinBox" name="precision">
|
||||
<property name="maximum">
|
||||
<number>15</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QSpinBox" name="background_value">
|
||||
<property name="maximum">
|
||||
<number>100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="max_point_label">
|
||||
<property name="text">
|
||||
<string>Max Points</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="scatter_size_label">
|
||||
<property name="text">
|
||||
<string>Scatter Size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QSpinBox" name="max_points">
|
||||
<property name="maximum">
|
||||
<number>10000</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QSpinBox" name="trace_dim">
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="color_label">
|
||||
<property name="text">
|
||||
<string>Color</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="1">
|
||||
<widget class="ColorButton" name="color"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>ColorButton</class>
|
||||
<extends>QPushButton</extends>
|
||||
<header>color_button</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QAction, QIcon
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QWidget
|
||||
|
||||
from bec_widgets.widgets.device_inputs import DeviceComboBox
|
||||
from bec_widgets.widgets.toolbar.toolbar import ToolBarAction
|
||||
|
||||
|
||||
class DeviceSelectionAction(ToolBarAction):
|
||||
def __init__(self, label: str):
|
||||
self.label = label
|
||||
self.device_combobox = DeviceComboBox(device_filter="Positioner")
|
||||
|
||||
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
widget = QWidget()
|
||||
layout = QHBoxLayout(widget)
|
||||
|
||||
label = QLabel(f"{self.label}")
|
||||
|
||||
layout.addWidget(label)
|
||||
layout.addWidget(self.device_combobox)
|
||||
toolbar.addWidget(widget)
|
||||
|
||||
def set_combobox_style(self, color: str):
|
||||
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
|
||||
|
||||
|
||||
class ConnectAction(ToolBarAction):
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
current_path = os.path.dirname(__file__)
|
||||
parent_path = os.path.dirname(current_path)
|
||||
icon = QIcon()
|
||||
icon.addFile(os.path.join(parent_path, "assets", "connection.svg"), size=QSize(20, 20))
|
||||
self.action = QAction(icon, "Connect Motors", target)
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
|
||||
class ResetHistoryAction(ToolBarAction):
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
current_path = os.path.dirname(__file__)
|
||||
parent_path = os.path.dirname(current_path)
|
||||
icon = QIcon()
|
||||
icon.addFile(os.path.join(parent_path, "assets", "history.svg"), size=QSize(20, 20))
|
||||
self.action = QAction(icon, "Reset Trace History", target)
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
|
||||
class SettingsAction(ToolBarAction):
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
current_path = os.path.dirname(__file__)
|
||||
parent_path = os.path.dirname(current_path)
|
||||
icon = QIcon()
|
||||
icon.addFile(os.path.join(parent_path, "assets", "settings.svg"), size=QSize(20, 20))
|
||||
self.action = QAction(icon, "Open Configuration Dialog", target)
|
||||
toolbar.addAction(self.action)
|
||||
235
bec_widgets/widgets/motor_map/motor_map_widget.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from qtpy import PYSIDE6
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
from bec_widgets.widgets.figure.plots.motor_map.motor_map import MotorMapConfig
|
||||
from bec_widgets.widgets.motor_map.motor_map_dialog.motor_map_settings import MotorMapDialog
|
||||
from bec_widgets.widgets.motor_map.motor_map_dialog.motor_map_toolbar import (
|
||||
ConnectAction,
|
||||
DeviceSelectionAction,
|
||||
ResetHistoryAction,
|
||||
SettingsAction,
|
||||
)
|
||||
from bec_widgets.widgets.toolbar import ModularToolBar
|
||||
|
||||
|
||||
class BECMotorMapWidget(BECConnector, QWidget):
|
||||
USER_ACCESS = [
|
||||
"change_motors",
|
||||
"set_max_points",
|
||||
"set_precision",
|
||||
"set_num_dim_points",
|
||||
"set_background_value",
|
||||
"set_scatter_size",
|
||||
"get_data",
|
||||
"reset_history",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: MotorMapConfig | None = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
) -> None:
|
||||
if not PYSIDE6:
|
||||
raise RuntimeError(
|
||||
"PYSIDE6 is not available in the environment. This widget is compatible only with PySide6."
|
||||
)
|
||||
if config is None:
|
||||
config = MotorMapConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = MotorMapConfig(**config)
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.fig = BECFigure()
|
||||
self.toolbar = ModularToolBar(
|
||||
actions={
|
||||
"motor_x": DeviceSelectionAction("Motor X:"),
|
||||
"motor_y": DeviceSelectionAction("Motor Y:"),
|
||||
"connect": ConnectAction(),
|
||||
"history": ResetHistoryAction(),
|
||||
"config": SettingsAction(),
|
||||
},
|
||||
target_widget=self,
|
||||
)
|
||||
|
||||
self.layout.addWidget(self.toolbar)
|
||||
self.layout.addWidget(self.fig)
|
||||
|
||||
self.map = self.fig.motor_map()
|
||||
self.map.apply_config(config)
|
||||
|
||||
self._hook_actions()
|
||||
|
||||
self.config = config
|
||||
|
||||
def _hook_actions(self):
|
||||
self.toolbar.widgets["connect"].action.triggered.connect(self._action_motors)
|
||||
self.toolbar.widgets["config"].action.triggered.connect(self.show_settings)
|
||||
self.toolbar.widgets["history"].action.triggered.connect(self.reset_history)
|
||||
|
||||
if self.map.motor_x is None and self.map.motor_y is None:
|
||||
self._enable_actions(False)
|
||||
|
||||
def _enable_actions(self, enable: bool):
|
||||
self.toolbar.widgets["config"].action.setEnabled(enable)
|
||||
self.toolbar.widgets["history"].action.setEnabled(enable)
|
||||
|
||||
def _action_motors(self):
|
||||
toolbar_x = self.toolbar.widgets["motor_x"].device_combobox
|
||||
toolbar_y = self.toolbar.widgets["motor_y"].device_combobox
|
||||
motor_x = toolbar_x.currentText()
|
||||
motor_y = toolbar_y.currentText()
|
||||
self.change_motors(motor_x, motor_y, None, None, True)
|
||||
toolbar_x.setStyleSheet("QComboBox {{ background-color: " "; }}")
|
||||
toolbar_y.setStyleSheet("QComboBox {{ background-color: " "; }}")
|
||||
|
||||
def show_settings(self) -> None:
|
||||
dialog = MotorMapDialog(self, target_widget=self)
|
||||
dialog.exec()
|
||||
|
||||
###################################
|
||||
# User Access Methods from MotorMap
|
||||
###################################
|
||||
|
||||
def change_motors(
|
||||
self,
|
||||
motor_x: str,
|
||||
motor_y: str,
|
||||
motor_x_entry: str = None,
|
||||
motor_y_entry: str = None,
|
||||
validate_bec: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Change the active motors for the plot.
|
||||
|
||||
Args:
|
||||
motor_x(str): Motor name for the X axis.
|
||||
motor_y(str): Motor name for the Y axis.
|
||||
motor_x_entry(str): Motor entry for the X axis.
|
||||
motor_y_entry(str): Motor entry for the Y axis.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
"""
|
||||
self.map.change_motors(motor_x, motor_y, motor_x_entry, motor_y_entry, validate_bec)
|
||||
if self.map.motor_x is not None and self.map.motor_y is not None:
|
||||
self._enable_actions(True)
|
||||
toolbar_x = self.toolbar.widgets["motor_x"].device_combobox
|
||||
toolbar_y = self.toolbar.widgets["motor_y"].device_combobox
|
||||
|
||||
if toolbar_x.currentText() != motor_x:
|
||||
toolbar_x.setCurrentText(motor_x)
|
||||
toolbar_x.setStyleSheet("QComboBox {{ background-color: " "; }}")
|
||||
if toolbar_y.currentText() != motor_y:
|
||||
toolbar_y.setCurrentText(motor_y)
|
||||
toolbar_y.setStyleSheet("QComboBox {{ background-color: " "; }}")
|
||||
|
||||
def get_data(self) -> dict:
|
||||
"""
|
||||
Get the data of the motor map.
|
||||
|
||||
Returns:
|
||||
dict: Data of the motor map.
|
||||
"""
|
||||
return self.map.get_data()
|
||||
|
||||
def reset_history(self) -> None:
|
||||
"""
|
||||
Reset the history of the motor map.
|
||||
"""
|
||||
self.map.reset_history()
|
||||
|
||||
def set_color(self, color: str | tuple):
|
||||
"""
|
||||
Set the color of the motor map.
|
||||
|
||||
Args:
|
||||
color(str, tuple): Color to set.
|
||||
"""
|
||||
self.map.set_color(color)
|
||||
|
||||
def set_max_points(self, max_points: int) -> None:
|
||||
"""
|
||||
Set the maximum number of points to display on the motor map.
|
||||
|
||||
Args:
|
||||
max_points(int): Maximum number of points to display.
|
||||
"""
|
||||
self.map.set_max_points(max_points)
|
||||
|
||||
def set_precision(self, precision: int) -> None:
|
||||
"""
|
||||
Set the precision of the motor map.
|
||||
|
||||
Args:
|
||||
precision(int): Precision to set.
|
||||
"""
|
||||
self.map.set_precision(precision)
|
||||
|
||||
def set_num_dim_points(self, num_dim_points: int) -> None:
|
||||
"""
|
||||
Set the number of points to display on the motor map.
|
||||
|
||||
Args:
|
||||
num_dim_points(int): Number of points to display.
|
||||
"""
|
||||
self.map.set_num_dim_points(num_dim_points)
|
||||
|
||||
def set_background_value(self, background_value: int) -> None:
|
||||
"""
|
||||
Set the background value of the motor map.
|
||||
|
||||
Args:
|
||||
background_value(int): Background value of the motor map.
|
||||
"""
|
||||
self.map.set_background_value(background_value)
|
||||
|
||||
def set_scatter_size(self, scatter_size: int) -> None:
|
||||
"""
|
||||
Set the scatter size of the motor map.
|
||||
|
||||
Args:
|
||||
scatter_size(int): Scatter size of the motor map.
|
||||
"""
|
||||
self.map.set_scatter_size(scatter_size)
|
||||
|
||||
def cleanup(self):
|
||||
self.fig.cleanup()
|
||||
self.toolbar.widgets["motor_x"].device_combobox.cleanup()
|
||||
self.toolbar.widgets["motor_y"].device_combobox.cleanup()
|
||||
return super().cleanup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.cleanup()
|
||||
QWidget().closeEvent(event)
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
|
||||
if not PYSIDE6:
|
||||
print(
|
||||
"PYSIDE6 is not available in the environment. UI files with BEC custom widgets are runnable only with PySide6."
|
||||
)
|
||||
return
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = BECMotorMapWidget()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.motor_map.bec_motor_map_widget_plugin import BECMotorMapWidgetPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECMotorMapWidgetPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
1
bec_widgets/widgets/ring_progress_bar/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .ring_progress_bar import RingProgressBar
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from bec_lib.endpoints import EndpointInfo
|
||||
from bec_lib.endpoints import EndpointInfo, MessageEndpoints
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy import QtGui
|
||||
@@ -10,24 +10,25 @@ from qtpy import QtGui
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
|
||||
|
||||
class RingConnections(BaseModel):
|
||||
class ProgressbarConnections(BaseModel):
|
||||
slot: Literal["on_scan_progress", "on_device_readback"] = None
|
||||
endpoint: EndpointInfo | str = None
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@field_validator("endpoint")
|
||||
@classmethod
|
||||
def validate_endpoint(cls, v, values):
|
||||
slot = values.data["slot"]
|
||||
v = v.endpoint if isinstance(v, EndpointInfo) else v
|
||||
if slot == "on_scan_progress":
|
||||
if v != "scans/scan_progress":
|
||||
if v != MessageEndpoints.scan_progress().endpoint:
|
||||
raise PydanticCustomError(
|
||||
"unsupported endpoint",
|
||||
"For slot 'on_scan_progress', endpoint must be MessageEndpoint.scan_progress or 'scans/scan_progress'.",
|
||||
{"wrong_value": v},
|
||||
)
|
||||
elif slot == "on_device_readback":
|
||||
if not v.startswith("internal/devices/readback/"):
|
||||
if not v.startswith(MessageEndpoints.device_readback("").endpoint):
|
||||
raise PydanticCustomError(
|
||||
"unsupported endpoint",
|
||||
"For slot 'on_device_readback', endpoint must be MessageEndpoint.device_readback(device) or 'internal/devices/readback/{device}'.",
|
||||
@@ -36,7 +37,7 @@ class RingConnections(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class RingConfig(ConnectionConfig):
|
||||
class ProgressbarConfig(ConnectionConfig):
|
||||
value: int | float | None = Field(0, description="Value for the progress bars.")
|
||||
direction: int | None = Field(
|
||||
-1, description="Direction of the progress bars. -1 for clockwise, 1 for counter-clockwise."
|
||||
@@ -62,16 +63,25 @@ class RingConfig(ConnectionConfig):
|
||||
update_behaviour: Literal["manual", "auto"] | None = Field(
|
||||
"auto", description="Update behaviour for the progress bars."
|
||||
)
|
||||
connections: RingConnections | None = Field(
|
||||
default_factory=RingConnections, description="Connections for the progress bars."
|
||||
connections: ProgressbarConnections | None = Field(
|
||||
default_factory=ProgressbarConnections, description="Connections for the progress bars."
|
||||
)
|
||||
|
||||
|
||||
class RingConfig(ProgressbarConfig):
|
||||
index: int | None = Field(0, description="Index of the progress bar. 0 is outer ring.")
|
||||
start_position: int | None = Field(
|
||||
90,
|
||||
description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to "
|
||||
"the top of the ring.",
|
||||
)
|
||||
|
||||
|
||||
class Ring(BECConnector):
|
||||
USER_ACCESS = [
|
||||
"get_all_rpc",
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"_get_all_rpc",
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"set_value",
|
||||
"set_color",
|
||||
"set_background",
|
||||
@@ -125,6 +135,7 @@ class Ring(BECConnector):
|
||||
float(max(self.config.min_value, min(self.config.max_value, value))),
|
||||
self.config.precision,
|
||||
)
|
||||
self.parent_progress_widget.update()
|
||||
|
||||
def set_color(self, color: str | tuple):
|
||||
"""
|
||||
@@ -135,6 +146,7 @@ class Ring(BECConnector):
|
||||
"""
|
||||
self.config.color = color
|
||||
self.color = self.convert_color(color)
|
||||
self.parent_progress_widget.update()
|
||||
|
||||
def set_background(self, color: str | tuple):
|
||||
"""
|
||||
@@ -145,6 +157,7 @@ class Ring(BECConnector):
|
||||
"""
|
||||
self.config.background_color = color
|
||||
self.color = self.convert_color(color)
|
||||
self.parent_progress_widget.update()
|
||||
|
||||
def set_line_width(self, width: int):
|
||||
"""
|
||||
@@ -154,6 +167,7 @@ class Ring(BECConnector):
|
||||
width(int): Line width for the ring widget
|
||||
"""
|
||||
self.config.line_width = width
|
||||
self.parent_progress_widget.update()
|
||||
|
||||
def set_min_max_values(self, min_value: int | float, max_value: int | float):
|
||||
"""
|
||||
@@ -165,6 +179,7 @@ class Ring(BECConnector):
|
||||
"""
|
||||
self.config.min_value = min_value
|
||||
self.config.max_value = max_value
|
||||
self.parent_progress_widget.update()
|
||||
|
||||
def set_start_angle(self, start_angle: int):
|
||||
"""
|
||||
@@ -175,6 +190,7 @@ class Ring(BECConnector):
|
||||
"""
|
||||
self.config.start_position = start_angle
|
||||
self.start_position = start_angle * 16
|
||||
self.parent_progress_widget.update()
|
||||
|
||||
@staticmethod
|
||||
def convert_color(color):
|
||||
@@ -230,7 +246,7 @@ class Ring(BECConnector):
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.config.connections.slot, self.config.connections.endpoint
|
||||
)
|
||||
self.config.connections = RingConnections(slot=slot, endpoint=endpoint)
|
||||
self.config.connections = ProgressbarConnections(slot=slot, endpoint=endpoint)
|
||||
self.bec_dispatcher.connect_slot(getattr(self, slot), endpoint)
|
||||
|
||||
def reset_connection(self):
|
||||
@@ -240,7 +256,7 @@ class Ring(BECConnector):
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.config.connections.slot, self.config.connections.endpoint
|
||||
)
|
||||
self.config.connections = RingConnections()
|
||||
self.config.connections = ProgressbarConnections()
|
||||
|
||||
def on_scan_progress(self, msg, meta):
|
||||
"""
|
||||
@@ -11,10 +11,10 @@ from qtpy.QtCore import QSize, Slot
|
||||
from qtpy.QtWidgets import QSizePolicy, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig, EntryValidator
|
||||
from bec_widgets.widgets.spiral_progress_bar.ring import Ring, RingConfig
|
||||
from bec_widgets.widgets.ring_progress_bar.ring import Ring, RingConfig
|
||||
|
||||
|
||||
class SpiralProgressBarConfig(ConnectionConfig):
|
||||
class RingProgressBarConfig(ConnectionConfig):
|
||||
color_map: Optional[str] = Field(
|
||||
"magma", description="Color scheme for the progress bars.", validate_default=True
|
||||
)
|
||||
@@ -32,6 +32,7 @@ class SpiralProgressBarConfig(ConnectionConfig):
|
||||
rings: list[RingConfig] | None = Field([], description="List of ring configurations.")
|
||||
|
||||
@field_validator("num_bars")
|
||||
@classmethod
|
||||
def validate_num_bars(cls, v, values):
|
||||
min_number_of_bars = values.data.get("min_number_of_bars", None)
|
||||
max_number_of_bars = values.data.get("max_number_of_bars", None)
|
||||
@@ -43,6 +44,7 @@ class SpiralProgressBarConfig(ConnectionConfig):
|
||||
return v
|
||||
|
||||
@field_validator("rings")
|
||||
@classmethod
|
||||
def validate_rings(cls, v, values):
|
||||
if v is not None and v is not []:
|
||||
num_bars = values.data.get("num_bars", None)
|
||||
@@ -64,11 +66,11 @@ class SpiralProgressBarConfig(ConnectionConfig):
|
||||
_validate_colormap = field_validator("color_map")(Colors.validate_color_map)
|
||||
|
||||
|
||||
class SpiralProgressBar(BECConnector, QWidget):
|
||||
class RingProgressBar(BECConnector, QWidget):
|
||||
USER_ACCESS = [
|
||||
"get_all_rpc",
|
||||
"rpc_id",
|
||||
"config_dict",
|
||||
"_get_all_rpc",
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"rings",
|
||||
"update_config",
|
||||
"add_ring",
|
||||
@@ -89,20 +91,20 @@ class SpiralProgressBar(BECConnector, QWidget):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
config: SpiralProgressBarConfig | dict | None = None,
|
||||
config: RingProgressBarConfig | dict | None = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
num_bars: int | None = None,
|
||||
):
|
||||
if config is None:
|
||||
config = SpiralProgressBarConfig(widget_class=self.__class__.__name__)
|
||||
config = RingProgressBarConfig(widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = SpiralProgressBarConfig(**config, widget_class=self.__class__.__name__)
|
||||
config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=None)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
@@ -129,7 +131,7 @@ class SpiralProgressBar(BECConnector, QWidget):
|
||||
def rings(self, value):
|
||||
self._rings = value
|
||||
|
||||
def update_config(self, config: SpiralProgressBarConfig | dict):
|
||||
def update_config(self, config: RingProgressBarConfig | dict):
|
||||
"""
|
||||
Update the configuration of the widget.
|
||||
|
||||
@@ -137,7 +139,7 @@ class SpiralProgressBar(BECConnector, QWidget):
|
||||
config(SpiralProgressBarConfig|dict): Configuration to update.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
config = SpiralProgressBarConfig(**config, widget_class=self.__class__.__name__)
|
||||
config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
self.clear_all()
|
||||
|
||||
@@ -1,53 +1,37 @@
|
||||
import qdarktheme
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.widgets.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.scan_control.scan_group_box import ScanGroupBox
|
||||
|
||||
|
||||
class ScanArgType:
|
||||
DEVICE = "device"
|
||||
FLOAT = "float"
|
||||
INT = "int"
|
||||
BOOL = "bool"
|
||||
STR = "str"
|
||||
class ScanControl(BECConnector, QWidget):
|
||||
|
||||
|
||||
class ScanControl(QWidget):
|
||||
WIDGET_HANDLER = {
|
||||
ScanArgType.DEVICE: QLineEdit,
|
||||
ScanArgType.FLOAT: QDoubleSpinBox,
|
||||
ScanArgType.INT: QSpinBox,
|
||||
ScanArgType.BOOL: QCheckBox,
|
||||
ScanArgType.STR: QLineEdit,
|
||||
}
|
||||
|
||||
def __init__(self, parent=None, client=None, allowed_scans=None):
|
||||
super().__init__(parent)
|
||||
def __init__(
|
||||
self, parent=None, client=None, gui_id: str | None = None, allowed_scans: list | None = None
|
||||
):
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
# Client from BEC + shortcuts to device manager and scans
|
||||
self.client = BECDispatcher().client if client is None else client
|
||||
self.dev = self.client.device_manager.devices
|
||||
self.scans = self.client.scans
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
# Main layout
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.arg_box = None
|
||||
self.kwarg_boxes = []
|
||||
self.expert_mode = False # TODO implement in the future versions
|
||||
|
||||
# Scan list - allowed scans for the GUI
|
||||
self.allowed_scans = allowed_scans
|
||||
@@ -56,389 +40,173 @@ class ScanControl(QWidget):
|
||||
self._init_UI()
|
||||
|
||||
def _init_UI(self):
|
||||
self.verticalLayout = QVBoxLayout(self)
|
||||
"""
|
||||
Initializes the UI of the scan control widget. Create the top box for scan selection and populate scans to main combobox.
|
||||
"""
|
||||
|
||||
# Scan selection group box
|
||||
self.scan_selection_group = QGroupBox("Scan Selection", self)
|
||||
self.scan_selection_layout = QVBoxLayout(self.scan_selection_group)
|
||||
self.comboBox_scan_selection = QComboBox(self.scan_selection_group)
|
||||
self.button_run_scan = QPushButton("Run Scan", self.scan_selection_group)
|
||||
self.scan_selection_layout.addWidget(self.comboBox_scan_selection)
|
||||
self.scan_selection_layout.addWidget(self.button_run_scan)
|
||||
self.verticalLayout.addWidget(self.scan_selection_group)
|
||||
|
||||
# Scan control group box
|
||||
self.scan_control_group = QGroupBox("Scan Control", self)
|
||||
self.scan_control_layout = QVBoxLayout(self.scan_control_group)
|
||||
self.verticalLayout.addWidget(self.scan_control_group)
|
||||
|
||||
# Kwargs layout - just placeholder
|
||||
self.kwargs_layout = QGridLayout()
|
||||
self.scan_control_layout.addLayout(self.kwargs_layout)
|
||||
|
||||
# 1st Separator
|
||||
self.add_horizontal_separator(self.scan_control_layout)
|
||||
|
||||
# Buttons
|
||||
self.button_layout = QHBoxLayout()
|
||||
self.pushButton_add_bundle = QPushButton("Add Bundle", self.scan_control_group)
|
||||
self.pushButton_add_bundle.clicked.connect(self.add_bundle)
|
||||
self.pushButton_remove_bundle = QPushButton("Remove Bundle", self.scan_control_group)
|
||||
self.pushButton_remove_bundle.clicked.connect(self.remove_bundle)
|
||||
self.button_layout.addWidget(self.pushButton_add_bundle)
|
||||
self.button_layout.addWidget(self.pushButton_remove_bundle)
|
||||
self.scan_control_layout.addLayout(self.button_layout)
|
||||
|
||||
# 2nd Separator
|
||||
self.add_horizontal_separator(self.scan_control_layout)
|
||||
|
||||
# Initialize the QTableWidget for args
|
||||
self.args_table = QTableWidget()
|
||||
self.args_table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
|
||||
|
||||
self.scan_control_layout.addWidget(self.args_table)
|
||||
self.scan_selection_group = self.create_scan_selection_group()
|
||||
self.scan_selection_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.layout.addWidget(self.scan_selection_group)
|
||||
|
||||
# Connect signals
|
||||
self.comboBox_scan_selection.currentIndexChanged.connect(self.on_scan_selected)
|
||||
self.button_run_scan.clicked.connect(self.run_scan)
|
||||
self.button_add_bundle.clicked.connect(self.add_arg_bundle)
|
||||
self.button_remove_bundle.clicked.connect(self.remove_arg_bundle)
|
||||
|
||||
# Initialize scan selection
|
||||
self.populate_scans()
|
||||
|
||||
def add_horizontal_separator(self, layout) -> None:
|
||||
def create_scan_selection_group(self) -> QGroupBox:
|
||||
"""
|
||||
Adds a horizontal separator to the given layout
|
||||
Creates the scan selection group box with combobox to select the scan and start/stop button.
|
||||
|
||||
Args:
|
||||
layout: Layout to add the separator to
|
||||
Returns:
|
||||
QGroupBox: Group box containing the scan selection widgets.
|
||||
"""
|
||||
separator = QFrame(self.scan_control_group)
|
||||
separator.setFrameShape(QFrame.HLine)
|
||||
separator.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(separator)
|
||||
|
||||
scan_selection_group = QGroupBox("Scan Selection", self)
|
||||
self.scan_selection_layout = QGridLayout(scan_selection_group)
|
||||
self.comboBox_scan_selection = QComboBox(scan_selection_group)
|
||||
# Run button
|
||||
self.button_run_scan = QPushButton("Start", scan_selection_group)
|
||||
self.button_run_scan.setStyleSheet("background-color: #559900; color: white")
|
||||
# Stop button
|
||||
self.button_stop_scan = StopButton(parent=scan_selection_group)
|
||||
# Add bundle button
|
||||
self.button_add_bundle = QPushButton("Add Bundle", scan_selection_group)
|
||||
# Remove bundle button
|
||||
self.button_remove_bundle = QPushButton("Remove Bundle", scan_selection_group)
|
||||
|
||||
self.scan_selection_layout.addWidget(self.comboBox_scan_selection, 0, 0, 1, 2)
|
||||
self.scan_selection_layout.addWidget(self.button_run_scan, 1, 0)
|
||||
self.scan_selection_layout.addWidget(self.button_stop_scan, 1, 1)
|
||||
self.scan_selection_layout.addWidget(self.button_add_bundle, 2, 0)
|
||||
self.scan_selection_layout.addWidget(self.button_remove_bundle, 2, 1)
|
||||
|
||||
return scan_selection_group
|
||||
|
||||
def populate_scans(self):
|
||||
"""Populates the scan selection combo box with available scans"""
|
||||
self.available_scans = self.client.producer.get(MessageEndpoints.available_scans()).resource
|
||||
"""Populates the scan selection combo box with available scans from BEC session."""
|
||||
self.available_scans = self.client.connector.get(
|
||||
MessageEndpoints.available_scans()
|
||||
).resource
|
||||
if self.allowed_scans is None:
|
||||
allowed_scans = self.available_scans.keys()
|
||||
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase"]
|
||||
allowed_scans = [
|
||||
scan_name
|
||||
for scan_name, scan_info in self.available_scans.items()
|
||||
if scan_info["base_class"] in supported_scans and len(scan_info["gui_config"]) > 0
|
||||
]
|
||||
|
||||
else:
|
||||
allowed_scans = self.allowed_scans
|
||||
# TODO check parent class is ScanBase -> filter out the scans not relevant for GUI
|
||||
self.comboBox_scan_selection.addItems(allowed_scans)
|
||||
|
||||
def on_scan_selected(self):
|
||||
"""Callback for scan selection combo box"""
|
||||
self.reset_layout()
|
||||
selected_scan_name = self.comboBox_scan_selection.currentText()
|
||||
selected_scan_info = self.available_scans.get(selected_scan_name, {})
|
||||
|
||||
print(selected_scan_info) # TODO remove when widget will be more mature
|
||||
# Generate kwargs input
|
||||
self.generate_kwargs_input_fields(selected_scan_info)
|
||||
gui_config = selected_scan_info.get("gui_config", {})
|
||||
self.arg_group = gui_config.get("arg_group", None)
|
||||
self.kwarg_groups = gui_config.get("kwarg_groups", None)
|
||||
|
||||
# Args section
|
||||
self.generate_args_input_fields(selected_scan_info)
|
||||
if self.arg_box is None:
|
||||
self.button_add_bundle.setEnabled(False)
|
||||
self.button_remove_bundle.setEnabled(False)
|
||||
|
||||
def add_labels_to_layout(self, labels: list, grid_layout: QGridLayout) -> None:
|
||||
if len(self.arg_group["arg_inputs"]) > 0:
|
||||
self.button_add_bundle.setEnabled(True)
|
||||
self.button_remove_bundle.setEnabled(True)
|
||||
self.add_arg_group(self.arg_group)
|
||||
if len(self.kwarg_groups) > 0:
|
||||
self.add_kwargs_boxes(self.kwarg_groups)
|
||||
|
||||
self.update()
|
||||
self.adjustSize()
|
||||
|
||||
def add_kwargs_boxes(self, groups: list):
|
||||
"""
|
||||
Adds labels to the given grid layout as a separate row.
|
||||
Adds the given gui_groups to the scan control layout.
|
||||
|
||||
Args:
|
||||
labels (list): List of label names to add.
|
||||
grid_layout (QGridLayout): The grid layout to which labels will be added.
|
||||
groups(list): List of dictionaries containing the gui_group information.
|
||||
"""
|
||||
row_index = grid_layout.rowCount() # Get the next available row
|
||||
for column_index, label_name in enumerate(labels):
|
||||
label = QLabel(label_name.capitalize(), self.scan_control_group)
|
||||
# Add the label to the grid layout at the calculated row and current column
|
||||
grid_layout.addWidget(label, row_index, column_index)
|
||||
for group in groups:
|
||||
box = ScanGroupBox(box_type="kwargs", config=group)
|
||||
box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.layout.addWidget(box)
|
||||
self.kwarg_boxes.append(box)
|
||||
|
||||
def add_labels_to_table(
|
||||
self, labels: list, table: QTableWidget
|
||||
) -> None: # TODO could be moved to BECTable
|
||||
def add_arg_group(self, group: dict):
|
||||
"""
|
||||
Adds labels to the given table widget as a header row.
|
||||
Adds the given gui_groups to the scan control layout.
|
||||
|
||||
Args:
|
||||
labels(list): List of label names to add.
|
||||
table(QTableWidget): The table widget to which labels will be added.
|
||||
"""
|
||||
table.setColumnCount(len(labels))
|
||||
table.setHorizontalHeaderLabels(labels)
|
||||
self.arg_box = ScanGroupBox(box_type="args", config=group)
|
||||
self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.layout.addWidget(self.arg_box)
|
||||
|
||||
def generate_args_input_fields(self, scan_info: dict) -> None:
|
||||
"""
|
||||
Generates input fields for args.
|
||||
def add_arg_bundle(self):
|
||||
self.arg_box.add_widget_bundle()
|
||||
|
||||
Args:
|
||||
scan_info(dict): Scan signature dictionary from BEC.
|
||||
"""
|
||||
def remove_arg_bundle(self):
|
||||
self.arg_box.remove_widget_bundle()
|
||||
|
||||
# Setup args table limits
|
||||
self.set_args_table_limits(self.args_table, scan_info)
|
||||
def reset_layout(self):
|
||||
"""Clears the scan control layout from GuiGroups and ArgGroups boxes."""
|
||||
if self.arg_box is not None:
|
||||
self.layout.removeWidget(self.arg_box)
|
||||
self.arg_box.deleteLater()
|
||||
self.arg_box = None
|
||||
if self.kwarg_boxes != []:
|
||||
self.remove_kwarg_boxes()
|
||||
|
||||
# Get arg_input from selected scan
|
||||
self.arg_input = scan_info.get("arg_input", {})
|
||||
|
||||
# Generate labels for table
|
||||
self.add_labels_to_table(list(self.arg_input.keys()), self.args_table)
|
||||
|
||||
# add minimum number of args rows
|
||||
if self.arg_size_min is not None:
|
||||
for i in range(self.arg_size_min):
|
||||
self.add_bundle()
|
||||
|
||||
def generate_kwargs_input_fields(self, scan_info: dict) -> None:
|
||||
"""
|
||||
Generates input fields for kwargs
|
||||
|
||||
Args:
|
||||
scan_info(dict): Scan signature dictionary from BEC.
|
||||
"""
|
||||
# Create a new kwarg layout to replace the old one - this is necessary because otherwise row count is not reseted
|
||||
self.clear_and_delete_layout(self.kwargs_layout)
|
||||
self.kwargs_layout = self.create_new_grid_layout() # Create new grid layout
|
||||
self.scan_control_layout.insertLayout(0, self.kwargs_layout)
|
||||
|
||||
# Get signature
|
||||
signature = scan_info.get("signature", [])
|
||||
|
||||
# Extract kwargs from the converted signature
|
||||
kwargs = [param["name"] for param in signature if param["kind"] == "KEYWORD_ONLY"]
|
||||
|
||||
# Add labels
|
||||
self.add_labels_to_layout(kwargs, self.kwargs_layout)
|
||||
|
||||
# Add widgets
|
||||
widgets = self.generate_widgets_from_signature(kwargs, signature)
|
||||
|
||||
self.add_widgets_row_to_layout(self.kwargs_layout, widgets)
|
||||
|
||||
def generate_widgets_from_signature(self, items: list, signature: dict = None) -> list:
|
||||
"""
|
||||
Generates widgets from the given list of items.
|
||||
|
||||
Args:
|
||||
items(list): List of items to create widgets for.
|
||||
signature(dict, optional): Scan signature dictionary from BEC.
|
||||
|
||||
Returns:
|
||||
list: List of widgets created from the given items.
|
||||
"""
|
||||
widgets = [] # Initialize an empty list to hold the widgets
|
||||
|
||||
for item in items:
|
||||
if signature:
|
||||
# If a signature is provided, extract type and name from it
|
||||
kwarg_info = next((info for info in signature if info["name"] == item), None)
|
||||
if kwarg_info:
|
||||
item_type = kwarg_info.get("annotation", "_empty")
|
||||
item_name = item
|
||||
else:
|
||||
# If no signature is provided, assume the item is a tuple of (name, type)
|
||||
item_name, item_type = item
|
||||
|
||||
widget_class = self.WIDGET_HANDLER.get(item_type, None)
|
||||
if widget_class is None:
|
||||
print(f"Unsupported annotation '{item_type}' for parameter '{item_name}'")
|
||||
continue
|
||||
|
||||
# Instantiate the widget and set some properties if necessary
|
||||
widget = widget_class()
|
||||
|
||||
# set high default range for spin boxes #TODO can be linked to motor/device limits from BEC
|
||||
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||||
widget.setRange(-9999, 9999)
|
||||
widget.setValue(0)
|
||||
# Add the widget to the list
|
||||
widgets.append(widget)
|
||||
|
||||
return widgets
|
||||
|
||||
def set_args_table_limits(self, table: QTableWidget, scan_info: dict) -> None:
|
||||
# Get bundle info
|
||||
arg_bundle_size = scan_info.get("arg_bundle_size", {})
|
||||
self.arg_size_min = arg_bundle_size.get("min", 1)
|
||||
self.arg_size_max = arg_bundle_size.get("max", None)
|
||||
|
||||
# Clear the previous input fields
|
||||
table.setRowCount(0) # Wipe table
|
||||
|
||||
def add_widgets_row_to_layout(
|
||||
self, grid_layout: QGridLayout, widgets: list, row_index: int = None
|
||||
) -> None:
|
||||
"""
|
||||
Adds a row of widgets to the given grid layout.
|
||||
|
||||
Args:
|
||||
grid_layout (QGridLayout): The grid layout to which widgets will be added.
|
||||
items (list): List of parameter names to create widgets for.
|
||||
row_index (int): The row index where the widgets should be added.
|
||||
"""
|
||||
# If row_index is not specified, add to the next available row
|
||||
if row_index is None:
|
||||
row_index = grid_layout.rowCount()
|
||||
|
||||
for column_index, widget in enumerate(widgets):
|
||||
# Add the widget to the grid layout at the specified row and column
|
||||
grid_layout.addWidget(widget, row_index, column_index)
|
||||
|
||||
def add_widgets_row_to_table(
|
||||
self, table_widget: QTableWidget, widgets: list, row_index: int = None
|
||||
) -> None:
|
||||
"""
|
||||
Adds a row of widgets to the given QTableWidget.
|
||||
|
||||
Args:
|
||||
table_widget (QTableWidget): The table widget to which widgets will be added.
|
||||
widgets (list): List of widgets to add to the table.
|
||||
row_index (int): The row index where the widgets should be added. If None, add to the end.
|
||||
"""
|
||||
# If row_index is not specified, add to the end of the table
|
||||
if row_index is None or row_index > table_widget.rowCount():
|
||||
row_index = table_widget.rowCount()
|
||||
if self.arg_size_max is not None: # ensure the max args size is not exceeded
|
||||
if row_index >= self.arg_size_max:
|
||||
return
|
||||
table_widget.insertRow(row_index)
|
||||
|
||||
for column_index, widget in enumerate(widgets):
|
||||
# If the widget is a subclass of QWidget, use setCellWidget
|
||||
if issubclass(type(widget), QWidget):
|
||||
table_widget.setCellWidget(row_index, column_index, widget)
|
||||
else:
|
||||
# Otherwise, assume it's a string or some other value that should be displayed as text
|
||||
item = QTableWidgetItem(str(widget))
|
||||
table_widget.setItem(row_index, column_index, item)
|
||||
|
||||
# Optionally, adjust the row height based on the content #TODO decide if needed
|
||||
table_widget.setRowHeight(
|
||||
row_index,
|
||||
max(widget.sizeHint().height() for widget in widgets if isinstance(widget, QWidget)),
|
||||
)
|
||||
|
||||
def remove_last_row_from_table(self, table_widget: QTableWidget) -> None:
|
||||
"""
|
||||
Removes the last row from the given QTableWidget until only one row is left.
|
||||
|
||||
Args:
|
||||
table_widget (QTableWidget): The table widget from which the last row will be removed.
|
||||
"""
|
||||
row_count = table_widget.rowCount()
|
||||
if (
|
||||
row_count > self.arg_size_min
|
||||
): # Check to ensure there is a minimum number of rows remaining
|
||||
table_widget.removeRow(row_count - 1)
|
||||
|
||||
def create_new_grid_layout(self):
|
||||
new_layout = QGridLayout()
|
||||
# TODO maybe setup other layouts properties here?
|
||||
return new_layout
|
||||
|
||||
def clear_and_delete_layout(self, layout: QLayout):
|
||||
"""
|
||||
Clears and deletes the given layout and all its child widgets.
|
||||
|
||||
Args:
|
||||
layout(QLayout): Layout to clear and delete
|
||||
"""
|
||||
if layout is not None:
|
||||
while layout.count():
|
||||
item = layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget:
|
||||
widget.deleteLater()
|
||||
else:
|
||||
sub_layout = item.layout()
|
||||
if sub_layout:
|
||||
self.clear_and_delete_layout(sub_layout)
|
||||
layout.deleteLater()
|
||||
|
||||
def add_bundle(self) -> None:
|
||||
"""Adds a new bundle to the scan control layout"""
|
||||
# Get widgets used for particular scan and save them to be able to use for adding bundles
|
||||
args_widgets = self.generate_widgets_from_signature(
|
||||
self.arg_input.items()
|
||||
) # TODO decide if make sense to put widget list into method parameters
|
||||
|
||||
# Add first widgets row to the table
|
||||
self.add_widgets_row_to_table(self.args_table, args_widgets)
|
||||
|
||||
def remove_bundle(self) -> None:
|
||||
"""Removes the last bundle from the scan control layout"""
|
||||
self.remove_last_row_from_table(self.args_table)
|
||||
|
||||
def extract_kwargs_from_grid_row(self, grid_layout: QGridLayout, row: int) -> dict:
|
||||
kwargs = {}
|
||||
for column in range(grid_layout.columnCount()):
|
||||
label_item = grid_layout.itemAtPosition(row, column)
|
||||
if label_item is not None:
|
||||
label_widget = label_item.widget()
|
||||
if isinstance(label_widget, QLabel):
|
||||
key = label_widget.text()
|
||||
|
||||
# The corresponding value widget is in the next row
|
||||
value_item = grid_layout.itemAtPosition(row + 1, column)
|
||||
if value_item is not None:
|
||||
value_widget = value_item.widget()
|
||||
# Use WidgetIO.get_value to extract the value
|
||||
value = WidgetIO.get_value(value_widget)
|
||||
kwargs[key] = value
|
||||
return kwargs
|
||||
|
||||
def extract_args_from_table(self, table: QTableWidget) -> list:
|
||||
"""
|
||||
Extracts the arguments from the given table widget.
|
||||
|
||||
Args:
|
||||
table(QTableWidget): Table widget from which to extract the arguments
|
||||
"""
|
||||
args = []
|
||||
for row in range(table.rowCount()):
|
||||
row_args = []
|
||||
for column in range(table.columnCount()):
|
||||
widget = table.cellWidget(row, column)
|
||||
if widget:
|
||||
if isinstance(widget, QLineEdit): # special case for QLineEdit for Devices
|
||||
value = widget.text().lower()
|
||||
if value in self.dev:
|
||||
value = getattr(self.dev, value)
|
||||
else:
|
||||
raise ValueError(f"The device '{value}' is not recognized.")
|
||||
else:
|
||||
value = WidgetIO.get_value(widget)
|
||||
row_args.append(value)
|
||||
args.extend(row_args)
|
||||
return args
|
||||
def remove_kwarg_boxes(self):
|
||||
for box in self.kwarg_boxes:
|
||||
self.layout.removeWidget(box)
|
||||
box.deleteLater()
|
||||
self.kwarg_boxes = []
|
||||
|
||||
def run_scan(self):
|
||||
# Extract kwargs for the scan
|
||||
kwargs = {
|
||||
k.lower(): v
|
||||
for k, v in self.extract_kwargs_from_grid_row(self.kwargs_layout, 1).items()
|
||||
}
|
||||
|
||||
# Extract args from the table
|
||||
args = self.extract_args_from_table(self.args_table)
|
||||
|
||||
# Convert args to lowercase if they are strings
|
||||
args = [arg.lower() if isinstance(arg, str) else arg for arg in args]
|
||||
|
||||
# Execute the scan
|
||||
args = []
|
||||
kwargs = {}
|
||||
if self.arg_box is not None:
|
||||
args = self.arg_box.get_parameters()
|
||||
for box in self.kwarg_boxes:
|
||||
box_kwargs = box.get_parameters()
|
||||
kwargs.update(box_kwargs)
|
||||
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
|
||||
if callable(scan_function):
|
||||
scan_function(*args, **kwargs)
|
||||
|
||||
def cleanup(self):
|
||||
self.button_stop_scan.cleanup()
|
||||
if self.arg_box:
|
||||
for widget in self.arg_box.widgets:
|
||||
if hasattr(widget, "cleanup"):
|
||||
widget.cleanup()
|
||||
for kwarg_box in self.kwarg_boxes:
|
||||
for widget in kwarg_box.widgets:
|
||||
if hasattr(widget, "cleanup"):
|
||||
widget.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.cleanup()
|
||||
return QWidget.closeEvent(self, event)
|
||||
|
||||
|
||||
# Application example
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# BECclient global variables
|
||||
client = BECDispatcher().client
|
||||
client.start()
|
||||
|
||||
app = QApplication([])
|
||||
scan_control = ScanControl(client=client) # allowed_scans=["line_scan", "grid_scan"])
|
||||
scan_control = ScanControl()
|
||||
|
||||
qdarktheme.setup_theme("auto")
|
||||
window = scan_control
|
||||
window.show()
|
||||
app.exec()
|
||||
|
||||
223
bec_widgets/widgets/scan_control/scan_group_box.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSpinBox,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.device_inputs import DeviceLineEdit
|
||||
|
||||
|
||||
class ScanArgType:
|
||||
DEVICE = "device"
|
||||
FLOAT = "float"
|
||||
INT = "int"
|
||||
BOOL = "bool"
|
||||
STR = "str"
|
||||
DEVICEBASE = "DeviceBase"
|
||||
LITERALS = "dict"
|
||||
|
||||
|
||||
class ScanSpinBox(QSpinBox):
|
||||
def __init__(
|
||||
self, parent=None, arg_name: str = None, default: int | None = None, *args, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self.arg_name = arg_name
|
||||
self.setRange(-9999, 9999)
|
||||
if default is not None:
|
||||
self.setValue(default)
|
||||
|
||||
|
||||
class ScanDoubleSpinBox(QDoubleSpinBox):
|
||||
def __init__(
|
||||
self, parent=None, arg_name: str = None, default: float | None = None, *args, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self.arg_name = arg_name
|
||||
self.setRange(-9999, 9999)
|
||||
if default is not None:
|
||||
self.setValue(default)
|
||||
|
||||
|
||||
class ScanLineEdit(QLineEdit):
|
||||
def __init__(
|
||||
self, parent=None, arg_name: str = None, default: str | None = None, *args, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self.arg_name = arg_name
|
||||
if default is not None:
|
||||
self.setText(default)
|
||||
|
||||
|
||||
class ScanCheckBox(QCheckBox):
|
||||
def __init__(
|
||||
self, parent=None, arg_name: str = None, default: bool | None = None, *args, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self.arg_name = arg_name
|
||||
if default is not None:
|
||||
self.setChecked(default)
|
||||
|
||||
|
||||
class ScanGroupBox(QGroupBox):
|
||||
WIDGET_HANDLER = {
|
||||
ScanArgType.DEVICE: DeviceLineEdit,
|
||||
ScanArgType.DEVICEBASE: DeviceLineEdit,
|
||||
ScanArgType.FLOAT: ScanDoubleSpinBox,
|
||||
ScanArgType.INT: ScanSpinBox,
|
||||
ScanArgType.BOOL: ScanCheckBox,
|
||||
ScanArgType.STR: ScanLineEdit,
|
||||
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
box_type=Literal["args", "kwargs"],
|
||||
config: dict | None = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self.config = config
|
||||
self.box_type = box_type
|
||||
|
||||
self.layout = QGridLayout(self)
|
||||
self.labels = []
|
||||
self.widgets = []
|
||||
|
||||
self.init_box(self.config)
|
||||
|
||||
def init_box(self, config: dict):
|
||||
box_name = config.get("name", "ScanGroupBox")
|
||||
self.inputs = config.get("inputs", {})
|
||||
self.setTitle(box_name)
|
||||
|
||||
# Labels
|
||||
self.add_input_labels(self.inputs, 0)
|
||||
|
||||
# Widgets
|
||||
if self.box_type == "args":
|
||||
min_bundle = self.config.get("min", 1)
|
||||
for i in range(1, min_bundle + 1):
|
||||
self.add_input_widgets(self.inputs, i)
|
||||
else:
|
||||
self.add_input_widgets(self.inputs, 1)
|
||||
|
||||
def add_input_labels(self, group_inputs: dict, row: int) -> None:
|
||||
"""
|
||||
Adds the given arg_group from arg_bundle to the scan control layout. The input labels are always added to the first row.
|
||||
|
||||
Args:
|
||||
group(dict): Dictionary containing the arg_group information.
|
||||
"""
|
||||
for column_index, item in enumerate(group_inputs):
|
||||
arg_name = item.get("name", None)
|
||||
display_name = item.get("display_name", arg_name)
|
||||
label = QLabel(text=display_name)
|
||||
self.layout.addWidget(label, row, column_index)
|
||||
self.labels.append(label)
|
||||
|
||||
def add_input_widgets(self, group_inputs: dict, row) -> None:
|
||||
"""
|
||||
Adds the given arg_group from arg_bundle to the scan control layout.
|
||||
|
||||
Args:
|
||||
group_inputs(dict): Dictionary containing the arg_group information.
|
||||
row(int): The row to add the widgets to.
|
||||
"""
|
||||
for column_index, item in enumerate(group_inputs):
|
||||
arg_name = item.get("name", None)
|
||||
default = item.get("default", None)
|
||||
widget = self.WIDGET_HANDLER.get(item["type"], None)
|
||||
if widget is None:
|
||||
print(f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'")
|
||||
continue
|
||||
if default == "_empty":
|
||||
default = None
|
||||
widget_to_add = widget(arg_name=arg_name, default=default)
|
||||
tooltip = item.get("tooltip", None)
|
||||
if tooltip is not None:
|
||||
widget_to_add.setToolTip(item["tooltip"])
|
||||
self.layout.addWidget(widget_to_add, row, column_index)
|
||||
self.widgets.append(widget_to_add)
|
||||
|
||||
def add_widget_bundle(self):
|
||||
"""
|
||||
Adds a new row of widgets to the scan control layout. Only usable for arg_groups.
|
||||
"""
|
||||
if self.box_type != "args":
|
||||
return
|
||||
arg_max = self.config.get("max", None)
|
||||
row = self.layout.rowCount()
|
||||
if arg_max is not None and row >= arg_max:
|
||||
return
|
||||
|
||||
self.add_input_widgets(self.inputs, row)
|
||||
|
||||
def remove_widget_bundle(self):
|
||||
"""
|
||||
Removes the last row of widgets from the scan control layout. Only usable for arg_groups.
|
||||
"""
|
||||
if self.box_type != "args":
|
||||
return
|
||||
arg_min = self.config.get("min", None)
|
||||
row = self.count_arg_rows()
|
||||
if arg_min is not None and row <= arg_min:
|
||||
return
|
||||
|
||||
for widget in self.widgets[-len(self.inputs) :]:
|
||||
widget.deleteLater()
|
||||
self.widgets = self.widgets[: -len(self.inputs)]
|
||||
|
||||
def get_parameters(self):
|
||||
"""
|
||||
Returns the parameters from the widgets in the scan control layout formated to run scan from BEC.
|
||||
"""
|
||||
if self.box_type == "args":
|
||||
return self._get_arg_parameterts()
|
||||
elif self.box_type == "kwargs":
|
||||
return self._get_kwarg_parameters()
|
||||
|
||||
def _get_arg_parameterts(self):
|
||||
args = []
|
||||
for i in range(1, self.layout.rowCount()):
|
||||
for j in range(self.layout.columnCount()):
|
||||
widget = self.layout.itemAtPosition(i, j).widget()
|
||||
if isinstance(widget, DeviceLineEdit):
|
||||
value = widget.get_device()
|
||||
else:
|
||||
value = WidgetIO.get_value(widget)
|
||||
args.append(value)
|
||||
return args
|
||||
|
||||
def _get_kwarg_parameters(self):
|
||||
kwargs = {}
|
||||
for i in range(self.layout.columnCount()):
|
||||
widget = self.layout.itemAtPosition(1, i).widget()
|
||||
if isinstance(widget, DeviceLineEdit):
|
||||
value = widget.get_device()
|
||||
else:
|
||||
value = WidgetIO.get_value(widget)
|
||||
kwargs[widget.arg_name] = value
|
||||
return kwargs
|
||||
|
||||
def count_arg_rows(self):
|
||||
widget_rows = 0
|
||||
for row in range(self.layout.rowCount()):
|
||||
for col in range(self.layout.columnCount()):
|
||||
item = self.layout.itemAtPosition(row, col)
|
||||
if item is not None:
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
if isinstance(widget, DeviceLineEdit):
|
||||
widget_rows += 1
|
||||
return widget_rows
|
||||
@@ -1 +0,0 @@
|
||||
from .spiral_progress_bar import SpiralProgressBar
|
||||
@@ -31,7 +31,7 @@ class TextBox(BECConnector, QTextEdit):
|
||||
|
||||
USER_ACCESS = ["set_color", "set_text", "set_font_size"]
|
||||
|
||||
def __init__(self, text: str = "", parent=None, client=None, config=None, gui_id=None):
|
||||
def __init__(self, parent=None, text: str = "", client=None, config=None, gui_id=None):
|
||||
if config is None:
|
||||
config = TextBoxConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
|
||||
@@ -1,123 +1,70 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from qtpy.QtCore import QSize, QTimer
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QAction
|
||||
from qtpy.QtWidgets import QApplication, QStyle, QToolBar, QWidget
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QSpinBox, QToolBar, QWidget
|
||||
|
||||
|
||||
class ToolBarAction(ABC):
|
||||
"""Abstract base class for action creators for the toolbar."""
|
||||
|
||||
@abstractmethod
|
||||
def create(self, target: QWidget):
|
||||
"""Creates and returns an action to be added to a toolbar.
|
||||
|
||||
This method must be implemented by subclasses.
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""Adds an action or widget to a toolbar.
|
||||
|
||||
Args:
|
||||
target (QWidget): The widget that the action will target.
|
||||
|
||||
Returns:
|
||||
QAction: The action created for the toolbar.
|
||||
toolbar (QToolBar): The toolbar to add the action or widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
|
||||
|
||||
class OpenFileAction: # (ToolBarAction):
|
||||
"""Action creator for the 'Open File' action in the toolbar."""
|
||||
class ColumnAdjustAction(ToolBarAction):
|
||||
"""Toolbar spinbox to adjust number of columns in the plot layout"""
|
||||
|
||||
def create(self, target: QWidget):
|
||||
"""Creates an 'Open File' action for the toolbar.
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""Creates a access history button for the toolbar.
|
||||
|
||||
Args:
|
||||
target (QWidget): The widget that the 'Open File' action will be targeted.
|
||||
toolbar (QToolBar): The toolbar to add the action to.
|
||||
target (QWidget): The widget that the 'Access Scan History' action will be targeted.
|
||||
|
||||
Returns:
|
||||
QAction: The 'Open File' action created for the toolbar.
|
||||
QAction: The 'Access Scan History' action created for the toolbar.
|
||||
"""
|
||||
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_DialogOpenButton)
|
||||
action = QAction(icon, "Open File", target)
|
||||
# action = QAction("Open File", target)
|
||||
action.triggered.connect(target.open_file)
|
||||
return action
|
||||
widget = QWidget()
|
||||
layout = QHBoxLayout(widget)
|
||||
|
||||
label = QLabel("Columns:")
|
||||
spin_box = QSpinBox()
|
||||
spin_box.setMinimum(1) # Set minimum value
|
||||
spin_box.setMaximum(10) # Set maximum value
|
||||
spin_box.setValue(target.get_column_count()) # Initial value
|
||||
spin_box.valueChanged.connect(lambda value: target.set_column_count(value))
|
||||
|
||||
class SaveFileAction:
|
||||
"""Action creator for the 'Save File' action in the toolbar."""
|
||||
|
||||
def create(self, target):
|
||||
"""Creates a 'Save File' action for the toolbar.
|
||||
|
||||
Args:
|
||||
target (QWidget): The widget that the 'Save File' action will be targeted.
|
||||
|
||||
Returns:
|
||||
QAction: The 'Save File' action created for the toolbar.
|
||||
"""
|
||||
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton)
|
||||
action = QAction(icon, "Save File", target)
|
||||
# action = QAction("Save File", target)
|
||||
action.triggered.connect(target.save_file)
|
||||
return action
|
||||
|
||||
|
||||
class RunScriptAction:
|
||||
"""Action creator for the 'Run Script' action in the toolbar."""
|
||||
|
||||
def create(self, target):
|
||||
"""Creates a 'Run Script' action for the toolbar.
|
||||
|
||||
Args:
|
||||
target (QWidget): The widget that the 'Run Script' action will be targeted.
|
||||
|
||||
Returns:
|
||||
QAction: The 'Run Script' action created for the toolbar.
|
||||
"""
|
||||
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)
|
||||
action = QAction(icon, "Run Script", target)
|
||||
# action = QAction("Run Script", target)
|
||||
action.triggered.connect(target.run_script)
|
||||
return action
|
||||
layout.addWidget(label)
|
||||
layout.addWidget(spin_box)
|
||||
toolbar.addWidget(widget)
|
||||
|
||||
|
||||
class ModularToolBar(QToolBar):
|
||||
"""Modular toolbar with optional automatic initialization.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
|
||||
auto_init (bool, optional): If True, automatically populates the toolbar based on the parent widget.
|
||||
actions (list[ToolBarAction], optional): A list of action creators to populate the toolbar. Defaults to None.
|
||||
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
|
||||
color (str, optional): The background color of the toolbar. Defaults to "black".
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, auto_init=True):
|
||||
def __init__(self, parent=None, actions=None, target_widget=None, color: str = "black"):
|
||||
super().__init__(parent)
|
||||
self.auto_init = auto_init
|
||||
self.handler = {
|
||||
"BECEditor": [OpenFileAction(), SaveFileAction(), RunScriptAction()],
|
||||
# BECMonitor: [SomeOtherAction(), AnotherAction()], # Example for another widget
|
||||
}
|
||||
self.setStyleSheet("QToolBar { background: transparent; }")
|
||||
# Set the icon size for the toolbar
|
||||
self.setIconSize(QSize(20, 20))
|
||||
|
||||
if self.auto_init:
|
||||
QTimer.singleShot(0, self.auto_detect_and_populate)
|
||||
self.widgets = defaultdict(dict)
|
||||
self.set_background_color(color)
|
||||
|
||||
def auto_detect_and_populate(self):
|
||||
"""Automatically detects the parent widget and populates the toolbar with relevant actions."""
|
||||
if not self.auto_init:
|
||||
return
|
||||
if actions is not None and target_widget is not None:
|
||||
self.populate_toolbar(actions, target_widget)
|
||||
|
||||
parent_widget = self.parent()
|
||||
if parent_widget is None:
|
||||
return
|
||||
|
||||
parent_widget_class_name = type(parent_widget).__name__
|
||||
for widget_type_name, actions in self.handler.items():
|
||||
if parent_widget_class_name == widget_type_name:
|
||||
self.populate_toolbar(actions, parent_widget)
|
||||
return
|
||||
|
||||
def populate_toolbar(self, actions, target_widget):
|
||||
def populate_toolbar(self, actions: dict, target_widget):
|
||||
"""Populates the toolbar with a set of actions.
|
||||
|
||||
Args:
|
||||
@@ -125,20 +72,13 @@ class ModularToolBar(QToolBar):
|
||||
target_widget (QWidget): The widget that the actions will target.
|
||||
"""
|
||||
self.clear()
|
||||
for action_creator in actions:
|
||||
action = action_creator.create(target_widget)
|
||||
self.addAction(action)
|
||||
for action_id, action in actions.items():
|
||||
action.add_to_toolbar(self, target_widget)
|
||||
self.widgets[action_id] = action
|
||||
|
||||
def set_manual_actions(self, actions, target_widget):
|
||||
"""Manually sets the actions for the toolbar.
|
||||
|
||||
Args:
|
||||
actions (list[QAction or ToolBarAction]): A list of actions or action creators to populate the toolbar.
|
||||
target_widget (QWidget): The widget that the actions will target.
|
||||
"""
|
||||
self.clear()
|
||||
for action in actions:
|
||||
if isinstance(action, QAction):
|
||||
self.addAction(action)
|
||||
elif isinstance(action, ToolBarAction):
|
||||
self.addAction(action.create(target_widget))
|
||||
def set_background_color(self, color: str):
|
||||
self.setStyleSheet(f"QToolBar {{ background: {color}; }}")
|
||||
self.setIconSize(QSize(20, 20))
|
||||
self.setMovable(False)
|
||||
self.setFloatable(False)
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
@@ -60,7 +60,7 @@ class VSCodeEditor(WebsiteWidget):
|
||||
"""
|
||||
Cleanup the VSCode editor.
|
||||
"""
|
||||
if not self.process:
|
||||
if not self.process or self.process.poll() is not None:
|
||||
return
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
self.process.wait()
|
||||
@@ -72,6 +72,13 @@ class VSCodeEditor(WebsiteWidget):
|
||||
self.cleanup_vscode()
|
||||
return super().cleanup()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the widget.
|
||||
"""
|
||||
self.cleanup_vscode()
|
||||
return super().close()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
@@ -21,7 +21,7 @@ class WebsiteWidget(BECConnector, QWebEngineView):
|
||||
|
||||
USER_ACCESS = ["set_url", "get_url", "reload", "back", "forward"]
|
||||
|
||||
def __init__(self, url: str = None, parent=None, config=None, client=None, gui_id=None):
|
||||
def __init__(self, parent=None, url: str = None, config=None, client=None, gui_id=None):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QWebEngineView.__init__(self, parent=parent)
|
||||
self.set_url(url)
|
||||
|
||||
@@ -10,7 +10,7 @@ Targeting the unique needs of beamline scientists, BEC Widgets stands out with i
|
||||
**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.
|
||||
- **Support for Pyside6 and PyQt6:** Provides compatibility with both Pyside6 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
|
||||
|
||||
@@ -16,10 +16,10 @@ To install BEC Widgets using the pip package manager, execute the following comm
|
||||
pip install 'bec_widgets[pyqt6]'
|
||||
```
|
||||
|
||||
In case you want to use PyQt5, you can install it by using the following command:
|
||||
In case you want to use Pyside6, you can install it by using the following command:
|
||||
|
||||
```bash
|
||||
pip install 'bec_widgets[pyqt5]'
|
||||
pip install 'bec_widgets[pyside6]'
|
||||
```
|
||||
|
||||
**Troubleshooting**
|
||||
|
||||
@@ -97,11 +97,11 @@ Note, we chain commands here which is possible since the `add_dock` and `add_wid
|
||||
cam_widget.set_title("Camera Image Eiger")
|
||||
cam_widget.set_vrange(vmin=0, vmax=100)
|
||||
```
|
||||
As a final step, we can now add also a SpiralProgressBar to a new dock, and perform a grid_scan with the motors *samx* and *samy*.
|
||||
As a final step, we can now add also a RingProgressBar to a new dock, and perform a grid_scan with the motors *samx* and *samy*.
|
||||
As you see in the example below, all docks are arranged below each other. This is the default behavior of the `add_dock` method. However, the docks can be freely arranged by drag and drop as desired by the user. We invite you to explore this by yourself following the example in the video, and build your custom GUI with BEC Widgets.
|
||||
|
||||
```python
|
||||
prog_bar = gui.add_dock(name="prog_dock").add_widget('SpiralProgressBar')
|
||||
prog_bar = gui.add_dock(name="prog_dock").add_widget('RingProgressBar')
|
||||
prog_bar.set_line_widths(15)
|
||||
scans.grid_scan(dev.samy, -2, 2, 10, dev.samx, -5, 5, 10, exp_time=0.1, relative=False)
|
||||
```
|
||||
|
||||
@@ -7,7 +7,6 @@ In the following, we describe 4 different type of widgets thaat are available in
|
||||
|
||||

|
||||
|
||||
(user.widgets.waveform_1d)=
|
||||
## [1D Waveform Widget](/api_reference/_autosummary/bec_widgets.cli.client.BECWaveform)
|
||||
|
||||
**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.
|
||||
@@ -20,11 +19,12 @@ In the following, we describe 4 different type of widgets thaat are available in
|
||||
**Example of Use:**
|
||||

|
||||
|
||||
**Code example**
|
||||
**Code example 1 - adding curves**
|
||||
|
||||
The following code snipped demonstrates how to create a 1D waveform plot using BEC Widgets within BEC. More details about BEC Widgets in BEC can be found in the getting started section within the [introduction to the command line.](user.command_line_introduction)
|
||||
```python
|
||||
# adds a new dock, a new BECFigure and a BECWaveForm to the dock
|
||||
plt = gui.add_dock().add_widget('BECFigure').plot('samx', 'bpm4i')
|
||||
plt = gui.add_dock().add_widget('BECFigure').plot(x_name='samx', y_name='bpm4i')
|
||||
# add a second curve to the same plot
|
||||
plt.plot(x_name='samx', y_name='bpm3i')
|
||||
plt.set_title("Gauss plots vs. samx")
|
||||
@@ -39,6 +39,48 @@ dev.bpm4i.sim.select_sim_model("GaussianModel")
|
||||
dev.bpm3i.sim.select_sim_model("StepModel")
|
||||
```
|
||||
|
||||
**Code example 2 - Adding Data Processing Pipeline Curve with LMFit Models**
|
||||
|
||||
Together with the scan curve, one can also add a second curve that fits the signal using a specified model
|
||||
from [LMFit](https://lmfit.github.io/lmfit-py/builtin_models.html). The following code snippet demonstrates how to
|
||||
create a 1D waveform curve with an attached DAP process, or how to add a DAP process to an existing curve using the BEC
|
||||
CLI. Please note that for this example, both devices were set as Gaussian signals.
|
||||
|
||||
```python
|
||||
# Add a new dock, a new BECFigure, and a BECWaveForm to the dock with a GaussianModel DAP
|
||||
plt = gui.add_dock().add_widget('BECFigure').plot(x_name='samx', y_name='bpm4i', dap="GaussianModel")
|
||||
|
||||
# Add a second curve to the same plot without DAP
|
||||
plt.plot(x_name='samx', y_name='bpm3a')
|
||||
|
||||
# Add DAP to the second curve
|
||||
plt.add_dap(x_name='samx', y_name='bpm3a', dap="GaussianModel")
|
||||
|
||||
```
|
||||
|
||||
To get the parameters of the fit, one has to retrieve the curve objects and call the dap_params property.
|
||||
|
||||
```python
|
||||
# Get the curve object by name from the legend
|
||||
dap_bpm4i = plt.get_curve("bpm4i-bpm4i-GaussianModel")
|
||||
dap_bpm3a = plt.get_curve("bpm3a-bpm3a-GaussianModel")
|
||||
|
||||
# Get the parameters of the fit
|
||||
print(dap_bpm4i.dap_params)
|
||||
# Output
|
||||
{'amplitude': 197.399639720862,
|
||||
'center': 5.013486095404885,
|
||||
'sigma': 0.9820868875739888}
|
||||
|
||||
print(dap_bpm3a.dap_params)
|
||||
# Output
|
||||
{'amplitude': 698.3072786185278,
|
||||
'center': 0.9702840866173836,
|
||||
'sigma': 1.97139754785518}
|
||||
```
|
||||
|
||||

|
||||
|
||||
(user.widgets.scatter_2d)=
|
||||
## [2D Scatter Plot](/api_reference/_autosummary/bec_widgets.cli.client.BECWaveform)
|
||||
|
||||
|
||||
BIN
docs/user/widgets/bec_figure_dap.gif
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
@@ -1,9 +1,9 @@
|
||||
(user.widgets.spiral_progress_bar)=
|
||||
# [Spiral Progress Bar](/api_reference/_autosummary/bec_widgets.cli.client.SpiralProgressBar)
|
||||
# [Ring Progress Bar](/api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar)
|
||||
|
||||
**Purpose:**
|
||||
|
||||
The Spiral Progress Bar widget is a circular progress bar that can be used to visualize the progress of a task. The
|
||||
The ring Progress Bar widget is a circular progress bar that can be used to visualize the progress of a task. The
|
||||
widget is designed to be used in applications where the progress of a task is represented as a percentage. The Spiral
|
||||
Progress Bar widget is a part of the BEC Widgets library and can be controlled directly using its API, or hooked up to
|
||||
the progress of a device readback or scan.
|
||||
@@ -15,22 +15,22 @@ the progress of a device readback or scan.
|
||||
- multiple progress rings to show different tasks in parallel.
|
||||
|
||||
**Example of Use:**
|
||||

|
||||

|
||||
|
||||
**Code example:**
|
||||
|
||||
The following code snipped demonstrates how to create a `SpiralProgressBar` using BEC Widgets within BEC.
|
||||
The following code snipped demonstrates how to create a `RingProgressBar` using BEC Widgets within BEC.
|
||||
```python
|
||||
# adds a new dock with a spiral progress bar
|
||||
progress = gui.add_dock().add_widget("SpiralProgressBar")
|
||||
# adds a new dock with a ring progress bar
|
||||
progress = gui.add_dock().add_widget("RingProgressBar")
|
||||
# customize the size of the ring
|
||||
progress.set_line_width(20)
|
||||
```
|
||||
|
||||
By default, the Spiral Progress Bar widget will display a single ring. To add more rings, use the add_ring method:
|
||||
By default, the Ring Progress Bar widget will display a single ring. To add more rings, use the add_ring method:
|
||||
|
||||
```python
|
||||
# adds a new dock with a spiral progress bar
|
||||
# adds a new dock with a ring progress bar
|
||||
progress.add_ring()
|
||||
```
|
||||
|
||||
@@ -42,7 +42,7 @@ progress.rings[0].set_line_width(20) # set the width of the first ring
|
||||
progress.rings[1].set_line_width(10) # set the width of the second ring
|
||||
```
|
||||
|
||||
By default, the `SpiralProgressBar` widget is set with `progress.enable_auto_update(True)`, which will automatically
|
||||
By default, the `RingProgressBar` widget is set with `progress.enable_auto_update(True)`, which will automatically
|
||||
update the bars in the widget. To manually set updates for each progress bar, use the set_update method. Note that
|
||||
manually updating a ring will disable the automatic update for the whole widget:
|
||||
|
||||
BIN
docs/user/widgets/scan_control.gif
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
35
docs/user/widgets/scan_control.md
Normal file
@@ -0,0 +1,35 @@
|
||||
(user.widgets.scan_control)=
|
||||
|
||||
# Scan Control
|
||||
|
||||
**Purpose:**
|
||||
|
||||
The `ScanControl` widget is designed to generate a graphical user interface (GUI) to control various scan operations
|
||||
based on the scan's signature and `gui_config`. The widget is used to control the scan operations, such as starting,
|
||||
stopping, and pausing the scan. The widget also provides a graphical representation of the scan progress and the scan
|
||||
status. The widget is designed to be used in conjunction with the `ScanServer` and `ScanBundler` services from the BEC
|
||||
core services.
|
||||
|
||||
By default the widget supports only the scans which have defined `gui_config` and are inhereted from these scan classes:
|
||||
|
||||
- [ScanBase](https://beamline-experiment-control.readthedocs.io/en/latest/api_reference/_autosummary/bec_server.scan_server.scans.ScanBase.html)
|
||||
- [SyncFlyScanBase](https://beamline-experiment-control.readthedocs.io/en/latest/api_reference/_autosummary/bec_server.scan_server.scans.SyncFlyScanBase.html)
|
||||
- [AsyncFlyScanBase](https://beamline-experiment-control.readthedocs.io/en/latest/api_reference/_autosummary/bec_server.scan_server.scans.AsyncFlyScanBase.html)
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Automatically generates a control interface based on scan signatures and `gui_config`.
|
||||
- Supports adding and removing argument bundles dynamically.
|
||||
- Provides a visual representation of scan parameters grouped by functionality.
|
||||
- Integrates start and stop controls for executing and halting scans.
|
||||
|
||||
**Example of Use:**
|
||||
|
||||
**Code example:**
|
||||
The following code snipped demonstrates how to create a `ScanControl` widget using BEC Widgets within `BECIPythonClient`
|
||||
|
||||

|
||||
|
||||
```python
|
||||
scan_control = gui.add_dock().add_widget("ScanControl")
|
||||
```
|
||||
@@ -9,7 +9,7 @@ hidden: false
|
||||
---
|
||||
|
||||
bec_figure/
|
||||
spiral_progress_bar/
|
||||
ring_progress_bar/
|
||||
website/
|
||||
buttons/
|
||||
text_box/
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "0.70.0"
|
||||
version = "0.81.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -13,32 +13,30 @@ classifiers = [
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"pydantic",
|
||||
"qtconsole",
|
||||
"jedi",
|
||||
"qtpy",
|
||||
"pyqtgraph",
|
||||
"bec_lib",
|
||||
"bec_ipython_client", # needed for jupyter widget
|
||||
"zmq",
|
||||
"h5py",
|
||||
"pyqtdarktheme",
|
||||
"black", # needed for bw-generate-cli
|
||||
"isort", # needed for bw-generate-cli
|
||||
"bec_ipython_client~=2.16", # needed for jupyter console
|
||||
"bec_lib~=2.16",
|
||||
"black~=24.0", # needed for bw-generate-cli
|
||||
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph~=0.13",
|
||||
"pyqtdarktheme~=2.1",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
"pyte", # needed for vt100 console
|
||||
]
|
||||
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"pytest-random-order",
|
||||
"pytest-timeout",
|
||||
"pytest-xvfb",
|
||||
"coverage",
|
||||
"pytest-qt",
|
||||
"fakeredis",
|
||||
"coverage~=7.0",
|
||||
"fakeredis~=2.23, >=2.23.2",
|
||||
"pytest-bec-e2e~=2.16",
|
||||
"pytest-qt~=4.4",
|
||||
"pytest-random-order~=1.1",
|
||||
"pytest-timeout~=2.2",
|
||||
"pytest-xvfb~=3.0",
|
||||
"pytest~=8.0",
|
||||
]
|
||||
pyqt5 = ["PyQt5>=5.9", "PyQtWebEngine>=5.9"]
|
||||
pyqt6 = ["PyQt6>=6.7", "PyQt6-WebEngine>=6.7"]
|
||||
pyside6 = ["PySide6>=6.7"]
|
||||
|
||||
|
||||
@@ -23,14 +23,14 @@ def test_rpc_add_dock_with_figure_e2e(bec_client_lib, rpc_server_dock):
|
||||
d1 = dock.add_dock("dock_1")
|
||||
d2 = dock.add_dock("dock_2")
|
||||
|
||||
dock_config = dock.config_dict
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 3
|
||||
# Add 3 figures with some widgets
|
||||
fig0 = d0.add_widget("BECFigure")
|
||||
fig1 = d1.add_widget("BECFigure")
|
||||
fig2 = d2.add_widget("BECFigure")
|
||||
|
||||
dock_config = dock.config_dict
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 3
|
||||
assert len(dock_config["docks"]["dock_0"]["widgets"]) == 1
|
||||
assert len(dock_config["docks"]["dock_1"]["widgets"]) == 1
|
||||
@@ -52,7 +52,8 @@ def test_rpc_add_dock_with_figure_e2e(bec_client_lib, rpc_server_dock):
|
||||
assert im.__class__.__name__ == "BECImageShow"
|
||||
assert im.__class__ == BECImageShow
|
||||
|
||||
assert mm.config_dict["signals"] == {
|
||||
assert mm._config_dict["signals"] == {
|
||||
"dap": None,
|
||||
"source": "device_readback",
|
||||
"x": {
|
||||
"name": "samx",
|
||||
@@ -70,13 +71,14 @@ def test_rpc_add_dock_with_figure_e2e(bec_client_lib, rpc_server_dock):
|
||||
},
|
||||
"z": None,
|
||||
}
|
||||
assert plt.config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
|
||||
assert plt._config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
|
||||
"dap": None,
|
||||
"source": "scan_segment",
|
||||
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
|
||||
"y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
|
||||
"z": None,
|
||||
}
|
||||
assert im.config_dict["images"]["eiger"]["monitor"] == "eiger"
|
||||
assert im._config_dict["images"]["eiger"]["monitor"] == "eiger"
|
||||
|
||||
# check initial position of motor map
|
||||
initial_pos_x = dev.samx.read()["samx"]["value"]
|
||||
@@ -124,61 +126,61 @@ def test_dock_manipulations_e2e(rpc_server_dock):
|
||||
d0 = dock.add_dock("dock_0")
|
||||
d1 = dock.add_dock("dock_1")
|
||||
d2 = dock.add_dock("dock_2")
|
||||
dock_config = dock.config_dict
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 3
|
||||
|
||||
d0.detach()
|
||||
dock.detach_dock("dock_2")
|
||||
dock_config = dock.config_dict
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 3
|
||||
assert len(dock.temp_areas) == 2
|
||||
|
||||
d0.attach()
|
||||
dock_config = dock.config_dict
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 3
|
||||
assert len(dock.temp_areas) == 1
|
||||
|
||||
d2.remove()
|
||||
dock_config = dock.config_dict
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 2
|
||||
|
||||
assert ["dock_0", "dock_1"] == list(dock_config["docks"])
|
||||
|
||||
dock.clear_all()
|
||||
|
||||
dock_config = dock.config_dict
|
||||
dock_config = dock._config_dict
|
||||
assert len(dock_config["docks"]) == 0
|
||||
assert len(dock.temp_areas) == 0
|
||||
|
||||
|
||||
def test_spiral_bar(rpc_server_dock):
|
||||
def test_ring_bar(rpc_server_dock):
|
||||
dock = BECDockArea(rpc_server_dock)
|
||||
|
||||
d0 = dock.add_dock(name="dock_0")
|
||||
|
||||
bar = d0.add_widget("SpiralProgressBar")
|
||||
assert bar.__class__.__name__ == "SpiralProgressBar"
|
||||
bar = d0.add_widget("RingProgressBar")
|
||||
assert bar.__class__.__name__ == "RingProgressBar"
|
||||
|
||||
bar.set_number_of_bars(5)
|
||||
bar.set_colors_from_map("viridis")
|
||||
bar.set_value([10, 20, 30, 40, 50])
|
||||
|
||||
bar_config = bar.config_dict
|
||||
bar_config = bar._config_dict
|
||||
|
||||
expected_colors = [list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB")]
|
||||
bar_colors = [ring.config_dict["color"] for ring in bar.rings]
|
||||
bar_values = [ring.config_dict["value"] for ring in bar.rings]
|
||||
bar_colors = [ring._config_dict["color"] for ring in bar.rings]
|
||||
bar_values = [ring._config_dict["value"] for ring in bar.rings]
|
||||
assert bar_config["num_bars"] == 5
|
||||
assert bar_values == [10, 20, 30, 40, 50]
|
||||
assert bar_colors == expected_colors
|
||||
|
||||
|
||||
def test_spiral_bar_scan_update(bec_client_lib, rpc_server_dock):
|
||||
def test_ring_bar_scan_update(bec_client_lib, rpc_server_dock):
|
||||
dock = BECDockArea(rpc_server_dock)
|
||||
|
||||
d0 = dock.add_dock("dock_0")
|
||||
|
||||
bar = d0.add_widget("SpiralProgressBar")
|
||||
bar = d0.add_widget("RingProgressBar")
|
||||
|
||||
client = bec_client_lib
|
||||
dev = client.device_manager.devices
|
||||
@@ -189,7 +191,7 @@ def test_spiral_bar_scan_update(bec_client_lib, rpc_server_dock):
|
||||
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
|
||||
status.wait()
|
||||
|
||||
bar_config = bar.config_dict
|
||||
bar_config = bar._config_dict
|
||||
assert bar_config["num_bars"] == 1
|
||||
assert bar_config["rings"][0]["value"] == 10
|
||||
assert bar_config["rings"][0]["min_value"] == 0
|
||||
@@ -198,7 +200,7 @@ def test_spiral_bar_scan_update(bec_client_lib, rpc_server_dock):
|
||||
status = scans.grid_scan(dev.samx, -5, 5, 4, dev.samy, -10, 10, 4, relative=True, exp_time=0.1)
|
||||
status.wait()
|
||||
|
||||
bar_config = bar.config_dict
|
||||
bar_config = bar._config_dict
|
||||
assert bar_config["num_bars"] == 1
|
||||
assert bar_config["rings"][0]["value"] == 16
|
||||
assert bar_config["rings"][0]["min_value"] == 0
|
||||
@@ -215,7 +217,7 @@ def test_spiral_bar_scan_update(bec_client_lib, rpc_server_dock):
|
||||
status = scans.umv(dev.samx, 5, dev.samy, 10, relative=True)
|
||||
status.wait()
|
||||
|
||||
bar_config = bar.config_dict
|
||||
bar_config = bar._config_dict
|
||||
assert bar_config["num_bars"] == 2
|
||||
assert bar_config["rings"][0]["value"] == final_samx
|
||||
assert bar_config["rings"][1]["value"] == final_samy
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
@@ -8,14 +10,14 @@ from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWave
|
||||
def test_rpc_waveform1d_custom_curve(rpc_server_figure):
|
||||
fig = BECFigure(rpc_server_figure)
|
||||
|
||||
ax = fig.add_plot()
|
||||
ax = fig.plot()
|
||||
curve = ax.plot(x=[1, 2, 3], y=[1, 2, 3])
|
||||
curve.set_color("red")
|
||||
curve = ax.curves[0]
|
||||
curve.set_color("blue")
|
||||
|
||||
assert len(fig.widgets) == 1
|
||||
assert len(fig.widgets[ax.rpc_id].curves) == 1
|
||||
assert len(fig.widgets[ax._rpc_id].curves) == 1
|
||||
|
||||
|
||||
def test_rpc_plotting_shortcuts_init_configs(rpc_server_figure, qtbot):
|
||||
@@ -24,7 +26,7 @@ def test_rpc_plotting_shortcuts_init_configs(rpc_server_figure, qtbot):
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i")
|
||||
im = fig.image("eiger")
|
||||
motor_map = fig.motor_map("samx", "samy")
|
||||
plt_z = fig.add_plot(x_name="samx", y_name="samy", z_name="bpm4i")
|
||||
plt_z = fig.plot(x_name="samx", y_name="samy", z_name="bpm4i", new=True)
|
||||
|
||||
# Checking if classes are correctly initialised
|
||||
assert len(fig.widgets) == 4
|
||||
@@ -37,16 +39,18 @@ def test_rpc_plotting_shortcuts_init_configs(rpc_server_figure, qtbot):
|
||||
|
||||
# check if the correct devices are set
|
||||
# plot
|
||||
assert plt.config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
|
||||
assert plt._config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
|
||||
"dap": None,
|
||||
"source": "scan_segment",
|
||||
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
|
||||
"y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
|
||||
"z": None,
|
||||
}
|
||||
# image
|
||||
assert im.config_dict["images"]["eiger"]["monitor"] == "eiger"
|
||||
assert im._config_dict["images"]["eiger"]["monitor"] == "eiger"
|
||||
# motor map
|
||||
assert motor_map.config_dict["signals"] == {
|
||||
assert motor_map._config_dict["signals"] == {
|
||||
"dap": None,
|
||||
"source": "device_readback",
|
||||
"x": {
|
||||
"name": "samx",
|
||||
@@ -65,7 +69,8 @@ def test_rpc_plotting_shortcuts_init_configs(rpc_server_figure, qtbot):
|
||||
"z": None,
|
||||
}
|
||||
# plot with z scatter
|
||||
assert plt_z.config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
|
||||
assert plt_z._config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
|
||||
"dap": None,
|
||||
"source": "scan_segment",
|
||||
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
|
||||
"y": {"name": "samy", "entry": "samy", "unit": None, "modifier": None, "limits": None},
|
||||
@@ -151,3 +156,55 @@ def test_rpc_motor_map(rpc_server_figure, bec_client_lib):
|
||||
np.testing.assert_equal(
|
||||
[motor_map_data["x"][-1], motor_map_data["y"][-1]], [final_pos_x, final_pos_y]
|
||||
)
|
||||
|
||||
|
||||
def test_dap_rpc(rpc_server_figure, bec_client_lib):
|
||||
|
||||
fig = BECFigure(rpc_server_figure)
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
|
||||
client = bec_client_lib
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
|
||||
dev.bpm4i.sim.sim_select_model("GaussianModel")
|
||||
params = dev.bpm4i.sim.sim_params
|
||||
params.update(
|
||||
{"noise": "uniform", "noise_multiplier": 10, "center": 5, "sigma": 1, "amplitude": 200}
|
||||
)
|
||||
dev.bpm4i.sim.sim_params = params
|
||||
time.sleep(1)
|
||||
|
||||
res = scans.line_scan(dev.samx, 0, 8, steps=50, relative=False)
|
||||
res.wait()
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
dap_curve = plt.get_curve("bpm4i-bpm4i-GaussianModel")
|
||||
fit_params = dap_curve.dap_params
|
||||
print(fit_params)
|
||||
|
||||
assert np.isclose(fit_params["center"], 5, atol=0.5)
|
||||
|
||||
|
||||
def test_removing_subplots(rpc_server_figure, bec_client_lib):
|
||||
fig = BECFigure(rpc_server_figure)
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
im = fig.image(monitor="eiger")
|
||||
mm = fig.motor_map(motor_x="samx", motor_y="samy")
|
||||
|
||||
assert len(fig.widget_list) == 3
|
||||
|
||||
# removing curves
|
||||
assert len(plt.curves) == 2
|
||||
plt.curves[0].remove()
|
||||
assert len(plt.curves) == 1
|
||||
plt.remove_curve("bpm4i-bpm4i")
|
||||
assert len(plt.curves) == 0
|
||||
|
||||
# removing all subplots from figure
|
||||
plt.remove()
|
||||
im.remove()
|
||||
mm.remove()
|
||||
|
||||
assert len(fig.widget_list) == 0
|
||||
|
||||
@@ -9,24 +9,24 @@ def test_rpc_register_list_connections(rpc_server_figure):
|
||||
plt = fig.plot(x_name="samx", y_name="bpm4i")
|
||||
im = fig.image("eiger")
|
||||
motor_map = fig.motor_map("samx", "samy")
|
||||
plt_z = fig.add_plot(x_name="samx", y_name="samy", z_name="bpm4i")
|
||||
plt_z = fig.plot(x_name="samx", y_name="samy", z_name="bpm4i", new=True)
|
||||
|
||||
# keep only class names from objects, since objects on server and client are different
|
||||
# so the best we can do is to compare types (rpc register is unit-tested elsewhere)
|
||||
all_connections = {obj_id: type(obj).__name__ for obj_id, obj in fig.get_all_rpc().items()}
|
||||
all_connections = {obj_id: type(obj).__name__ for obj_id, obj in fig._get_all_rpc().items()}
|
||||
|
||||
all_subwidgets_expected = {wid: type(widget).__name__ for wid, widget in fig.widgets.items()}
|
||||
curve_1D = fig.widgets[plt.rpc_id]
|
||||
curve_2D = fig.widgets[plt_z.rpc_id]
|
||||
curve_1D = fig.widgets[plt._rpc_id]
|
||||
curve_2D = fig.widgets[plt_z._rpc_id]
|
||||
curves_expected = {
|
||||
curve_1D.rpc_id: type(curve_1D).__name__,
|
||||
curve_2D.rpc_id: type(curve_2D).__name__,
|
||||
curve_1D._rpc_id: type(curve_1D).__name__,
|
||||
curve_2D._rpc_id: type(curve_2D).__name__,
|
||||
}
|
||||
curves_expected.update({curve._gui_id: type(curve).__name__ for curve in curve_1D.curves})
|
||||
curves_expected.update({curve._gui_id: type(curve).__name__ for curve in curve_2D.curves})
|
||||
fig_expected = {fig.rpc_id: type(fig).__name__}
|
||||
fig_expected = {fig._rpc_id: type(fig).__name__}
|
||||
image_item_expected = {
|
||||
fig.widgets[im.rpc_id].images[0].rpc_id: type(fig.widgets[im.rpc_id].images[0]).__name__
|
||||
fig.widgets[im._rpc_id].images[0]._rpc_id: type(fig.widgets[im._rpc_id].images[0]).__name__
|
||||
}
|
||||
|
||||
all_connections_expected = {
|
||||
|
||||
71
tests/end-2-end/test_scan_control_e2e.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.scan_control import ScanControl
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def scan_control(qtbot, bec_client_lib): # , mock_dev):
|
||||
widget = ScanControl(client=bec_client_lib)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_scan_control_populate_scans_e2e(scan_control):
|
||||
expected_scans = [
|
||||
"grid_scan",
|
||||
"fermat_scan",
|
||||
"round_scan",
|
||||
"cont_line_scan",
|
||||
"cont_line_fly_scan",
|
||||
"round_scan_fly",
|
||||
"round_roi_scan",
|
||||
"time_scan",
|
||||
"monitor_scan",
|
||||
"acquire",
|
||||
"line_scan",
|
||||
]
|
||||
items = [
|
||||
scan_control.comboBox_scan_selection.itemText(i)
|
||||
for i in range(scan_control.comboBox_scan_selection.count())
|
||||
]
|
||||
assert scan_control.comboBox_scan_selection.count() == len(expected_scans)
|
||||
assert sorted(items) == sorted(expected_scans)
|
||||
|
||||
|
||||
def test_run_line_scan_with_parameters_e2e(scan_control, bec_client_lib, qtbot):
|
||||
client = bec_client_lib
|
||||
queue = client.queue
|
||||
|
||||
scan_name = "line_scan"
|
||||
kwargs = {"exp_time": 0.01, "steps": 10, "relative": True, "burst_at_each_point": 1}
|
||||
args = {"device": "samx", "start": -5, "stop": 5}
|
||||
|
||||
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
|
||||
|
||||
# Set kwargs in the UI
|
||||
for kwarg_box in scan_control.kwarg_boxes:
|
||||
for widget in kwarg_box.widgets:
|
||||
for key, value in kwargs.items():
|
||||
if widget.arg_name == key:
|
||||
WidgetIO.set_value(widget, value)
|
||||
break
|
||||
# Set args in the UI
|
||||
for widget in scan_control.arg_box.widgets:
|
||||
for key, value in args.items():
|
||||
if widget.arg_name == key:
|
||||
WidgetIO.set_value(widget, value)
|
||||
break
|
||||
|
||||
# Run the scan
|
||||
scan_control.button_run_scan.click()
|
||||
time.sleep(2)
|
||||
|
||||
last_scan = queue.scan_storage.storage[-1]
|
||||
assert last_scan.status_message.info["scan_name"] == scan_name
|
||||
assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"]
|
||||
assert last_scan.status_message.info["scan_motors"] == [args["device"]]
|
||||
assert last_scan.status_message.info["num_points"] == kwargs["steps"]
|
||||
@@ -84,6 +84,7 @@ class DMMock:
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
|
||||
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
|
||||
FakePositioner("aptrx", limits=None, read_value=4.0),
|
||||
FakePositioner("aptry", limits=None, read_value=5.0),
|
||||
FakeDevice("gauss_bpm"),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import Slot
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
|
||||
@@ -55,3 +59,22 @@ def test_bec_connector_update_client(bec_connector, mocked_client):
|
||||
def test_bec_connector_get_config(bec_connector):
|
||||
assert bec_connector.get_config(dict_output=False) == bec_connector.config
|
||||
assert bec_connector.get_config() == bec_connector.config.model_dump()
|
||||
|
||||
|
||||
def test_bec_connector_submit_task(bec_connector):
|
||||
def test_func():
|
||||
time.sleep(2)
|
||||
print("done")
|
||||
|
||||
completed = False
|
||||
|
||||
@Slot()
|
||||
def complete_func():
|
||||
nonlocal completed
|
||||
completed = True
|
||||
|
||||
bec_connector.submit_task(test_func, on_complete=complete_func)
|
||||
assert not completed
|
||||
while not completed:
|
||||
QApplication.processEvents()
|
||||
time.sleep(0.1)
|
||||
|
||||
@@ -38,9 +38,9 @@ def test_bec_figure_add_remove_plot(bec_figure):
|
||||
initial_count = len(bec_figure._widgets)
|
||||
|
||||
# Adding 3 widgets - 2 WaveformBase and 1 PlotBase
|
||||
w0 = bec_figure.add_plot()
|
||||
w1 = bec_figure.add_plot()
|
||||
w2 = bec_figure.add_widget(widget_type="PlotBase")
|
||||
w0 = bec_figure.plot(new=True)
|
||||
w1 = bec_figure.plot(new=True)
|
||||
w2 = bec_figure.add_widget(widget_type="BECPlotBase")
|
||||
|
||||
# Check if the widgets were added
|
||||
assert len(bec_figure._widgets) == initial_count + 3
|
||||
@@ -75,7 +75,7 @@ def test_add_different_types_of_widgets(bec_figure):
|
||||
|
||||
|
||||
def test_access_widgets_access_errors(bec_figure):
|
||||
bec_figure.add_plot(row=0, col=0)
|
||||
bec_figure.plot(row=0, col=0)
|
||||
|
||||
# access widget by non-existent coordinates
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
@@ -97,18 +97,18 @@ def test_access_widgets_access_errors(bec_figure):
|
||||
|
||||
|
||||
def test_add_plot_to_occupied_position(bec_figure):
|
||||
bec_figure.add_plot(row=0, col=0)
|
||||
bec_figure.plot(row=0, col=0)
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
bec_figure.add_plot(row=0, col=0)
|
||||
bec_figure.plot(row=0, col=0, new=True)
|
||||
assert "Position at row 0 and column 0 is already occupied." in str(excinfo.value)
|
||||
|
||||
|
||||
def test_remove_plots(bec_figure):
|
||||
w1 = bec_figure.add_plot(row=0, col=0)
|
||||
w2 = bec_figure.add_plot(row=0, col=1)
|
||||
w3 = bec_figure.add_plot(row=1, col=0)
|
||||
w4 = bec_figure.add_plot(row=1, col=1)
|
||||
w1 = bec_figure.plot(row=0, col=0)
|
||||
w2 = bec_figure.plot(row=0, col=1)
|
||||
w3 = bec_figure.plot(row=1, col=0)
|
||||
w4 = bec_figure.plot(row=1, col=1)
|
||||
|
||||
assert bec_figure[0, 0] == w1
|
||||
assert bec_figure[0, 1] == w2
|
||||
@@ -135,10 +135,10 @@ def test_remove_plots(bec_figure):
|
||||
|
||||
|
||||
def test_remove_plots_by_coordinates_ints(bec_figure):
|
||||
w1 = bec_figure.add_plot(row=0, col=0)
|
||||
w2 = bec_figure.add_plot(row=0, col=1)
|
||||
w1 = bec_figure.plot(row=0, col=0)
|
||||
w2 = bec_figure.plot(row=0, col=1)
|
||||
|
||||
bec_figure.remove(0, 0)
|
||||
bec_figure.remove(row=0, col=0)
|
||||
assert w1.gui_id not in bec_figure._widgets
|
||||
assert w2.gui_id in bec_figure._widgets
|
||||
assert bec_figure[0, 0] == w2
|
||||
@@ -146,8 +146,8 @@ def test_remove_plots_by_coordinates_ints(bec_figure):
|
||||
|
||||
|
||||
def test_remove_plots_by_coordinates_tuple(bec_figure):
|
||||
w1 = bec_figure.add_plot(row=0, col=0)
|
||||
w2 = bec_figure.add_plot(row=0, col=1)
|
||||
w1 = bec_figure.plot(row=0, col=0)
|
||||
w2 = bec_figure.plot(row=0, col=1)
|
||||
|
||||
bec_figure.remove(coordinates=(0, 0))
|
||||
assert w1.gui_id not in bec_figure._widgets
|
||||
@@ -157,7 +157,7 @@ def test_remove_plots_by_coordinates_tuple(bec_figure):
|
||||
|
||||
|
||||
def test_remove_plot_by_id_error(bec_figure):
|
||||
bec_figure.add_plot(row=0, col=0)
|
||||
bec_figure.plot()
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
bec_figure.remove(widget_id="non_existent_widget")
|
||||
@@ -165,7 +165,7 @@ def test_remove_plot_by_id_error(bec_figure):
|
||||
|
||||
|
||||
def test_remove_plot_by_coordinates_error(bec_figure):
|
||||
bec_figure.add_plot(row=0, col=0)
|
||||
bec_figure.plot(row=0, col=0)
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
bec_figure.remove(0, 1)
|
||||
@@ -173,7 +173,7 @@ def test_remove_plot_by_coordinates_error(bec_figure):
|
||||
|
||||
|
||||
def test_remove_plot_by_providing_nothing(bec_figure):
|
||||
bec_figure.add_plot(row=0, col=0)
|
||||
bec_figure.plot(row=0, col=0)
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
bec_figure.remove()
|
||||
@@ -193,10 +193,10 @@ def test_remove_plot_by_providing_nothing(bec_figure):
|
||||
|
||||
|
||||
def test_change_layout(bec_figure):
|
||||
w1 = bec_figure.add_plot(row=0, col=0)
|
||||
w2 = bec_figure.add_plot(row=0, col=1)
|
||||
w3 = bec_figure.add_plot(row=1, col=0)
|
||||
w4 = bec_figure.add_plot(row=1, col=1)
|
||||
w1 = bec_figure.plot(row=0, col=0)
|
||||
w2 = bec_figure.plot(row=0, col=1)
|
||||
w3 = bec_figure.plot(row=1, col=0)
|
||||
w4 = bec_figure.plot(row=1, col=1)
|
||||
|
||||
bec_figure.change_layout(max_columns=1)
|
||||
|
||||
@@ -216,10 +216,10 @@ def test_change_layout(bec_figure):
|
||||
|
||||
|
||||
def test_clear_all(bec_figure):
|
||||
bec_figure.add_plot(row=0, col=0)
|
||||
bec_figure.add_plot(row=0, col=1)
|
||||
bec_figure.add_plot(row=1, col=0)
|
||||
bec_figure.add_plot(row=1, col=1)
|
||||
bec_figure.plot(row=0, col=0)
|
||||
bec_figure.plot(row=0, col=1)
|
||||
bec_figure.plot(row=1, col=0)
|
||||
bec_figure.plot(row=1, col=1)
|
||||
|
||||
bec_figure.clear_all()
|
||||
|
||||
@@ -238,3 +238,26 @@ def test_shortcuts(bec_figure):
|
||||
assert im.__class__ == BECImageShow
|
||||
assert motor_map.config.widget_class == "BECMotorMap"
|
||||
assert motor_map.__class__ == BECMotorMap
|
||||
|
||||
|
||||
def test_plot_access_factory(bec_figure):
|
||||
plt_00 = bec_figure.plot(x_name="samx", y_name="bpm4i")
|
||||
plt_01 = bec_figure.plot(x_name="samx", y_name="bpm4i", row=0, col=1)
|
||||
plt_10 = bec_figure.plot(new=True)
|
||||
|
||||
assert bec_figure.widget_list[0] == plt_00
|
||||
assert bec_figure.widget_list[1] == plt_01
|
||||
assert bec_figure.widget_list[2] == plt_10
|
||||
assert bec_figure.axes(row=0, col=0) == plt_00
|
||||
assert bec_figure.axes(row=0, col=1) == plt_01
|
||||
assert bec_figure.axes(row=1, col=0) == plt_10
|
||||
|
||||
assert len(plt_00.curves) == 1
|
||||
assert len(plt_01.curves) == 1
|
||||
assert len(plt_10.curves) == 0
|
||||
|
||||
# update plt_00
|
||||
bec_figure.plot(x_name="samx", y_name="bpm3a")
|
||||
bec_figure.plot(x=[1, 2, 3], y=[1, 2, 3], row=0, col=0)
|
||||
|
||||
assert len(plt_00.curves) == 3
|
||||
|
||||
65
tests/unit_tests/test_bec_image.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from bec_lib import messages
|
||||
from qtpy.QtGui import QFontInfo
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .test_bec_figure import bec_figure
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bec_image_show(bec_figure):
|
||||
yield bec_figure.image("eiger")
|
||||
|
||||
|
||||
def test_on_image_update(bec_image_show):
|
||||
data = np.random.rand(100, 100)
|
||||
msg = messages.DeviceMonitorMessage(
|
||||
device="eiger", data=data, metadata={"scan_id": "12345"}
|
||||
).model_dump()
|
||||
bec_image_show.on_image_update(msg)
|
||||
img = bec_image_show.images[0]
|
||||
assert np.array_equal(img.get_data(), data)
|
||||
|
||||
|
||||
def test_autorange_on_image_update(bec_image_show):
|
||||
# Check if autorange mode "mean" works, should be default
|
||||
data = np.random.rand(100, 100)
|
||||
msg = messages.DeviceMonitorMessage(
|
||||
device="eiger", data=data, metadata={"scan_id": "12345"}
|
||||
).model_dump()
|
||||
bec_image_show.on_image_update(msg)
|
||||
img = bec_image_show.images[0]
|
||||
assert np.array_equal(img.get_data(), data)
|
||||
vmin = max(np.mean(data) - 2 * np.std(data), 0)
|
||||
vmax = np.mean(data) + 2 * np.std(data)
|
||||
assert np.isclose(img.color_bar.getLevels(), (vmin, vmax), rtol=(1e-5, 1e-5)).all()
|
||||
# Test general update with autorange True, mode "max"
|
||||
bec_image_show.set_autorange_mode("max")
|
||||
bec_image_show.on_image_update(msg)
|
||||
img = bec_image_show.images[0]
|
||||
vmin = np.min(data)
|
||||
vmax = np.max(data)
|
||||
assert np.array_equal(img.get_data(), data)
|
||||
assert np.isclose(img.color_bar.getLevels(), (vmin, vmax), rtol=(1e-5, 1e-5)).all()
|
||||
# Change the input data, and switch to autorange False, colormap levels should stay untouched
|
||||
data *= 100
|
||||
msg = messages.DeviceMonitorMessage(
|
||||
device="eiger", data=data, metadata={"scan_id": "12345"}
|
||||
).model_dump()
|
||||
bec_image_show.set_autorange(False)
|
||||
bec_image_show.on_image_update(msg)
|
||||
img = bec_image_show.images[0]
|
||||
assert np.array_equal(img.get_data(), data)
|
||||
assert np.isclose(img.color_bar.getLevels(), (vmin, vmax), rtol=(1e-3, 1e-3)).all()
|
||||
# Reactivate autorange, should now scale the new data
|
||||
bec_image_show.set_autorange(True)
|
||||
bec_image_show.set_autorange_mode("mean")
|
||||
bec_image_show.on_image_update(msg)
|
||||
img = bec_image_show.images[0]
|
||||
vmin = max(np.mean(data) - 2 * np.std(data), 0)
|
||||
vmax = np.mean(data) + 2 * np.std(data)
|
||||
assert np.isclose(img.color_bar.getLevels(), (vmin, vmax), rtol=(1e-5, 1e-5)).all()
|
||||
@@ -1,100 +1,106 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap, MotorMapConfig
|
||||
from bec_widgets.widgets.figure.plots.waveform.waveform_curve import SignalData
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .test_bec_figure import bec_figure
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def bec_motor_map(qtbot, mocked_client):
|
||||
widget = BECMotorMap(client=mocked_client, gui_id="BECMotorMap_test")
|
||||
# qtbot.addWidget(widget)
|
||||
# qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
def test_motor_map_init(bec_figure):
|
||||
default_config = MotorMapConfig(widget_class="BECMotorMap")
|
||||
|
||||
mm = bec_figure.motor_map(config=default_config.model_dump())
|
||||
default_config.gui_id = mm.gui_id
|
||||
|
||||
assert mm.config == default_config
|
||||
|
||||
|
||||
def test_motor_map_init(bec_motor_map):
|
||||
default_config = MotorMapConfig(widget_class="BECMotorMap", gui_id="BECMotorMap_test")
|
||||
def test_motor_map_change_motors(bec_figure):
|
||||
mm = bec_figure.motor_map("samx", "samy")
|
||||
|
||||
assert bec_motor_map.config == default_config
|
||||
assert mm.motor_x == "samx"
|
||||
assert mm.motor_y == "samy"
|
||||
assert mm.config.signals.x == SignalData(name="samx", entry="samx", limits=[-10, 10])
|
||||
assert mm.config.signals.y == SignalData(name="samy", entry="samy", limits=[-5, 5])
|
||||
|
||||
mm.change_motors("samx", "samz")
|
||||
|
||||
assert mm.config.signals.x == SignalData(name="samx", entry="samx", limits=[-10, 10])
|
||||
assert mm.config.signals.y == SignalData(name="samz", entry="samz", limits=[-8, 8])
|
||||
|
||||
|
||||
def test_motor_map_change_motors(bec_motor_map):
|
||||
bec_motor_map.change_motors("samx", "samy")
|
||||
|
||||
assert bec_motor_map.config.signals.x == SignalData(name="samx", entry="samx", limits=[-10, 10])
|
||||
assert bec_motor_map.config.signals.y == SignalData(name="samy", entry="samy", limits=[-5, 5])
|
||||
|
||||
|
||||
def test_motor_map_get_limits(bec_motor_map):
|
||||
def test_motor_map_get_limits(bec_figure):
|
||||
mm = bec_figure.motor_map("samx", "samy")
|
||||
expected_limits = {"samx": [-10, 10], "samy": [-5, 5]}
|
||||
|
||||
for motor_name, expected_limit in expected_limits.items():
|
||||
actual_limit = bec_motor_map._get_motor_limit(motor_name)
|
||||
actual_limit = mm._get_motor_limit(motor_name)
|
||||
assert actual_limit == expected_limit
|
||||
|
||||
|
||||
def test_motor_map_get_init_position(bec_motor_map):
|
||||
bec_motor_map.set_precision(2)
|
||||
def test_motor_map_get_init_position(bec_figure):
|
||||
mm = bec_figure.motor_map("samx", "samy")
|
||||
mm.set_precision(2)
|
||||
|
||||
motor_map_dev = bec_motor_map.client.device_manager.devices
|
||||
motor_map_dev = mm.client.device_manager.devices
|
||||
|
||||
expected_positions = {
|
||||
("samx", "samx"): motor_map_dev["samx"].read()["samx"]["value"],
|
||||
("samy", "samy"): motor_map_dev["samy"].read()["samy"]["value"],
|
||||
("aptrx", "aptrx"): motor_map_dev["aptrx"].read()["aptrx"]["value"],
|
||||
("aptry", "aptry"): motor_map_dev["aptry"].read()["aptry"]["value"],
|
||||
}
|
||||
|
||||
for (motor_name, entry), expected_position in expected_positions.items():
|
||||
actual_position = bec_motor_map._get_motor_init_position(motor_name, entry, 2)
|
||||
actual_position = mm._get_motor_init_position(motor_name, entry, 2)
|
||||
assert actual_position == expected_position
|
||||
|
||||
|
||||
def test_motor_movement_updates_position_and_database(bec_motor_map):
|
||||
motor_map_dev = bec_motor_map.client.device_manager.devices
|
||||
def test_motor_movement_updates_position_and_database(bec_figure):
|
||||
mm = bec_figure.motor_map("samx", "samy")
|
||||
motor_map_dev = mm.client.device_manager.devices
|
||||
|
||||
init_positions = {
|
||||
"samx": [motor_map_dev["samx"].read()["samx"]["value"]],
|
||||
"samy": [motor_map_dev["samy"].read()["samy"]["value"]],
|
||||
}
|
||||
|
||||
bec_motor_map.change_motors("samx", "samy")
|
||||
mm.change_motors("samx", "samy")
|
||||
|
||||
assert bec_motor_map.database_buffer["x"] == init_positions["samx"]
|
||||
assert bec_motor_map.database_buffer["y"] == init_positions["samy"]
|
||||
assert mm.database_buffer["x"] == init_positions["samx"]
|
||||
assert mm.database_buffer["y"] == init_positions["samy"]
|
||||
|
||||
# Simulate motor movement for 'samx' only
|
||||
new_position_samx = 4.0
|
||||
bec_motor_map.on_device_readback({"signals": {"samx": {"value": new_position_samx}}})
|
||||
mm.on_device_readback({"signals": {"samx": {"value": new_position_samx}}})
|
||||
|
||||
init_positions["samx"].append(new_position_samx)
|
||||
init_positions["samy"].append(init_positions["samy"][-1])
|
||||
# Verify database update for 'samx'
|
||||
assert bec_motor_map.database_buffer["x"] == init_positions["samx"]
|
||||
assert mm.database_buffer["x"] == init_positions["samx"]
|
||||
|
||||
# Verify 'samy' retains its last known position
|
||||
assert bec_motor_map.database_buffer["y"] == init_positions["samy"]
|
||||
assert mm.database_buffer["y"] == init_positions["samy"]
|
||||
|
||||
|
||||
def test_scatter_plot_rendering(bec_motor_map):
|
||||
motor_map_dev = bec_motor_map.client.device_manager.devices
|
||||
def test_scatter_plot_rendering(bec_figure):
|
||||
mm = bec_figure.motor_map("samx", "samy")
|
||||
motor_map_dev = mm.client.device_manager.devices
|
||||
|
||||
init_positions = {
|
||||
"samx": [motor_map_dev["samx"].read()["samx"]["value"]],
|
||||
"samy": [motor_map_dev["samy"].read()["samy"]["value"]],
|
||||
}
|
||||
|
||||
bec_motor_map.change_motors("samx", "samy")
|
||||
mm.change_motors("samx", "samy")
|
||||
|
||||
# Simulate motor movement for 'samx' only
|
||||
new_position_samx = 4.0
|
||||
bec_motor_map.on_device_readback({"signals": {"samx": {"value": new_position_samx}}})
|
||||
bec_motor_map._update_plot()
|
||||
mm.on_device_readback({"signals": {"samx": {"value": new_position_samx}}})
|
||||
mm._update_plot()
|
||||
|
||||
# Get the scatter plot item
|
||||
scatter_plot_item = bec_motor_map.plot_components["scatter"]
|
||||
scatter_plot_item = mm.plot_components["scatter"]
|
||||
|
||||
# Check the scatter plot item properties
|
||||
assert len(scatter_plot_item.data) > 0, "Scatter plot data is empty"
|
||||
@@ -106,16 +112,148 @@ def test_scatter_plot_rendering(bec_motor_map):
|
||||
), "Scatter plot Y data should retain last known position"
|
||||
|
||||
|
||||
def test_plot_visualization_consistency(bec_motor_map):
|
||||
bec_motor_map.change_motors("samx", "samy")
|
||||
def test_plot_visualization_consistency(bec_figure):
|
||||
mm = bec_figure.motor_map("samx", "samy")
|
||||
mm.change_motors("samx", "samy")
|
||||
# Simulate updating the plot with new data
|
||||
bec_motor_map.on_device_readback({"signals": {"samx": {"value": 5}}})
|
||||
bec_motor_map.on_device_readback({"signals": {"samy": {"value": 9}}})
|
||||
bec_motor_map._update_plot()
|
||||
mm.on_device_readback({"signals": {"samx": {"value": 5}}})
|
||||
mm.on_device_readback({"signals": {"samy": {"value": 9}}})
|
||||
mm._update_plot()
|
||||
|
||||
scatter_plot_item = bec_motor_map.plot_components["scatter"]
|
||||
scatter_plot_item = mm.plot_components["scatter"]
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
def test_change_background_value(bec_figure, qtbot):
|
||||
mm = bec_figure.motor_map("samx", "samy")
|
||||
|
||||
assert mm.config.background_value == 25
|
||||
assert np.all(mm.plot_components["limit_map"].image == 25.0)
|
||||
|
||||
mm.set_background_value(50)
|
||||
qtbot.wait(200)
|
||||
|
||||
assert mm.config.background_value == 50
|
||||
assert np.all(mm.plot_components["limit_map"].image == 50.0)
|
||||
|
||||
|
||||
def test_motor_map_init_from_config(bec_figure):
|
||||
config = {
|
||||
"widget_class": "BECMotorMap",
|
||||
"gui_id": "mm_id",
|
||||
"parent_id": bec_figure.gui_id,
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"axis": {
|
||||
"title": "Motor position: (-0.0, 0.0)",
|
||||
"title_size": None,
|
||||
"x_label": "Motor X (samx)",
|
||||
"x_label_size": None,
|
||||
"y_label": "Motor Y (samy)",
|
||||
"y_label_size": None,
|
||||
"legend_label_size": None,
|
||||
"x_scale": "linear",
|
||||
"y_scale": "linear",
|
||||
"x_lim": None,
|
||||
"y_lim": None,
|
||||
"x_grid": True,
|
||||
"y_grid": True,
|
||||
},
|
||||
"signals": {
|
||||
"source": "device_readback",
|
||||
"x": {
|
||||
"name": "samx",
|
||||
"entry": "samx",
|
||||
"unit": None,
|
||||
"modifier": None,
|
||||
"limits": [-10.0, 10.0],
|
||||
},
|
||||
"y": {
|
||||
"name": "samy",
|
||||
"entry": "samy",
|
||||
"unit": None,
|
||||
"modifier": None,
|
||||
"limits": [-5.0, 5.0],
|
||||
},
|
||||
"z": None,
|
||||
"dap": None,
|
||||
},
|
||||
"color": (255, 255, 255, 255),
|
||||
"scatter_size": 5,
|
||||
"max_points": 50,
|
||||
"num_dim_points": 10,
|
||||
"precision": 5,
|
||||
"background_value": 50,
|
||||
}
|
||||
mm = bec_figure.motor_map(config=config)
|
||||
config["gui_id"] = mm.gui_id
|
||||
|
||||
assert mm._config_dict == config
|
||||
|
||||
|
||||
def test_motor_map_set_scatter_size(bec_figure, qtbot):
|
||||
mm = bec_figure.motor_map("samx", "samy")
|
||||
|
||||
assert mm.config.scatter_size == 5
|
||||
assert mm.plot_components["scatter"].opts["size"] == 5
|
||||
|
||||
mm.set_scatter_size(10)
|
||||
qtbot.wait(200)
|
||||
|
||||
assert mm.config.scatter_size == 10
|
||||
assert mm.plot_components["scatter"].opts["size"] == 10
|
||||
|
||||
|
||||
def test_motor_map_change_precision(bec_figure):
|
||||
mm = bec_figure.motor_map("samx", "samy")
|
||||
|
||||
assert mm.config.precision == 2
|
||||
mm.set_precision(10)
|
||||
assert mm.config.precision == 10
|
||||
|
||||
|
||||
def test_motor_map_set_color(bec_figure, qtbot):
|
||||
mm = bec_figure.motor_map("samx", "samy")
|
||||
|
||||
assert mm.config.color == (255, 255, 255, 255)
|
||||
|
||||
mm.set_color((0, 0, 0, 255))
|
||||
qtbot.wait(200)
|
||||
assert mm.config.color == (0, 0, 0, 255)
|
||||
|
||||
|
||||
def test_motor_map_get_data_max_points(bec_figure, qtbot):
|
||||
mm = bec_figure.motor_map("samx", "samy")
|
||||
motor_map_dev = mm.client.device_manager.devices
|
||||
|
||||
init_positions = {
|
||||
"samx": [motor_map_dev["samx"].read()["samx"]["value"]],
|
||||
"samy": [motor_map_dev["samy"].read()["samy"]["value"]],
|
||||
}
|
||||
mm.on_device_readback({"signals": {"samx": {"value": 5.0}}})
|
||||
mm.on_device_readback({"signals": {"samy": {"value": 9.0}}})
|
||||
mm.on_device_readback({"signals": {"samx": {"value": 6.0}}})
|
||||
mm.on_device_readback({"signals": {"samy": {"value": 7.0}}})
|
||||
|
||||
expected_x = [init_positions["samx"][-1], 5.0, 5.0, 6.0, 6.0]
|
||||
expected_y = [init_positions["samy"][-1], init_positions["samy"][-1], 9.0, 9.0, 7.0]
|
||||
get_data = mm.get_data()
|
||||
|
||||
assert mm.database_buffer["x"] == expected_x
|
||||
assert mm.database_buffer["y"] == expected_y
|
||||
assert get_data["x"] == expected_x
|
||||
assert get_data["y"] == expected_y
|
||||
|
||||
mm.set_max_points(3)
|
||||
qtbot.wait(200)
|
||||
get_data = mm.get_data()
|
||||
assert len(get_data["x"]) == 3
|
||||
assert len(get_data["y"]) == 3
|
||||
assert get_data["x"] == expected_x[-3:]
|
||||
assert get_data["y"] == expected_y[-3:]
|
||||
assert mm.database_buffer["x"] == expected_x[-3:]
|
||||
assert mm.database_buffer["y"] == expected_y[-3:]
|
||||
|
||||
111
tests/unit_tests/test_bec_queue.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import pytest
|
||||
from bec_lib import messages
|
||||
|
||||
from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bec_queue_msg_full():
|
||||
content = {
|
||||
"primary": {
|
||||
"info": [
|
||||
{
|
||||
"active_request_block": None,
|
||||
"is_scan": [True],
|
||||
"queue_id": "600163fc-5e56-4901-af25-14e9ee76817c",
|
||||
"request_blocks": [
|
||||
{
|
||||
"RID": "89a76021-28c0-4297-828e-74ae40b941e5",
|
||||
"content": {
|
||||
"parameter": {
|
||||
"args": {"samx": [-0.1, 0.1]},
|
||||
"kwargs": {
|
||||
"exp_time": 0.5,
|
||||
"relative": True,
|
||||
"steps": 20,
|
||||
"system_config": {
|
||||
"file_directory": None,
|
||||
"file_suffix": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
"queue": "primary",
|
||||
"scan_type": "line_scan",
|
||||
},
|
||||
"is_scan": True,
|
||||
"metadata": {
|
||||
"RID": "89a76021-28c0-4297-828e-74ae40b941e5",
|
||||
"file_directory": None,
|
||||
"file_suffix": None,
|
||||
"user_metadata": {"sample_name": "testA"},
|
||||
},
|
||||
"msg": messages.ScanQueueMessage(
|
||||
metadata={
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
"user_metadata": {"sample_name": "testA"},
|
||||
"RID": "89a76021-28c0-4297-828e-74ae40b941e5",
|
||||
},
|
||||
scan_type="line_scan",
|
||||
parameter={
|
||||
"args": {"samx": [-0.1, 0.1]},
|
||||
"kwargs": {
|
||||
"steps": 20,
|
||||
"exp_time": 0.5,
|
||||
"relative": True,
|
||||
"system_config": {
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
queue="primary",
|
||||
),
|
||||
"readout_priority": {
|
||||
"async": [],
|
||||
"baseline": [],
|
||||
"monitored": ["samx"],
|
||||
"on_request": [],
|
||||
},
|
||||
"report_instructions": [{"scan_progress": 20}],
|
||||
"scan_id": "2d704cc3-c172-404c-866d-608ce09fce40",
|
||||
"scan_motors": ["samx"],
|
||||
"scan_number": 1289,
|
||||
}
|
||||
],
|
||||
"scan_id": ["2d704cc3-c172-404c-866d-608ce09fce40"],
|
||||
"scan_number": [1289],
|
||||
"status": "COMPLETED",
|
||||
}
|
||||
],
|
||||
"status": "RUNNING",
|
||||
}
|
||||
}
|
||||
msg = messages.ScanQueueStatusMessage(metadata={}, queue=content)
|
||||
return msg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bec_queue(qtbot, mocked_client):
|
||||
widget = BECQueue(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_bec_queue(bec_queue, bec_queue_msg_full):
|
||||
bec_queue.update_queue(bec_queue_msg_full.content, {})
|
||||
assert bec_queue.rowCount() == 1
|
||||
assert bec_queue.item(0, 0).text() == "1289"
|
||||
assert bec_queue.item(0, 1).text() == "line_scan"
|
||||
assert bec_queue.item(0, 2).text() == "COMPLETED"
|
||||
|
||||
|
||||
def test_bec_queue_empty(bec_queue):
|
||||
bec_queue.update_queue({}, {})
|
||||
assert bec_queue.rowCount() == 1
|
||||
assert bec_queue.item(0, 0).text() == ""
|
||||
assert bec_queue.item(0, 1).text() == ""
|
||||
assert bec_queue.item(0, 2).text() == ""
|
||||
@@ -1,9 +1,8 @@
|
||||
import re
|
||||
# pylint: skip-file
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from bec_lib.messages import BECStatus, ServiceMetricMessage, StatusMessage
|
||||
from qtpy.QtCore import QMetaMethod
|
||||
|
||||
from bec_widgets.widgets.bec_status_box.bec_status_box import BECServiceInfoContainer, BECStatusBox
|
||||
|
||||
@@ -11,44 +10,24 @@ from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def status_box(qtbot, mocked_client):
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
|
||||
) as mock_service_status_mixin:
|
||||
widget = BECStatusBox(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
def service_status_fixture():
|
||||
yield mock.MagicMock()
|
||||
|
||||
|
||||
def test_status_box_init(qtbot, mocked_client):
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
|
||||
) as mock_service_status_mixin:
|
||||
name = "my test"
|
||||
widget = BECStatusBox(parent=None, service_name=name, client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
assert widget.headerItem().DontShowIndicator.value == 1
|
||||
assert widget.children()[0].children()[0].config.service_name == name
|
||||
@pytest.fixture
|
||||
def status_box(qtbot, mocked_client, service_status_fixture):
|
||||
widget = BECStatusBox(client=mocked_client, bec_service_status_mixin=service_status_fixture)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_update_top_item(qtbot, mocked_client):
|
||||
with (
|
||||
mock.patch(
|
||||
"bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
|
||||
) as mock_service_status_mixin,
|
||||
mock.patch(
|
||||
"bec_widgets.widgets.bec_status_box.status_item.StatusItem.update_config"
|
||||
) as mock_update,
|
||||
):
|
||||
name = "my test"
|
||||
widget = BECStatusBox(parent=None, service_name=name, client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
widget.update_top_item_status(status="RUNNING")
|
||||
assert widget.bec_service_info_container[name].status == "RUNNING"
|
||||
assert mock_update.call_args == mock.call(widget.bec_service_info_container[name].dict())
|
||||
def test_update_top_item(status_box):
|
||||
assert status_box.tree.children()[0].children()[0].config.status == "IDLE"
|
||||
name = status_box.box_name
|
||||
status_box.update_top_item_status(status="RUNNING")
|
||||
assert status_box.status_container[name]["info"].status == "RUNNING"
|
||||
assert status_box.tree.children()[0].children()[0].config.status == "RUNNING"
|
||||
|
||||
|
||||
def test_create_status_widget(status_box):
|
||||
@@ -69,13 +48,13 @@ def test_bec_service_container(status_box):
|
||||
info = {"test": "test"}
|
||||
metrics = {"metric": "test_metric"}
|
||||
expected_return = BECServiceInfoContainer(
|
||||
service_name=name, status=status, info=info, metrics=metrics
|
||||
service_name=name, status=status.name, info=info, metrics=metrics
|
||||
)
|
||||
assert status_box.service_name in status_box.bec_service_info_container
|
||||
assert len(status_box.bec_service_info_container) == 1
|
||||
status_box._update_bec_service_container(name, status, info, metrics)
|
||||
assert len(status_box.bec_service_info_container) == 2
|
||||
assert status_box.bec_service_info_container[name] == expected_return
|
||||
assert status_box.box_name in status_box.status_container
|
||||
assert len(status_box.status_container) == 1
|
||||
status_box._update_status_container(name, status, info, metrics)
|
||||
assert len(status_box.status_container) == 2
|
||||
assert status_box.status_container[name]["info"] == expected_return
|
||||
|
||||
|
||||
def test_add_tree_item(status_box):
|
||||
@@ -83,10 +62,10 @@ def test_add_tree_item(status_box):
|
||||
status = BECStatus.IDLE
|
||||
info = {"test": "test"}
|
||||
metrics = {"metric": "test_metric"}
|
||||
assert len(status_box.children()[0].children()) == 1
|
||||
assert len(status_box.tree.children()[0].children()) == 1
|
||||
status_box.add_tree_item(name, status, info, metrics)
|
||||
assert len(status_box.children()[0].children()) == 2
|
||||
assert name in status_box.tree_items
|
||||
assert len(status_box.tree.children()[0].children()) == 2
|
||||
assert name in status_box.status_container
|
||||
|
||||
|
||||
def test_update_service_status(status_box):
|
||||
@@ -103,41 +82,31 @@ def test_update_service_status(status_box):
|
||||
services_metrics = {name: ServiceMetricMessage(name=name, metrics=metrics)}
|
||||
|
||||
with mock.patch.object(status_box, "update_core_services", return_value=services_status):
|
||||
assert not_connected_name in status_box.tree_items
|
||||
assert not_connected_name in status_box.status_container
|
||||
status_box.update_service_status(services_status, services_metrics)
|
||||
assert status_box.tree_items[name][1].config.metrics == metrics
|
||||
assert not_connected_name not in status_box.tree_items
|
||||
assert status_box.status_container[name]["widget"].config.metrics == metrics
|
||||
assert not_connected_name not in status_box.status_container
|
||||
|
||||
|
||||
def test_update_core_services(qtbot, mocked_client):
|
||||
with (
|
||||
mock.patch(
|
||||
"bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
|
||||
) as mock_service_status_mixin,
|
||||
mock.patch(
|
||||
"bec_widgets.widgets.bec_status_box.bec_status_box.BECStatusBox.update_top_item_status"
|
||||
) as mock_update,
|
||||
):
|
||||
name = "my test"
|
||||
status_box = BECStatusBox(parent=None, service_name=name, client=mocked_client)
|
||||
qtbot.addWidget(status_box)
|
||||
qtbot.waitExposed(status_box)
|
||||
status_box.CORE_SERVICES = ["test_service"]
|
||||
name = "test_service"
|
||||
status = BECStatus.RUNNING
|
||||
info = {"test": "test"}
|
||||
metrics = {"metric": "test_metric"}
|
||||
services_status = {name: StatusMessage(name=name, status=status, info=info)}
|
||||
services_metrics = {name: ServiceMetricMessage(name=name, metrics=metrics)}
|
||||
def test_update_core_services(status_box):
|
||||
status_box.CORE_SERVICES = ["test_service"]
|
||||
name = "test_service"
|
||||
status = BECStatus.RUNNING
|
||||
info = {"test": "test"}
|
||||
metrics = {"metric": "test_metric"}
|
||||
services_status = {name: StatusMessage(name=name, status=status, info=info)}
|
||||
services_metrics = {name: ServiceMetricMessage(name=name, metrics=metrics)}
|
||||
|
||||
status_box.update_core_services(services_status, services_metrics)
|
||||
assert mock_update.call_args == mock.call(status.name)
|
||||
status_box.update_core_services(services_status, services_metrics)
|
||||
assert status_box.tree.children()[0].children()[0].config.status == "RUNNING"
|
||||
assert status_box.status_container[name]["widget"].config.metrics == metrics
|
||||
|
||||
status = BECStatus.IDLE
|
||||
services_status = {name: StatusMessage(name=name, status=status, info=info)}
|
||||
services_metrics = {name: ServiceMetricMessage(name=name, metrics=metrics)}
|
||||
status_box.update_core_services(services_status, services_metrics)
|
||||
assert mock_update.call_args == mock.call("ERROR")
|
||||
status = BECStatus.IDLE
|
||||
services_status = {name: StatusMessage(name=name, status=status, info=info)}
|
||||
services_metrics = {name: ServiceMetricMessage(name=name, metrics=metrics)}
|
||||
status_box.update_core_services(services_status, services_metrics)
|
||||
assert status_box.tree.children()[0].children()[0].config.status == status.name
|
||||
assert status_box.status_container[name]["widget"].config.metrics == metrics
|
||||
|
||||
|
||||
def test_double_click_item(status_box):
|
||||
@@ -146,7 +115,9 @@ def test_double_click_item(status_box):
|
||||
info = {"test": "test"}
|
||||
metrics = {"MyData": "This should be shown nicely"}
|
||||
status_box.add_tree_item(name, status, info, metrics)
|
||||
item, status_item = status_box.tree_items[name]
|
||||
container = status_box.status_container[name]
|
||||
item = container["item"]
|
||||
status_item = container["widget"]
|
||||
with mock.patch.object(status_item, "show_popup") as mock_show_popup:
|
||||
status_box.itemDoubleClicked.emit(item, 0)
|
||||
status_box.tree.itemDoubleClicked.emit(item, 0)
|
||||
assert mock_show_popup.call_count == 1
|
||||
|
||||