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

Compare commits

..

71 Commits

Author SHA1 Message Date
e2f074b1aa fix: make main() function working, to be able to test out of Qt Designer 2024-07-03 14:10:04 +02:00
011103fde3 refactor: put QTreeWidget in a container, rather than inheriting from it, and avoid multiple inheritance of BECConnector
Inheriting from QTreeWidget causes havoc with display (see #245),
also it is important to parent items OR to give them a label (weird)
otherwise it also has display glitches.

Most of the time, composition has to be preferred over inheritance ;
inheritance is a question of behaviour - is the behaviour the same ?
Here, a widget is really not a BECConnector, but uses a BECConnector
(at least this is my understanding). Because a Widget and BECConnector
do not behave the same. It is easier, lighter to deal with single
inheritance.

The singleton usage is superfluous, since the underlying client is already
a singleton. Multiple BECConnector objects can be created, there won't
be more connections.

BECServiceStatusMixin has been removed in favor of the widget's own timer,
since it was causing "QObject::killTimer: Timers cannot be stopped from
another thread" errors (at least on my computer).

Also removes "redundant items check" ; where do those would come from?
2024-07-03 14:09:55 +02:00
f90bc00c18 fix: make error StatusMessage in case service info msg is None
Makes handling of status easier, no need for special cases
2024-07-03 14:09:55 +02:00
63a0056388 fix: add designer plugin classes 2024-07-03 14:09:55 +02:00
5d435bd5ee refactor: simplify logic in bec_status_box 2024-07-03 14:05:45 +02:00
semantic-release
0e802d8194 0.79.1
Automatically generated by python-semantic-release
2024-07-03 09:34:03 +00:00
d7718d4dcb fix: use libdir env var to preload Python library, also for Linux platform 2024-07-03 11:07:30 +02:00
semantic-release
4c2e02e912 0.79.0
Automatically generated by python-semantic-release
2024-07-03 08:45:07 +00:00
b8774e0b0b fix(toolbar): change default color to black to match BECFigure theme 2024-07-03 10:34:05 +02:00
6e75642090 feat(motor_map_widget): standalone MotorMap Widget with toolbar + plugin 2024-07-03 10:34:05 +02:00
aaa0d1003d fix(motor_map): fixed bug with residual trace after changing motors 2024-07-03 10:34:05 +02:00
5960918137 feat(motor_map): method to reset history trace 2024-07-03 10:34:05 +02:00
3dc0532df0 fix(widget_io): widget handler adjusted for spinboxes and comboboxes 2024-07-03 10:34:05 +02:00
96863adf53 refactor(toolbar): cleanup and adjusted colors 2024-07-03 10:34:05 +02:00
semantic-release
08425a623e 0.78.1
Automatically generated by python-semantic-release
2024-07-02 21:06:54 +00:00
b787759f44 fix(ui_loader): ui loader is compatible with bec plugins 2024-07-02 22:07:24 +02:00
semantic-release
25ef7c05e6 0.78.0
Automatically generated by python-semantic-release
2024-07-02 20:03:45 +00:00
c36bb80d6a feat(color_button): patched ColorButton from pyqtgraph to be able to be opened in another QDialog 2024-07-02 20:54:39 +02:00
semantic-release
c069f3e1b3 0.77.0
Automatically generated by python-semantic-release
2024-07-02 11:00:18 +00:00
215d59c8bf fix(waveform): scatter 2D brush error 2024-07-02 12:43:56 +02:00
008a33a9b1 fix(figure): API cleanup 2024-07-02 12:43:38 +02:00
3e787234c7 fix(figure): if/else logic corrected in subplot_factory 2024-07-02 12:43:38 +02:00
1173510105 fix(image): processing of already displayed data; closes #106 2024-07-02 12:43:38 +02:00
a391f3018c feat(bec_connector): export config to yaml 2024-07-02 12:43:38 +02:00
b6e1e20b7c fix(bec_figure): full reconstruction with config from other bec figure 2024-07-02 12:43:38 +02:00
572f2fb811 feat(utils): colors added convertor for rgba to hex 2024-07-02 12:43:38 +02:00
2e2d422910 fix(motor_map): API changes updates current visualisation; motor_map can be initialised from config 2024-07-02 12:43:38 +02:00
f0556e4411 fix(image): image add_custom_image fixed, closes #225 2024-07-02 12:43:38 +02:00
4a97105e4b fix(figure): subplot methods consolidated; added subplot factory 2024-07-02 12:43:38 +02:00
797f73c39a fix(image): image can be fully reconstructed from config 2024-07-02 12:43:38 +02:00
b8f796fd3f fix(image_item): vrange added int for pydantic model check 2024-07-02 12:43:38 +02:00
78673ea11a fix(bec_figure): waveforms can be initialised from the config; widgets are deleteLater after removal 2024-07-02 12:43:38 +02:00
c6a14c0768 Resolve "add VT100 console executing BEC as a widget" 2024-07-01 15:11:09 +02:00
semantic-release
70a966d8dc 0.76.1
Automatically generated by python-semantic-release
2024-06-29 12:17:07 +00:00
c42511dd44 fix(plugins): fixes and tests for auto-gen plugins 2024-06-28 13:49:12 +02:00
semantic-release
db62f9e998 0.76.0
Automatically generated by python-semantic-release
2024-06-28 10:20:18 +00:00
0610d2f9f0 fix: fixed qwidget inheritance for ring progress bar 2024-06-28 12:12:18 +02:00
c1dd0ee190 feat(designer): added support for creating designer plugins automatically 2024-06-28 12:12:18 +02:00
a45c407568 fix:parent set as first kwarg TextBox and WebsiteWidget 2024-06-27 17:47:08 +02:00
semantic-release
813f57861c 0.75.0
Automatically generated by python-semantic-release
2024-06-26 19:38:24 +00:00
3faee98ec8 feat(widgets): added simple bec queue widget 2024-06-26 20:42:37 +02:00
ca02132c8d refactor(dispatcher): cleanup 2024-06-26 20:42:37 +02:00
semantic-release
cb4ef25b73 0.74.1
Automatically generated by python-semantic-release
2024-06-26 13:10:06 +00:00
c8b7367815 fix(rings): rings properties updated right after setting 2024-06-26 11:46:21 +02:00
a268caaa30 test(bec_figure): tests for removing widgets with rpc e2e 2024-06-26 11:41:29 +02:00
6b25abff70 fix(motor_map): motor map can be removed from BECFigure with .remove() 2024-06-26 11:24:46 +02:00
21c807f358 chore: sorted dependencies alphabetically 2024-06-26 10:27:46 +02:00
56fdae4275 build: added missing pytest-bec-e2e dependency; closes #219 2024-06-26 10:24:25 +02:00
e6a06c9f43 build: fixed dependency ranges; closes #135 2024-06-26 10:09:48 +02:00
f979a63d3d docs: fixed doc string 2024-06-26 09:46:14 +02:00
semantic-release
327bc54e22 0.74.0
Automatically generated by python-semantic-release
2024-06-25 16:44:56 +00:00
a51b15da3f docs(becfigure): docs added 2024-06-25 18:37:23 +02:00
7271b422f9 test(waveform1d): dap e2e test added 2024-06-25 18:37:23 +02:00
1866ba66c8 feat(waveform1d): dap LMFit model can be added to plot 2024-06-25 18:37:20 +02:00
semantic-release
6175a04a90 0.73.2
Automatically generated by python-semantic-release
2024-06-25 16:24:11 +00:00
7120f3e93b fix(vscode): only run terminate if the process is still alive 2024-06-25 18:17:02 +02:00
acc13183e2 fix(rpc): trigger shutdown of server when gui is terminated 2024-06-25 16:45:39 +02:00
f75fc19c5b fix(rpc): remove of calling "close" and waiting for gui_is_alive 2024-06-25 15:22:29 +02:00
semantic-release
2650c8b8cf 0.73.1
Automatically generated by python-semantic-release
2024-06-25 10:25:13 +00:00
1de3cbf65a fix(ringprogressbar): removed hard-coded endpoint strings 2024-06-25 12:18:14 +02:00
semantic-release
4a9d0c9e44 0.73.0
Automatically generated by python-semantic-release
2024-06-25 10:05:53 +00:00
88ecd05b95 test: add test for imageitem 2024-06-25 11:58:55 +02:00
df812eaad5 feat: add new default scaling of image_item 2024-06-25 11:58:55 +02:00
semantic-release
d62da494c8 0.72.2
Automatically generated by python-semantic-release
2024-06-25 09:23:32 +00:00
e631fc15d8 fix(designer): fixed designer for pyenv and venv; closes #237 2024-06-24 18:38:53 +02:00
semantic-release
ecbf1ce0c8 0.72.1
Automatically generated by python-semantic-release
2024-06-24 14:42:07 +00:00
e5c0087c9a fix: renamed spiral progress bar to ring progress bar; closes #235 2024-06-24 16:37:36 +02:00
4348ed1bb2 test: bugfix to prohibit leackage of mock 2024-06-24 14:04:42 +02:00
semantic-release
5c11fde0a9 0.72.0
Automatically generated by python-semantic-release
2024-06-24 11:47:52 +00:00
4ca1efeeb8 feat(connector): added threadpool wrapper 2024-06-24 13:41:10 +02:00
aa7ce2ea27 tests(status_box_test): temporary disabled tests for status_box due to high rate of failures 2024-06-24 13:15:59 +02:00
95 changed files with 4771 additions and 1650 deletions

View File

@@ -1,153 +1,151 @@
# CHANGELOG
## v0.71.1 (2024-06-23)
## v0.79.1 (2024-07-03)
### Fix
* fix: don't print exception if the auto-update module cannot be found in plugins ([`860517a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/860517a3211075d1f6e2af7fa6a567b9e0cd77f3))
* fix: use libdir env var to preload Python library, also for Linux platform ([`d7718d4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d7718d4dcb9728c050b6421388af4d484f3741f2))
## v0.71.0 (2024-06-23)
## v0.79.0 (2024-07-03)
### Feature
* feat(scan_group_box): scan box for args and kwargs separated from ScanControlGUI code ([`d8cf441`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d8cf44134c30063e586771f9068947fef7a306d1))
* 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(cleanup): cleanup added to device_input widgets and scan_control ([`8badb6a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8badb6adc1d003dbf0b2b1a800c34821f3fc9aa3))
* fix(toolbar): change default color to black to match BECFigure theme ([`b8774e0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b8774e0b0bc43dcd00f94f42539a778e507ca27d))
* fix(scan_group_box): added row counter based on widgets ([`37682e7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/37682e7b8a6ede38308880d285e41a948d6fe831))
* fix(motor_map): fixed bug with residual trace after changing motors ([`aaa0d10`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/aaa0d1003d2e94b45bafe4f700852c2c05288aea))
* fix(scan_control): added default min limit for args bundle if specified ([`ec4574e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ec4574ed5c2c85ea6fbbe2b98f162a8e1220653b))
* fix(scan_control): argbox delete later added to prevent overlapping gui if scan changed ([`7ce3a83`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7ce3a83c58cb69c2bf7cb7f4eaba7e6a2ca6c546))
* fix(scan_control): only scans with defined gui_config are allowed ([`6dff187`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6dff1879c4178df0f8ebfd35101acdebb028d572))
* fix(WidgetIO): find handlers within base classes ([`ca85638`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ca856384f380dabf28d43f1cd48511af784c035b))
* fix(scan_control): adapted widget to scan BEC gui config ([`8b822e0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8b822e0fa8e28f080b9a4bf81948a7280a4c07bf))
* fix(scan_control): scan_control.py combatible with the newest BEC versions, test disabled ([`67d398c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/67d398caf74e08ab25a70cc5d85a5f0c2de8212d))
* fix(widget_io): widget handler adjusted for spinboxes and comboboxes ([`3dc0532`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3dc0532df05b6ec0a2522107fa0b1e210ce7d91b))
### Refactor
* refactor(device_line_edit): renamed default_device to default ([`4e2c9df`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4e2c9df6a4979d935285fd7eba17fd7fd455a35c))
* refactor(toolbar): cleanup and adjusted colors ([`96863ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/96863adf53c15112645d20eb6200733617801c6d))
### Test
## v0.78.1 (2024-07-02)
* test(scan_control): tests added ([`56e74a0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/56e74a0e7da72d18e89bc30d1896dbf9ef97cd6b))
### 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
* test(scan_control):e2e tests added ([`83001a0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/83001a0d8267e1320549b07032857dcf46ecd293))
* Resolve "add VT100 console executing BEC as a widget" ([`c6a14c0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c6a14c0768a90695567a83a7895247ed0c64f3ce))
* doc(scan_control): docs added ([`1b7921a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1b7921a7f2e3bcc846219a2a7aa0de0fd27bb8fe))
* fix(device_line_edit):SizePolicy fixed for 100 horizontal ([`21d20e0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/21d20e0fc78e9a3853abe802733388cce119ce20))
* tests WIP ([`c09644b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c09644b29ddb291c91dc58bcd6ebf02ff45cab36))
## v0.70.0 (2024-06-21)
### Documentation
* docs: fix typo in link ([`fdf11d8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fdf11d8147750e379af9b17792761a267b49ae53))
### Feature
* feat(bec-designer): automatic plugin discovery ([`4639eee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4639eee0b975ebd7a946e0e290449f5b88c372eb))
* 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.76.1 (2024-06-29)
### 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(plugins): fixes and tests for auto-gen plugins ([`c42511d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c42511dd44cc13577e108a6cef3166376e594f54))
## v0.69.0 (2024-06-21)
## v0.76.0 (2024-06-28)
### Feature
* feat(widgets): added vscode widget ([`48ae950`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/48ae950d57b454307ce409e2511f7b7adf3cfc6b))
* feat(designer): added support for creating designer plugins automatically ([`c1dd0ee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c1dd0ee1906dba1f2e2ae9ce40a84d55c26a1cce))
### 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: fixed qwidget inheritance for ring progress bar ([`0610d2f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0610d2f9f027f8659e7149f2dfbb316ff30e337d))
## v0.68.0 (2024-06-21)
### Unknown
* fix:parent set as first kwarg TextBox and WebsiteWidget ([`a45c407`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a45c4075684b93bfdcee03e5a416b84f61d3bc6f))
## v0.75.0 (2024-06-26)
### 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))
* feat(widgets): added simple bec queue widget ([`3faee98`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3faee98ec80041a27e4c1f1156178de6f9dcdc63))
### Refactor
* refactor: Change inheritance to QTreeWidget from QWidget ([`d2f2b20`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d2f2b206bb0eab60b8a9b0d0ac60a6b7887fa6fb))
* refactor(dispatcher): cleanup ([`ca02132`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ca02132c8d18535b37e9192e00459d2aca6ba5cf))
### Test
## v0.74.1 (2024-06-26)
* test: add test suite for bec_status_box and status_item ([`5d4ca81`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d4ca816cdedec4c88aba9eb326f85392504ea1c))
### Build
### Unknown
* build: added missing pytest-bec-e2e dependency; closes #219 ([`56fdae4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/56fdae42757bdb9fa301c1e425a77e98b6eaf92b))
* Update file requirements.txt ([`505a5ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/505a5ec8334ff4422913b3a7b79d39bcb42ad535))
* build: fixed dependency ranges; closes #135 ([`e6a06c9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e6a06c9f43e0ad6bbfcfa550a2f580d2a27aff66))
## v0.66.1 (2024-06-20)
### Chore
* chore: sorted dependencies alphabetically ([`21c807f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/21c807f35831fdd1ef2e488ab90edae4719f0cb7))
### Documentation
* docs: fixed doc string ([`f979a63`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f979a63d3d1a008f80e500510909750878ff4303))
### Fix
* fix: fixed shutdown for pyside ([`2718bc6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2718bc624731301756df524d0d5beef6cb1c1430))
* fix(rings): rings properties updated right after setting ([`c8b7367`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c8b7367815b095f8e4aa8b819481efb701f2e542))
## v0.66.0 (2024-06-20)
* fix(motor_map): motor map can be removed from BECFigure with .remove() ([`6b25abf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b25abff70280271e2eeb70450553c05d4b7c99c))
### Test
* test(bec_figure): tests for removing widgets with rpc e2e ([`a268caa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a268caaa30711fcc7ece542d24578d74cbf65c77))
## v0.74.0 (2024-06-25)
### Documentation
* docs(becfigure): docs added ([`a51b15d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a51b15da3f5e83e0c897a0342bdb05b9c677a179))
### Feature
* feat(rpc): discover widgets automatically ([`ef25f56`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ef25f5638032f931ceb292540ada618508bb2aed))
## v0.65.2 (2024-06-20)
### Fix
* fix(pyqt): webengine must be imported before qcoreapplication ([`cbbd23a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cbbd23aa33095141e4c265719d176c4aa8c25996))
## v0.65.1 (2024-06-20)
### Fix
* 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)
* feat(waveform1d): dap LMFit model can be added to plot ([`1866ba6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1866ba66c8e3526661beb13fff3e13af6a0ae562))
### Test
* test(device_input): tests added ([`1a0a98a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1a0a98a45367db414bed813bbd346b3e1ae8d550))
* test(waveform1d): dap e2e test added ([`7271b42`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7271b422f98ef9264970d708811c414b69a644db))
## v0.73.2 (2024-06-25)
### Fix
* fix(vscode): only run terminate if the process is still alive ([`7120f3e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7120f3e93b054b788f15e2d5bcd688e3c140c1ce))

View File

@@ -13,12 +13,14 @@ class Widgets(str, enum.Enum):
Enum for the available widgets.
"""
BECQueue = "BECQueue"
BECStatusBox = "BECStatusBox"
BECDock = "BECDock"
BECDockArea = "BECDockArea"
BECFigure = "BECFigure"
BECMotorMapWidget = "BECMotorMapWidget"
RingProgressBar = "RingProgressBar"
ScanControl = "ScanControl"
SpiralProgressBar = "SpiralProgressBar"
TextBox = "TextBox"
VSCodeEditor = "VSCodeEditor"
WebsiteWidget = "WebsiteWidget"
@@ -33,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.
@@ -143,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.
@@ -157,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.
"""
@@ -275,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.
@@ -383,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.
"""
@@ -402,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.
@@ -417,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":
"""
@@ -439,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,
@@ -550,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":
"""
@@ -568,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:
@@ -582,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":
"""
@@ -593,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:
@@ -601,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.
@@ -609,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:
@@ -659,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]":
@@ -678,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.
@@ -711,6 +660,7 @@ class BECImageItem(RPCBase):
- log
- rot
- transpose
- autorange_mode
"""
@rpc_call
@@ -767,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"):
"""
@@ -796,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.
@@ -818,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.
@@ -845,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,
@@ -888,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,
@@ -931,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):
"""
@@ -1022,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":
"""
@@ -1166,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.
@@ -1243,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.
@@ -1391,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.
@@ -1403,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.
"""
@@ -1412,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.
@@ -1442,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.
@@ -1458,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):
"""
@@ -1504,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":
"""
@@ -1673,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.
@@ -1682,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.
"""
@@ -1691,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.
@@ -1700,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.
"""
@@ -1709,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.
@@ -1718,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.
"""
@@ -1726,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.
@@ -1824,41 +1905,23 @@ class Ring(RPCBase):
"""
class ScanControl(RPCBase):
@property
class RingProgressBar(RPCBase):
@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.
"""
class SpiralProgressBar(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.
@@ -1874,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.
@@ -2021,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.
@@ -2033,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.
"""

View File

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

View File

@@ -10,6 +10,7 @@ from typing import Literal
import black
import isort
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
from bec_widgets.utils.plugin_utils import get_rpc_classes
if sys.version_info >= (3, 11):
@@ -161,6 +162,26 @@ def main():
generator.generate_client(rpc_classes)
generator.write(client_path)
for cls in rpc_classes["top_level_classes"]:
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"]

View File

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

View File

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

View File

@@ -1,14 +1,19 @@
# 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.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 +38,35 @@ class ConnectionConfig(BaseModel):
return v
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:
"""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 +93,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 +155,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 +165,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 +286,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):

View File

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

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import argparse
import collections
from collections.abc import Callable
from typing import TYPE_CHECKING, Union

View File

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

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

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

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

View File

@@ -1,6 +1,30 @@
from qtpy import QT_VERSION
from qtpy import PYQT6, PYSIDE6, QT_VERSION
from qtpy.QtCore import QFile, QIODevice
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
from bec_widgets.utils.plugin_utils import get_rpc_classes
from bec_widgets.widgets.buttons.color_button.color_button import ColorButton
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance):
super().__init__(baseinstance)
widgets = get_rpc_classes("bec_widgets").get("top_level_classes", [])
widgets.append(ColorButton)
self.custom_widgets = {widget.__name__: widget for widget in widgets}
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."""
@@ -14,14 +38,14 @@ class UILoader:
self.loader = uic.loadUi
elif QT_VERSION.startswith("6"):
# PyQt6 or PySide6
try:
from PySide6.QtUiTools import QUiLoader
if PYSIDE6:
self.loader = self.load_ui_pyside6
except ImportError:
elif PYQT6:
from PyQt6.uic import loadUi
self.loader = loadUi
else:
raise ImportError("No compatible Qt bindings found.")
def load_ui_pyside6(self, ui_file, parent=None):
"""
@@ -33,9 +57,8 @@ class UILoader:
Returns:
QWidget: The loaded widget.
"""
from PySide6.QtUiTools import QUiLoader
loader = QUiLoader(parent)
loader = CustomUiLoader(parent)
file = QFile(ui_file)
if not file.open(QIODevice.ReadOnly):
raise IOError(f"Cannot open file: {ui_file}")

View File

@@ -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):
@@ -142,6 +145,26 @@ class WidgetIO:
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):
"""

View File

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

View File

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

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

View File

@@ -0,0 +1 @@
{'files': ['bec_queue.py']}

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

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

View File

@@ -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.QtCore import Signal, Slot
from qtpy.QtWidgets import QTreeWidget, QTreeWidgetItem, QVBoxLayout, 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:
@@ -21,77 +22,25 @@ if TYPE_CHECKING:
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
StatusMessage = lazy_import_from("bec_lib.messages", ("StatusMessage",))
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.
Args:
client (BECClient): The client object to connect to the BEC server.
"""
services_update = Signal(dict, dict)
def __init__(self, client: BECClient):
super().__init__()
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."""
# 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(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 +48,68 @@ 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,
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)
QWidget.__init__(self, parent=parent)
self.setLayout(QVBoxLayout(self))
self.tree = QTreeWidget(self)
self.layout().addWidget(self.tree)
self.tree.setHeaderHidden(True)
self.tree.setStyleSheet(
"QTreeWidget::item:!selected "
"{ "
"border: 1px solid gainsboro; "
"border-left: none; "
"border-top: none; "
"}"
"QTreeWidget::item:selected {}"
)
self.box_name = box_name
self.status_container = defaultdict(lambda: {"info": None, "item": None, "widget": None})
self.service_name = service_name
self.config = config
self.bec_service_info_container = {}
self.tree_items = {}
self.tree_top_item = None
self.bec_service_status = BECServiceStatusMixin(client=self.client)
self.connector = BECConnector(client=client, gui_id=gui_id)
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.startTimer(
1000
) # use qobject's own timer instead of creating one, which may be stopped from another thread(?)
def timerEvent(self, event):
"""Get the latest service status from the BEC server."""
# pylint: disable=protected-access
self.connector.client._update_existing_services()
self.update_service_status(
self.connector.client._services_info, self.connector.client._services_metric
)
def init_ui(self) -> None:
"""Initialize the UI for the status box, and add QTreeWidget as the basis for the status box."""
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)
"""Init the UI for the BECStatusBox widget"""
top_label = self._create_status_widget(self.box_name, status=BECStatus.IDLE)
tree_item = QTreeWidgetItem(self.tree)
tree_item.setExpanded(True)
tree_item.setDisabled(True)
self.status_container[self.box_name].update({"item": tree_item, "widget": top_label})
self.tree.setItemWidget(tree_item, 0, top_label)
self.tree.addTopLevelItem(tree_item)
self.service_update.connect(top_label.update_config)
self._initialized = True
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 +122,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.tree, config=self.status_container[service_name]["info"])
return item
@Slot(str)
@@ -179,30 +134,32 @@ 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, 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 +170,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 +178,13 @@ 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())
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.check_redundant_tree_items(checked)
if service_name not in self.status_container:
self.add_tree_item(service_name, msg.status, msg.info, metrics)
self._update_status_container(service_name, msg.status, msg.info, metrics)
self.service_update.emit(self.status_container[service_name]["info"])
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,42 +193,26 @@ 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"
)
msg = services_info.pop(service_name, None)
if msg is None:
msg = StatusMessage(name=service_name, status=BECStatus.ERROR, info={})
if service_name not in self.status_container:
self.add_tree_item(service_name, msg.status, msg.info, metrics)
if service_name in self.tree_items:
self.service_update.emit(self.bec_service_info_container[service_name].model_dump())
continue
self.add_tree_item(service_name, msg.status, msg.info, metrics)
self._update_status_container(service_name, msg.status, msg.info, metrics)
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.add_tree_item(service_name, msg.status, msg.info, metrics)
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:
"""Utility method to check and remove redundant objects from the BECStatusBox.
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]
for key in to_be_deleted:
item, _ = self.tree_items.pop(key)
self.tree_top_item.removeChild(item)
def add_tree_item(
self, service_name: str, status: BECStatus, info: dict = None, metrics: dict = None
) -> None:
@@ -294,30 +220,18 @@ 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 = QTreeWidgetItem()
item_widget = self._create_status_widget(service_name, status, info, metrics)
toplevel_item = self.status_container[self.box_name]["item"]
item = QTreeWidgetItem(toplevel_item) # setDisabled=True
toplevel_item.addChild(item)
self.tree.setItemWidget(item, 0, item_widget)
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[service_name].update({"item": item, "widget": item_widget})
@Slot(QTreeWidgetItem, int)
def on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
@@ -327,24 +241,47 @@ 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):
super().cleanup()
QTreeWidget().closeEvent(event)
self.connector.cleanup()
def main():
"""Main method to run the BECStatusBox widget."""
# pylint: disable=import-outside-toplevel
from bec_lib.client import BECClient
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
# logging has to be configured before create QApplication,
# otherwise it ends badly with segfault...
# (seems to be a threading issue with loguru and probably Redis connector,
# which has to be a QtRedisConnector for Qt apps... Otherwise it is not
# thread-safe somehow ; didn't want to debug all this now)
logger = bec_logger.logger
service_config = ServiceConfig()
bec_logger.configure(
service_config.redis,
QtRedisConnector,
service_name="test_status_box",
service_config=service_config.service_config,
)
app = QApplication(sys.argv)
qdarktheme.setup_theme("auto")
main_window = BECStatusBox()
main_window.show()
client = BECClient()
status = BECStatusBox(parent=None, client=client, gui_id="test")
status.show()
sys.exit(app.exec())

View File

@@ -0,0 +1 @@
{'files': ['bec_status_box.py']}

View 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 "Widget to display the BECStatus from all active services."
def whatsThis(self):
return self.toolTip()

View 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_status_box.bec_status_box_plugin import BECStatusBoxPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECStatusBoxPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -0,0 +1,17 @@
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()

View File

@@ -0,0 +1 @@
{'files': ['color_button.py']}

View File

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

View 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.buttons.color_button.color_button_plugin import ColorButtonPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(ColorButtonPlugin())
if __name__ == "__main__": # pragma: no cover
main()

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,7 +46,7 @@ class SubplotConfig(ConnectionConfig):
class BECPlotBase(BECConnector, pg.GraphicsLayout):
USER_ACCESS = [
"config_dict",
"_config_dict",
"set",
"set_title",
"set_x_label",

View File

@@ -1,10 +1,12 @@
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.endpoints import MessageEndpoints
from bec_lib.scan_data import ScanData
from pydantic import Field, ValidationError
@@ -33,15 +35,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 +59,7 @@ class BECWaveform(BECPlotBase):
"set_legend_label_size",
]
scan_signal_update = pyqtSignal()
dap_params_update = pyqtSignal(dict)
def __init__(
self,
@@ -73,6 +76,7 @@ class BECWaveform(BECPlotBase):
)
self._curves_data = defaultdict(dict)
self.old_scan_id = None
self.scan_id = None
# Scan segment update proxy
@@ -80,6 +84,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 +220,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 +237,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 +246,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 +267,7 @@ class BECWaveform(BECPlotBase):
y: list | np.ndarray,
label: str = None,
color: str = None,
curve_source: str = "custom",
**kwargs,
) -> BECCurve:
"""
@@ -266,12 +278,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 +327,12 @@ class BECWaveform(BECPlotBase):
color_map_z: Optional[str] = "plasma",
label: Optional[str] = None,
validate_bec: bool = True,
source: str = "scan_segment",
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.
@@ -335,7 +350,7 @@ class BECWaveform(BECPlotBase):
BECCurve: The curve object.
"""
# Check if curve already exists
curve_source = "scan_segment"
curve_source = source
# Get entry if not provided and validate
x_entry, y_entry, z_entry = self._validate_signal_entries(
@@ -371,12 +386,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 +605,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 +703,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 +749,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 +805,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()

View File

@@ -31,6 +31,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 +64,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 +77,7 @@ class BECCurve(BECConnector, pg.PlotDataItem):
"set_pen_width",
"set_pen_style",
"get_data",
"dap_params",
]
def __init__(
@@ -96,6 +99,7 @@ class BECCurve(BECConnector, pg.PlotDataItem):
self.parent_item = parent_item
self.apply_config()
self.dap_params = None
if kwargs:
self.set(**kwargs)
@@ -119,6 +123,14 @@ 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)
@@ -241,5 +253,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()

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View 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

View File

@@ -0,0 +1 @@
{'files': ['motor_map_widget.py','motor_map_widget_plugin.py']}

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

View File

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

View File

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

View File

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

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

View 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.motor_map.bec_motor_map_widget_plugin import BECMotorMapWidgetPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECMotorMapWidgetPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1 @@
from .ring_progress_bar import RingProgressBar

View File

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

View File

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

View File

@@ -1 +0,0 @@
from .spiral_progress_bar import SpiralProgressBar

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ In the following, we describe 4 different type of widgets thaat are available in
![BECFigure.png](BECFigure.png)
(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:**
![Waveform 1D](./w1D.gif)
**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}
```
![Waveform 1D_DAP](./bec_figure_dap.gif)
(user.widgets.scatter_2d)=
## [2D Scatter Plot](/api_reference/_autosummary/bec_widgets.cli.client.BECWaveform)

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

View File

@@ -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:**
![SpiralProgressBar](./progress_bar.gif)
![RingProgressBar](./progress_bar.gif)
**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:

View File

@@ -9,7 +9,7 @@ hidden: false
---
bec_figure/
spiral_progress_bar/
ring_progress_bar/
website/
buttons/
text_box/

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "0.71.1"
version = "0.79.1"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -13,30 +13,29 @@ 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"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

@@ -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.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.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):
@@ -86,7 +65,7 @@ def test_add_tree_item(status_box):
assert len(status_box.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 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.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.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)
assert mock_show_popup.call_count == 1

View File

@@ -58,3 +58,18 @@ def test_color_validation_RGBA():
assert "The color values must be between 0 and 255 in RGBA format (R,G,B,A)" in str(
excinfo.value
)
def test_hex_to_rgba():
assert Colors.hex_to_rgba("#FF5733") == (255, 87, 51, 255)
assert Colors.hex_to_rgba("#FF573380") == (255, 87, 51, 128)
assert Colors.hex_to_rgba("#FF5733", 128) == (255, 87, 51, 128)
with pytest.raises(ValueError):
Colors.hex_to_rgba("#FF573")
def test_rgba_to_hex():
assert Colors.rgba_to_hex(255, 87, 51, 255) == "#FF5733FF"
assert Colors.rgba_to_hex(255, 87, 51, 128) == "#FF573380"
assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF"

View File

@@ -58,7 +58,7 @@ def test_device_input_base_set_default_device_error(device_input_base):
def test_device_input_base_get_device_list(device_input_base):
devices = device_input_base.get_device_list("FakePositioner")
assert devices == ["samx", "samy", "aptrx", "aptry"]
assert devices == ["samx", "samy", "samz", "aptrx", "aptry"]
def test_device_input_base_get_filters(device_input_base):

View File

@@ -56,6 +56,7 @@ def test_device_input_combobox_init(device_input_combobox):
assert device_input_combobox.devices == [
"samx",
"samy",
"samz",
"aptrx",
"aptry",
"gauss_bpm",
@@ -141,6 +142,7 @@ def test_device_input_line_edit_init(device_input_line_edit):
assert device_input_line_edit.devices == [
"samx",
"samy",
"samz",
"aptrx",
"aptry",
"gauss_bpm",

View File

@@ -0,0 +1,155 @@
import importlib
import inspect
import os
import sys
import pytest
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
def load_plugin(dir_path, content, plugin_name="MyWidget"):
plugin_path = dir_path.mkdir("plugin").join("plugin.py")
plugin_path.write(content)
sys.path.append(str(dir_path))
plugin = importlib.import_module("plugin.plugin")
importlib.reload(plugin)
yield getattr(plugin, plugin_name)
sys.path.pop()
@pytest.fixture(
params=[
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
QWidget.__init__(self, parent)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
QWidget.__init__(self, parent=parent)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent=parent)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super(QWidget, self).__init__(parent)"""
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super(QWidget, self).__init__(parent=parent)
""",
]
)
def plugin_with_correct_parent(tmpdir, request):
yield from load_plugin(tmpdir, request.param)
@pytest.fixture(
params=[
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
QWidget.__init__(self)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super().__init__()
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, parent=None):
super(QWidget, self).__init__()
""",
]
)
def plugin_with_missing_parent(tmpdir, request):
yield from load_plugin(tmpdir, request.param)
def test_generate_plugin(plugin_with_correct_parent):
generator = DesignerPluginGenerator(plugin_with_correct_parent)
generator.run()
assert os.path.exists(f"{generator.info.base_path}/register_my_widget.py")
assert os.path.exists(f"{generator.info.base_path}/my_widget_plugin.py")
assert os.path.exists(f"{generator.info.base_path}/my_widget.pyproject")
def test_generate_plugin_with_missing_parent(plugin_with_missing_parent):
with pytest.raises(ValueError) as excinfo:
generator = DesignerPluginGenerator(plugin_with_missing_parent)
generator.run()
assert "Widget class MyWidget must call the super constructor with parent." in str(
excinfo.value
)
@pytest.fixture()
def plugin_with_excluded_widget(tmpdir):
content = """
from qtpy.QtWidgets import QWidget
class BECDock(QWidget):
def __init__(self, parent=None):
QWidget.__init__(self, parent)
"""
yield from load_plugin(tmpdir, content, plugin_name="BECDock")
def test_generate_plugin_with_excluded_widget(plugin_with_excluded_widget, capsys):
generator = DesignerPluginGenerator(plugin_with_excluded_widget)
generator.run()
captured = capsys.readouterr()
assert "Plugin BECDock is excluded from generation." in captured.out
assert not os.path.exists(f"{generator.info.base_path}/register_bec_dock.py")
assert not os.path.exists(f"{generator.info.base_path}/bec_dock_plugin.py")
assert not os.path.exists(f"{generator.info.base_path}/bec_dock.pyproject")
@pytest.fixture(
params=[
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self):
QWidget.__init__(self)
""",
"""
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def __init__(self, config, parent=None):
super().__init__()
""",
]
)
def plugin_with_no_parent_as_first_arg(tmpdir, request):
yield from load_plugin(tmpdir, request.param)
def test_generate_plugin_raises_exception_when_first_argument_is_not_parent(
plugin_with_no_parent_as_first_arg,
):
with pytest.raises(ValueError) as excinfo:
generator = DesignerPluginGenerator(plugin_with_no_parent_as_first_arg)
generator.run()
assert "Widget class MyWidget must have parent as the first argument." in str(excinfo.value)

View File

@@ -74,7 +74,7 @@ def test_motor_thread_initialization(mocked_client):
def test_get_all_motors_names(mocked_client):
motor_thread = MotorThread(client=mocked_client)
motor_names = motor_thread.get_all_motors_names()
expected_names = ["samx", "samy", "aptrx", "aptry"]
expected_names = ["samx", "samy", "samz", "aptrx", "aptry"]
assert sorted(motor_names) == sorted(expected_names)
assert all(name in motor_names for name in expected_names)
assert len(motor_names) == len(expected_names) # Ensure only these motors are returned
@@ -155,11 +155,12 @@ def motor_selection_widget(qtbot, mocked_client, motor_thread):
def test_initialization_and_population(motor_selection_widget):
assert motor_selection_widget.comboBox_motor_x.count() == 4
assert motor_selection_widget.comboBox_motor_x.count() == 5
assert motor_selection_widget.comboBox_motor_x.itemText(0) == "samx"
assert motor_selection_widget.comboBox_motor_y.itemText(1) == "samy"
assert motor_selection_widget.comboBox_motor_x.itemText(2) == "aptrx"
assert motor_selection_widget.comboBox_motor_y.itemText(3) == "aptry"
assert motor_selection_widget.comboBox_motor_y.itemText(2) == "samz"
assert motor_selection_widget.comboBox_motor_x.itemText(3) == "aptrx"
assert motor_selection_widget.comboBox_motor_y.itemText(4) == "aptry"
def test_selection_and_signal_emission(motor_selection_widget):

View File

@@ -9,14 +9,14 @@ from .test_bec_figure import bec_figure
def test_init_plot_base(bec_figure):
plot_base = bec_figure.add_widget(widget_type="PlotBase", widget_id="test_plot")
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
assert plot_base is not None
assert plot_base.config.widget_class == "BECPlotBase"
assert plot_base.config.gui_id == "test_plot"
def test_plot_base_axes_by_separate_methods(bec_figure):
plot_base = bec_figure.add_widget(widget_type="PlotBase", widget_id="test_plot")
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
plot_base.set_title("Test Title")
plot_base.set_x_label("Test x Label")
@@ -66,7 +66,7 @@ def test_plot_base_axes_by_separate_methods(bec_figure):
def test_plot_base_axes_added_by_kwargs(bec_figure):
plot_base = bec_figure.add_widget(widget_type="PlotBase", widget_id="test_plot")
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
plot_base.set(
title="Test Title",

View File

@@ -0,0 +1,338 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import pytest
from bec_lib.endpoints import MessageEndpoints
from pydantic import ValidationError
from bec_widgets.utils import Colors
from bec_widgets.widgets.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.ring_progress_bar.ring import ProgressbarConnections, RingConfig
from bec_widgets.widgets.ring_progress_bar.ring_progress_bar import RingProgressBarConfig
from .client_mocks import mocked_client
@pytest.fixture
def ring_progress_bar(qtbot, mocked_client):
widget = RingProgressBar(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_bar_init(ring_progress_bar):
assert ring_progress_bar is not None
assert ring_progress_bar.client is not None
assert isinstance(ring_progress_bar, RingProgressBar)
assert ring_progress_bar.config.widget_class == "RingProgressBar"
assert ring_progress_bar.config.gui_id is not None
assert ring_progress_bar.gui_id == ring_progress_bar.config.gui_id
def test_config_validation_num_of_bars():
config = RingProgressBarConfig(num_bars=100, min_num_bars=1, max_num_bars=10)
assert config.num_bars == 10
def test_config_validation_num_of_ring_error():
ring_config_0 = RingConfig(index=0)
ring_config_1 = RingConfig(index=1)
with pytest.raises(ValidationError) as excinfo:
RingProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=1)
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "different number of configs"
assert "Length of rings configuration (2) does not match the number of bars (1)." in str(
excinfo.value
)
def test_config_validation_ring_indices_wrong_order():
ring_config_0 = RingConfig(index=2)
ring_config_1 = RingConfig(index=5)
with pytest.raises(ValidationError) as excinfo:
RingProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=2)
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "wrong indices"
assert (
"Indices of ring configurations must be unique and in order from 0 to num_bars 2."
in str(excinfo.value)
)
def test_config_validation_ring_same_indices():
ring_config_0 = RingConfig(index=0)
ring_config_1 = RingConfig(index=0)
with pytest.raises(ValidationError) as excinfo:
RingProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=2)
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "wrong indices"
assert (
"Indices of ring configurations must be unique and in order from 0 to num_bars 2."
in str(excinfo.value)
)
def test_config_validation_invalid_colormap():
with pytest.raises(ValueError) as excinfo:
RingProgressBarConfig(color_map="crazy_colors")
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "unsupported colormap"
assert "Colormap 'crazy_colors' not found in the current installation of pyqtgraph" in str(
excinfo.value
)
def test_ring_connection_endpoint_validation():
with pytest.raises(ValueError) as excinfo:
ProgressbarConnections(slot="on_scan_progress", endpoint="non_existing")
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "unsupported endpoint"
assert (
"For slot 'on_scan_progress', endpoint must be MessageEndpoint.scan_progress or 'scans/scan_progress'."
in str(excinfo.value)
)
with pytest.raises(ValueError) as excinfo:
ProgressbarConnections(slot="on_device_readback", endpoint="non_existing")
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "unsupported endpoint"
assert (
"For slot 'on_device_readback', endpoint must be MessageEndpoint.device_readback(device) or 'internal/devices/readback/{device}'."
in str(excinfo.value)
)
def test_bar_add_number_of_bars(ring_progress_bar):
assert ring_progress_bar.config.num_bars == 1
ring_progress_bar.set_number_of_bars(5)
assert ring_progress_bar.config.num_bars == 5
ring_progress_bar.set_number_of_bars(2)
assert ring_progress_bar.config.num_bars == 2
def test_add_remove_bars_individually(ring_progress_bar):
ring_progress_bar.add_ring()
ring_progress_bar.add_ring()
assert ring_progress_bar.config.num_bars == 3
assert len(ring_progress_bar.config.rings) == 3
ring_progress_bar.remove_ring(1)
assert ring_progress_bar.config.num_bars == 2
assert len(ring_progress_bar.config.rings) == 2
assert ring_progress_bar.rings[0].config.index == 0
assert ring_progress_bar.rings[1].config.index == 1
def test_bar_set_value(ring_progress_bar):
ring_progress_bar.set_number_of_bars(5)
assert ring_progress_bar.config.num_bars == 5
assert len(ring_progress_bar.config.rings) == 5
assert len(ring_progress_bar.rings) == 5
ring_progress_bar.set_value([10, 20, 30, 40, 50])
ring_values = [ring.config.value for ring in ring_progress_bar.rings]
assert ring_values == [10, 20, 30, 40, 50]
# update just one bar
ring_progress_bar.set_value(90, 1)
ring_values = [ring.config.value for ring in ring_progress_bar.rings]
assert ring_values == [10, 90, 30, 40, 50]
def test_bar_set_precision(ring_progress_bar):
ring_progress_bar.set_number_of_bars(3)
assert ring_progress_bar.config.num_bars == 3
assert len(ring_progress_bar.config.rings) == 3
assert len(ring_progress_bar.rings) == 3
ring_progress_bar.set_precision(2)
ring_precision = [ring.config.precision for ring in ring_progress_bar.rings]
assert ring_precision == [2, 2, 2]
ring_progress_bar.set_value([10.1234, 20.1234, 30.1234])
ring_values = [ring.config.value for ring in ring_progress_bar.rings]
assert ring_values == [10.12, 20.12, 30.12]
ring_progress_bar.set_precision(4, 1)
ring_precision = [ring.config.precision for ring in ring_progress_bar.rings]
assert ring_precision == [2, 4, 2]
ring_progress_bar.set_value([10.1234, 20.1234, 30.1234])
ring_values = [ring.config.value for ring in ring_progress_bar.rings]
assert ring_values == [10.12, 20.1234, 30.12]
def test_set_min_max_value(ring_progress_bar):
ring_progress_bar.set_number_of_bars(2)
ring_progress_bar.set_min_max_values(0, 10)
ring_min_values = [ring.config.min_value for ring in ring_progress_bar.rings]
ring_max_values = [ring.config.max_value for ring in ring_progress_bar.rings]
assert ring_min_values == [0, 0]
assert ring_max_values == [10, 10]
ring_progress_bar.set_value([5, 15])
ring_values = [ring.config.value for ring in ring_progress_bar.rings]
assert ring_values == [5, 10]
def test_setup_colors_from_colormap(ring_progress_bar):
ring_progress_bar.set_number_of_bars(5)
ring_progress_bar.set_colors_from_map("viridis", "RGB")
expected_colors = Colors.golden_angle_color("viridis", 5, "RGB")
converted_colors = [ring.color.getRgb() for ring in ring_progress_bar.rings]
ring_config_colors = [ring.config.color for ring in ring_progress_bar.rings]
assert expected_colors == converted_colors
assert ring_config_colors == expected_colors
def get_colors_from_rings(rings):
converted_colors = [ring.color.getRgb() for ring in rings]
ring_config_colors = [ring.config.color for ring in rings]
return converted_colors, ring_config_colors
def test_set_colors_from_colormap_and_change_num_of_bars(ring_progress_bar):
ring_progress_bar.set_number_of_bars(2)
ring_progress_bar.set_colors_from_map("viridis", "RGB")
expected_colors = Colors.golden_angle_color("viridis", 2, "RGB")
converted_colors, ring_config_colors = get_colors_from_rings(ring_progress_bar.rings)
assert expected_colors == converted_colors
assert ring_config_colors == expected_colors
# increase the number of bars to 6
ring_progress_bar.set_number_of_bars(6)
expected_colors = Colors.golden_angle_color("viridis", 6, "RGB")
converted_colors, ring_config_colors = get_colors_from_rings(ring_progress_bar.rings)
assert expected_colors == converted_colors
assert ring_config_colors == expected_colors
# decrease the number of bars to 3
ring_progress_bar.set_number_of_bars(3)
expected_colors = Colors.golden_angle_color("viridis", 3, "RGB")
converted_colors, ring_config_colors = get_colors_from_rings(ring_progress_bar.rings)
assert expected_colors == converted_colors
assert ring_config_colors == expected_colors
def test_set_colors_directly(ring_progress_bar):
ring_progress_bar.set_number_of_bars(3)
# setting as a list of rgb tuples
colors = [(255, 0, 0, 255), (0, 255, 0, 255), (0, 0, 255, 255)]
ring_progress_bar.set_colors_directly(colors)
converted_colors = get_colors_from_rings(ring_progress_bar.rings)[0]
assert colors == converted_colors
ring_progress_bar.set_colors_directly((255, 0, 0, 255), 1)
converted_colors = get_colors_from_rings(ring_progress_bar.rings)[0]
assert converted_colors == [(255, 0, 0, 255), (255, 0, 0, 255), (0, 0, 255, 255)]
def test_set_line_width(ring_progress_bar):
ring_progress_bar.set_number_of_bars(3)
ring_progress_bar.set_line_widths(5)
line_widths = [ring.config.line_width for ring in ring_progress_bar.rings]
assert line_widths == [5, 5, 5]
ring_progress_bar.set_line_widths([10, 20, 30])
line_widths = [ring.config.line_width for ring in ring_progress_bar.rings]
assert line_widths == [10, 20, 30]
ring_progress_bar.set_line_widths(15, 1)
line_widths = [ring.config.line_width for ring in ring_progress_bar.rings]
assert line_widths == [10, 15, 30]
def test_set_gap(ring_progress_bar):
ring_progress_bar.set_number_of_bars(3)
ring_progress_bar.set_gap(20)
assert ring_progress_bar.config.gap == 20
def test_auto_update(ring_progress_bar):
ring_progress_bar.enable_auto_updates(True)
scan_queue_status_scan_progress = {
"queue": {
"primary": {
"info": [{"active_request_block": {"report_instructions": [{"scan_progress": 10}]}}]
}
}
}
meta = {}
ring_progress_bar.on_scan_queue_status(scan_queue_status_scan_progress, meta)
assert ring_progress_bar._auto_updates is True
assert len(ring_progress_bar._rings) == 1
assert ring_progress_bar._rings[0].config.connections == ProgressbarConnections(
slot="on_scan_progress", endpoint=MessageEndpoints.scan_progress()
)
scan_queue_status_device_readback = {
"queue": {
"primary": {
"info": [
{
"active_request_block": {
"report_instructions": [
{
"readback": {
"devices": ["samx", "samy"],
"start": [1, 2],
"end": [10, 20],
}
}
]
}
}
]
}
}
}
ring_progress_bar.on_scan_queue_status(scan_queue_status_device_readback, meta)
assert ring_progress_bar._auto_updates is True
assert len(ring_progress_bar._rings) == 2
assert ring_progress_bar._rings[0].config.connections == ProgressbarConnections(
slot="on_device_readback", endpoint=MessageEndpoints.device_readback("samx")
)
assert ring_progress_bar._rings[1].config.connections == ProgressbarConnections(
slot="on_device_readback", endpoint=MessageEndpoints.device_readback("samy")
)
assert ring_progress_bar._rings[0].config.min_value == 1
assert ring_progress_bar._rings[0].config.max_value == 10
assert ring_progress_bar._rings[1].config.min_value == 2
assert ring_progress_bar._rings[1].config.max_value == 20

View File

@@ -4,4 +4,4 @@ from bec_widgets.cli.rpc_wigdet_handler import RPCWidgetHandler
def test_rpc_widget_handler():
handler = RPCWidgetHandler()
assert "BECFigure" in handler.widget_classes
assert "SpiralProgressBar" in handler.widget_classes
assert "RingProgressBar" in handler.widget_classes

View File

@@ -1,338 +0,0 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import pytest
from bec_lib.endpoints import MessageEndpoints
from pydantic import ValidationError
from bec_widgets.utils import Colors
from bec_widgets.widgets.spiral_progress_bar import SpiralProgressBar
from bec_widgets.widgets.spiral_progress_bar.ring import RingConfig, RingConnections
from bec_widgets.widgets.spiral_progress_bar.spiral_progress_bar import SpiralProgressBarConfig
from .client_mocks import mocked_client
@pytest.fixture
def spiral_progress_bar(qtbot, mocked_client):
widget = SpiralProgressBar(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_bar_init(spiral_progress_bar):
assert spiral_progress_bar is not None
assert spiral_progress_bar.client is not None
assert isinstance(spiral_progress_bar, SpiralProgressBar)
assert spiral_progress_bar.config.widget_class == "SpiralProgressBar"
assert spiral_progress_bar.config.gui_id is not None
assert spiral_progress_bar.gui_id == spiral_progress_bar.config.gui_id
def test_config_validation_num_of_bars():
config = SpiralProgressBarConfig(num_bars=100, min_num_bars=1, max_num_bars=10)
assert config.num_bars == 10
def test_config_validation_num_of_ring_error():
ring_config_0 = RingConfig(index=0)
ring_config_1 = RingConfig(index=1)
with pytest.raises(ValidationError) as excinfo:
SpiralProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=1)
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "different number of configs"
assert "Length of rings configuration (2) does not match the number of bars (1)." in str(
excinfo.value
)
def test_config_validation_ring_indices_wrong_order():
ring_config_0 = RingConfig(index=2)
ring_config_1 = RingConfig(index=5)
with pytest.raises(ValidationError) as excinfo:
SpiralProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=2)
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "wrong indices"
assert (
"Indices of ring configurations must be unique and in order from 0 to num_bars 2."
in str(excinfo.value)
)
def test_config_validation_ring_same_indices():
ring_config_0 = RingConfig(index=0)
ring_config_1 = RingConfig(index=0)
with pytest.raises(ValidationError) as excinfo:
SpiralProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=2)
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "wrong indices"
assert (
"Indices of ring configurations must be unique and in order from 0 to num_bars 2."
in str(excinfo.value)
)
def test_config_validation_invalid_colormap():
with pytest.raises(ValueError) as excinfo:
SpiralProgressBarConfig(color_map="crazy_colors")
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "unsupported colormap"
assert "Colormap 'crazy_colors' not found in the current installation of pyqtgraph" in str(
excinfo.value
)
def test_ring_connection_endpoint_validation():
with pytest.raises(ValueError) as excinfo:
RingConnections(slot="on_scan_progress", endpoint="non_existing")
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "unsupported endpoint"
assert (
"For slot 'on_scan_progress', endpoint must be MessageEndpoint.scan_progress or 'scans/scan_progress'."
in str(excinfo.value)
)
with pytest.raises(ValueError) as excinfo:
RingConnections(slot="on_device_readback", endpoint="non_existing")
errors = excinfo.value.errors()
assert len(errors) == 1
assert errors[0]["type"] == "unsupported endpoint"
assert (
"For slot 'on_device_readback', endpoint must be MessageEndpoint.device_readback(device) or 'internal/devices/readback/{device}'."
in str(excinfo.value)
)
def test_bar_add_number_of_bars(spiral_progress_bar):
assert spiral_progress_bar.config.num_bars == 1
spiral_progress_bar.set_number_of_bars(5)
assert spiral_progress_bar.config.num_bars == 5
spiral_progress_bar.set_number_of_bars(2)
assert spiral_progress_bar.config.num_bars == 2
def test_add_remove_bars_individually(spiral_progress_bar):
spiral_progress_bar.add_ring()
spiral_progress_bar.add_ring()
assert spiral_progress_bar.config.num_bars == 3
assert len(spiral_progress_bar.config.rings) == 3
spiral_progress_bar.remove_ring(1)
assert spiral_progress_bar.config.num_bars == 2
assert len(spiral_progress_bar.config.rings) == 2
assert spiral_progress_bar.rings[0].config.index == 0
assert spiral_progress_bar.rings[1].config.index == 1
def test_bar_set_value(spiral_progress_bar):
spiral_progress_bar.set_number_of_bars(5)
assert spiral_progress_bar.config.num_bars == 5
assert len(spiral_progress_bar.config.rings) == 5
assert len(spiral_progress_bar.rings) == 5
spiral_progress_bar.set_value([10, 20, 30, 40, 50])
ring_values = [ring.config.value for ring in spiral_progress_bar.rings]
assert ring_values == [10, 20, 30, 40, 50]
# update just one bar
spiral_progress_bar.set_value(90, 1)
ring_values = [ring.config.value for ring in spiral_progress_bar.rings]
assert ring_values == [10, 90, 30, 40, 50]
def test_bar_set_precision(spiral_progress_bar):
spiral_progress_bar.set_number_of_bars(3)
assert spiral_progress_bar.config.num_bars == 3
assert len(spiral_progress_bar.config.rings) == 3
assert len(spiral_progress_bar.rings) == 3
spiral_progress_bar.set_precision(2)
ring_precision = [ring.config.precision for ring in spiral_progress_bar.rings]
assert ring_precision == [2, 2, 2]
spiral_progress_bar.set_value([10.1234, 20.1234, 30.1234])
ring_values = [ring.config.value for ring in spiral_progress_bar.rings]
assert ring_values == [10.12, 20.12, 30.12]
spiral_progress_bar.set_precision(4, 1)
ring_precision = [ring.config.precision for ring in spiral_progress_bar.rings]
assert ring_precision == [2, 4, 2]
spiral_progress_bar.set_value([10.1234, 20.1234, 30.1234])
ring_values = [ring.config.value for ring in spiral_progress_bar.rings]
assert ring_values == [10.12, 20.1234, 30.12]
def test_set_min_max_value(spiral_progress_bar):
spiral_progress_bar.set_number_of_bars(2)
spiral_progress_bar.set_min_max_values(0, 10)
ring_min_values = [ring.config.min_value for ring in spiral_progress_bar.rings]
ring_max_values = [ring.config.max_value for ring in spiral_progress_bar.rings]
assert ring_min_values == [0, 0]
assert ring_max_values == [10, 10]
spiral_progress_bar.set_value([5, 15])
ring_values = [ring.config.value for ring in spiral_progress_bar.rings]
assert ring_values == [5, 10]
def test_setup_colors_from_colormap(spiral_progress_bar):
spiral_progress_bar.set_number_of_bars(5)
spiral_progress_bar.set_colors_from_map("viridis", "RGB")
expected_colors = Colors.golden_angle_color("viridis", 5, "RGB")
converted_colors = [ring.color.getRgb() for ring in spiral_progress_bar.rings]
ring_config_colors = [ring.config.color for ring in spiral_progress_bar.rings]
assert expected_colors == converted_colors
assert ring_config_colors == expected_colors
def get_colors_from_rings(rings):
converted_colors = [ring.color.getRgb() for ring in rings]
ring_config_colors = [ring.config.color for ring in rings]
return converted_colors, ring_config_colors
def test_set_colors_from_colormap_and_change_num_of_bars(spiral_progress_bar):
spiral_progress_bar.set_number_of_bars(2)
spiral_progress_bar.set_colors_from_map("viridis", "RGB")
expected_colors = Colors.golden_angle_color("viridis", 2, "RGB")
converted_colors, ring_config_colors = get_colors_from_rings(spiral_progress_bar.rings)
assert expected_colors == converted_colors
assert ring_config_colors == expected_colors
# increase the number of bars to 6
spiral_progress_bar.set_number_of_bars(6)
expected_colors = Colors.golden_angle_color("viridis", 6, "RGB")
converted_colors, ring_config_colors = get_colors_from_rings(spiral_progress_bar.rings)
assert expected_colors == converted_colors
assert ring_config_colors == expected_colors
# decrease the number of bars to 3
spiral_progress_bar.set_number_of_bars(3)
expected_colors = Colors.golden_angle_color("viridis", 3, "RGB")
converted_colors, ring_config_colors = get_colors_from_rings(spiral_progress_bar.rings)
assert expected_colors == converted_colors
assert ring_config_colors == expected_colors
def test_set_colors_directly(spiral_progress_bar):
spiral_progress_bar.set_number_of_bars(3)
# setting as a list of rgb tuples
colors = [(255, 0, 0, 255), (0, 255, 0, 255), (0, 0, 255, 255)]
spiral_progress_bar.set_colors_directly(colors)
converted_colors = get_colors_from_rings(spiral_progress_bar.rings)[0]
assert colors == converted_colors
spiral_progress_bar.set_colors_directly((255, 0, 0, 255), 1)
converted_colors = get_colors_from_rings(spiral_progress_bar.rings)[0]
assert converted_colors == [(255, 0, 0, 255), (255, 0, 0, 255), (0, 0, 255, 255)]
def test_set_line_width(spiral_progress_bar):
spiral_progress_bar.set_number_of_bars(3)
spiral_progress_bar.set_line_widths(5)
line_widths = [ring.config.line_width for ring in spiral_progress_bar.rings]
assert line_widths == [5, 5, 5]
spiral_progress_bar.set_line_widths([10, 20, 30])
line_widths = [ring.config.line_width for ring in spiral_progress_bar.rings]
assert line_widths == [10, 20, 30]
spiral_progress_bar.set_line_widths(15, 1)
line_widths = [ring.config.line_width for ring in spiral_progress_bar.rings]
assert line_widths == [10, 15, 30]
def test_set_gap(spiral_progress_bar):
spiral_progress_bar.set_number_of_bars(3)
spiral_progress_bar.set_gap(20)
assert spiral_progress_bar.config.gap == 20
def test_auto_update(spiral_progress_bar):
spiral_progress_bar.enable_auto_updates(True)
scan_queue_status_scan_progress = {
"queue": {
"primary": {
"info": [{"active_request_block": {"report_instructions": [{"scan_progress": 10}]}}]
}
}
}
meta = {}
spiral_progress_bar.on_scan_queue_status(scan_queue_status_scan_progress, meta)
assert spiral_progress_bar._auto_updates is True
assert len(spiral_progress_bar._rings) == 1
assert spiral_progress_bar._rings[0].config.connections == RingConnections(
slot="on_scan_progress", endpoint=MessageEndpoints.scan_progress()
)
scan_queue_status_device_readback = {
"queue": {
"primary": {
"info": [
{
"active_request_block": {
"report_instructions": [
{
"readback": {
"devices": ["samx", "samy"],
"start": [1, 2],
"end": [10, 20],
}
}
]
}
}
]
}
}
}
spiral_progress_bar.on_scan_queue_status(scan_queue_status_device_readback, meta)
assert spiral_progress_bar._auto_updates is True
assert len(spiral_progress_bar._rings) == 2
assert spiral_progress_bar._rings[0].config.connections == RingConnections(
slot="on_device_readback", endpoint=MessageEndpoints.device_readback("samx")
)
assert spiral_progress_bar._rings[1].config.connections == RingConnections(
slot="on_device_readback", endpoint=MessageEndpoints.device_readback("samy")
)
assert spiral_progress_bar._rings[0].config.min_value == 1
assert spiral_progress_bar._rings[0].config.max_value == 10
assert spiral_progress_bar._rings[1].config.min_value == 2
assert spiral_progress_bar._rings[1].config.max_value == 20

View File

@@ -46,7 +46,8 @@ def test_start_server(qtbot, mocked_client):
)
def test_close_event(qtbot, vscode_widget):
@pytest.fixture
def patched_vscode_process(qtbot, vscode_widget):
with mock.patch("bec_widgets.widgets.vscode.vscode.os.killpg") as mock_killpg:
with mock.patch("bec_widgets.widgets.vscode.vscode.os.getpgid") as mock_getpgid:
with mock.patch(
@@ -54,8 +55,24 @@ def test_close_event(qtbot, vscode_widget):
) as mock_close_event:
mock_getpgid.return_value = 123
vscode_widget.process = mock.Mock()
vscode_widget.process.pid = 123
vscode_widget.closeEvent(None)
mock_killpg.assert_called_once_with(123, 15)
vscode_widget.process.wait.assert_called_once()
mock_close_event.assert_called_once()
yield vscode_widget, mock_killpg, mock_close_event
def test_close_event(qtbot, patched_vscode_process):
vscode_patched, mock_killpg, mock_close_event = patched_vscode_process
vscode_patched.process.pid = 123
vscode_patched.process.poll.return_value = None
vscode_patched.closeEvent(None)
mock_killpg.assert_called_once_with(123, 15)
vscode_patched.process.wait.assert_called_once()
mock_close_event.assert_called_once()
def test_close_event_on_terminated_code(qtbot, patched_vscode_process):
vscode_patched, mock_killpg, mock_close_event = patched_vscode_process
vscode_patched.process.pid = 123
vscode_patched.process.poll.return_value = 0
vscode_patched.closeEvent(None)
mock_killpg.assert_not_called()
vscode_patched.process.wait.assert_not_called()
mock_close_event.assert_called_once()

View File

@@ -11,7 +11,7 @@ from .test_bec_figure import bec_figure
def test_adding_curve_to_waveform(bec_figure):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
# adding curve which is in bec - only names
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
@@ -39,7 +39,7 @@ def test_adding_curve_to_waveform(bec_figure):
def test_adding_curve_with_same_id(bec_figure):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i", gui_id="test_curve")
with pytest.raises(ValueError) as excinfo:
@@ -85,6 +85,7 @@ def test_create_waveform1D_by_config(bec_figure):
"pen_style": "dash",
"source": "scan_segment",
"signals": {
"dap": None,
"source": "scan_segment",
"x": {
"name": "samx",
@@ -121,9 +122,10 @@ def test_create_waveform1D_by_config(bec_figure):
},
}
w1 = bec_figure.add_plot(config=w1_config_input)
w1 = bec_figure.plot(config=w1_config_input)
w1_config_output = w1.get_config()
w1_config_input["gui_id"] = w1.gui_id
assert w1_config_input == w1_config_output
assert w1.plot_item.titleLabel.text == "Widget 1"
@@ -131,7 +133,7 @@ def test_create_waveform1D_by_config(bec_figure):
def test_change_gui_id(bec_figure):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
w1.change_gui_id("new_id")
@@ -140,7 +142,7 @@ def test_change_gui_id(bec_figure):
def test_getting_curve(bec_figure):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i", gui_id="test_curve")
c1_expected_config = CurveConfig(
widget_class="BECCurve",
@@ -170,7 +172,7 @@ def test_getting_curve(bec_figure):
def test_getting_curve_errors(bec_figure):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i", gui_id="test_curve")
with pytest.raises(ValueError) as excinfo:
@@ -187,7 +189,7 @@ def test_getting_curve_errors(bec_figure):
def test_add_curve(bec_figure):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
@@ -198,7 +200,7 @@ def test_add_curve(bec_figure):
def test_change_legend_font_size(bec_figure):
plot = bec_figure.add_plot()
plot = bec_figure.plot()
w1 = plot.add_curve_scan(x_name="samx", y_name="bpm4i")
my_func = plot.plot_item.legend
@@ -210,7 +212,7 @@ def test_change_legend_font_size(bec_figure):
def test_remove_curve(bec_figure):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
w1.add_curve_scan(x_name="samx", y_name="bpm4i")
w1.add_curve_scan(x_name="samx", y_name="bpm3a")
@@ -228,7 +230,7 @@ def test_remove_curve(bec_figure):
def test_change_curve_appearance_methods(bec_figure, qtbot):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
@@ -248,6 +250,7 @@ def test_change_curve_appearance_methods(bec_figure, qtbot):
assert c1.config.pen_style == "dashdot"
assert c1.config.source == "scan_segment"
assert c1.config.signals.model_dump() == {
"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},
@@ -256,7 +259,7 @@ def test_change_curve_appearance_methods(bec_figure, qtbot):
def test_change_curve_appearance_args(bec_figure):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
@@ -277,6 +280,7 @@ def test_change_curve_appearance_args(bec_figure):
assert c1.config.pen_style == "dashdot"
assert c1.config.source == "scan_segment"
assert c1.config.signals.model_dump() == {
"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},
@@ -285,7 +289,7 @@ def test_change_curve_appearance_args(bec_figure):
def test_set_custom_curve_data(bec_figure, qtbot):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_custom(
x=[1, 2, 3],
@@ -333,7 +337,7 @@ def test_custom_data_2D_array(bec_figure, qtbot):
def test_get_all_data(bec_figure):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_custom(
x=[1, 2, 3],
@@ -368,7 +372,7 @@ def test_get_all_data(bec_figure):
def test_curve_add_by_config(bec_figure):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1_config_input = {
"widget_class": "BECCurve",
@@ -384,6 +388,7 @@ def test_curve_add_by_config(bec_figure):
"pen_style": "dash",
"source": "scan_segment",
"signals": {
"dap": None,
"source": "scan_segment",
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
"y": {
@@ -407,7 +412,7 @@ def test_curve_add_by_config(bec_figure):
def test_scan_update(bec_figure, qtbot):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
@@ -441,7 +446,7 @@ def test_scan_update(bec_figure, qtbot):
def test_scan_history_with_val_access(bec_figure, qtbot):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
@@ -466,7 +471,7 @@ def test_scan_history_with_val_access(bec_figure, qtbot):
def test_scatter_2d_update(bec_figure, qtbot):
w1 = bec_figure.add_plot()
w1 = bec_figure.plot()
c1 = w1.add_curve_scan(x_name="samx", y_name="samx", z_name="bpm4i")

View File

@@ -7,7 +7,7 @@ import pytest
import yaml
from qtpy.QtWidgets import QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils.yaml_dialog import load_yaml, save_yaml
from bec_widgets.utils.yaml_dialog import load_yaml_gui, save_yaml_gui
@pytest.fixture(scope="function")
@@ -33,7 +33,7 @@ def test_load_yaml(qtbot, example_widget):
temp_file.write(b"name: test\nvalue: 42")
def load_yaml_wrapper():
config = load_yaml(example_widget)
config = load_yaml_gui(example_widget)
if config:
example_widget.config.update(config)
@@ -49,7 +49,7 @@ def test_load_yaml(qtbot, example_widget):
def test_load_yaml_file_not_found(qtbot, example_widget, capsys):
def load_yaml_wrapper():
config = load_yaml(example_widget)
config = load_yaml_gui(example_widget)
if config:
example_widget.config.update(config)
@@ -76,7 +76,7 @@ def test_load_yaml_general_exception(qtbot, example_widget, capsys, monkeypatch)
monkeypatch.setattr("builtins.open", mock_open)
def load_yaml_wrapper():
config = load_yaml(example_widget)
config = load_yaml_gui(example_widget)
if config:
example_widget.config.update(config)
@@ -96,7 +96,7 @@ def test_load_yaml_permission_error(qtbot, example_widget, monkeypatch, capsys):
os.chmod(temp_file_path, 0o000) # Remove permissions
def load_yaml_wrapper():
config = load_yaml(example_widget)
config = load_yaml_gui(example_widget)
if config:
example_widget.config.update(config)
@@ -120,7 +120,7 @@ def test_load_yaml_invalid_yaml(qtbot, example_widget, capsys):
temp_file.write(b"\tinvalid_yaml: [unbalanced_brackets: ]")
def load_yaml_wrapper():
config = load_yaml(example_widget)
config = load_yaml_gui(example_widget)
if config:
example_widget.config.update(config)
@@ -147,7 +147,7 @@ def test_save_yaml(qtbot, example_widget):
example_widget.saved_config = {"name": "test", "value": 42}
def save_yaml_wrapper():
save_yaml(example_widget, example_widget.saved_config)
save_yaml_gui(example_widget, example_widget.saved_config)
example_widget.export_button.clicked.connect(save_yaml_wrapper)