1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 09:47:52 +02:00

Compare commits

..

453 Commits

Author SHA1 Message Date
semantic-release
d281d6576c 0.37.0
Automatically generated by python-semantic-release
2024-01-17 14:09:10 +00:00
wyzula-jan
8bebc4f692 refactor: pylint improvement 2024-01-17 14:59:53 +01:00
wyzula-jan
1cd273c375 test: test_motor_map.py added 2024-01-17 14:59:53 +01:00
wyzula-jan
249170ea30 refactor: motor_map.py clean up 2024-01-17 14:59:53 +01:00
wyzula-jan
1a429b3024 feat: independent motor_map widget 2024-01-17 14:59:53 +01:00
semantic-release
e05cab812a 0.36.2
Automatically generated by python-semantic-release
2024-01-17 13:56:53 +00:00
wyzula-jan
7607d7a3b6 fix: bec_dispatcher.py can partially disconnect topics from slot 2024-01-16 16:02:31 +01:00
wyzula-jan
e51be04b95 fix: bec_dispatcher.py can connect multiple topics to one callback slot 2024-01-16 16:02:31 +01:00
semantic-release
de1f5c968a 0.36.1
Automatically generated by python-semantic-release
2024-01-15 16:04:22 +00:00
wyzula-jan
bf819bcf48 refactor: motor_example.py get coordinates by .readback.get() method 2024-01-15 16:14:15 +01:00
wyzula-jan
6f26e5cc3d refactor: using motor.readback.read() to access motor coordinates 2024-01-12 16:44:55 +01:00
wyzula-jan
f9c5c82381 fix: motor_example.py fix to the new .read() structure from bec_lib 2024-01-12 16:44:55 +01:00
semantic-release
79487dbec2 0.36.0
Automatically generated by python-semantic-release
2024-01-12 13:23:34 +00:00
58721bea1a feat: bec_dispatcher can link multiple endpoints topics for one qt slot 2024-01-12 14:22:29 +01:00
semantic-release
03e96669da 0.35.0
Automatically generated by python-semantic-release
2024-01-12 09:33:49 +00:00
wyzula-jan
eb529d24d2 refactor: review response for MR !31 2024-01-11 16:58:53 +01:00
wyzula-jan
ebd4fccda2 fix: monitor.py clear command from BECPlotter CLI clear now flush database and clear the plots 2024-01-10 17:59:36 +01:00
wyzula-jan
97dcc5ac76 fix: monitor.py crosshair enabled by default 2024-01-09 12:51:22 +01:00
wyzula-jan
9c7a189beb ci: fix cobertura for gitlab/16 2024-01-09 12:45:40 +01:00
wyzula-jan
6061b3150e fix: monitor.py change import of ConfigDialog from relative to absolute in order to make BECPlotter be able to open it 2024-01-09 12:32:45 +01:00
wyzula-jan
3982c5d498 refactor: config_dialog.py refactored to accept new config formatting 2024-01-08 16:31:56 +01:00
wyzula-jan
404ca49821 refactor: modular_app.py configs changed to new format 2024-01-08 16:17:14 +01:00
wyzula-jan
6e4775a124 feat: monitor.py can access custom data send through redis 2024-01-08 15:23:39 +01:00
wyzula-jan
5ab82bc133 fix: monitor_config_validator.py changed to check .describe() instead of signals 2023-12-21 16:59:02 +01:00
wyzula-jan
00ef3ae925 fix: monitor.py fixed not updating config changes after receiving refresh from BECPlotter 2023-12-18 15:42:10 +01:00
wyzula-jan
90d8069cc3 test: test_validator_errors.py fixed 2023-12-15 19:05:38 +01:00
wyzula-jan
457567ef74 test: test_bec_monitor.py fixed 2023-12-15 18:28:29 +01:00
wyzula-jan
1128ca5252 refactor: monitor.py clean up 2023-12-15 13:49:08 +01:00
wyzula-jan
86c5f25205 fix: monitor_config_validator.py valid color is Literal['black','white'] 2023-12-15 13:19:16 +01:00
wyzula-jan
a706da2490 fix: monitor.py fixed scan mode 2023-12-15 13:12:05 +01:00
wyzula-jan
d67bdd2616 fix: motor_config_validation changed to new monitor config structure 2023-12-14 14:01:28 +01:00
wyzula-jan
c3f2ad45c3 refactor: monitor.py data for scan segment are only accessed through queue.scan_storage 2023-12-13 10:30:58 +01:00
wyzula-jan
26c07c3205 feat: monitor.py access data directly from scan storage 2023-12-13 09:37:45 +01:00
wyzula-jan
c995e0d235 refactor: monitor.py config hierarchy refactor for source (can be 'scan_segment','history', 'redis') 2023-12-13 09:37:45 +01:00
wyzula-jan
463a60a99c refactor: monitor.py on_scan_segment old logic separated from on_scan_segment function 2023-12-13 09:37:21 +01:00
semantic-release
98a46a85b2 0.34.1
Automatically generated by python-semantic-release
2023-12-12 19:20:19 +00:00
wyzula-jan
186c42d667 fix: formatter and tests fixed 2023-12-12 18:24:38 +01:00
wyzula-jan
f3a47a5b08 refactor: repo reorganisation 2023-12-12 17:26:28 +01:00
wyzula-jan
af995a74f3 docs: readdocs updated 2023-12-12 17:26:28 +01:00
wyzula-jan
3abd955465 refactor: repo reorganization 2023-12-12 17:26:22 +01:00
wyzula-jan
cba8131367 docs: readme.md updated 2023-12-12 17:25:25 +01:00
wyzula-jan
831eddc136 docs: gitlab templates for issues and merge requests from main bec repo 2023-12-12 17:25:25 +01:00
9e852d1afc refactor: replace deprecated imports from typing
https://peps.python.org/pep-0585/#implementation
2023-12-12 15:22:59 +01:00
3ec9caae09 build: fix python requirement 2023-12-12 15:14:18 +01:00
11281fef53 ci: added rtd update job 2023-12-11 19:22:01 +01:00
semantic-release
9d497b70bf 0.34.0
Automatically generated by python-semantic-release
2023-12-08 09:57:26 +00:00
wyzula-jan
2a334156a8 test: validation errors tests 2023-12-07 19:32:21 +01:00
wyzula-jan
086804780d fix: monitor_config_validator.py - Signal validation changed from field_validator to model_validator to check first name and then entry 2023-12-07 19:32:21 +01:00
wyzula-jan
731fba55ec refactor: monitor.py pylint improvement 2023-12-07 19:32:21 +01:00
wyzula-jan
a3b24f9242 feat: monitor.py error message popup 2023-12-07 19:32:20 +01:00
wyzula-jan
af71e35e73 fix: monitor_config_validator.py fix entry validation executed only if name validator is successful 2023-12-07 19:20:26 +01:00
semantic-release
3e8996a024 0.33.0
Automatically generated by python-semantic-release
2023-12-07 17:10:16 +00:00
03bdf980bc fix: fixed default config options 2023-12-07 18:03:29 +01:00
1084bc0a80 fix: added hooks to react to incoming config messages and instructions 2023-12-07 15:28:45 +00:00
504944f696 feat: added axis_width and axis_color as optional plot settings 2023-12-07 15:28:45 +00:00
semantic-release
b53f72f0ad 0.32.2
Automatically generated by python-semantic-release
2023-12-06 19:39:51 +00:00
wyzula-jan
aad754f472 test: removed captured code for Permission tests 2023-12-06 16:27:09 +01:00
wyzula-jan
f5d1127d21 test: additional tests for error handling for yaml_dialog.py 2023-12-06 16:12:51 +01:00
wyzula-jan
080c258d15 fix: changed exec_ to exec for all apps 2023-12-06 16:02:28 +01:00
wyzula-jan
5adde23a45 fix: yaml_dialog.py changed to use native solution of OS -> should prevent crashing on py3.11 2023-12-06 16:01:19 +01:00
semantic-release
2359a08519 0.32.1
Automatically generated by python-semantic-release
2023-12-06 10:21:25 +00:00
wyzula-jan
d1f9979ab1 fix: widget_io print_widget_hierarchy fix comboboxes 2023-12-06 09:44:32 +01:00
wyzula-jan
bcc47f3740 refactor: improve pylint for WidgetIO 2023-12-04 19:47:56 +01:00
wyzula-jan
4f700976dd fix: WidgetIO combobox fixed for qt6 distributions 2023-12-04 19:37:42 +01:00
039f963661 Ci multiple python versions 2023-11-30 15:11:30 +01:00
semantic-release
3ebdb4bed0 0.32.0
Automatically generated by python-semantic-release
2023-11-30 13:28:36 +00:00
wyzula-jan
016b26f5cf feat: jupyter rich console added as alternative to default QTextEdit terminal output 2023-11-22 14:33:57 +01:00
wyzula-jan
e5010c7772 build: added qtconsole to dependency 2023-11-22 14:32:54 +01:00
wyzula-jan
a4d9713785 refactor: improve pylint score 2023-11-22 13:24:23 +01:00
wyzula-jan
b21c1db2a9 test: test_editor.py tests added 2023-11-22 13:12:25 +01:00
wyzula-jan
d967fafe3c refactor: editor.py open/save file refactored to not use native window 2023-11-22 12:48:47 +01:00
wyzula-jan
7d15397ce3 refactor: changed dependency to CAPS 2023-11-22 10:15:49 +01:00
wyzula-jan
d978740f98 fix: added missing dependency jedi 2023-11-22 00:50:25 +01:00
wyzula-jan
c174326762 build: disabled support to PySide2/PySide6, due to no QScintilla support; added pyqtdarktheme 2023-11-22 00:06:16 +01:00
wyzula-jan
3d9dc5c008 doc: editor.py and toolbar.py documentation added 2023-11-21 23:42:51 +01:00
wyzula-jan
3cc05cde14 fix: editor.py switch to disable docstring 2023-11-21 22:56:27 +01:00
wyzula-jan
f96caccfcb fix: editor.py compact signature on tooltip 2023-11-21 22:40:21 +01:00
wyzula-jan
d7a2c6830f refactor: editor.py signature tooltip process moved to AutoCompleter; simpler logic for signature tooltip 2023-11-21 19:45:52 +01:00
wyzula-jan
045b1baa60 feat: editor.py basic signature calltip 2023-11-21 17:28:11 +01:00
wyzula-jan
fb555b278a feat: editor.py jedi autocomplete hooked 2023-11-20 16:36:57 +01:00
wyzula-jan
d865e2f1af fix: editor.py removed automatic background behind edited text 2023-11-19 20:12:21 +01:00
wyzula-jan
c70ddb3cb1 feat: editor.py added splitter between editor and terminal 2023-11-19 19:24:01 +01:00
wyzula-jan
8ad3059592 fix: toolbar.py automatic initialisation works 2023-11-19 19:04:32 +01:00
wyzula-jan
ee3b616ec1 refactor: toolbar.py migration to native qt QToolBar 2023-11-19 15:17:23 +01:00
wyzula-jan
286e62df92 feat: toolbar.py proof-of-concept 2023-11-18 15:53:24 +01:00
wyzula-jan
b07bb3dde2 refactor: editor.py migration to qtpy 2023-11-18 13:35:57 +01:00
wyzula-jan
10dfe9fb65 refactor: change from QMainWindow to QWidget 2023-11-17 19:07:16 +01:00
wyzula-jan
a0d172e3dc fix: terminal output as QThread 2023-11-16 14:07:59 +01:00
wyzula-jan
94878448c8 feat: basic text editor + running terminal output 2023-11-16 14:06:06 +01:00
wyzula-jan
745aa6e812 ci: added pylint to ci 2023-11-15 12:19:06 +01:00
wyzula-jan
b14d95ad2b build: added option to add PyQt6/PyQt5/PySide2/PySide6 as qt distribution with PyQt6 as default 2023-11-14 00:34:57 +01:00
65cbd6ef28 ci: added libdbus 2023-11-13 21:56:24 +01:00
wyzula-jan
bb64088282 ci: added libegl1-mesa to the apt-get install command in tests 2023-11-13 18:51:21 +01:00
wyzula-jan
b6f6bc5b20 refactor: migration to qtpy 2023-11-13 18:37:32 +01:00
semantic-release
7957d2c566 0.31.0
Automatically generated by python-semantic-release
2023-11-13 14:52:28 +00:00
wyzula-jan
84ef7e59c9 refactor: monitor_config_validator.py name validation logic 2023-11-13 15:42:37 +01:00
wyzula-jan
cae4f8b934 refactor: fix bec_lib imports in tests 2023-11-13 15:42:37 +01:00
wyzula-jan
53494c7327 refactor: monitor_config_validator.py device_manager renamed to devices 2023-11-13 15:42:37 +01:00
wyzula-jan
05c822617a test: tests fixed; test_bec_monitor.py extended for FakeDevice class 2023-11-13 15:42:37 +01:00
wyzula-jan
6b114c2461 refactor: clean up 2023-11-13 15:42:37 +01:00
wyzula-jan
cd9cd9ef9d refactor: BECMonitor cleanup for validation in on_scan_segment; dropped support for multiple entries for single device 2023-11-13 15:42:37 +01:00
wyzula-jan
37278e363c refactor: configs for BECMonitor are validated by pydantic outside the main widget 2023-11-13 15:42:25 +01:00
wyzula-jan
92a5325aad docs: pydantic validation module docs 2023-11-13 15:41:55 +01:00
wyzula-jan
7fec0c7e44 feat: pydantic validation module for monitor.py 2023-11-13 15:41:55 +01:00
wyzula-jan
59bc40427c refactor: monitor.py update_config renamed to on_config_update; gui_id added 2023-11-13 15:41:55 +01:00
5ec2b08e34 refactor: fix bec_lib imports 2023-11-13 08:29:07 +01:00
semantic-release
b38e942acb 0.30.0
Automatically generated by python-semantic-release
2023-11-10 09:58:53 +00:00
wyzula-jan
832a438b24 test: tests for scan_control 2023-11-09 21:53:09 +01:00
wyzula-jan
43777f58f6 refactor: changed buttons name to be consistent with other projects 2023-11-09 21:02:26 +01:00
wyzula-jan
aa4c7c3385 feat: WidgetIO support for QLabel 2023-11-09 21:02:25 +01:00
wyzula-jan
b85cc898d5 fix: added imports to __init__.py in widget for ScanControl class 2023-11-09 16:30:06 +01:00
wyzula-jan
da9025e032 fix: scan_control.py args_size_max fixed 2023-11-09 12:13:24 +01:00
wyzula-jan
5c67026637 fix: scan_control.py default spinBox limits increases 2023-11-08 16:21:21 +01:00
wyzula-jan
ee2f36fb40 fix: scan_control.py supports minimum and maximum number of args 2023-11-08 16:02:50 +01:00
wyzula-jan
5ac3526384 fix: scan_control.py wipe table and reinitialise devices when scan is changed 2023-11-08 15:17:41 +01:00
wyzula-jan
3be9c974b5 refactor: scan_control.py refactor to use WidgetIO 2023-11-08 14:35:43 +01:00
wyzula-jan
18a702572f fix: widget_IO.py added handler for QCheckBox 2023-11-08 14:25:43 +01:00
wyzula-jan
63f23cf78e refactor: scan_control.py extraction of args separated 2023-11-08 14:25:43 +01:00
wyzula-jan
2e42ba174f fix: scan_control.py scan can be executed from GUI 2023-11-08 14:25:43 +01:00
wyzula-jan
8bc88ca195 refactor: scan_control.py kwargs are in grid layout, args in table widget 2023-11-08 14:25:43 +01:00
wyzula-jan
f5ff15fb9a refactor: scan_control.py kwargs and args layouts changed to QGridLayouts 2023-11-08 14:25:43 +01:00
wyzula-jan
4b7592c279 fix: scan_control.py all kwargs are rendered 2023-11-08 14:25:43 +01:00
wyzula-jan
0fe06ade5b feat: scan_control.py added option to limit scan selection from list of strings as init parameter 2023-11-08 14:25:43 +01:00
wyzula-jan
27f6a89a29 refactor: scan_control.py clean up 2023-11-08 14:25:42 +01:00
wyzula-jan
b311069722 fix: scan_control.py kwargs and args are added to the correct layouts 2023-11-08 14:25:42 +01:00
wyzula-jan
26c6e1f4b8 refactor: scan_control.py generate_input_field refactored into smaller functions 2023-11-08 14:25:42 +01:00
wyzula-jan
088fa516a8 feat: scan_control.py a general widget which can generate GUI for scan input 2023-11-08 14:25:42 +01:00
wyzula-jan
975aadbf07 refactor: changed widget_IO.py to widget_io.py for consistency; widget_io.py example excluded from coverage 2023-11-08 14:21:29 +01:00
wyzula-jan
1f0103480d refactor: clean up 2023-11-08 11:23:25 +01:00
wyzula-jan
679d3e1980 test: test_widget_io.py fixed 2023-11-08 11:20:40 +01:00
wyzula-jan
3c28cf0e01 refactor: widget_hierarchy.py renamed to widgets_io.py 2023-11-08 11:07:58 +01:00
wyzula-jan
9308f60b88 refactor: widget_hierarchy.py changed into general purpose modul to extract values from widgets using handlers 2023-11-08 11:06:37 +01:00
semantic-release
ebd0e588d4 0.29.0
Automatically generated by python-semantic-release
2023-10-31 15:22:02 +00:00
wyzula-jan
42fe859fca refactor: yaml_dialog.py save/load logic changed 2023-10-31 16:18:17 +01:00
wyzula-jan
850f02338e test: test_yaml_dialog.py tests for loading/saving dialog for .yaml export 2023-10-31 16:03:53 +01:00
wyzula-jan
10539f0ba5 fix: yaml_dialog.py added support for .yml files 2023-10-31 15:20:08 +01:00
wyzula-jan
4a6e73f4f7 docs: config_dialog.py comments added to example cases 2023-10-31 15:15:39 +01:00
wyzula-jan
f396f98e73 test: test_hierarchy.py added 2023-10-31 15:11:49 +01:00
wyzula-jan
de23c28e40 refactor: qt_utils/hierarchy function refactored to use widget handler allowing to add more widget support in the future 2023-10-31 13:04:44 +01:00
wyzula-jan
fd49f1b484 refactor: config_dialog.py add_new_plot_tab and add_new_scan_tab changed names 2023-10-31 13:03:20 +01:00
wyzula-jan
ff1d918d43 fix: yaml_dialog.py added return None if no file path is specified 2023-10-31 11:17:18 +01:00
wyzula-jan
d52aa15aac fix: wrong __init__.py in modular_app 2023-10-30 18:52:07 +01:00
wyzula-jan
3a4cbb1bb6 refactor: test_bec_monitor.py and test_config_dialog.py cleaned up 2023-10-30 17:48:09 +00:00
wyzula-jan
3866d7ce4d fix: test_bec_monitor.py config loaded fresh in the test function to avoid parameter leak 2023-10-30 17:48:09 +00:00
wyzula-jan
989cd51162 fix: test_bec_monitor.py setup_monitor help function changed to pytest.fixture 2023-10-30 17:48:09 +00:00
wyzula-jan
1fd018512f refactor: test_config_dialog.py and test_bec_monitor.py clean up 2023-10-30 17:48:09 +00:00
wyzula-jan
1cdd760e40 fix: test_config_dialog.py - QApplication removed 2023-10-30 17:48:09 +00:00
wyzula-jan
1333e6cbca fix: test_config_dialog.py - test_add_new_plot_and_modify qtbot action .click() changed -> function called directly 2023-10-30 17:48:09 +00:00
wyzula-jan
4e710dda5e fix: test_config_dialog.py disabled 2023-10-30 17:48:09 +00:00
wyzula-jan
77e1d0925d fix: test_bec_monitor.py QApplication instance removed 2023-10-30 17:48:09 +00:00
wyzula-jan
60e864b259 fix: test_config_dialog.py QApplication instance added 2023-10-30 17:48:09 +00:00
wyzula-jan
a3a72b9b93 refactor: test_bec_monitor.py widget name changed 2023-10-30 17:48:09 +00:00
wyzula-jan
6a5e0adfb2 test: test_config_dialog.py added 2023-10-30 17:48:09 +00:00
wyzula-jan
5ad19b4d7b refactor: test configs are saved as yaml and shared for similar tests 2023-10-30 17:48:09 +00:00
wyzula-jan
cb6fb9d78b refactor: BECDeviceMonitor changed to BECMonitor 2023-10-30 17:48:09 +00:00
wyzula-jan
e4336cca30 test: BECDeviceMonitor tests 2023-10-30 17:48:09 +00:00
wyzula-jan
a785bca880 docs: device_monitor.py update docstrings 2023-10-30 17:48:09 +00:00
wyzula-jan
afab283988 fix: device_monitor.py BECDeviceMonitor can be promoted in the QtDesigner and then setup in the modular app 2023-10-30 17:48:09 +00:00
wyzula-jan
644a97aee8 fix: device_monitor.py crosshairs can be attached again 2023-10-30 17:48:09 +00:00
wyzula-jan
12469c8c1e fix: config_dialog.py prevents to add one scan twice 2023-10-30 17:48:09 +00:00
wyzula-jan
93db0c21ef refactor: config_dialog.py clean up 2023-10-30 17:48:09 +00:00
wyzula-jan
7e99920fc5 fix: config_dialog.py export to .yaml fixed 2023-10-30 17:48:09 +00:00
wyzula-jan
cda8daeb35 feat: widget_hierarchy.py tool to inspect hierarchy of the widget 2023-10-30 17:48:09 +00:00
wyzula-jan
2b29b6cfe2 feat: yaml_dialog.py interactive QFileDialog window to load/save .yaml files to/from dict 2023-10-30 17:48:09 +00:00
wyzula-jan
7d5429a162 refactor: config_dialog.py load dict without scan mode 2023-10-30 17:48:09 +00:00
wyzula-jan
e41d81cbd9 fix: config_dialog.py scan_type structure implemented 2023-10-30 17:48:09 +00:00
wyzula-jan
55b5ca7381 fix: config_dialog.py config from default mode can be exported to dict 2023-10-30 17:48:09 +00:00
wyzula-jan
ec88564e65 fix: config_dialog.py tabs for scans and plots are closable now 2023-10-30 17:48:09 +00:00
wyzula-jan
fbb7a918cc refactor: config_dialog.py hook_plot_tab_signals refactored 2023-10-30 17:48:09 +00:00
wyzula-jan
8ffb7d8961 refactor: config_dialog.py simpler add_new_scan and add_new_plot 2023-10-30 17:48:09 +00:00
wyzula-jan
7db9e0ef16 feature: DialogConfig ability to add different scan configuration 2023-10-30 17:48:09 +00:00
wyzula-jan
f1d7abeb25 refactor: DialogConfig implemented directly to the BECDeviceMonitor 2023-10-30 17:48:09 +00:00
wyzula-jan
d78940da3f fix: modular_app.py configs are linked to the actual version of the state of the device monitor 2023-10-30 17:48:09 +00:00
wyzula-jan
a6616f5986 feat: qt_utils custom class for class where one can delete the row with backspace or delete 2023-10-30 17:48:09 +00:00
wyzula-jan
f94a29bf4b fix: config_dialog.py can load the current configuration of the plot 2023-10-30 17:48:09 +00:00
wyzula-jan
bf2a09e630 feat: modular_app.py, device_monitor.py, config_dialog.py linked together, plot configuration can be done through GUI 2023-10-30 17:48:09 +00:00
wyzula-jan
486ffa2505 fix,refactor: config_dialog.ui changed design of general box, remove plot works 2023-10-30 17:48:09 +00:00
wyzula-jan
c9e5dd542c feat: config_dialog.py interactive editor of plot settings 2023-10-30 17:48:09 +00:00
9d36b9686e docs: added sphinx base structure 2023-10-30 17:38:27 +01:00
semantic-release
282f2db756 0.28.1
Automatically generated by python-semantic-release
2023-10-19 12:52:14 +00:00
wyzula-jan
2925a5f20e test: test_stream_plot.py basic tests for stream_plot.py, test_basic_plot.py removed 2023-10-17 13:40:30 +02:00
7152c5b229 test: add bec_dispatcher tests 2023-10-17 13:38:33 +02:00
wyzula-jan
17ea7ab703 refactor: stream_plot.py changed client initialization 2023-10-17 11:11:11 +02:00
wyzula-jan
8f83115efc test: test_basic_plot.py deactivated due to non-existing method on_scan_segment 2023-10-17 10:45:06 +02:00
wyzula-jan
6d6b1e9155 refactor: bec_dispatcher.py changed to Ivan's version 2023-10-17 10:45:06 +02:00
wyzula-jan
144e56cdd9 refactor: duplicate scripts of BasicPlot removed, BasicPlot renamed to StreamPlot 2023-10-17 10:45:06 +02:00
wyzula-jan
28908dd07c fix: stream_plot.py on_dap_update data dict opened correctly 2023-10-17 10:45:06 +02:00
wyzula-jan
ad2b798f11 refactor: stream_plot.py color static methods removed 2023-10-17 10:45:06 +02:00
wyzula-jan
f022153fa2 refactor: placeholders for stream plot 2023-10-17 10:45:06 +02:00
semantic-release
141b49ff39 0.28.0
Automatically generated by python-semantic-release
2023-10-13 14:31:31 +00:00
wyzula-jan
59bba1429c fix: scan_mode for BECDeviceMonitor fixed init_ui 2023-10-12 15:45:17 +02:00
wyzula-jan
f3f55a7ee0 feat: BECDeviceMonitor modular class which can be used to replace placeholder in .ui file. 2023-10-12 15:45:17 +02:00
wyzula-jan
75af0404b3 feat: placeholders initialised 2023-10-12 15:45:17 +02:00
semantic-release
483e18259a 0.27.2
Automatically generated by python-semantic-release
2023-10-12 13:42:06 +00:00
f7cbdbc5ca fix: scan_plot tests
Add scanID key to scan_segment in tests
2023-10-12 15:32:52 +02:00
7335aa9597 refactor: replace connect with connect_slot 2023-10-12 15:13:57 +02:00
f01078fc21 refactor: remove all custom topic connection methods 2023-10-12 14:28:30 +02:00
616de26150 refactor: switch to generic connect_slot method in plots 2023-10-12 13:40:07 +02:00
78b666ffdb refactor: emit content and metadata from messages in connect_slot 2023-10-12 13:32:29 +02:00
semantic-release
68be2a0418 0.27.1
Automatically generated by python-semantic-release
2023-10-10 13:42:41 +00:00
wyzula-jan
78a2b21466 Merge remote-tracking branch 'origin/extreme-scan-tests' into extreme-scan-tests 2023-10-10 12:43:16 +02:00
wyzula-jan
5814113f73 fix: extreme.py default config file changed to the config_example.yaml 2023-10-10 12:43:07 +02:00
wyzula-jan
eb1f1d481e refactor: test_extreme.py corrected typos 2023-10-10 12:43:07 +02:00
wyzula-jan
6c3dfddd28 test: test_extreme.py MessageBox buttons Cancel and Retry tested 2023-10-10 12:43:07 +02:00
wyzula-jan
5162270d28 fix: extreme.py retry action fixed in ErrorHandler 2023-10-10 12:43:07 +02:00
wyzula-jan
ac2a41d2d8 test: test_extreme.py ErrorHandler tested separately 2023-10-10 12:43:07 +02:00
wyzula-jan
5637c938cf refactor: extreme.py ErrorHandler fixed, new configs are correctly loaded 2023-10-10 12:43:07 +02:00
wyzula-jan
d2c12a9f1c refactor: extreme.py error messages for config file moved to ErrorHandler class 2023-10-10 12:43:07 +02:00
wyzula-jan
51c3a9e9ee fix: extreme.py advanced error handling with possibility to reload different config 2023-10-10 12:43:07 +02:00
wyzula-jan
9750039097 fix: extreme.py error in configuration are displayed as messagebox 2023-10-10 12:43:07 +02:00
wyzula-jan
824ce821cd fix: extreme.py validation function to check config key component structure 2023-10-10 12:43:07 +02:00
wyzula-jan
90f22c2288 test: test_extreme.py error handling tested 2023-10-10 12:43:07 +02:00
wyzula-jan
fbd299c7e7 fix: extreme.py improved error handling for scan types mode 2023-10-10 12:43:07 +02:00
wyzula-jan
36942b316a test: test_extreme.py init_ui more edge cases 2023-10-10 12:43:07 +02:00
wyzula-jan
6c773c7c94 fix: extreme.py init_ui changed > to >= for setting number of columns 2023-10-10 12:43:07 +02:00
wyzula-jan
c525eba885 fix: extreme.py init_plot_background error handling 2023-10-10 12:43:07 +02:00
wyzula-jan
0338462a85 test: test_extreme.py test_init_config fixed for scan_config 2023-10-10 12:43:07 +02:00
wyzula-jan
fc6098414e fix: extreme.py ui is initialised for the first scan of config in scan mode 2023-10-10 12:43:07 +02:00
wyzula-jan
daf4ee190e test: test_extreme.py test_init_config new config tested 2023-10-10 12:43:07 +02:00
wyzula-jan
0ec65a0b41 test: test_extreme.py on_scan_segment tested with all entries correctly defined 2023-10-10 12:43:07 +02:00
wyzula-jan
ae79faa7ed fix: extreme.py client and device manager initialisation 2023-10-10 12:43:07 +02:00
wyzula-jan
d356cf734b fix: extreme.py default config file changed to the config_example.yaml 2023-10-10 12:17:05 +02:00
wyzula-jan
9e7224e0ae refactor: test_extreme.py corrected typos 2023-10-10 12:14:53 +02:00
wyzula-jan
2faeb639be test: test_extreme.py MessageBox buttons Cancel and Retry tested 2023-10-10 12:08:02 +02:00
wyzula-jan
b76df1b583 fix: extreme.py retry action fixed in ErrorHandler 2023-10-10 12:01:06 +02:00
wyzula-jan
835bda0a53 test: test_extreme.py ErrorHandler tested separately 2023-10-10 11:30:52 +02:00
wyzula-jan
aed65b411a refactor: extreme.py ErrorHandler fixed, new configs are correctly loaded 2023-10-04 18:49:58 +02:00
wyzula-jan
8050bdf82d refactor: extreme.py error messages for config file moved to ErrorHandler class 2023-10-03 16:33:46 +02:00
wyzula-jan
d623cf9539 fix: extreme.py advanced error handling with possibility to reload different config 2023-10-03 15:07:55 +02:00
wyzula-jan
89a52a0948 fix: extreme.py error in configuration are displayed as messagebox 2023-10-03 13:27:23 +02:00
wyzula-jan
5a7ac860a8 fix: extreme.py validation function to check config key component structure 2023-10-03 13:19:01 +02:00
wyzula-jan
fc31960c61 test: test_extreme.py error handling tested 2023-10-03 11:24:18 +02:00
wyzula-jan
ece1859a63 fix: extreme.py improved error handling for scan types mode 2023-10-03 11:23:51 +02:00
wyzula-jan
7b3a873800 test: test_extreme.py init_ui more edge cases 2023-10-03 10:47:46 +02:00
wyzula-jan
a0a89fe704 fix: extreme.py init_ui changed > to >= for setting number of columns 2023-10-03 10:38:24 +02:00
wyzula-jan
dafb6fae7a fix: extreme.py init_plot_background error handling 2023-10-03 10:20:35 +02:00
wyzula-jan
69aaea24f9 test: test_extreme.py test_init_config fixed for scan_config 2023-10-02 16:41:53 +02:00
wyzula-jan
82bebe6b41 fix: extreme.py ui is initialised for the first scan of config in scan mode 2023-10-02 16:38:12 +02:00
wyzula-jan
f8d30c9b0e test: test_extreme.py test_init_config new config tested 2023-10-02 16:26:05 +02:00
wyzula-jan
126451a7a9 test: test_extreme.py on_scan_segment tested with all entries correctly defined 2023-10-02 11:38:33 +02:00
wyzula-jan
cf15163bd9 fix: extreme.py client and device manager initialisation 2023-10-02 10:03:13 +02:00
wyzula-jan
6322c4720f test: test_eiger_plot.py added qtbot.waitExposed(widget) 2023-09-29 09:34:15 +00:00
wyzula-jan
08d956940e test: test_eiger_plot.py optimised imports 2023-09-29 09:34:15 +00:00
wyzula-jan
f74a6a0b8b refactor: added __init__.py to all example folders 2023-09-29 09:34:15 +00:00
wyzula-jan
779f34f500 test: added initial tests for extreme.py 2023-09-29 09:34:15 +00:00
wyzula-jan
c827a25dab test: added test_zmq_consumer for eiger_plot.py 2023-09-29 09:34:15 +00:00
wyzula-jan
0a0d51d278 test: added test_start_zmq_consumer for eiger_plot.py 2023-09-29 09:34:15 +00:00
wyzula-jan
70684d119f test: added test_on_image_update for eiger_plot.py 2023-09-29 09:34:15 +00:00
wyzula-jan
153c5f4f9d fix: formatter fixed 2023-09-28 17:19:45 +02:00
wyzula-jan
8b3a0baaa6 test: test_eiger_plot.py added qtbot.waitExposed(widget) 2023-09-28 17:06:39 +02:00
wyzula-jan
5e9deae765 test: test_eiger_plot.py optimised imports 2023-09-28 17:06:07 +02:00
wyzula-jan
4772c244c2 refactor: added __init__.py to all example folders 2023-09-28 16:13:04 +02:00
wyzula-jan
80190ccba7 test: added initial tests for extreme.py 2023-09-28 16:10:09 +02:00
wyzula-jan
abd3ebec1f test: added test_zmq_consumer for eiger_plot.py 2023-09-27 12:06:53 +02:00
wyzula-jan
7485aa999f test: added test_start_zmq_consumer for eiger_plot.py 2023-09-27 11:58:45 +02:00
wyzula-jan
ad1d69fa66 test: added test_on_image_update for eiger_plot.py 2023-09-25 17:07:12 +02:00
977ce3ae93 refactor: fixed formatting for mca plot 2023-09-25 13:52:55 +02:00
semantic-release
93f21eefd7 0.27.0
Automatically generated by python-semantic-release
2023-09-25 08:27:18 +00:00
wyzula-jan
44cc881ac9 fix: epics removed from requirements 2023-09-25 10:26:07 +02:00
wyzula-jan
ee3cae6472 Merge branch 'motor_go_end' 2023-09-25 10:21:52 +02:00
wyzula-jan
b78152b149 fix: motor_example.py load .csv logic fixed 2023-09-22 14:29:14 +02:00
wyzula-jan
85841cdf1f fix: motor_example.py export .csv logic fixed 2023-09-22 14:17:22 +02:00
wyzula-jan
ed3f656d5e refactor: motor_example.py removed old table related functions 2023-09-22 14:10:24 +02:00
wyzula-jan
a4fb6bd1d2 perf: motor_example.py replot logic optimizes 2023-09-22 13:52:48 +02:00
wyzula-jan
05f48de3f1 fix: motor_example.py precision in duplicate table fixed 2023-09-22 13:46:53 +02:00
wyzula-jan
401fec8539 fix: motor_example.py duplicate table fixed 2023-09-22 13:43:57 +02:00
wyzula-jan
b13509e9eb fix: motor_example.py manual changing coordinates in start/stop works again 2023-09-22 11:22:26 +02:00
wyzula-jan
a15860abac fix: motor_example.py replot points logic simplified 2023-09-22 10:59:08 +02:00
wyzula-jan
673ed325d1 fix: motor_example.py new independent mapping relying on the table 2023-09-22 09:39:58 +02:00
wyzula-jan
63f52fc841 fix: extreme.py formatting fixed 2023-09-21 11:27:27 +02:00
wyzula-jan
200e8b2351 Merge branch 'fix-line-plot' 2023-09-21 11:23:13 +02:00
wyzula-jan
e4f23f5101 fix: line_plot.py ROI interactions fixed 2023-09-21 11:22:43 +02:00
e21536
b41d63ea4d fix: online changes e21543 2023-09-21 10:43:45 +02:00
wyzula-jan
418480f1fc fix: motor_example.py user is blocked to duplicate last entry in start/end mode if end coordinate was not defined 2023-09-19 14:16:41 +02:00
semantic-release
6955b6e292 0.26.7
Automatically generated by python-semantic-release
2023-09-19 12:07:32 +00:00
wyzula-jan
abe35bf967 fix: eiger_plot_hist.py removed 2023-09-19 14:06:24 +02:00
semantic-release
174ab8fd8b 0.26.6
Automatically generated by python-semantic-release
2023-09-19 12:03:25 +00:00
wyzula-jan
7ff72b4086 docs: extreme.py updated documentation 2023-09-19 14:01:05 +02:00
wyzula-jan
cb144c7c2c fix: extreme.py saved to .yaml works correctly for different scans configurations 2023-09-19 12:09:17 +02:00
wyzula-jan
5f3d55b760 refactor: extreme.py plot init moved to config_init 2023-09-19 12:06:24 +02:00
wyzula-jan
a6940235be refactor: extreme.py changed initialisation of config 2023-09-19 11:57:43 +02:00
wyzula-jan
4287ac8885 fix: extreme.py fixed logic of loading new config.yaml during app operation 2023-09-19 11:51:57 +02:00
wyzula-jan
08f508f4c3 fix: motor_example.py - new more robust logic for getting coordinates for table go buttons 2023-09-14 17:11:24 +02:00
wyzula-jan
6124eab971 refactor: motor_example.py - function to connect buttons in the table 2023-09-14 15:50:15 +02:00
wyzula-jan
65b045e1a2 feat: motor_example.py in start/end mode new button allowing user to go to end position 2023-09-14 15:22:03 +02:00
semantic-release
bd28aa0361 0.26.5
Automatically generated by python-semantic-release
2023-09-13 08:05:28 +00:00
wyzula-jan
a5c6ffaa02 fix: motor_example.py help extended 2023-09-13 10:04:22 +02:00
wyzula-jan
34c785b92c refactor: extreme config example 2023-09-13 09:50:56 +02:00
semantic-release
7ad1cb47f3 0.26.4
Automatically generated by python-semantic-release
2023-09-12 15:43:45 +00:00
wyzula-jan
7cb56e9e7f fix: logic fixed 2023-09-12 17:41:47 +02:00
semantic-release
4fabee69d8 0.26.3
Automatically generated by python-semantic-release
2023-09-12 15:05:43 +00:00
wyzula-jan
230ccba909 Merge remote-tracking branch 'origin/master' 2023-09-12 17:04:37 +02:00
wyzula-jan
b867f25c78 fix: import works for both modes 2023-09-12 17:04:25 +02:00
semantic-release
9b715c69c0 0.26.2
Automatically generated by python-semantic-release
2023-09-12 14:54:55 +00:00
wyzula-jan
cacc076959 fix: import with start/stop mode works again 2023-09-12 16:53:50 +02:00
semantic-release
7df7aadea8 0.26.1
Automatically generated by python-semantic-release
2023-09-12 14:02:16 +00:00
wyzula-jan
56e619d239 Merge remote-tracking branch 'origin/master' 2023-09-12 16:01:12 +02:00
wyzula-jan
0e634ee2ac fix: removed scipy from eiger_plot.py 2023-09-12 15:59:38 +02:00
semantic-release
19746c0b76 0.26.0
Automatically generated by python-semantic-release
2023-09-12 13:57:21 +00:00
wyzula-jan
7b844c805d Merge branch 'extreme-feedback' 2023-09-12 15:56:08 +02:00
wyzula-jan
723503851b refactor: config_example.yaml 2023-09-12 15:55:59 +02:00
wyzula-jan
57e69907d5 feat: plot different signals and plot configurations based on different scans 2023-09-12 14:54:18 +02:00
semantic-release
f03dac0167 0.25.1
Automatically generated by python-semantic-release
2023-09-12 10:01:01 +00:00
wyzula-jan
8ff983f16e fix: specific config for csaxs 2023-09-12 11:59:34 +02:00
wyzula-jan
10ccf0cc97 fix: mode lock in config to disable changing the mode for users 2023-09-12 11:57:29 +02:00
semantic-release
c510f4eb63 0.25.0
Automatically generated by python-semantic-release
2023-09-12 09:44:32 +00:00
wyzula-jan
12b46a71a2 Merge branch 'cSAX-feedback' 2023-09-12 11:43:24 +02:00
semantic-release
8d860ec3d1 0.24.2
Automatically generated by python-semantic-release
2023-09-12 06:52:49 +00:00
e20643
265744076c fix: changes e20643 2023-09-12 08:51:24 +02:00
wyzula-jan
2123361ada fix: extra columns works again 2023-09-11 17:50:03 +02:00
wyzula-jan
f2fde2cf5c feat: comboBox to switch between entries mode 2023-09-11 17:04:38 +02:00
wyzula-jan
14a0c92fb9 refactor: changed order of columns 2023-09-11 16:42:12 +02:00
wyzula-jan
702e758812 refactor: align_table_center as a static method 2023-09-11 11:35:21 +02:00
wyzula-jan
63e3896725 fix: resize table is user controlled 2023-09-11 11:21:09 +02:00
semantic-release
ddaafa6a04 0.24.1
Automatically generated by python-semantic-release
2023-09-08 16:05:03 +00:00
wyzula-jan
f79a143417 Merge remote-tracking branch 'origin/master' 2023-09-08 18:04:03 +02:00
wyzula-jan
3b12f1bc1d fix: typo fixed in mca_plot.py 2023-09-08 18:03:54 +02:00
semantic-release
a7934d58d8 0.24.0
Automatically generated by python-semantic-release
2023-09-08 15:57:09 +00:00
wyzula-jan
ae040727fc feat: histogramLUT for mca_plot 2023-09-08 17:56:07 +02:00
semantic-release
7998a67e09 0.23.0
Automatically generated by python-semantic-release
2023-09-08 15:24:17 +00:00
wyzula-jan
ade893d33d feat: added key bindings and help dialog 2023-09-08 17:23:15 +02:00
semantic-release
ea64afdbc5 0.22.0
Automatically generated by python-semantic-release
2023-09-08 14:36:01 +00:00
wyzula-jan
3774b1ae81 Merge remote-tracking branch 'origin/master' 2023-09-08 16:34:58 +02:00
wyzula-jan
b984f0f36e feat: added FFT 2023-09-08 16:34:39 +02:00
semantic-release
b83a4926dc 0.21.2
Automatically generated by python-semantic-release
2023-09-08 14:28:30 +00:00
wyzula-jan
87d5467643 fix: moved mask as a last step of image processing 2023-09-08 16:27:23 +02:00
semantic-release
562b28365a 0.21.1
Automatically generated by python-semantic-release
2023-09-08 14:23:58 +00:00
wyzula-jan
43f03b5430 fix: update_signal typo fixed 2023-09-08 16:22:54 +02:00
semantic-release
5e579b5bc4 0.21.0
Automatically generated by python-semantic-release
2023-09-08 14:19:17 +00:00
wyzula-jan
d95c42eafc Merge remote-tracking branch 'origin/master' 2023-09-08 16:18:15 +02:00
wyzula-jan
ef42921c9a fix: path to mask fixed 2023-09-08 16:18:06 +02:00
wyzula-jan
33d1193c96 feat: added functionality to load mask 2023-09-08 16:16:02 +02:00
semantic-release
794f2aec3a 0.20.0
Automatically generated by python-semantic-release
2023-09-08 14:08:23 +00:00
wyzula-jan
ae8fc94979 fix: added missing .ui file to git 2023-09-08 15:57:57 +02:00
wyzula-jan
acd7a3bc92 feat: added rotate and transpose logic 2023-09-08 14:39:48 +02:00
semantic-release
2398ee3e8c 0.19.2
Automatically generated by python-semantic-release
2023-09-08 12:37:37 +00:00
wyzula-jan
c46b0024f6 Merge remote-tracking branch 'origin/master' 2023-09-08 14:36:34 +02:00
wyzula-jan
6733371c2c fix: rotation logic fixed 2023-09-08 14:36:25 +02:00
semantic-release
8cb3c377ad 0.19.1
Automatically generated by python-semantic-release
2023-09-08 12:34:47 +00:00
wyzula-jan
9f80f0f4d4 Merge remote-tracking branch 'origin/master' 2023-09-08 14:33:45 +02:00
wyzula-jan
00385abbf9 fix: rotation always counter-clockwise 2023-09-08 14:33:35 +02:00
semantic-release
f0ddfe47ca 0.19.0
Automatically generated by python-semantic-release
2023-09-08 12:31:40 +00:00
wyzula-jan
66da042882 Merge remote-tracking branch 'origin/master' 2023-09-08 14:31:19 +02:00
semantic-release
b36e154b8b 0.18.1
Automatically generated by python-semantic-release
2023-09-08 12:30:31 +00:00
wyzula-jan
f2ee03fa08 Merge remote-tracking branch 'origin/master' 2023-09-08 14:29:53 +02:00
e20636
ff640ddc24 Merge branch 'master' of https://gitlab.psi.ch/bec/bec-widgets 2023-09-08 14:29:20 +02:00
e20636
29c983fb26 fix: online changes 2023-09-08 14:28:43 +02:00
wyzula-jan
327f6b3df3 feat: rotation of the image to the left/right by 90, 180, 270 degree 2023-09-08 14:24:17 +02:00
wyzula-jan
4fa8d46631 feat: simulation stream with Gaussian peak in 1st quadrant 2023-09-08 14:14:04 +02:00
wyzula-jan
5cbedec5d9 feat: eiger_plot.py in example folder with new GUI 2023-09-08 13:57:37 +02:00
semantic-release
c2ae974bbb 0.18.0
Automatically generated by python-semantic-release
2023-09-08 09:44:56 +00:00
e20636
ba5a7f1248 Merge branch 'master' of https://gitlab.psi.ch/bec/bec-widgets 2023-09-08 11:43:19 +02:00
e20636
70d74c774d feat: eigerplot added 2023-09-08 11:42:19 +02:00
semantic-release
177421b4ea 0.17.1
Automatically generated by python-semantic-release
2023-09-08 06:46:44 +00:00
wyzula-jan
46a3981e7d fix: start_device_consumer changed from EP device_status to scan_status 2023-09-08 08:45:33 +02:00
semantic-release
9e63f9228d 0.17.0
Automatically generated by python-semantic-release
2023-09-07 14:44:48 +00:00
wyzula-jan
fb52b2a8e5 feat: console arguments added for Redis port, device, and sub_device tag 2023-09-07 16:43:04 +02:00
wyzula-jan
c368871919 feat: plot flips every second row 2023-09-07 16:35:13 +02:00
wyzula-jan
9271b91113 feat: device_consumer is getting scanID and initialise stream_consumer 2023-09-07 16:33:41 +02:00
wyzula-jan
b7136e769f refactor: functionalities separated to different methods 2023-09-07 15:43:44 +02:00
wyzula-jan
7d996ec8e7 refactor: project cleaned up 2023-09-07 15:29:49 +02:00
wyzula-jan
bfef71382e feat: simulation and simple 2D plot for mca card stream 2023-09-07 15:27:33 +02:00
semantic-release
a40f0a7070 0.16.4
Automatically generated by python-semantic-release
2023-09-06 15:25:44 +00:00
wyzula-jan
b62509a28e fix: self.limit_map_data fixed to be initialised only with integers from limits 2023-09-06 17:24:39 +02:00
semantic-release
3a68744b9a 0.16.3
Automatically generated by python-semantic-release
2023-09-06 15:21:32 +00:00
wyzula-jan
a1264fe4e2 fix: limit spinBoxes morphed to doubleSpinBoxes 2023-09-06 17:20:20 +02:00
wyzula-jan
2397af140f docs: pyqtGraph controls in help 2023-09-06 17:08:18 +02:00
wyzula-jan
63744b0fbf refactor: code cleaned up 2023-09-06 16:58:19 +02:00
wyzula-jan
92c81c6dd7 Merge remote-tracking branch 'origin/master' 2023-09-06 16:55:36 +02:00
wyzula-jan
197ebad765 refactor: code cleaned up 2023-09-06 16:55:10 +02:00
semantic-release
d7c9a82fa4 0.16.2
Automatically generated by python-semantic-release
2023-09-06 14:52:31 +00:00
wyzula-jan
f45512e0ae fix: x and y motor can be linked again 2023-09-06 16:51:21 +02:00
semantic-release
ad8ec4c8ff 0.16.1
Automatically generated by python-semantic-release
2023-09-06 14:40:21 +00:00
wyzula-jan
8a6e2daaf9 fix: default values fixed from .yaml 2023-09-06 16:39:10 +02:00
semantic-release
8086fad450 0.16.0
Automatically generated by python-semantic-release
2023-09-06 14:08:14 +00:00
wyzula-jan
9fba0334a0 fix: help extended 2023-09-06 16:05:53 +02:00
wyzula-jan
2087d19d3c feat: added help button 2023-09-06 16:02:14 +02:00
wyzula-jan
bf12963247 fix: table loads number of columns correctly 2023-09-06 15:51:43 +02:00
wyzula-jan
15d995f66b feat: table can be loaded from .csv 2023-09-06 15:38:58 +02:00
wyzula-jan
772f18fa09 feat: table can be exported to csv 2023-09-06 15:38:57 +02:00
wyzula-jan
74884a3707 fix: content always aligned to centre 2023-09-06 15:03:55 +02:00
wyzula-jan
3132b4fb4d refactor: change order of columns 2023-09-06 15:02:46 +02:00
wyzula-jan
1235294b03 feat: additional extra rows takes values from previous row 2023-09-06 14:46:45 +02:00
wyzula-jan
fa76acbd6d feat: additional columns can be added through .yaml 2023-09-06 14:35:27 +02:00
semantic-release
03bc363cbf 0.15.0
Automatically generated by python-semantic-release
2023-09-06 12:08:52 +00:00
wyzula-jan
8011da2f6a Merge branch 'motor_csax_features_request' 2023-09-06 14:07:35 +02:00
wyzula-jan
7e6244c5d3 fix: table checkbox fixed 2023-09-06 14:07:10 +02:00
wyzula-jan
75f5c8fcd6 fix: partial fix to table checkBox 2023-09-06 13:30:08 +02:00
wyzula-jan
1241fc7516 refactor: code cleaned up 2023-09-06 11:27:47 +02:00
wyzula-jan
0aa667b70d fix: coordinates markers are updated on the map, if X, Y in table manually is changed 2023-09-06 11:22:33 +02:00
wyzula-jan
ca099eced3 refactor: DoubleValidationDelegate moved to qt_utils 2023-09-06 10:37:38 +02:00
wyzula-jan
be1bd81d60 fix: added float validator to the table 2023-09-06 10:33:59 +02:00
wyzula-jan
9d83a455e8 fix: table bug, when deleted multiple rows 2023-09-06 10:02:13 +02:00
wyzula-jan
63e6d61c2e fix: table bug, when user deleted row and wanted to go to the previous position 2023-09-06 09:55:18 +02:00
wyzula-jan
9beaa8f8cf refactor: sync_step_sizes generalised to one function 2023-09-05 16:29:30 +02:00
wyzula-jan
aede1988ec refactor: change_step_size generalised to one function 2023-09-05 16:21:15 +02:00
wyzula-jan
16ab746f54 feat: step for x and y can be linked or separated 2023-09-05 16:15:05 +02:00
wyzula-jan
b9920f3b6c refactor: init_ui separated into multiple sections for each ui functionality 2023-09-05 15:47:54 +02:00
wyzula-jan
6324199299 feat: user can choose if to save coordinates when moving to absolute coordinates 2023-09-05 15:13:56 +02:00
semantic-release
3cfc8a919a 0.14.2
Automatically generated by python-semantic-release
2023-09-05 12:47:46 +00:00
wyzula-jan
b7a1b8bca1 fix: bec_config initialisation by command line argument 2023-09-05 14:46:41 +02:00
semantic-release
c456bd7120 0.14.1
Automatically generated by python-semantic-release
2023-09-05 10:13:36 +00:00
wyzula-jan
d854fa611a Merge remote-tracking branch 'origin/master' 2023-09-05 12:11:39 +02:00
wyzula-jan
3c74fa59b7 fix: gui default tab changed to coordinates table 2023-09-05 12:11:26 +02:00
semantic-release
701a38e2f0 0.14.0
Automatically generated by python-semantic-release
2023-09-05 10:03:06 +00:00
wyzula-jan
f67bb9a940 Merge branch 'motor_marks' 2023-09-05 12:01:21 +02:00
wyzula-jan
5b30dfd43f fix: motor position points can be switched on/off if points were deleted 2023-09-05 12:00:55 +02:00
wyzula-jan
84155d2264 feat: enable gui button, in the case that motor movement is not finished 2023-09-05 11:56:03 +02:00
wyzula-jan
3fb8651dd5 fix: highlight disapear with new motor 2023-09-05 11:50:18 +02:00
wyzula-jan
fb105513e5 fix: new points do not make invisible points visible again 2023-09-05 11:34:45 +02:00
wyzula-jan
a178c434b1 fix: checkbox visibility toggle is working. 2023-09-05 11:28:47 +02:00
wyzula-jan
c32e95a57d fix: saved coordinates can be removed from table and from the map again 2023-09-05 11:25:10 +02:00
wyzula-jan
0ca665a1e9 feat: saved coordinates are shown on the map 2023-09-05 11:19:14 +02:00
semantic-release
1ecbf20715 0.13.0
Automatically generated by python-semantic-release
2023-09-05 09:09:32 +00:00
wyzula-jan
8de08cf9cc fix: spinbox limits in ui file 2023-09-05 11:07:19 +02:00
wyzula-jan
9228e5aea3 feat: crosshair highlight at motor position 2023-09-05 10:43:23 +02:00
wyzula-jan
e9ef1e315b feat: increase step size double with key bindings 2023-09-05 10:35:14 +02:00
wyzula-jan
172ccc6905 fix: precision updated correctly 2023-09-05 10:23:32 +02:00
wyzula-jan
5d6a328728 feat: go, set, save current coordinates and keyboard shortcuts 2023-09-05 10:04:22 +02:00
semantic-release
83bd86e020 0.12.0
Automatically generated by python-semantic-release
2023-09-04 13:19:10 +00:00
wyzula-jan
0f74782d74 Merge branch 'motor_dispatcher_migration' 2023-09-04 15:17:45 +02:00
wyzula-jan
bf93b02cdc fix: error message if motor do not have limits attribute 2023-09-04 15:17:28 +02:00
wyzula-jan
4afaa1b0ce docs: added documentation to all classes and methods 2023-09-04 15:16:32 +02:00
wyzula-jan
1a67758470 feat: config from .yaml file 2023-09-04 14:21:35 +02:00
wyzula-jan
34212d4d45 feat: removal of motor configurations from user 2023-09-04 13:43:03 +02:00
wyzula-jan
3c5f90e0e3 refefactor: motor_by_string removed, motor names passed directly to dev[motor_name] 2023-09-04 11:28:24 +02:00
semantic-release
5fad25286c 0.11.0
Automatically generated by python-semantic-release
2023-09-04 08:56:32 +00:00
wyzula-jan
359f9e37d2 Merge branch 'extreme_tweaking' 2023-09-04 10:55:19 +02:00
wyzula-jan
8e7885f36d fix: user selected colors are preserved with the new scan 2023-09-04 10:54:59 +02:00
wyzula-jan
6d2e1c9d08 fix: colorbutton change now symbols as well 2023-09-01 10:40:07 +02:00
wyzula-jan
2c6719cf39 feat: colorbutton next to each curve in the table to be able to set up colors 2023-09-01 10:39:09 +02:00
semantic-release
14f54da852 0.10.0
Automatically generated by python-semantic-release
2023-09-01 08:17:36 +00:00
wyzula-jan
8ad7ad23ae Merge branch 'four_window_app' 2023-09-01 10:16:31 +02:00
wyzula-jan
0ebe35ac7a docs: updated documentation and TODOs 2023-09-01 10:16:07 +02:00
wyzula-jan
aac6e172f6 fix: check if num_columns is not higher that actual number of plots 2023-09-01 09:41:32 +02:00
wyzula-jan
e527353974 feat: load and export configuration into .yaml from GUI 2023-09-01 09:38:32 +02:00
wyzula-jan
fbd71c1313 fix: add max number of columns according to the number of plots 2023-09-01 09:24:36 +02:00
wyzula-jan
583e643dac fix: more specific error messages 2023-09-01 01:19:00 +02:00
wyzula-jan
415c4ee3f2 feat: error messages if name or entry is wrong 2023-09-01 01:15:19 +02:00
wyzula-jan
96a88d2315 refactor: changed the .yaml structure and the logic of the whole app how to access 2023-09-01 00:59:11 +02:00
wyzula-jan
1a06dd7534 refactor: moved colormap related static methods to qt_utils colors.py 2023-08-31 21:52:45 +02:00
wyzula-jan
7bcf88d5eb fix: bec_dispatcher.py can take multiple workers as a list 2023-08-31 13:56:54 +02:00
wyzula-jan
b8aa37321d fix: config.yaml can be passed as a console argument to extreme.py 2023-08-31 13:56:24 +02:00
wyzula-jan
2f7c1b92a9 docs: fixed documentation 2023-08-30 15:42:22 +02:00
wyzula-jan
2d851b6b4e fix: columns span generalised for any number of columns 2023-08-30 15:32:05 +02:00
wyzula-jan
65bfccce8f feat: number of columns can be dynamically changed 2023-08-30 15:22:32 +02:00
wyzula-jan
69c38d67e4 feat: multi window interface created for extreme BL 2023-08-30 13:06:01 +02:00
semantic-release
f7db33f2c5 0.9.0
Automatically generated by python-semantic-release
2023-08-29 14:49:46 +00:00
wyzula-jan
a9f16884b0 feat: migrate to .yaml config file instead of argparse 2023-08-29 16:48:29 +02:00
wyzula-jan
0eff18f5a0 feat: better color coding of curves 2023-08-29 16:31:57 +02:00
wyzula-jan
abd88f7109 refactor: x_value and y_values arguments separately 2023-08-29 15:28:14 +02:00
semantic-release
7dca0c09ff 0.8.1
Automatically generated by python-semantic-release
2023-08-29 12:53:44 +00:00
wyzula-jan
f0589b79ec fix: added missing local .ui file 2023-08-29 14:52:48 +02:00
120 changed files with 12133 additions and 2296 deletions

View File

@@ -14,6 +14,7 @@ include:
stages:
- Formatter
- test
- AdditionalTests
- Deploy
formatter:
@@ -25,9 +26,10 @@ formatter:
pylint:
stage: Formatter
needs: []
script:
before_script:
- pip install pylint pylint-exit anybadge
- pip install -e .[dev]
script:
- mkdir ./pylint
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
@@ -38,6 +40,37 @@ pylint:
- ./pylint/
expire_in: 1 week
pylint-check:
stage: Formatter
needs: []
allow_failure: true
before_script:
- pip install pylint pylint-exit anybadge
- apt-get update
- apt-get install -y bc
script:
# Identify changed Python files
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse $CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
CHANGED_FILES=$(git diff --name-only $SOURCE_BRANCH_COMMIT_SHA $TARGET_BRANCH_COMMIT_SHA | grep '\.py$' || true);
else
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
fi
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
# Run pylint only on changed files
- mkdir ./pylint
- pylint $CHANGED_FILES --output-format=text . | tee ./pylint/pylint_changed_files.log || pylint-exit $?
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
- echo "Pylint score is $PYLINT_SCORE"
# Fail the job if the pylint score is below 9
- if [ "$(echo "$PYLINT_SCORE < 9" | bc)" -eq 1 ]; then echo "Your pylint score is below the acceptable threshold (9)."; exit 1; fi
artifacts:
paths:
- ./pylint/
expire_in: 1 week
tests:
stage: test
needs: []
@@ -45,7 +78,7 @@ tests:
QT_QPA_PLATFORM: "offscreen"
script:
- apt-get update
- apt-get install -y libgl1-mesa-glx x11-utils libxkbcommon-x11-0
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install .[dev]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests
- coverage report
@@ -54,7 +87,38 @@ tests:
artifacts:
reports:
junit: report.xml
cobertura: coverage.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
#tests-3.9-pyqt5: #todo enable when we decide what qt distributions we want to support
# extends: "tests"
# stage: AdditionalTests
# image: $CI_DOCKER_REGISTRY/python:3.9
# script:
# - apt-get update
# - apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
# - pip install .[dev,pyqt5]
# - pytest -v --random-order ./tests
tests-3.10:
extends: "tests"
stage: AdditionalTests
image: $CI_DOCKER_REGISTRY/python:3.10
allow_failure: true
tests-3.11:
extends: "tests"
stage: AdditionalTests
image: $CI_DOCKER_REGISTRY/python:3.11
allow_failure: true
tests-3.12:
extends: "tests"
stage: AdditionalTests
image: $CI_DOCKER_REGISTRY/python:3.12
allow_failure: true
semver:
@@ -80,22 +144,20 @@ semver:
semantic-release publish -v DEBUG
-D version_variable=./setup.py:__version__
-D hvcs=gitlab
allow_failure: false
rules:
- if: '$CI_COMMIT_REF_NAME == "master"'
# pages:
# stage: Deploy
# needs: ["tests"]
# script:
# - git clone --branch $OPHYD_DEVICES_BRANCH https://oauth2:$CI_OPHYD_DEVICES_KEY@gitlab.psi.ch/bec/ophyd_devices.git
# - export OPHYD_DEVICES_PATH=$PWD/ophyd_devices
# - pip install -r ./docs/source/requirements.txt
# - apt-get install -y gcc
# - *install-bec-services
# - cd ./docs/source; make html
# - curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/beamline-experiment-control/221870/
# rules:
# - if: '$CI_COMMIT_REF_NAME == "master"'
# - if: '$CI_COMMIT_REF_NAME == "production"'
pages:
stage: Deploy
needs: ["semver"]
variables:
TARGET_BRANCH: $CI_COMMIT_REF_NAME
rules:
- if: '$CI_COMMIT_TAG != null'
variables:
TARGET_BRANCH: $CI_COMMIT_TAG
- if: '$CI_COMMIT_REF_NAME == "master"'
script:
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/

View File

@@ -0,0 +1,17 @@
## Bug report
## Summary
[Provide a brief description of the bug.]
## Expected Behavior vs Actual Behavior
[Describe what you expected to happen and what actually happened.]
## Steps to Reproduce
[Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.]
## Related Issues
[Paste links to any related issues or feature requests.]

View File

@@ -0,0 +1,27 @@
## Documentation Section
[Specify the section or page of the documentation that needs updating]
## Current Information
[Provide the current information in the documentation that needs to be updated]
## Proposed Update
[Describe the proposed update or correction. Be specific about the changes that need to be made]
## Reason for Update
[Explain the reason for the documentation update. Include any recent changes, new features, or corrections that necessitate the update]
## Additional Context
[Include any additional context or information that can help the documentation team understand the update better]
## Attachments
[Attach any files, screenshots, or references that can assist in making the documentation update]
## Priority
[Assign a priority level to the documentation update based on its urgency. Use a scale such as Low, Medium, High]

View File

@@ -0,0 +1,40 @@
## Feature Summary
[Provide a brief and clear summary of the new feature you are requesting]
## Problem Description
[Explain the problem or need that this feature aims to address. Be specific about the issues or gaps in the current functionality]
## Use Case
[Describe a real-world scenario or use case where this feature would be beneficial. Explain how it would improve the user experience or workflow]
## Proposed Solution
[If you have a specific solution in mind, describe it here. Explain how it would work and how it would address the problem described above]
## Benefits
[Explain the benefits and advantages of implementing this feature. Highlight how it adds value to the product or improves user satisfaction]
## Alternatives Considered
[If you've considered alternative solutions or workarounds, mention them here. Explain why the proposed feature is the preferred option]
## Impact on Existing Functionality
[Discuss how the new feature might impact or interact with existing features. Address any potential conflicts or dependencies]
## Priority
[Assign a priority level to the feature request based on its importance. Use a scale such as Low, Medium, High]
## Attachments
[Include any relevant attachments, such as sketches, diagrams, or references that can help the development team understand your feature request better]
## Additional Information
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]

View File

@@ -0,0 +1,28 @@
## Description
[Provide a brief description of the changes introduced by this merge request.]
## Related Issues
[Cite any related issues or feature requests that are addressed or resolved by this merge request. Use the gitlab syntax for linking issues, for example, `fixes #123` or `closes #123`.]
## Type of Change
- Change 1
- Change 2
## Potential side effects
[Describe any potential side effects or risks of merging this MR.]
## Screenshots / GIFs (if applicable)
[Include any relevant screenshots or GIFs to showcase the changes made.]
## Additional Comments
[Add any additional comments or information that may be helpful for reviewers.]
## Definition of Done
- [ ] Documentation is up-to-date.

25
.readthedocs.yaml Normal file
View File

@@ -0,0 +1,25 @@
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-20.04
tools:
python: "3.9"
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
# If using Sphinx, optionally build your docs in additional formats such as PDF
# formats:
# - pdf
# Optionally declare the Python requirements required to build your docs
python:
install:
- requirements: docs/requirements.txt

View File

@@ -2,6 +2,602 @@
<!--next-version-placeholder-->
## v0.37.0 (2024-01-17)
### Feature
* Independent motor_map widget ([`1a429b3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1a429b3024e76446ed530bee71ed797c20843fba))
## v0.36.2 (2024-01-17)
### Fix
* Bec_dispatcher.py can partially disconnect topics from slot ([`7607d7a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7607d7a3b64b3861f4833c9b8f5afc360f31b38d))
* Bec_dispatcher.py can connect multiple topics to one callback slot ([`e51be04`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e51be04b95f1a9549a4a3b00d76944aa58b0526a))
## v0.36.1 (2024-01-15)
### Fix
* Motor_example.py fix to the new .read() structure from bec_lib ([`f9c5c82`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f9c5c82381907a19582bf9132740fe27b48d48cc))
## v0.36.0 (2024-01-12)
### Feature
* Bec_dispatcher can link multiple endpoints topics for one qt slot ([`58721be`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/58721bea1a2b4b06220ef0e3b2dcec8c1656213d))
## v0.35.0 (2024-01-12)
### Feature
* Monitor.py can access custom data send through redis ([`6e4775a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6e4775a1248153f6027be754054f3f43c18514d1))
* Monitor.py access data directly from scan storage ([`26c07c3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/26c07c3205debaf88a346410a8ebab0a3ab7a5d9))
### Fix
* Monitor.py clear command from BECPlotter CLI clear now flush database and clear the plots ([`ebd4fcc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ebd4fccda2321aa0dc108a5436fb4cc717911d4b))
* Monitor.py crosshair enabled by default ([`97dcc5a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/97dcc5ac768cc4f0122382591238fd5a9d035270))
* Monitor.py change import of ConfigDialog from relative to absolute in order to make BECPlotter be able to open it ([`6061b31`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6061b3150e990141eafb8d5b17c7e931c7bf8631))
* Monitor_config_validator.py changed to check .describe() instead of signals ([`5ab82bc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5ab82bc13340adb992c921a7211e8e2265861f7a))
* Monitor.py fixed not updating config changes after receiving refresh from BECPlotter ([`00ef3ae`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/00ef3ae9256a368f4842c1dc38a407131181ec1d))
* Monitor_config_validator.py valid color is Literal['black','white'] ([`86c5f25`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/86c5f25205dbaa45b7b2efd255f3a3cb2d3eb0b1))
* Monitor.py fixed scan mode ([`a706da2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a706da2490f4cce80e9515633e8437b3667b0db0))
* Motor_config_validation changed to new monitor config structure ([`d67bdd2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d67bdd26167dca6c65627192dbd098af08355d06))
## v0.34.1 (2023-12-12)
### Fix
* Formatter and tests fixed ([`186c42d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/186c42d6676a495bc2f66d8b7ed37dbf7d0be747))
### Documentation
* Readdocs updated ([`af995a7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/af995a74f34d59eeaff5d9100117f103ec79765d))
* Readme.md updated ([`cba8131`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cba81313671acfee0a40410753c1974008316d07))
* Gitlab templates for issues and merge requests from main bec repo ([`831eddc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/831eddc13600cc06b67de92d39509af37bb05002))
## v0.34.0 (2023-12-08)
### Feature
* Monitor.py error message popup ([`a3b24f9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a3b24f92420420c8968ef4793342c3857c826e57))
### Fix
* Monitor_config_validator.py - Signal validation changed from field_validator to model_validator to check first name and then entry ([`0868047`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/086804780d19956331d8385381d2f7f9c181e77c))
* Monitor_config_validator.py fix entry validation executed only if name validator is successful ([`af71e35`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/af71e35e73733472228c4be0061faefaf655b769))
## v0.33.0 (2023-12-07)
### Feature
* Added axis_width and axis_color as optional plot settings ([`504944f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/504944f696a7b2881adec06d29c271fec7e2c981))
### Fix
* Fixed default config options ([`03bdf98`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/03bdf980bcfc37e217cde1beb258d11cee97e0eb))
* Added hooks to react to incoming config messages and instructions ([`1084bc0`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1084bc0a803ff73cfa2ab53819bc9809588fa622))
## v0.32.2 (2023-12-06)
### Fix
* Changed exec_ to exec for all apps ([`080c258`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/080c258d1542aaace093bca74225297b30453f77))
* Yaml_dialog.py changed to use native solution of OS -> should prevent crashing on py3.11 ([`5adde23`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5adde23a457bbd3ae1488b77d4b927b5bded0473))
## v0.32.1 (2023-12-06)
### Fix
* Widget_io print_widget_hierarchy fix comboboxes ([`d1f9979`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d1f9979ab1372c2f650c8aff12ccb17d668b52eb))
* WidgetIO combobox fixed for qt6 distributions ([`4f70097`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4f700976ddd78a6f06e358950786b731ef9051ce))
## v0.32.0 (2023-11-30)
### Feature
* Jupyter rich console added as alternative to default QTextEdit terminal output ([`016b26f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/016b26f5cf05e90da144487a9359ac2a54c8e549))
* Editor.py basic signature calltip ([`045b1ba`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/045b1baa60a93d2266800821940d7aa29bd8bbe1))
* Editor.py jedi autocomplete hooked ([`fb555b2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fb555b278a5139f180592280408742d34dc5fa84))
* Editor.py added splitter between editor and terminal ([`c70ddb3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c70ddb3cb19fecf7ce14b551d7d265e2e0cff357))
* Toolbar.py proof-of-concept ([`286e62d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/286e62df92927d2efe0b4ab07995f7b5e36a0435))
* Basic text editor + running terminal output ([`9487844`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/94878448c8f39a69e9e65df2789da029a9acfc0e))
### Fix
* Added missing dependency jedi ([`d978740`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d978740f9879580d01e092ad1fead46786d3ed5c))
* Editor.py switch to disable docstring ([`3cc05cd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3cc05cde147cd520b98f0896beb64781ea47d816))
* Editor.py compact signature on tooltip ([`f96cacc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f96caccfcb43c904887ebfc0b34fd779ffff8bf1))
* Editor.py removed automatic background behind edited text ([`d865e2f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d865e2f1af6eb3d5fb31f9c53088b629a232343f))
* Toolbar.py automatic initialisation works ([`8ad3059`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8ad305959257a58b297896218baae06d09520ee1))
* Terminal output as QThread ([`a0d172e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a0d172e3dc35bdc2d7e1b43185e31bb9a3629631))
## v0.31.0 (2023-11-13)
### Feature
* Pydantic validation module for monitor.py ([`7fec0c7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7fec0c7e4411c221413d69aeeb4d68ade10d502b))
### Documentation
* Pydantic validation module docs ([`92a5325`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/92a5325aad02fe308caaf9088a3c4386ca055124))
## v0.30.0 (2023-11-10)
### Feature
* WidgetIO support for QLabel ([`aa4c7c3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/aa4c7c3385f52e4bbc805ee2aced181929943a89))
* Scan_control.py added option to limit scan selection from list of strings as init parameter ([`0fe06ad`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0fe06ade5b44d13a9188aef474364b36baa480ef))
* Scan_control.py a general widget which can generate GUI for scan input ([`088fa51`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/088fa516a8876d112a98cd60aa2a5701dff6b97c))
### Fix
* Added imports to __init__.py in widget for ScanControl class ([`b85cc89`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b85cc898d521df1c99a65e579b8fe853bb04cc32))
* Scan_control.py args_size_max fixed ([`da9025e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/da9025e032c2bc9b34cf359a20745e3156d2f731))
* Scan_control.py default spinBox limits increases ([`5c67026`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5c67026637472d9c77185a59e1bf9a24cfe01307))
* Scan_control.py supports minimum and maximum number of args ([`ee2f36f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ee2f36fb402d626c300a018afacbd57eff14a665))
* Scan_control.py wipe table and reinitialise devices when scan is changed ([`5ac3526`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5ac3526384b9ee0eb94568bac035b348eaa52abd))
* Widget_IO.py added handler for QCheckBox ([`18a7025`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/18a702572f6bed41081d368e39c8fc69122c6203))
* Scan_control.py scan can be executed from GUI ([`2e42ba1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2e42ba174f7abe1590b9afb099dc2d068eb848ae))
* Scan_control.py all kwargs are rendered ([`4b7592c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4b7592c2795a26591b3e30870c73aa406316588d))
* Scan_control.py kwargs and args are added to the correct layouts ([`b311069`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b311069722226b95a7902f42815d2c1e219e9584))
## v0.29.0 (2023-10-31)
### Feature
* Widget_hierarchy.py tool to inspect hierarchy of the widget ([`cda8dae`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cda8daeb35b36692316173a19fb29f1cc0dbdb7c))
* Yaml_dialog.py interactive QFileDialog window to load/save .yaml files to/from dict ([`2b29b6c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2b29b6cfe2ea94a974da4a332c47473176ddddff))
* Qt_utils custom class for class where one can delete the row with backspace or delete ([`a6616f5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a6616f5986d59ad8d065105234f5b704731cce71))
* Modular_app.py, device_monitor.py, config_dialog.py linked together, plot configuration can be done through GUI ([`bf2a09e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bf2a09e6307da63ecf02a1286095a19e5f1dcab4))
* Config_dialog.py interactive editor of plot settings ([`c9e5dd5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c9e5dd542c9eb7c9069d1c0f1256a634a166eb40))
### Fix
* Yaml_dialog.py added support for .yml files ([`10539f0`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/10539f0ba59be716102b2c0577ea62f5c4a3136a))
* Yaml_dialog.py added return None if no file path is specified ([`ff1d918`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ff1d918d43f0f2e5fe8d78c6de9051c50e0e12c1))
* Wrong __init__.py in modular_app ([`d52aa15`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d52aa15aac42f09487c828836e377c78596037bd))
* Test_bec_monitor.py config loaded fresh in the test function to avoid parameter leak ([`3866d7c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3866d7ce4de3391fe57ef872808f7620562eeeb0))
* Test_bec_monitor.py setup_monitor help function changed to pytest.fixture ([`989cd51`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/989cd51162147805db1229b50b330af29f275204))
* Test_config_dialog.py - QApplication removed ([`1cdd760`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1cdd760e4062de1f19837737766ce05edc9ac2da))
* Test_config_dialog.py - test_add_new_plot_and_modify qtbot action .click() changed -> function called directly ([`1333e6c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1333e6cbca18943b0a61206dc1ba63720b031b40))
* Test_config_dialog.py disabled ([`4e710dd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4e710dda5e88b2e82ec87db350e8b1fe6aa09181))
* Test_bec_monitor.py QApplication instance removed ([`77e1d09`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/77e1d0925db2dc6669159fbe3fb08daf330cb5c8))
* Test_config_dialog.py QApplication instance added ([`60e864b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/60e864b2590d121e1b0ad645d39d1a028abe8d7b))
* Device_monitor.py BECDeviceMonitor can be promoted in the QtDesigner and then setup in the modular app ([`afab283`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/afab283988a1acc008bc53ae5a56a8f67504da81))
* Device_monitor.py crosshairs can be attached again ([`644a97a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/644a97aee848c973125375d5f28d3edf2ffc20cf))
* Config_dialog.py prevents to add one scan twice ([`12469c8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/12469c8c1e45f83cc0c65708bd412103a8ec1838))
* Config_dialog.py export to .yaml fixed ([`7e99920`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7e99920fc565acd59cb3a4286ac5ee40597d8af4))
* Config_dialog.py scan_type structure implemented ([`e41d81c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e41d81cbd9371f8633c1e7de82c8f9b64fcb721b))
* Config_dialog.py config from default mode can be exported to dict ([`55b5ca7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/55b5ca7381dc33119baac0f48c76fc9d9e8215ae))
* Config_dialog.py tabs for scans and plots are closable now ([`ec88564`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ec88564e6577cd6579c30f36193c4a0e5fcbc483))
* Modular_app.py configs are linked to the actual version of the state of the device monitor ([`d78940d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d78940da3f114062aa397b87c169f26cbc131a5f))
* Config_dialog.py can load the current configuration of the plot ([`f94a29b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f94a29bf4be0a883abc200821746c3d81a0c00d4))
### Documentation
* Config_dialog.py comments added to example cases ([`4a6e73f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4a6e73f4f791d2152ff29680a4e28529a8df0b47))
* Device_monitor.py update docstrings ([`a785bca`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a785bca8806613f9e1e4b67380e72867b581fe6e))
* Added sphinx base structure ([`9d36b96`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9d36b9686e31b2c4b4206ad47b385c8f2769c641))
## v0.28.1 (2023-10-19)
### Fix
* Stream_plot.py on_dap_update data dict opened correctly ([`28908dd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/28908dd07c1eef8a9d3213a581393e665b310d1b))
## v0.28.0 (2023-10-13)
### Feature
* BECDeviceMonitor modular class which can be used to replace placeholder in .ui file. ([`f3f55a7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f3f55a7ee0ad58aab74526a24f27436fd2bef61d))
* Placeholders initialised ([`75af040`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/75af0404b3aa5f454528255e8971af07c4e8b39b))
### Fix
* Scan_mode for BECDeviceMonitor fixed init_ui ([`59bba14`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/59bba1429c1f8aeeb562b539583e71303506bd58))
## v0.27.2 (2023-10-12)
### Fix
* Scan_plot tests ([`f7cbdbc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f7cbdbc5ca318d60a9501df3fa03c7dea15b5b21))
## v0.27.1 (2023-10-10)
### Fix
* Extreme.py default config file changed to the config_example.yaml ([`5814113`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5814113f73fb1c4552bb715b27d3330decd9c878))
* Extreme.py retry action fixed in ErrorHandler ([`5162270`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5162270d28ca8eab4eac9d9665e2fb4c5e8a33a3))
* Extreme.py advanced error handling with possibility to reload different config ([`51c3a9e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/51c3a9e9ee3d75c8324300afac366dcdb9adb876))
* Extreme.py error in configuration are displayed as messagebox ([`9750039`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9750039097c9e4b9a45603dcefe76e5b2e8920fd))
* Extreme.py validation function to check config key component structure ([`824ce82`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/824ce821cd5f060f2c550b970afb1f3479a006ef))
* Extreme.py improved error handling for scan types mode ([`fbd299c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fbd299c7e7cf548886e2b1787d8e188c708ee8cd))
* Extreme.py init_ui changed > to >= for setting number of columns ([`6c773c7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6c773c7c94e5eee700b74a792657978be86dbbf4))
* Extreme.py init_plot_background error handling ([`c525eba`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c525eba88576e0094063019d00fba6a43c52b42e))
* Extreme.py ui is initialised for the first scan of config in scan mode ([`fc60984`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fc6098414e328e14ec9ab6006538f46e36f17723))
* Extreme.py client and device manager initialisation ([`ae79faa`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ae79faa7ed8e9d8f680e1be1afefe43706305d9a))
* Extreme.py default config file changed to the config_example.yaml ([`d356cf7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d356cf734b81fd7ed2c9b48ee85a1722af179d83))
* Extreme.py retry action fixed in ErrorHandler ([`b76df1b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b76df1b583a5922229f97876f9e65e0cad64c88e))
* Extreme.py advanced error handling with possibility to reload different config ([`d623cf9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d623cf95391adfc89837cd54ca1b2a1b6e491a3c))
* Extreme.py error in configuration are displayed as messagebox ([`89a52a0`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/89a52a0948ee300e57bb7198eac339ee771bff06))
* Extreme.py validation function to check config key component structure ([`5a7ac86`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5a7ac860a8cf5cef53ae699b2869e649c1721f9d))
* Extreme.py improved error handling for scan types mode ([`ece1859`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ece1859a63b83b1d56b33cc610efea6876dd9e1f))
* Extreme.py init_ui changed > to >= for setting number of columns ([`a0a89fe`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a0a89fe704db6c11a99a26a080051af1c677ba7a))
* Extreme.py init_plot_background error handling ([`dafb6fa`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/dafb6fae7a526d5b311ded1d0424ac4dbb3c8b74))
* Extreme.py ui is initialised for the first scan of config in scan mode ([`82bebe6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/82bebe6b41004befcb1b54db141e20ff844f76e5))
* Extreme.py client and device manager initialisation ([`cf15163`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cf15163bd91291e9851662c147b2e799ae022b9e))
* Formatter fixed ([`153c5f4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/153c5f4f9d168f433380bd2deddd2b17a45916a3))
## v0.27.0 (2023-09-25)
### Feature
* Motor_example.py in start/end mode new button allowing user to go to end position ([`65b045e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/65b045e1a26a0f799311e9dca25e2a9dfd7f7147))
### Fix
* Epics removed from requirements ([`44cc881`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/44cc881ac9e69c68f1f5296fea62a14daa55d4e3))
* Motor_example.py load .csv logic fixed ([`b78152b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b78152b14999ba5c07d7cd2ef2e3309df1ba5ca6))
* Motor_example.py export .csv logic fixed ([`85841cd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/85841cdf1fc44472cfcc7e3e6529a41018140896))
* Motor_example.py precision in duplicate table fixed ([`05f48de`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/05f48de3f1f6793de3f6a8bc2c5e3ad3261dfcf0))
* Motor_example.py duplicate table fixed ([`401fec8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/401fec85395886ea2816b6993bf8084b6e652967))
* Motor_example.py manual changing coordinates in start/stop works again ([`b13509e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b13509e9eb88b55a59b141b0cec06f3c8a983151))
* Motor_example.py replot points logic simplified ([`a15860a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a15860abac984328382868b0a953960c44792c41))
* Motor_example.py new independent mapping relying on the table ([`673ed32`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/673ed325d1f56505035899549ea555497823a31f))
* Extreme.py formatting fixed ([`63f52fc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/63f52fc8419cd53856a32af6be3f548f8e077cd1))
* Line_plot.py ROI interactions fixed ([`e4f23f5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e4f23f51012b54cde5cd41bb9ab356a277ef4b2f))
* Online changes e21543 ([`b41d63e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b41d63ea4d6e15c80a7baab7a70c607079152d0a))
* Motor_example.py user is blocked to duplicate last entry in start/end mode if end coordinate was not defined ([`418480f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/418480f1fcdc72c05887e8b73c24f76e1e8475b2))
* Motor_example.py - new more robust logic for getting coordinates for table go buttons ([`08f508f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/08f508f4c3c3e1f3c2d4c6dda0d8e6693e9331b5))
### Performance
* Motor_example.py replot logic optimizes ([`a4fb6bd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a4fb6bd1d2c819740077cdc7291daf28a9e4abdd))
## v0.26.7 (2023-09-19)
### Fix
* Eiger_plot_hist.py removed ([`abe35bf`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/abe35bf96757a38395733bddbd8702a29fd26f42))
## v0.26.6 (2023-09-19)
### Fix
* Extreme.py saved to .yaml works correctly for different scans configurations ([`cb144c7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cb144c7c2cba50fc49ba53b0a9e3293b549665be))
* Extreme.py fixed logic of loading new config.yaml during app operation ([`4287ac8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4287ac888591abf27a4e4ce8c23f94d54bc6c2a9))
### Documentation
* Extreme.py updated documentation ([`7ff72b4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7ff72b4086e5e340d591d130f011f83fc8370315))
## v0.26.5 (2023-09-13)
### Fix
* Motor_example.py help extended ([`a5c6ffa`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a5c6ffaa024a0dd6901976c81ea9146e5be016ec))
## v0.26.4 (2023-09-12)
### Fix
* Logic fixed ([`7cb56e9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7cb56e9e7f2cbeee5a141c4a52a3489c26963839))
## v0.26.3 (2023-09-12)
### Fix
* Import works for both modes ([`b867f25`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b867f25c780ba97393ca65fe76c1cb492f365ded))
## v0.26.2 (2023-09-12)
### Fix
* Import with start/stop mode works again ([`cacc076`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cacc076959cdd55218b74de2974d890e583c3d94))
## v0.26.1 (2023-09-12)
### Fix
* Removed scipy from eiger_plot.py ([`0e634ee`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0e634ee2ac58b8be43b7f4e64fbc08ef08675aa1))
## v0.26.0 (2023-09-12)
### Feature
* Plot different signals and plot configurations based on different scans ([`57e6990`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/57e69907d55f7693e97d48026f3bb426adfb4870))
## v0.25.1 (2023-09-12)
### Fix
* Specific config for csaxs ([`8ff983f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8ff983f16e78d881582d4aaaa0261e10d9d62bf2))
* Mode lock in config to disable changing the mode for users ([`10ccf0c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/10ccf0cc977cae30c0c185a920e15b9cf2def58f))
## v0.25.0 (2023-09-12)
### Feature
* ComboBox to switch between entries mode ([`f2fde2c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f2fde2cf5c4b219520eb0257c1c8e02ce66cde87))
### Fix
* Extra columns works again ([`2123361`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2123361ada9767333792d34de56d6f1447f67cda))
* Resize table is user controlled ([`63e3896`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/63e389672560e505159de2014846d1506b05633f))
## v0.24.2 (2023-09-12)
### Fix
* Changes e20643 ([`2657440`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/265744076cc53bd054b45c12de3bb24b23e1845c))
## v0.24.1 (2023-09-08)
### Fix
* Typo fixed in mca_plot.py ([`3b12f1b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3b12f1bc1d65772fc3613f62013809445dcead7a))
## v0.24.0 (2023-09-08)
### Feature
* HistogramLUT for mca_plot ([`ae04072`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ae040727fc60160de8b50ac1af51fba676106e52))
## v0.23.0 (2023-09-08)
### Feature
* Added key bindings and help dialog ([`ade893d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ade893d33d07f1190994de19b84d4021586bcbcb))
## v0.22.0 (2023-09-08)
### Feature
* Added FFT ([`b984f0f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b984f0f36e2178690eaaec091d4a7b9443f2378f))
## v0.21.2 (2023-09-08)
### Fix
* Moved mask as a last step of image processing ([`87d5467`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/87d546764318679cd80e56d17d590f0e31e51504))
## v0.21.1 (2023-09-08)
### Fix
* Update_signal typo fixed ([`43f03b5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/43f03b543083da9b743828139a92f87732187dd9))
## v0.21.0 (2023-09-08)
### Feature
* Added functionality to load mask ([`33d1193`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/33d1193c9623b157cc74883184677a727b8e33ce))
### Fix
* Path to mask fixed ([`ef42921`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ef42921c9a585bce8a97fc8bb251e27a9455a771))
## v0.20.0 (2023-09-08)
### Feature
* Added rotate and transpose logic ([`acd7a3b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/acd7a3bc92746c7e56dc8699c4378d2ab778267f))
### Fix
* Added missing .ui file to git ([`ae8fc94`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ae8fc9497954ca49c16d76eaeea7ecc7659c1269))
## v0.19.2 (2023-09-08)
### Fix
* Rotation logic fixed ([`6733371`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6733371c2ccb4e233d9aa9421e21d627978925d7))
## v0.19.1 (2023-09-08)
### Fix
* Rotation always counter-clockwise ([`00385ab`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/00385abbf98add7945af170b292774d377473a70))
## v0.19.0 (2023-09-08)
### Feature
* Rotation of the image to the left/right by 90, 180, 270 degree ([`327f6b3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/327f6b3df300d1f88b475973a86175379688aa9b))
* Simulation stream with Gaussian peak in 1st quadrant ([`4fa8d46`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4fa8d46631ff822d5465564434d173dd766a6b1a))
* Eiger_plot.py in example folder with new GUI ([`5cbedec`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5cbedec5d9f6a6ae763e2cb336ecb40c4d3e1ed1))
## v0.18.1 (2023-09-08)
### Fix
* Online changes ([`29c983f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/29c983fb268bb2dbcfe552453501ff42442f075f))
## v0.18.0 (2023-09-08)
### Feature
* Eigerplot added ([`70d74c7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/70d74c774d2b318d99c049f0f03743e77812df98))
## v0.17.1 (2023-09-08)
### Fix
* Start_device_consumer changed from EP device_status to scan_status ([`46a3981`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/46a3981e7dfd5ded7b7f325301d2a25c47abd16f))
## v0.17.0 (2023-09-07)
### Feature
* Console arguments added for Redis port, device, and sub_device tag ([`fb52b2a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fb52b2a8e59fca556764e0dc32bd4edc167e31d3))
* Plot flips every second row ([`c368871`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c36887191914d23e85a1b480dac324be0eefb963))
* Device_consumer is getting scanID and initialise stream_consumer ([`9271b91`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9271b91113a3bbd46f0bffdaef7b50b629e4f44f))
* Simulation and simple 2D plot for mca card stream ([`bfef713`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bfef71382e6a1180d750d2c800650942c5da7a21))
## v0.16.4 (2023-09-06)
### Fix
* Self.limit_map_data fixed to be initialised only with integers from limits ([`b62509a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b62509a28e970358c3ffd4f7d55c2a6bbef35970))
## v0.16.3 (2023-09-06)
### Fix
* Limit spinBoxes morphed to doubleSpinBoxes ([`a1264fe`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a1264fe4e2e0c864c68786d6db16550f489b00fa))
### Documentation
* PyqtGraph controls in help ([`2397af1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2397af140f2f9ee23ed5e62ef9bdf4d0aba249a1))
## v0.16.2 (2023-09-06)
### Fix
* X and y motor can be linked again ([`f45512e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f45512e0ae9c189a1d26456333c5b348cd681ce7))
## v0.16.1 (2023-09-06)
### Fix
* Default values fixed from .yaml ([`8a6e2da`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8a6e2daaf95cb5417951cbe3cca0cb3e909b08b4))
## v0.16.0 (2023-09-06)
### Feature
* Added help button ([`2087d19`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2087d19d3c2349e160327880210a5cf129852f09))
* Table can be loaded from .csv ([`15d995f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/15d995f66b892f55526bd8b0954b6886d8f861ea))
* Table can be exported to csv ([`772f18f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/772f18fa09bef54c849d2fdd58e02e8dada84a4e))
* Additional extra rows takes values from previous row ([`1235294`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1235294b034dae50ff9a2ea93bc1a318383cbbf5))
* Additional columns can be added through .yaml ([`fa76acb`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fa76acbd6dda1695add1c1159c4a96c33741a4c7))
### Fix
* Help extended ([`9fba033`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9fba0334a0389f66344b84dd434d4d9a39b1565e))
* Table loads number of columns correctly ([`bf12963`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bf129632471da2e6dc5d637a5b02c321d8d3dcac))
* Content always aligned to centre ([`74884a3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/74884a37076cd047e2dc75e07246f73e5f93167e))
## v0.15.0 (2023-09-06)
### Feature
* Step for x and y can be linked or separated ([`16ab746`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/16ab746f54007ee0647b6602b7d74a4a59401705))
* User can choose if to save coordinates when moving to absolute coordinates ([`6324199`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/632419929921fbe4e970149ce8d4e617566f71fc))
### Fix
* Table checkbox fixed ([`7e6244c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7e6244c5d3698e6fea944b9501064470b6c884c7))
* Partial fix to table checkBox ([`75f5c8f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/75f5c8fcd6e80288e1f3bc1b9c0c0b3edd1335bc))
* Coordinates markers are updated on the map, if X, Y in table manually is changed ([`0aa667b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0aa667b70d48356bdda59b879baa3862c5e2e756))
* Added float validator to the table ([`be1bd81`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/be1bd81d60373a0d9e776dc3f0d879d1bf905f7a))
* Table bug, when deleted multiple rows ([`9d83a45`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9d83a455e899e3018364123707064882076c4eb0))
* Table bug, when user deleted row and wanted to go to the previous position ([`63e6d61`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/63e6d61c2e6f9cbc069c9d55c7006d18b6b34b4d))
## v0.14.2 (2023-09-05)
### Fix
* Bec_config initialisation by command line argument ([`b7a1b8b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b7a1b8bca1b89df859c9ed0ed17862bb6d533de7))
## v0.14.1 (2023-09-05)
### Fix
* Gui default tab changed to coordinates table ([`3c74fa5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3c74fa59b7b83976b13afc821c1333868e62a686))
## v0.14.0 (2023-09-05)
### Feature
* Enable gui button, in the case that motor movement is not finished ([`84155d2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/84155d22640e229820fa5104975d2675f63cef31))
* Saved coordinates are shown on the map ([`0ca665a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0ca665a1e91d9c5dee9af0218c2e211de8304b26))
### Fix
* Motor position points can be switched on/off if points were deleted ([`5b30dfd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5b30dfd43fcbe4b9941e26cab76005ffeb21d95f))
* Highlight disapear with new motor ([`3fb8651`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3fb8651dd5777861488928b414d5bdacb517d0e9))
* New points do not make invisible points visible again ([`fb10551`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fb105513e52bcd9c62dfead16e91b45ecd817612))
* Checkbox visibility toggle is working. ([`a178c43`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a178c434b1d9efc1795b6f5115e2a8b9685ccdf2))
* Saved coordinates can be removed from table and from the map again ([`c32e95a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c32e95a57d3faec46652b413581d830698855367))
## v0.13.0 (2023-09-05)
### Feature
* Crosshair highlight at motor position ([`9228e5a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9228e5aea3d5e4192733539643654fd635c63559))
* Increase step size double with key bindings ([`e9ef1e3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e9ef1e315bc7222c38c1f2f3f410f5cdff994f08))
* Go, set, save current coordinates and keyboard shortcuts ([`5d6a328`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5d6a328728a017eb4f1d191c96d2659800d41941))
### Fix
* Spinbox limits in ui file ([`8de08cf`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8de08cf9ccb092b3cfa5cf751f69fbf5edd2b217))
* Precision updated correctly ([`172ccc6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/172ccc69056380abcddf572f668a4ddbd5d34eec))
## v0.12.0 (2023-09-04)
### Feature
* Config from .yaml file ([`1a67758`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1a677584708e1c91491fe84db169103bdda488e5))
* Removal of motor configurations from user ([`34212d4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/34212d4d45c88a7bba75f289a25e5488ff95fc73))
### Fix
* Error message if motor do not have limits attribute ([`bf93b02`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bf93b02cdc82086b32e2bd16f4b506c1bb76c65d))
### Documentation
* Added documentation to all classes and methods ([`4afaa1b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4afaa1b0ce1f29e4193e6999ecc13b1f0f662213))
## v0.11.0 (2023-09-04)
### Feature
* Colorbutton next to each curve in the table to be able to set up colors ([`2c6719c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2c6719cf390e6638cadbc814eb0c085bb45c3c6c))
### Fix
* User selected colors are preserved with the new scan ([`8e7885f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8e7885f36dd2812e3285c4d2d101212055644c7b))
* Colorbutton change now symbols as well ([`6d2e1c9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6d2e1c9d08595a45f502287c6490905e8df3db10))
## v0.10.0 (2023-09-01)
### Feature
* Load and export configuration into .yaml from GUI ([`e527353`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e5273539741a1261e69b1bf76af78c7c1ab0d901))
* Error messages if name or entry is wrong ([`415c4ee`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/415c4ee3f232c02ee5a00a82352c7fbb0d324449))
* Number of columns can be dynamically changed ([`65bfccc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/65bfccce8fce158150652fead769721de805d99e))
* Multi window interface created for extreme BL ([`69c38d6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/69c38d67e4e9b8a30767f6f67defce6c5c2e5b16))
### Fix
* Check if num_columns is not higher that actual number of plots ([`aac6e17`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/aac6e172f6e4583e751bee00db6f381aaff8ac69))
* Add max number of columns according to the number of plots ([`fbd71c1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fbd71c131386508a9ec7bb5963afefc13f8b1618))
* More specific error messages ([`583e643`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/583e643dacac3d7aaa744751baef2da69f6f892e))
* Bec_dispatcher.py can take multiple workers as a list ([`7bcf88d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7bcf88d5eb139aa3cf491185b9fb3f45aa5e39a2))
* Config.yaml can be passed as a console argument to extreme.py ([`b8aa373`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b8aa37321d6ac0ebd9f2237c8d2ed6594b614b57))
* Columns span generalised for any number of columns ([`2d851b6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2d851b6b4eb0002e32908c2effbfb79122f18c24))
### Documentation
* Updated documentation and TODOs ([`0ebe35a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0ebe35ac7a144db84c323f9ecb85dfdf6de66c21))
* Fixed documentation ([`2f7c1b9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2f7c1b92a9624741f6dea44fc8f3c19a8a506fd9))
## v0.9.0 (2023-08-29)
### Feature
* Migrate to .yaml config file instead of argparse ([`a9f1688`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a9f16884b0b274e36fdb531b56a26343692a78f5))
* Better color coding of curves ([`0eff18f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0eff18f5a074ea806d43d52ae72bf87f0187a26d))
## v0.8.1 (2023-08-29)
### Fix
* Added missing local .ui file ([`f0589b7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f0589b79ec7f50ee9d040b911d1874b4232659d5))
## v0.8.0 (2023-08-29)
### Feature

View File

@@ -1,2 +1,73 @@
# BEC Widgets
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
## Installation
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
```bash
pip install bec-widgets
```
For development purposes, you can clone the repository and install the package locally in editable mode:
```bash
git clone https://gitlab.psi.ch/bec/bec-widgets
cd bec-widgets
pip install -e .[dev]
```
BEC Widgets currently supports both PyQt5 and PyQt6. By default, PyQt6 is installed.
To select a specific Python Qt distribution, install the package with an additional tag:
```bash
pip install bec-widgets[pyqt6]
```
or
```bash
pip install bec-widgets[pyqt5]
```
## Documentation
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://beamline-experiment-control.readthedocs.io/en/latest/).
## Contributing
All commits should use the Angular commit scheme:
> #### <a name="commit-header"></a>Angular Commit Message Header
>
> ```
> <type>(<scope>): <short summary>
> │ │ │
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
> │ │
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
> │ elements|forms|http|language-service|localize|platform-browser|
> │ platform-browser-dynamic|platform-server|router|service-worker|
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
> │ devtools
>
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
> ```
>
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
> ##### Type
>
> Must be one of the following:
>
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
> * **docs**: Documentation only changes
> * **feat**: A new feature
> * **fix**: A bug fix
> * **perf**: A code change that improves performance
> * **refactor**: A code change that neither fixes a bug nor adds a feature
> * **test**: Adding missing tests or correcting existing tests
## License
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)

View File

@@ -1,457 +0,0 @@
import os
import threading
import time
import warnings
from typing import Any
import numpy as np
import pyqtgraph
import pyqtgraph as pg
from bec_lib.core import BECMessage
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtWidgets import QCheckBox, QTableWidgetItem
from pyqtgraph import mkBrush, mkColor, mkPen
from pyqtgraph.Qt import QtCore, QtWidgets, uic
from pyqtgraph.Qt.QtCore import pyqtSignal
from bec_widgets.bec_dispatcher import bec_dispatcher
from bec_lib.core.redis_connector import MessageObject, RedisConnector
client = bec_dispatcher.client
class BasicPlot(QtWidgets.QWidget):
update_signal = pyqtSignal()
roi_signal = pyqtSignal(tuple)
def __init__(self, name="", y_value_list=["gauss_bpm"]) -> None:
"""
Basic plot widget for displaying scan data.
Args:
name (str, optional): Name of the plot. Defaults to "".
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
"""
super(BasicPlot, self).__init__()
# Set style for pyqtgraph plots
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "basic_plot.ui"), self)
# Set splitter distribution of widgets
self.splitter.setSizes([3, 1])
self._idle_time = 100
self.title = ""
self.label_bottom = ""
self.label_left = ""
self.producer = RedisConnector(["localhost:6379"]).producer()
self.scan_motors = []
self.y_value_list = y_value_list
self.previous_y_value_list = None
self.plotter_data_x = []
self.plotter_data_y = []
self.curves = []
self.pens = []
self.brushs = []
self.plotter_scan_id = None
# TODO to be moved to utils function
plotstyles = {
"symbol": "o",
"symbolSize": 10,
}
color_list = BasicPlot.golden_angle_color(colormap="CET-R2", num=len(self.y_value_list))
# setup plots - GraphicsLayoutWidget
# LabelItem
self.label = pg.LabelItem(justify="center")
self.glw.addItem(self.label)
self.label.setText("ROI region")
# PlotItem - main window
self.glw.nextRow()
self.plot = pg.PlotItem()
self.plot.setLogMode(True, True)
self.glw.addItem(self.plot)
self.plot.addLegend()
# ImageItem - 2D view #TODO add 2D plot for ROI and 1D plot for mouse click
self.glw.nextRow()
self.plot_roi = pg.PlotItem()
self.img = pg.ImageItem()
self.glw.addItem(self.plot_roi)
self.plot_roi.addItem(self.img)
# ROI selector - so far from [-1,1] #TODO update to scale with xrange
self.roi_selector = pg.LinearRegionItem([-1, 1])
for ii, y_value in enumerate(self.y_value_list):
pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
brush = mkBrush(color=color_list[ii])
curve = pg.PlotDataItem(
**plotstyles, symbolBrush=brush, pen=pen, skipFiniteCheck=True, name=y_value
)
self.plot.addItem(curve)
self.curves.append(curve)
self.pens.append(pen)
self.brushs.append(brush)
self.add_crosshair(self.plot)
self.add_crosshair(self.plot_roi)
self.crosshair_v = pg.InfiniteLine(angle=90, movable=False)
self.crosshair_h = pg.InfiniteLine(angle=0, movable=False)
#
# for plot in (self.plot_roi, self.plot):
# plot.addItem(self.crosshair_v, ignoreBounds=True)
# plot.addItem(self.crosshair_h, ignoreBounds=True)
# self.plot.addItem(self.crosshair_v, ignoreBounds=True)
# self.plot.addItem(self.crosshair_h, ignoreBounds=True)
# self.plot_roi.addItem(self.crosshair_v, ignoreBounds=True)
# self.plot_roi.addItem(self.crosshair_h, ignoreBounds=True)
# Add textItems
self.add_text_items()
# Manage signals
self.proxy = pg.SignalProxy(
self.plot.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved
)
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
# Debug functions
self.pushButton_debug.clicked.connect(self.generate_2D_data_update)
# self.generate_2D_data()
self._current_proj = None
self._current_metadata_ep = "px_stream/projection_{}/metadata"
self.data_retriever = threading.Thread(target=self.on_projection, daemon=True)
self.data_retriever.start()
def debug(self):
"""
Debug button just for quick testing
"""
def generate_2D_data(self):
data = np.random.normal(size=(1, 100))
self.img.setImage(data)
def generate_2D_data_update(self):
data = np.random.normal(size=(200, 300))
self.img.setImage(data, levels=(0.2, 0.5))
def add_crosshair(self, plot):
crosshair_v = pg.InfiniteLine(angle=90, movable=False)
crosshair_h = pg.InfiniteLine(angle=0, movable=False)
plot.addItem(crosshair_v)
plot.addItem(crosshair_h)
def get_roi_region(self):
"""For testing purpose now, get roi region and print it to self.label as tuple"""
region = self.roi_selector.getRegion()
self.label.setText(f"x = {(10**region[0]):.4f}, y ={(10**region[1]):.4f}")
return_dict = {
"horiz_roi": [
np.where(self.plotter_data_x[0] > 10 ** region[0])[0][0],
np.where(self.plotter_data_x[0] < 10 ** region[1])[0][-1],
]
}
msg = BECMessage.DeviceMessage(signals=return_dict).dumps()
self.producer.set_and_publish("px_stream/gui_event", msg=msg)
self.roi_signal.emit(region)
def add_text_items(self): # TODO probably can be removed
"""Add text items to the plot"""
# self.mouse_box_data.setText("Mouse cursor")
# TODO Via StyleSheet, one may set the color of the full QLabel
# self.mouse_box_data.setStyleSheet(f"QLabel {{color : rgba{self.pens[0].color().getRgb()}}}")
def mouse_moved(self, event: tuple) -> None:
"""
Update the mouse table with the current mouse position and the corresponding data.
Args:
event (tuple): Mouse event containing the position of the mouse cursor.
The position is stored in first entry as horizontal, vertical pixel.
"""
pos = event[0]
if not self.plot.sceneBoundingRect().contains(pos):
return
mousePoint = self.plot.vb.mapSceneToView(pos)
self.crosshair_v.setPos(mousePoint.x())
self.crosshair_h.setPos(mousePoint.y())
if not self.plotter_data_x:
return
closest_point = self.closest_x_y_value(
mousePoint.x(), self.plotter_data_x[0], self.plotter_data_y[0]
)
# self.precision = 3
# ii = 0
# y_value = self.y_value_list[ii]
# x_data = f"{10**closest_point[0]:.{self.precision}f}"
# y_data = f"{10**closest_point[1]:.{self.precision}f}"
#
# # Write coordinate to QTable
# self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
# self.mouse_table.setItem(ii, 2, QTableWidgetItem(str(x_data)))
# self.mouse_table.setItem(ii, 3, QTableWidgetItem(str(y_data)))
#
# self.mouse_table.resizeColumnsToContents()
def closest_x_y_value(self, input_value, list_x, list_y) -> tuple:
"""
Find the closest x and y value to the input value.
Args:
input_value (float): Input value
list_x (list): List of x values
list_y (list): List of y values
Returns:
tuple: Closest x and y value
"""
arr = np.asarray(list_x)
i = (np.abs(arr - input_value)).argmin()
return list_x[i], list_y[i]
def update(self):
"""Update the plot with the new data."""
# check if roi selector is in the plot
if self.roi_selector not in self.plot.items:
self.plot.addItem(self.roi_selector)
# check if QTable was initialised and if list of devices was changed
if self.y_value_list != self.previous_y_value_list:
self.setup_cursor_table()
self.previous_y_value_list = self.y_value_list.copy() if self.y_value_list else None
self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
# if len(self.plotter_data_x[0]) <= 1:
# return
# self.plot.setLabel("bottom", self.label_bottom)
# self.plot.setLabel("left", self.label_left)
# for ii in range(len(self.y_value_list)):
# self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
@pyqtSlot(dict, dict)
def on_scan_segment(self, data: dict, metadata: dict) -> None:
"""Update function that is called during the scan callback. To avoid
too many renderings, the GUI is only processing events every <_idle_time> ms.
Args:
data (dict): Dictionary containing a new scan segment
metadata (dict): Scan metadata
"""
if metadata["scanID"] != self.plotter_scan_id:
self.plotter_scan_id = metadata["scanID"]
self._reset_plot_data()
self.title = f"Scan {metadata['scan_number']}"
self.scan_motors = scan_motors = metadata.get("scan_report_devices")
# client = BECClient()
remove_y_value_index = [
index
for index, y_value in enumerate(self.y_value_list)
if y_value not in client.device_manager.devices
]
if remove_y_value_index:
for ii in sorted(remove_y_value_index, reverse=True):
# TODO Use bec warning message??? to be discussed with Klaus
warnings.warn(
f"Warning: no matching signal for {self.y_value_list[ii]} found in list of devices. Removing from plot."
)
self.remove_curve_by_name(self.plot, self.y_value_list[ii])
self.y_value_list.pop(ii)
self.precision = client.device_manager.devices[scan_motors[0]]._info["describe"][
scan_motors[0]
]["precision"]
# TODO after update of bec_lib, this will be new way to access data
# self.precision = client.device_manager.devices[scan_motors[0]].precision
x = data["data"][scan_motors[0]][scan_motors[0]]["value"]
self.plotter_data_x.append(x)
for ii, y_value in enumerate(self.y_value_list):
y = data["data"][y_value][y_value]["value"]
self.plotter_data_y[ii].append(y)
self.label_bottom = scan_motors[0]
self.label_left = f"{', '.join(self.y_value_list)}"
# print(f'metadata scan N{metadata["scan_number"]}') #TODO put as label on top of plot
# print(f'Data point = {data["point_id"]}') #TODO can be used for progress bar
if len(self.plotter_data_x) <= 1:
return
self.update_signal.emit()
def _reset_plot_data(self):
"""Reset the plot data."""
self.plotter_data_x = []
self.plotter_data_y = []
for ii in range(len(self.y_value_list)):
self.curves[ii].setData([], [])
self.plotter_data_y.append([])
def setup_cursor_table(self):
"""QTable formatting according to N of devices displayed in plot."""
# Init number of rows in table according to n of devices
self.mouse_table.setRowCount(len(self.y_value_list))
for ii, y_value in enumerate(self.y_value_list):
checkbox = QCheckBox()
checkbox.setChecked(True)
# TODO just for testing, will be replaced by removing/adding curve
checkbox.stateChanged.connect(lambda: print("status Changed"))
# checkbox.stateChanged.connect(lambda: self.remove_curve_by_name(plot=self.plot, checkbox=checkbox, name=y_value))
self.mouse_table.setCellWidget(ii, 0, checkbox)
self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
self.mouse_table.resizeColumnsToContents()
@staticmethod
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
"""Removes a curve from the given plot by the specified name.
Args:
plot (pyqtgraph.PlotItem): The plot from which to remove the curve.
name (str): The name of the curve to remove.
"""
# if checkbox.isChecked():
for item in plot.items:
if isinstance(item, pg.PlotDataItem) and getattr(item, "opts", {}).get("name") == name:
plot.removeItem(item)
return
# else:
# return
@staticmethod
def golden_ratio(num: int) -> list:
"""Calculate the golden ratio for a given number of angles.
Args:
num (int): Number of angles
"""
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
angles = []
for ii in range(num):
x = np.cos(ii * phi)
y = np.sin(ii * phi)
angle = np.arctan2(y, x)
angles.append(angle)
return angles
@staticmethod
def golden_angle_color(colormap: str, num: int) -> list:
"""
Extract num colors for from the specified colormap following golden angle distribution.
Args:
colormap (str): Name of the colormap
num (int): Number of requested colors
Returns:
list: List of colors with length <num>
Raises:
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
"""
cmap = pg.colormap.get(colormap)
cmap_colors = cmap.color
if num > len(cmap_colors):
raise ValueError(
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
)
angles = BasicPlot.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = [
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
]
return colors
def on_projection(self):
while True:
if self._current_proj is None:
time.sleep(0.1)
continue
endpoint = f"px_stream/projection_{self._current_proj}/data"
msgs = client.producer.lrange(topic=endpoint, start=-1, end=-1)
data = [BECMessage.DeviceMessage.loads(msg) for msg in msgs]
if not data:
continue
with np.errstate(divide="ignore", invalid="ignore"):
self.plotter_data_y = [
np.sum(
np.sum(data[-1].content["signals"]["data"] * self._current_norm, axis=1)
/ np.sum(self._current_norm, axis=0),
axis=0,
).squeeze()
]
self.update_signal.emit()
@pyqtSlot(dict, dict)
def on_dap_update(self, data: dict, metadata: dict):
self.img.setImage(data["z"].T)
# time.sleep(0,1)
@pyqtSlot(dict)
def new_proj(self, data):
proj_nr = data["proj_nr"]
endpoint = f"px_stream/projection_{proj_nr}/metadata"
msg_raw = client.producer.get(topic=endpoint)
msg = BECMessage.DeviceMessage.loads(msg_raw)
self._current_q = msg.content["signals"]["q"]
self._current_norm = msg.content["signals"]["norm_sum"]
self._current_metadata = msg.content["signals"]["metadata"]
self.plotter_data_x = [self._current_q]
self._current_proj = proj_nr
if __name__ == "__main__":
import argparse
from bec_widgets import ctrl_c
from bec_widgets.bec_dispatcher import bec_dispatcher
parser = argparse.ArgumentParser()
parser.add_argument(
"--signals",
help="specify recorded signals",
nargs="+",
default=["gauss_bpm"],
)
# default = ["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
value = parser.parse_args()
print(f"Plotting signals for: {', '.join(value.signals)}")
client = bec_dispatcher.client
# client.start()
app = QtWidgets.QApplication([])
ctrl_c.setup(app)
plot = BasicPlot(y_value_list=value.signals)
# bec_dispatcher.connect(plot)
bec_dispatcher.connect_proj_id(plot.new_proj)
bec_dispatcher.connect_dap_slot(plot.on_dap_update, "px_dap_worker")
plot.show()
# client.callbacks.register("scan_segment", plot, sync=False)
app.exec_()

View File

@@ -1,77 +0,0 @@
<?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>845</width>
<height>635</height>
</rect>
</property>
<property name="windowTitle">
<string>Line Plot</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="opaqueResize">
<bool>false</bool>
</property>
<widget class="QWidget" name="verticalLayoutWidget">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="pushButton_debug">
<property name="text">
<string>Debug</string>
</property>
</widget>
</item>
<item>
<widget class="GraphicsLayoutWidget" name="glw"/>
</item>
</layout>
</widget>
<widget class="QTableWidget" name="mouse_table">
<property name="textElideMode">
<enum>Qt::ElideMiddle</enum>
</property>
<column>
<property name="text">
<string>Display</string>
</property>
</column>
<column>
<property name="text">
<string>Device</string>
</property>
</column>
<column>
<property name="text">
<string>X</string>
</property>
</column>
<column>
<property name="text">
<string>Y</string>
</property>
</column>
</widget>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsLayoutWidget</class>
<extends>QGraphicsView</extends>
<header>pyqtgraph.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,232 +0,0 @@
import argparse
import itertools
import os
from dataclasses import dataclass
from threading import RLock
from bec_lib import BECClient
from bec_lib.core import BECMessage, MessageEndpoints, ServiceConfig
from bec_lib.core.redis_connector import RedisConsumerThreaded
from PyQt5.QtCore import QObject, pyqtSignal
@dataclass
class _BECDap:
"""Utility class to keep track of slots associated with a particular dap redis consumer"""
consumer: RedisConsumerThreaded
slots = set()
# Adding a new pyqt signal requres a class factory, as they must be part of the class definition
# and cannot be dynamically added as class attributes after the class has been defined.
_signal_class_factory = (
type(f"Signal{i}", (QObject,), dict(signal=pyqtSignal(dict, dict))) for i in itertools.count()
)
@dataclass
class _Connection:
"""Utility class to keep track of slots connected to a particular redis consumer"""
consumer: RedisConsumerThreaded
slots = set()
# keep a reference to a new signal class, so it is not gc'ed
_signal_container = next(_signal_class_factory)()
def __post_init__(self):
self.signal = self._signal_container.signal
class _BECDispatcher(QObject):
new_scan = pyqtSignal(dict, dict)
scan_segment = pyqtSignal(dict, dict)
new_dap_data = pyqtSignal(dict, dict)
new_projection_id = pyqtSignal(dict)
new_projection_data = pyqtSignal(dict)
def __init__(self, bec_config=None):
super().__init__()
self.client = BECClient()
# TODO: this is a workaround for now to provide service config within qtdesigner, but is
# it possible to provide config via a cli arg?
if bec_config is None and os.path.isfile("bec_config.yaml"):
bec_config = "bec_config.yaml"
self.client.initialize(config=ServiceConfig(config_path=bec_config))
self._slot_signal_map = {
"on_scan_segment": self.scan_segment,
"on_new_scan": self.new_scan,
}
self._daps = {}
self._connections = {}
self._scan_id = None
scan_lock = RLock()
# self.new_projection_id.connect(self.new_projection_data)
def _scan_segment_cb(msg):
msg = BECMessage.ScanMessage.loads(msg.value)[0]
with scan_lock:
# TODO: use ScanStatusMessage instead?
scan_id = msg.content["scanID"]
if self._scan_id != scan_id:
self._scan_id = scan_id
self.new_scan.emit(msg.content, msg.metadata)
self.scan_segment.emit(msg.content, msg.metadata)
scan_segment_topic = MessageEndpoints.scan_segment()
self._scan_segment_thread = self.client.connector.consumer(
topics=scan_segment_topic,
cb=_scan_segment_cb,
)
self._scan_segment_thread.start()
def connect(self, widget):
for slot_name, signal in self._slot_signal_map.items():
slot = getattr(widget, slot_name, None)
if callable(slot):
signal.connect(slot)
def connect_slot(self, slot, topic):
# create new connection for topic if it doesn't exist
if topic not in self._connections:
def cb(msg):
msg = BECMessage.MessageReader.loads(msg.value)
if not isinstance(msg, list):
msg = [msg]
for msg_i in msg:
self._connections[topic].signal.emit(msg_i.content, msg_i.metadata)
consumer = self.client.connector.consumer(topics=topic, cb=cb)
consumer.start()
self._connections[topic] = _Connection(consumer)
# connect slot if it's not connected
if slot not in self._connections[topic].slots:
self._connections[topic].signal.connect(slot)
self._connections[topic].slots.add(slot)
def disconnect_slot(self, slot, topic):
if topic not in self._connections:
return
if slot not in self._connections[topic].slots:
return
self._connections[topic].signal.disconnect(slot)
self._connections[topic].slots.remove(slot)
if not self._connections[topic].slots:
# shutdown consumer if there are no more connected slots
self._connections[topic].consumer.shutdown()
del self._connections[topic]
def connect_dap_slot(self, slot, dap_name):
if dap_name not in self._daps:
# create a new consumer and connect slot
def _dap_cb(msg):
msg = BECMessage.ProcessedDataMessage.loads(msg.value)
if not isinstance(msg, list):
msg = [msg]
for i in msg:
self.new_dap_data.emit(i.content["data"], i.metadata)
dap_ep = MessageEndpoints.processed_data(dap_name)
consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb)
consumer.start()
self.new_dap_data.connect(slot)
self._daps[dap_name] = _BECDap(consumer)
self._daps[dap_name].slots.add(slot)
else:
# connect slot if it's not yet connected
if slot not in self._daps[dap_name].slots:
self.new_dap_data.connect(slot)
self._daps[dap_name].slots.add(slot)
def disconnect_dap_slot(self, slot, dap_name):
if dap_name not in self._daps:
return
if slot not in self._daps[dap_name].slots:
return
self.new_dap_data.disconnect(slot)
self._daps[dap_name].slots.remove(slot)
if not self._daps[dap_name].slots:
# shutdown consumer if there are no more connected slots
self._daps[dap_name].consumer.shutdown()
del self._daps[dap_name]
# def connect_proj_data(self, slot):
# keys = self.client.producer.keys("px_stream/projection_*")
# keys = keys or []
#
# def _dap_cb(msg):
# msg = BECMessage.DeviceMessage.loads(msg.value)
# self.new_projection_data.emit(msg.content["data"])
#
# proj_numbers = set(key.decode().split("px_stream/projection_")[1].split("/")[0] for key in keys)
# last_proj_id = sorted(proj_numbers)[-1]
# dap_ep = MessageEndpoints.processed_data(f"px_stream/projection_{last_proj_id}/")
#
# consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb)
# consumer.start()
#
# self.new_projection_data.connect(slot)
def connect_proj_id(self, slot):
def _dap_cb(msg):
msg = BECMessage.DeviceMessage.loads(msg.value)
self.new_projection_id.emit(msg.content["signals"])
dap_ep = "px_stream/proj_nr"
consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb)
consumer.start()
self.new_projection_id.connect(slot)
def connect_proj_data(self, slot: object, data_ep: str) -> object:
def _dap_cb(msg):
msg = BECMessage.DeviceMessage.loads(msg.value)
self.new_projection_data.emit(msg.content["signals"])
consumer = self.client.connector.consumer(topics=data_ep, cb=_dap_cb)
consumer.start()
self._daps[data_ep] = _BECDap(consumer)
self._daps[data_ep].slots.add(slot)
self.new_projection_data.connect(slot)
def disconnect_proj_data(self, slot, data_ep):
if data_ep not in self._daps:
return
if slot not in self._daps[data_ep].slots:
return
self.new_projection_data.disconnect(slot)
self._daps[data_ep].slots.remove(slot)
if not self._daps[data_ep].slots:
# shutdown consumer if there are no more connected slots
self._daps[data_ep].consumer.shutdown()
del self._daps[data_ep]
parser = argparse.ArgumentParser()
parser.add_argument("--bec-config", default=None)
args, _ = parser.parse_known_args()
bec_dispatcher = _BECDispatcher(args.bec_config)

View File

@@ -1,129 +0,0 @@
from typing import List
import numpy as np
import pyqtgraph as pg
from pyqtgraph import mkPen
from pyqtgraph.Qt import QtCore, QtWidgets
class ConfigPlotter(pg.GraphicsWidget):
"""
ConfigPlotter is a widget that can be used to plot data from multiple channels
in a grid layout. The layout is specified by a list of dicts, where each dict
specifies the position of the plot in the grid, the channels to plot, and the
type of plot to use. The plot type is specified by the name of the pyqtgraph
item to use. For example, to plot a single channel in a PlotItem, the config
would look like this:
config = [
{
"cols": 1,
"rows": 1,
"y": 0,
"x": 0,
"config": {"channels": ["a"], "label_xy": ["", "a"], "item": "PlotItem"},
}
]
"""
def __init__(self, configs: List[dict], parent=None):
super().__init__(parent)
self.configs = configs
self.plots = {}
self._init_ui()
self._init_plots()
def _init_ui(self):
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
# pylint: disable=no-member
self.pen = mkPen(color=(56, 76, 107), width=4, style=QtCore.Qt.SolidLine)
self.view = pg.GraphicsView()
self.view.setAntialiasing(True)
self.view.show()
self.layout = pg.GraphicsLayout()
self.view.setCentralWidget(self.layout)
def _init_plots(self):
for config in self.configs:
channels = config["config"]["channels"]
for channel in channels:
item = pg.PlotItem()
self.layout.addItem(
item,
row=config["y"],
col=config["x"],
rowspan=config["rows"],
colspan=config["cols"],
)
# call the corresponding init function, e.g. init_plotitem
init_func = getattr(self, f"init_{config['config']['item']}")
init_func(channel, config["config"], item)
# self.init_ImageItem(channel, config["config"], item)
def init_PlotItem(self, channel: str, config: dict, item: pg.GraphicsItem):
"""
Initialize a PlotItem
Args:
channel(str): channel to plot
config(dict): config dict for the channel
item(pg.GraphicsItem): PlotItem to plot the data
"""
# pylint: disable=invalid-name
plot_data = item.plot(np.random.rand(100), pen=self.pen)
item.setLabel("left", channel)
self.plots[channel] = {"item": item, "plot_data": plot_data}
def init_ImageItem(self, channel: str, config: dict, item: pg.GraphicsItem):
"""
Initialize an ImageItem
Args:
channel(str): channel to plot
config(dict): config dict for the channel
item(pg.GraphicsItem): ImageItem to plot the data
"""
# pylint: disable=invalid-name
img = pg.ImageItem()
item.addItem(img)
img.setImage(np.random.rand(100, 100))
self.plots[channel] = {"item": item, "plot_data": img}
if __name__ == "__main__":
import sys
CONFIG = [
{
"cols": 1,
"rows": 1,
"y": 0,
"x": 0,
"config": {"channels": ["a"], "label_xy": ["", "a"], "item": "PlotItem"},
},
{
"cols": 1,
"rows": 1,
"y": 1,
"x": 0,
"config": {"channels": ["b"], "label_xy": ["", "b"], "item": "PlotItem"},
},
{
"cols": 1,
"rows": 2,
"y": 0,
"x": 1,
"config": {"channels": ["c"], "label_xy": ["", "c"], "item": "ImageItem"},
},
]
app = QtWidgets.QApplication(sys.argv)
win = ConfigPlotter(CONFIG)
pg.exec()

View File

@@ -1,38 +0,0 @@
import signal
import socket
from PyQt5.QtNetwork import QAbstractSocket
def setup(app):
app.signalwatchdog = SignalWatchdog() # need to store to keep socket pair alive
signal.signal(signal.SIGINT, make_quit_handler(app))
def make_quit_handler(app):
def handler(*args):
print() # make ^C appear on its own line
app.quit()
return handler
class SignalWatchdog(QAbstractSocket):
def __init__(self):
"""
Propagates system signals from Python to QEventLoop
adapted from https://stackoverflow.com/a/65802260/655404
"""
super().__init__(QAbstractSocket.SctpSocket, None)
self.writer, self.reader = writer, reader = socket.socketpair()
writer.setblocking(False)
fd_writer = writer.fileno()
fd_reader = reader.fileno()
signal.set_wakeup_fd(fd_writer) # Python hook
self.setSocketDescriptor(fd_reader) # Qt hook
self.readyRead.connect(
lambda: None
) # dummy function call that lets the Python interpreter run

View File

@@ -1,33 +0,0 @@
import os
import sys
from PyQt5 import QtWidgets, uic
class UI(QtWidgets.QWidget):
def __init__(self, uipath):
super().__init__()
self.ui = uic.loadUi(uipath, self)
_, fname = os.path.split(uipath)
self.setWindowTitle(fname)
self.show()
def main():
"""A basic script to display UI file
Run the script, passing UI file path as an argument, e.g.
$ python bec_widgets/display_ui_file.py bec_widgets/line_plot.ui
"""
app = QtWidgets.QApplication(sys.argv)
UI(sys.argv[1])
sys.exit(app.exec_())
if __name__ == "__main__":
main()

View File

View File

@@ -1,6 +1,6 @@
import numpy as np
import pyqtgraph as pg
from PyQt5.QtWidgets import (
from qtpy.QtWidgets import (
QApplication,
QVBoxLayout,
QLabel,
@@ -12,7 +12,7 @@ from PyQt5.QtWidgets import (
)
from pyqtgraph import mkPen
from pyqtgraph.Qt import QtCore
from bec_widgets.qt_utils import Crosshair
from bec_widgets.utils import Crosshair
class ExampleApp(QWidget):
@@ -165,4 +165,4 @@ if __name__ == "__main__":
app = QApplication([])
window = ExampleApp()
window.show()
app.exec_()
app.exec()

View File

@@ -0,0 +1,298 @@
import json
import os
import threading
import h5py
import numpy as np
import pyqtgraph as pg
import zmq
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import (
QWidget,
QFileDialog,
QShortcut,
QDialog,
QVBoxLayout,
QLabel,
QFrame,
)
from pyqtgraph.Qt import uic
# from scipy.stats import multivariate_normal
class EigerPlot(QWidget):
update_signal = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
# pg.setConfigOptions(background="w", foreground="k", antialias=True)
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "eiger_plot.ui"), self)
# Set widow name
self.setWindowTitle("Eiger Plot")
self.hist_lims = None
self.mask = None
self.image = None
# UI
self.init_ui()
self.hook_signals()
self.key_bindings()
# ZMQ Consumer
self.start_zmq_consumer()
def init_ui(self):
# Create Plot and add ImageItem
self.plot_item = pg.PlotItem()
self.plot_item.setAspectLocked(True)
self.imageItem = pg.ImageItem()
self.plot_item.addItem(self.imageItem)
# Setting up histogram
self.hist = pg.HistogramLUTItem()
self.hist.setImageItem(self.imageItem)
self.hist.gradient.loadPreset("magma")
self.update_hist()
# Adding Items to Graphical Layout
self.glw.addItem(self.plot_item)
self.glw.addItem(self.hist)
def hook_signals(self):
# Buttons
# self.pushButton_test.clicked.connect(self.start_sim_stream)
self.pushButton_mask.clicked.connect(self.load_mask_dialog)
self.pushButton_delete_mask.clicked.connect(self.delete_mask)
self.pushButton_help.clicked.connect(self.show_help_dialog)
# SpinBoxes
self.doubleSpinBox_hist_min.valueChanged.connect(self.update_hist)
self.doubleSpinBox_hist_max.valueChanged.connect(self.update_hist)
# Signal/Slots
self.update_signal.connect(self.on_image_update)
def key_bindings(self):
# Key bindings for rotation
rotate_plus = QShortcut(QKeySequence("Ctrl+A"), self)
rotate_minus = QShortcut(QKeySequence("Ctrl+Z"), self)
self.comboBox_rotation.setToolTip("Increase rotation: Ctrl+A\nDecrease rotation: Ctrl+Z")
self.checkBox_transpose.setToolTip("Toggle transpose: Ctrl+T")
max_index = self.comboBox_rotation.count() - 1 # Maximum valid index
rotate_plus.activated.connect(
lambda: self.comboBox_rotation.setCurrentIndex(
min(self.comboBox_rotation.currentIndex() + 1, max_index)
)
)
rotate_minus.activated.connect(
lambda: self.comboBox_rotation.setCurrentIndex(
max(self.comboBox_rotation.currentIndex() - 1, 0)
)
)
# Key bindings for transpose
transpose = QShortcut(QKeySequence("Ctrl+T"), self)
transpose.activated.connect(self.checkBox_transpose.toggle)
FFT = QShortcut(QKeySequence("Ctrl+F"), self)
FFT.activated.connect(self.checkBox_FFT.toggle)
self.checkBox_FFT.setToolTip("Toggle FFT: Ctrl+F")
log = QShortcut(QKeySequence("Ctrl+L"), self)
log.activated.connect(self.checkBox_log.toggle)
self.checkBox_log.setToolTip("Toggle log: Ctrl+L")
mask = QShortcut(QKeySequence("Ctrl+M"), self)
mask.activated.connect(self.pushButton_mask.click)
self.pushButton_mask.setToolTip("Load mask: Ctrl+M")
delete_mask = QShortcut(QKeySequence("Ctrl+D"), self)
delete_mask.activated.connect(self.pushButton_delete_mask.click)
self.pushButton_delete_mask.setToolTip("Delete mask: Ctrl+D")
def update_hist(self):
self.hist_levels = [
self.doubleSpinBox_hist_min.value(),
self.doubleSpinBox_hist_max.value(),
]
self.hist.setLevels(min=self.hist_levels[0], max=self.hist_levels[1])
self.hist.setHistogramRange(
self.hist_levels[0] - 0.1 * self.hist_levels[0],
self.hist_levels[1] + 0.1 * self.hist_levels[1],
)
def load_mask_dialog(self):
options = QFileDialog.Options()
options |= QFileDialog.ReadOnly
file_name, _ = QFileDialog.getOpenFileName(
self, "Select Mask File", "", "H5 Files (*.h5);;All Files (*)", options=options
)
if file_name:
self.load_mask(file_name)
def load_mask(self, path):
try:
with h5py.File(path, "r") as f:
self.mask = f["data"][...]
if self.mask is not None:
# Set label to mask name without path
self.label_mask.setText(os.path.basename(path))
except KeyError as e:
# Update GUI with the error message
print(f"Error: {str(e)}")
def delete_mask(self):
self.mask = None
self.label_mask.setText("No Mask")
@pyqtSlot()
def on_image_update(self):
# TODO first rotate then transpose
if self.mask is not None:
# self.image = np.ma.masked_array(self.image, mask=self.mask) #TODO test if np works
self.image = self.image * (1 - self.mask) + 1
if self.checkBox_FFT.isChecked():
self.image = np.abs(np.fft.fftshift(np.fft.fft2(self.image)))
if self.comboBox_rotation.currentIndex() > 0: # rotate
self.image = np.rot90(self.image, k=self.comboBox_rotation.currentIndex(), axes=(0, 1))
if self.checkBox_transpose.isChecked(): # transpose
self.image = np.transpose(self.image)
if self.checkBox_log.isChecked():
self.image = np.log10(self.image)
self.imageItem.setImage(self.image, autoLevels=False)
###############################
# ZMQ Consumer
###############################
def start_zmq_consumer(self):
consumer_thread = threading.Thread(target=self.zmq_consumer, daemon=True).start()
def zmq_consumer(self):
try:
print("starting consumer")
live_stream_url = "tcp://129.129.95.38:20000"
receiver = zmq.Context().socket(zmq.SUB)
receiver.connect(live_stream_url)
receiver.setsockopt_string(zmq.SUBSCRIBE, "")
while True:
raw_meta, raw_data = receiver.recv_multipart()
meta = json.loads(raw_meta.decode("utf-8"))
self.image = np.frombuffer(raw_data, dtype=meta["type"]).reshape(meta["shape"])
self.update_signal.emit()
finally:
receiver.disconnect(live_stream_url)
receiver.context.term()
###############################
# just simulations from here
###############################
def show_help_dialog(self):
dialog = QDialog(self)
dialog.setWindowTitle("Help")
layout = QVBoxLayout()
# Key bindings section
layout.addWidget(QLabel("Keyboard Shortcuts:"))
key_bindings = [
("Ctrl+A", "Increase rotation"),
("Ctrl+Z", "Decrease rotation"),
("Ctrl+T", "Toggle transpose"),
("Ctrl+F", "Toggle FFT"),
("Ctrl+L", "Toggle log scale"),
("Ctrl+M", "Load mask"),
("Ctrl+D", "Delete mask"),
]
for keys, action in key_bindings:
layout.addWidget(QLabel(f"{keys} - {action}"))
# Separator
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setFrameShadow(QFrame.Sunken)
layout.addWidget(separator)
# Histogram section
layout.addWidget(QLabel("Histogram:"))
layout.addWidget(
QLabel(
"Use the Double Spin Boxes to adjust the minimum and maximum values of the histogram."
)
)
# Another Separator
another_separator = QFrame()
another_separator.setFrameShape(QFrame.HLine)
another_separator.setFrameShadow(QFrame.Sunken)
layout.addWidget(another_separator)
# Mask section
layout.addWidget(QLabel("Mask:"))
layout.addWidget(
QLabel(
"Use 'Load Mask' to load a mask from an H5 file. 'Delete Mask' removes the current mask."
)
)
dialog.setLayout(layout)
dialog.exec()
###############################
# just simulations from here
###############################
# def start_sim_stream(self):
# sim_stream_thread = threading.Thread(target=self.sim_stream, daemon=True)
# sim_stream_thread.start()
#
# def sim_stream(self):
# for i in range(100):
# # Generate 100x100 image of random noise
# self.image = np.random.rand(100, 100) * 0.2
#
# # Define Gaussian parameters
# x, y = np.mgrid[0:50, 0:50]
# pos = np.dstack((x, y))
#
# # Center at (25, 25) longer along y-axis
# rv = multivariate_normal(mean=[25, 25], cov=[[25, 0], [0, 80]])
#
# # Generate Gaussian in the first quadrant
# gaussian_quadrant = rv.pdf(pos) * 40
#
# # Place Gaussian in the first quadrant
# self.image[0:50, 0:50] += gaussian_quadrant * 10
#
# self.update_signal.emit()
# time.sleep(0.1)
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
plot = EigerPlot()
plot.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,207 @@
<?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>874</width>
<height>762</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Plot Control</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Histogram MIN</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QDoubleSpinBox" name="doubleSpinBox_hist_min">
<property name="minimum">
<double>-100000.000000000000000</double>
</property>
<property name="maximum">
<double>100000.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Histogram MAX</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="doubleSpinBox_hist_max">
<property name="minimum">
<double>-100000.000000000000000</double>
</property>
<property name="maximum">
<double>100000.000000000000000</double>
</property>
<property name="value">
<double>2.000000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Data Control</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QCheckBox" name="checkBox_FFT">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>FFT</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBox_log">
<property name="text">
<string>log</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_mask">
<property name="text">
<string>Load Mask</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_delete_mask">
<property name="text">
<string>Delete Mask</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_3">
<property name="title">
<string>Orientation</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="2" column="1">
<widget class="QComboBox" name="comboBox_rotation">
<item>
<property name="text">
<string>0</string>
</property>
</item>
<item>
<property name="text">
<string>90</string>
</property>
</item>
<item>
<property name="text">
<string>180</string>
</property>
</item>
<item>
<property name="text">
<string>270</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Rotation</string>
</property>
</widget>
</item>
<item row="0" column="0" colspan="2">
<widget class="QCheckBox" name="checkBox_transpose">
<property name="text">
<string>Transpose</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_4">
<property name="title">
<string>Help</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLabel" name="label_mask">
<property name="text">
<string>No Mask</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_help">
<property name="text">
<string>Help</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="GraphicsLayoutWidget" name="glw"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsLayoutWidget</class>
<extends>QGraphicsView</extends>
<header>pyqtgraph.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,161 @@
# import simulation_progress as SP
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
from qtpy.QtWidgets import (
QApplication,
QVBoxLayout,
QWidget,
)
from bec_lib import MessageEndpoints, messages
class StreamApp(QWidget):
update_signal = pyqtSignal()
new_scanID = pyqtSignal(str)
def __init__(self, device, sub_device):
super().__init__()
pg.setConfigOptions(background="w", foreground="k")
self.init_ui()
self.setWindowTitle("MCA readout")
self.data = None
self.scanID = None
self.stream_consumer = None
self.device = device
self.sub_device = sub_device
self.start_device_consumer()
# self.start_device_consumer(self.device) # for simulation
self.new_scanID.connect(self.create_new_stream_consumer)
self.update_signal.connect(self.plot_new)
def init_ui(self):
# Create layout and add widgets
self.layout = QVBoxLayout()
self.setLayout(self.layout)
# Create plot
self.glw = pg.GraphicsLayoutWidget()
self.layout.addWidget(self.glw)
# Create Plot and add ImageItem
self.plot_item = pg.PlotItem()
self.plot_item.setAspectLocked(False)
self.imageItem = pg.ImageItem()
# self.plot_item1D = pg.PlotItem()
# self.plot_item.addItem(self.imageItem)
# self.plot_item.addItem(self.plot_item1D)
# Setting up histogram
# self.hist = pg.HistogramLUTItem()
# self.hist.setImageItem(self.imageItem)
# self.hist.gradient.loadPreset("magma")
# self.update_hist()
# Adding Items to Graphical Layout
self.glw.addItem(self.plot_item)
# self.glw.addItem(self.hist)
@pyqtSlot(str)
def create_new_stream_consumer(self, scanID: str):
print(f"Creating new stream consumer for scanID: {scanID}")
self.connect_stream_consumer(scanID, self.device)
def connect_stream_consumer(self, scanID, device):
if self.stream_consumer is not None:
self.stream_consumer.shutdown()
self.stream_consumer = connector.stream_consumer(
topics=MessageEndpoints.device_async_readback(scanID=scanID, device=device),
cb=self._streamer_cb,
parent=self,
)
self.stream_consumer.start()
def start_device_consumer(self):
self.device_consumer = connector.consumer(
topics=MessageEndpoints.scan_status(), cb=self._device_cv, parent=self
)
self.device_consumer.start()
# def start_device_consumer(self, device): #for simulation
# self.device_consumer = connector.consumer(
# topics=MessageEndpoints.device_status(device), cb=self._device_cv, parent=self
# )
#
# self.device_consumer.start()
def plot_new(self):
print(f"Printing data from plot update: {self.data}")
self.plot_item.plot(self.data[0])
# self.imageItem.setImage(self.data, autoLevels=False)
@staticmethod
def _streamer_cb(msg, *, parent, **_kwargs) -> None:
msgMCS = messages.DeviceMessage.loads(msg.value)
print(msgMCS)
row = msgMCS.content["signals"][parent.sub_device]
metadata = msgMCS.metadata
# Check if the current number of rows is odd
# if parent.data is not None and parent.data.shape[0] % 2 == 1:
# row = np.flip(row) # Flip the row
print(f"Printing data from callback update: {row}")
parent.data = np.array([row])
# if parent.data is None:
# parent.data = np.array([row])
# else:
# parent.data = np.vstack((parent.data, row))
parent.update_signal.emit()
@staticmethod
def _device_cv(msg, *, parent, **_kwargs) -> None:
print("Getting ScanID")
msgDEV = messages.ScanStatusMessage.loads(msg.value)
current_scanID = msgDEV.content["scanID"]
if parent.scanID is None:
parent.scanID = current_scanID
parent.new_scanID.emit(current_scanID)
print(f"New scanID: {current_scanID}")
if current_scanID != parent.scanID:
parent.scanID = current_scanID
# parent.data = None
# parent.imageItem.clear()
parent.new_scanID.emit(current_scanID)
print(f"New scanID: {current_scanID}")
if __name__ == "__main__":
import argparse
from bec_lib import RedisConnector
parser = argparse.ArgumentParser(description="Stream App.")
parser.add_argument("--port", type=str, default="pc15543:6379", help="Port for RedisConnector")
parser.add_argument("--device", type=str, default="mcs", help="Device name")
parser.add_argument("--sub_device", type=str, default="mca4", help="Sub-device name")
args = parser.parse_args()
connector = RedisConnector(args.port)
app = QApplication([])
streamApp = StreamApp(device=args.device, sub_device=args.sub_device)
streamApp.show()
app.exec()

View File

@@ -0,0 +1,34 @@
from bec_lib import messages, MessageEndpoints, RedisConnector
import time
connector = RedisConnector("localhost:6379")
producer = connector.producer()
metadata = {}
scanID = "ScanID1"
metadata.update(
{
"scanID": scanID, # this will be different for each scan
"async_update": "append",
}
)
for ii in range(20):
data = {"mca1": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "mca2": [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]}
msg = messages.DeviceMessage(
signals=data,
metadata=metadata,
).dumps()
# producer.send(topic=MessageEndpoints.device_status(device="mca"), msg=msg)
producer.xadd(
topic=MessageEndpoints.device_async_readback(
scanID=scanID, device="mca"
), # scanID will be different for each scan
msg={"data": msg},
expire=1800,
)
print(f"Sent {ii}")
time.sleep(0.5)

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1433</width>
<height>689</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="2">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Plot Config 2</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="BECMonitor" name="plot_1"/>
</item>
<item row="1" column="3">
<widget class="QPushButton" name="pushButton_setting_2">
<property name="text">
<string>Setting Plot 2</string>
</property>
</widget>
</item>
<item row="3" column="2" colspan="2">
<widget class="BECMonitor" name="plot_2"/>
</item>
<item row="1" column="4">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Plot Scan Types = True</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="pushButton_setting_1">
<property name="text">
<string>Setting Plot 1</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Plot Config 1</string>
</property>
</widget>
</item>
<item row="1" column="5">
<widget class="QPushButton" name="pushButton_setting_3">
<property name="text">
<string>Setting Plot 3</string>
</property>
</widget>
</item>
<item row="3" column="4" colspan="2">
<widget class="BECMonitor" name="plot_3"/>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1433</width>
<height>37</height>
</rect>
</property>
</widget>
<widget class="QStatusBar" name="statusbar"/>
</widget>
<customwidgets>
<customwidget>
<class>BECMonitor</class>
<extends>QGraphicsView</extends>
<header location="global">bec_widgets.widgets.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,201 @@
import os
from qtpy import uic
from qtpy.QtWidgets import QMainWindow, QApplication
from bec_widgets.widgets import BECMonitor
# some default configs for demonstration purposes
CONFIG_SIMPLE = {
"plot_settings": {
"background_color": "black",
"num_columns": 2,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor X",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
# {
# "type": "history",
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
# {
# "type": "dap",
# 'worker':'some_worker',
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
}
CONFIG_SCAN_MODE = {
"plot_settings": {
"background_color": "white",
"num_columns": 3,
"colormap": "plasma",
"scan_types": True,
},
"plot_data": {
"grid_scan": [
{
"plot_name": "Grid plot 1",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}],
},
}
],
},
{
"plot_name": "Grid plot 2",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_adc1"}],
},
}
],
},
{
"plot_name": "Grid plot 3",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "gauss_adc2"}],
},
}
],
},
{
"plot_name": "Grid plot 4",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "gauss_adc3"}],
},
}
],
},
],
"line_scan": [
{
"plot_name": "BPM plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
},
}
class ModularApp(QMainWindow):
def __init__(self, client=None, parent=None):
super(ModularApp, self).__init__(parent)
# Client and device manager from BEC
self.client = bec_dispatcher.client if client is None else client
# Loading UI
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "modular.ui"), self)
self._init_plots()
def _init_plots(self):
"""Initialize plots and connect the buttons to the config dialogs"""
plots = [self.plot_1, self.plot_2, self.plot_3]
configs = [CONFIG_SIMPLE, CONFIG_SCAN_MODE, CONFIG_SCAN_MODE]
buttons = [self.pushButton_setting_1, self.pushButton_setting_2, self.pushButton_setting_3]
# hook plots, configs and buttons together
for plot, config, button in zip(plots, configs, buttons):
plot.on_config_update(config)
button.clicked.connect(plot.show_config_dialog)
if __name__ == "__main__":
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
# BECclient global variables
client = bec_dispatcher.client
client.start()
app = QApplication([])
modularApp = ModularApp(client=client)
window = modularApp
window.show()
app.exec()

View File

@@ -0,0 +1,17 @@
selected_motors:
motor_x: "samx"
motor_y: "samy"
plot_motors:
max_points: 1000
num_dim_points: 100
scatter_size: 5
precision: 3
mode_lock: False # "Individual" or "Start/Stop". False to unlock
extra_columns:
- sample name: "sample 1"
- step_x [mu]: 25
- step_y [mu]: 25
- exp_time [s]: 1
- start: 1
- tilt [deg]: 0

View File

@@ -0,0 +1,10 @@
redis:
host: pc15543
port: 6379
mongodb:
host: localhost
port: 27017
scibec:
host: http://localhost
port: 3030
beamline: MyBeamline

View File

@@ -0,0 +1,17 @@
selected_motors:
motor_x: "samx"
motor_y: "samy"
plot_motors:
max_points: 1000
num_dim_points: 100
scatter_size: 5
precision: 3
mode_lock: Start/Stop # "Individual" or "Start/Stop"
extra_columns:
- sample name: "sample 1"
- step_x [mu]: 25
- step_y [mu]: 25
- exp_time [s]: 1
- start: 1
- tilt [deg]: 0

View File

@@ -6,10 +6,16 @@
<rect>
<x>0</x>
<y>0</y>
<width>1129</width>
<height>550</height>
<width>1561</width>
<height>748</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>1409</width>
<height>748</height>
</size>
</property>
<property name="windowTitle">
<string>Motor Controller</string>
</property>
@@ -29,9 +35,21 @@
<height>471</height>
</size>
</property>
<layout class="QVBoxLayout" name="verticalLayout_6">
<layout class="QVBoxLayout" name="verticalLayout_6" stretch="1,1,1,0,1">
<property name="spacing">
<number>1</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SetMinimumSize</enum>
</property>
<item>
<widget class="QGroupBox" name="motorSelection">
<property name="minimumSize">
<size>
<width>261</width>
<height>145</height>
</size>
</property>
<property name="title">
<string>Motor Selection</string>
</property>
@@ -71,6 +89,9 @@
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
@@ -81,6 +102,12 @@
</item>
<item>
<widget class="QGroupBox" name="motorControl">
<property name="minimumSize">
<size>
<width>261</width>
<height>339</height>
</size>
</property>
<property name="title">
<string>Motor Relative</string>
</property>
@@ -93,12 +120,137 @@
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<widget class="QCheckBox" name="checkBox_same_xy">
<property name="text">
<string>Step [X] = Step [Y]</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="step_grid">
<item row="2" column="0">
<widget class="QLabel" name="label_step_y">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Step [Y]</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Decimal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_step_x">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>99.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_step_x">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Step [X]</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QDoubleSpinBox" name="spinBox_step_y">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>99.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="spinBox_precision">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="maximum">
<number>8</number>
</property>
<property name="value">
<number>2</number>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QGridLayout" name="direction_grid">
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<item row="1" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
<widget class="QToolButton" name="toolButton_up">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
@@ -122,6 +274,12 @@
</item>
<item row="3" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
<widget class="QToolButton" name="toolButton_down">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
@@ -132,6 +290,12 @@
</item>
<item row="2" column="1">
<widget class="QToolButton" name="toolButton_left">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
@@ -155,6 +319,12 @@
</item>
<item row="2" column="3">
<widget class="QToolButton" name="toolButton_right">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
@@ -189,19 +359,6 @@
</property>
</spacer>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="spinBox_step">
<property name="minimum">
<double>0.100000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
</layout>
</item>
</layout>
@@ -212,33 +369,71 @@
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>13</height>
<height>18</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QGroupBox" name="motorControl_absolute">
<property name="minimumSize">
<size>
<width>261</width>
<height>195</height>
</size>
</property>
<property name="title">
<string>Move Absolute</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QCheckBox" name="checkBox_save_with_go">
<property name="text">
<string>Save position with Go</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="1">
<widget class="QLabel" name="label_6">
<property name="text">
<string>X</string>
</property>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="0" column="2">
<item row="1" column="0">
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_7">
<property name="text">
<string>Y</string>
@@ -248,39 +443,41 @@
</property>
</widget>
</item>
<item row="1" column="0">
<item row="0" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="pushButton_save">
<property name="text">
<string>Save</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_set">
<property name="text">
<string>Set</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_go_absolute">
<property name="text">
<string>Go</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
</layout>
</item>
<item>
@@ -309,6 +506,44 @@
<string>Coordinates</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Entries Mode:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox_mode">
<item>
<property name="text">
<string>Individual</string>
</property>
</item>
<item>
<property name="text">
<string>Start/Stop</string>
</property>
</item>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTableWidget" name="tableWidget_coordinates">
<property name="selectionMode">
@@ -319,12 +554,17 @@
</property>
<column>
<property name="text">
<string>Tag</string>
<string>Show</string>
</property>
</column>
<column>
<property name="text">
<string>Show</string>
<string>Move</string>
</property>
</column>
<column>
<property name="text">
<string>Tag</string>
</property>
</column>
<column>
@@ -337,13 +577,57 @@
<string>Y</string>
</property>
</column>
<column>
<property name="text">
<string>Move</string>
</property>
</column>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="1">
<widget class="QPushButton" name="pushButton_resize_table">
<property name="text">
<string>Resize Table</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QCheckBox" name="checkBox_resize_auto">
<property name="text">
<string>Resize Auto</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="pushButton_importCSV">
<property name="text">
<string>Import CSV</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="pushButton_exportCSV">
<property name="text">
<string>Export CSV</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QPushButton" name="pushButton_help">
<property name="text">
<string>Help</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="pushButton_duplicate">
<property name="text">
<string>Duplicate Last Entry</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_settings">
@@ -353,26 +637,13 @@
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QGroupBox" name="motorLimits">
<property name="enabled">
<bool>false</bool>
</property>
<property name="title">
<string>Motor Limits</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="3" column="1">
<widget class="QSpinBox" name="spinBox_y_min">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>-1000</number>
</property>
<property name="maximum">
<number>1000</number>
</property>
<property name="value">
<number>-100</number>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="pushButton_updateLimits">
<property name="text">
@@ -380,22 +651,6 @@
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QSpinBox" name="spinBox_x_max">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>-1000</number>
</property>
<property name="maximum">
<number>1000</number>
</property>
<property name="value">
<number>100</number>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_Y_max">
<property name="text">
@@ -406,38 +661,6 @@
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="spinBox_y_max">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>-1000</number>
</property>
<property name="maximum">
<number>1000</number>
</property>
<property name="value">
<number>100</number>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QSpinBox" name="spinBox_x_min">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>-1000</number>
</property>
<property name="maximum">
<number>1000</number>
</property>
<property name="value">
<number>-100</number>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QLabel" name="label_Y_min">
<property name="text">
@@ -468,190 +691,68 @@
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_y_max">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QDoubleSpinBox" name="spinBox_y_min">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QDoubleSpinBox" name="spinBox_x_min">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="spinBox_x_max">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Motor Config</string>
<string>Plotting Options</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="3" column="1">
<widget class="QSpinBox" name="spinBox_3">
<property name="enabled">
<bool>false</bool>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QSpinBox" name="spinBox_6">
<property name="enabled">
<bool>false</bool>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_9">
<property name="text">
<string>X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Speed</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="spinBox_speed_x">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="maximum">
<number>1000</number>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="spinBox_update_frequency_x">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="maximum">
<number>500</number>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QSpinBox" name="spinBox_speed_y">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="maximum">
<number>1000</number>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLabel" name="label_10">
<property name="text">
<string>Y</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="label_15">
<property name="text">
<string>Max Points</string>
</property>
</widget>
</item>
<item row="6" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_num_dim_points">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>10</number>
</property>
<property name="maximum">
<number>1000</number>
</property>
<property name="singleStep">
<number>10</number>
</property>
<property name="value">
<number>100</number>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Update Frequency</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QDoubleSpinBox" name="doubleSpinBox_2">
<property name="enabled">
<bool>false</bool>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_3">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Tolerance</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QDoubleSpinBox" name="doubleSpinBox">
<property name="enabled">
<bool>false</bool>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QSpinBox" name="spinBox_update_frequency_y">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="maximum">
<number>500</number>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="pushButton_update_config">
<property name="text">
<string>Update</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_4">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Decimal Precision</string>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QLabel" name="label_16">
<property name="text">
<string>N dim</string>
</property>
</widget>
</item>
<item row="5" column="1" colspan="2">
<item row="0" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_max_points">
<property name="alignment">
<set>Qt::AlignCenter</set>
@@ -670,14 +771,14 @@
</property>
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="label_11">
<item row="0" column="0">
<widget class="QLabel" name="label_15">
<property name="text">
<string>Scatter Size</string>
<string>Max Points</string>
</property>
</widget>
</item>
<item row="7" column="1" colspan="2">
<item row="2" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_scatter_size">
<property name="alignment">
<set>Qt::AlignCenter</set>
@@ -693,6 +794,53 @@
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_11">
<property name="text">
<string>Scatter Size</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="3">
<widget class="QPushButton" name="pushButton_update_config">
<property name="text">
<string>Update Settings</string>
</property>
</widget>
</item>
<item row="1" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_num_dim_points">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>10</number>
</property>
<property name="maximum">
<number>1000</number>
</property>
<property name="singleStep">
<number>10</number>
</property>
<property name="value">
<number>100</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_16">
<property name="text">
<string>N dim</string>
</property>
</widget>
</item>
<item row="4" column="0" colspan="3">
<widget class="QPushButton" name="pushButton_enableGUI">
<property name="text">
<string>Enable Control GUI</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
@@ -703,6 +851,16 @@
<string>Queue</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>Work in progress</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_5">
<property name="enabled">

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,3 @@
x_value: "samx"
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
dap_worker: "gaussian_fit_worker_3"

View File

@@ -0,0 +1,3 @@
x_value: "samx"
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
dap_worker: None

View File

@@ -1,21 +1,21 @@
import os
import PyQt5.QtWidgets
import numpy as np
import qtpy.QtWidgets
import pyqtgraph as pg
from PyQt5.QtCore import pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QApplication, QWidget
from PyQt5.QtWidgets import QTableWidgetItem
from pyqtgraph import mkBrush, mkPen
from bec_lib import MessageEndpoints
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
from qtpy.QtWidgets import QApplication, QTableWidgetItem, QWidget
from pyqtgraph import mkBrush, mkColor, mkPen
from pyqtgraph.Qt import QtCore, uic
from bec_widgets.qt_utils import Crosshair
from bec_lib.core import MessageEndpoints
from bec_widgets.utils import Crosshair, ctrl_c
# TODO implement:
# - implement scanID database for visualizing previous scans
# - multiple signals for different monitors
# - change how dap is handled in bec_dispatcher to handle more workers
class PlotApp(QWidget):
@@ -27,7 +27,8 @@ class PlotApp(QWidget):
update_dap_signal (pyqtSignal): Signal to trigger DAP updates.
Args:
x_y_values (list of tuple, optional): List of (x, y) device/signal pairs for plotting.
x_value (str): The x device/signal for plotting.
y_values (list of str): List of y device/signals for plotting.
dap_worker (str, optional): DAP process to specify. Set to None to disable.
parent (QWidget, optional): Parent widget.
"""
@@ -35,16 +36,14 @@ class PlotApp(QWidget):
update_signal = pyqtSignal()
update_dap_signal = pyqtSignal()
def __init__(self, x_y_values=None, dap_worker=None, parent=None):
def __init__(self, x_value, y_values, dap_worker=None, parent=None):
super(PlotApp, self).__init__(parent)
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "oneplot.ui"), self)
self.x_y_values = x_y_values if x_y_values is not None else []
self.dap_worker = dap_worker # if dap_worker is not None else ""
self.x_values = [x for x, y in self.x_y_values]
self.y_values = [y for x, y in self.x_y_values]
self.x_value = x_value
self.y_values = y_values
self.dap_worker = dap_worker
self.scanID = None
self.data_x = []
@@ -68,10 +67,10 @@ class PlotApp(QWidget):
def init_ui(self) -> None:
"""Initialize the UI components."""
self.plot = pg.PlotItem(title=self.y_values[0])
self.plot = pg.PlotItem()
self.glw.addItem(self.plot)
self.plot.setLabel("bottom", self.x_values[0])
self.plot.setLabel("left", self.y_values[0])
self.plot.setLabel("bottom", self.x_value)
self.plot.setLabel("left", ", ".join(self.y_values))
self.plot.addLegend()
def init_curves(self) -> None:
@@ -80,36 +79,35 @@ class PlotApp(QWidget):
self.curves_data = []
self.curves_dap = []
self.pens = []
self.brushs = [] # todo check if needed
color_list = [
"#384c6b",
"#e28a2b",
"#5E3023",
"#e41a1c",
"#984e83",
"#4daf4a",
] # todo change to cmap
colors_y_values = PlotApp.golden_angle_color(colormap="CET-R2", num=len(self.y_values))
# colors_y_daps = PlotApp.golden_angle_color(
# colormap="CET-I2", num=len(self.dap_worker)
# ) # TODO adapt for multiple dap_workers
for ii, monitor in enumerate(self.y_values):
pen_curve = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
brush = mkBrush(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
# Initialize curves for y_values
for ii, (signal, color) in enumerate(zip(self.y_values, colors_y_values)):
pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine)
brush_curve = mkBrush(color=color)
curve_data = pg.PlotDataItem(
symbolSize=5,
symbolBrush=brush_curve,
pen=pen_curve,
skipFiniteCheck=True,
symbolBrush=brush,
symbolSize=5,
name=monitor + "_data",
name=f"{signal}",
)
self.curves_data.append(curve_data)
self.pens.append(pen_curve)
self.plot.addItem(curve_data)
if self.dap_worker is not None:
pen_dap = mkPen(color=color_list[ii + 1], width=2, style=QtCore.Qt.DashLine)
curve_dap = pg.PlotDataItem(pen=pen_dap, size=5, name=monitor + "_fit")
self.curves_dap.append(curve_dap)
self.plot.addItem(curve_dap)
# Initialize curves for DAP if dap_worker is not None
if self.dap_worker is not None:
# for ii, (monitor, color) in enumerate(zip(self.dap_worker, colors_y_daps)):#TODO adapt for multiple dap_workers
pen_dap = mkPen(color="#3b5998", width=2, style=QtCore.Qt.DashLine)
curve_dap = pg.PlotDataItem(
pen=pen_dap, skipFiniteCheck=True, symbolSize=5, name=f"{self.dap_worker}"
)
self.curves_dap.append(curve_dap)
self.plot.addItem(curve_dap)
self.tableWidget_crosshair.setRowCount(len(self.y_values))
self.tableWidget_crosshair.setVerticalHeaderLabels(self.y_values)
@@ -126,7 +124,7 @@ class PlotApp(QWidget):
)
def update_table(
self, table_widget: PyQt5.QtWidgets.QTableWidget, x: float, y_values: list, column: int
self, table_widget: qtpy.QtWidgets.QTableWidget, x: float, y_values: list, column: int
) -> None:
for i, y in enumerate(y_values):
table_widget.setItem(i, column, QTableWidgetItem(f"({x}, {y})"))
@@ -134,8 +132,12 @@ class PlotApp(QWidget):
def update_plot(self) -> None:
"""Update the plot data."""
self.curves_data[0].setData(self.data_x, self.data_y)
for ii, curve in enumerate(self.curves_data):
curve.setData(self.data_x, self.data_y[ii])
if self.dap_worker is not None:
# for ii, curve in enumerate(self.curves_dap): #TODO adapt for multiple dap_workers
# curve.setData(self.dap_x, self.dap_y[ii])
self.curves_dap[0].setData(self.dap_x, self.dap_y)
def update_fit_table(self):
@@ -153,6 +155,7 @@ class PlotApp(QWidget):
metadata (dict): Metadata of the DAP.
"""
# TODO adapt for multiple dap_workers
self.dap_x = msg[self.dap_worker]["x"]
self.dap_y = msg[self.dap_worker]["y"]
@@ -174,55 +177,78 @@ class PlotApp(QWidget):
if current_scanID != self.scanID:
self.scanID = current_scanID
self.data_x = []
self.data_y = []
self.data_y = [[] for _ in self.y_values]
self.init_curves()
dev_x = self.x_values[0]
dev_y = self.y_values[0]
# TODO put warning that I am putting 1st one
dev_x = self.x_value
data_x = msg["data"][dev_x][dev[dev_x]._hints[0]]["value"]
data_y = msg["data"][dev_y][dev[dev_y]._hints[0]]["value"]
self.data_x.append(data_x)
self.data_y.append(data_y)
for ii, dev_y in enumerate(self.y_values):
data_y = msg["data"][dev_y][dev[dev_y]._hints[0]]["value"]
self.data_y[ii].append(data_y)
self.update_signal.emit()
@staticmethod
def golden_ratio(num: int) -> list:
"""Calculate the golden ratio for a given number of angles.
Args:
num (int): Number of angles
"""
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
angles = []
for ii in range(num):
x = np.cos(ii * phi)
y = np.sin(ii * phi)
angle = np.arctan2(y, x)
angles.append(angle)
return angles
@staticmethod
def golden_angle_color(colormap: str, num: int) -> list:
"""
Extract num colors for from the specified colormap following golden angle distribution.
Args:
colormap (str): Name of the colormap
num (int): Number of requested colors
Returns:
list: List of colors with length <num>
Raises:
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
"""
cmap = pg.colormap.get(colormap)
cmap_colors = cmap.color
if num > len(cmap_colors):
raise ValueError(
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
)
angles = PlotApp.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = [
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
]
return colors
if __name__ == "__main__":
import argparse
import ast
import yaml
from bec_widgets import ctrl_c
from bec_widgets.bec_dispatcher import bec_dispatcher
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
parser = argparse.ArgumentParser()
with open("config_noworker.yaml", "r") as file:
config = yaml.safe_load(file)
parser.add_argument(
"--x_y_values",
type=str,
default="[('samx', 'gauss_bpm')]",
help="Specify x y device/signals pairs for plotting as [tuple(str,str)]",
)
x_value = config["x_value"]
y_values = config["y_values"]
dap_worker = config["dap_worker"]
parser.add_argument("--dap_worker", type=str, default=None, help="Specify the DAP process")
args = parser.parse_args()
try:
x_y_values = ast.literal_eval(args.x_y_values)
if not all(isinstance(item, tuple) and len(item) == 2 for item in x_y_values):
raise ValueError("Invalid format: All elements must be 2-tuples.")
except (ValueError, SyntaxError):
raise ValueError("Invalid input format. Expected a list of 2-tuples.")
# Convert dap_worker to None if it's the string "None", for testing "gaussian_fit_worker_3"
dap_worker = None if args.dap_worker == "None" else args.dap_worker
# Retrieve the dap_process value
# dap_worker = args.dap_worker
dap_worker = None if dap_worker == "None" else dap_worker
# BECclient global variables
client = bec_dispatcher.client
@@ -233,13 +259,13 @@ if __name__ == "__main__":
queue = client.queue
app = QApplication([])
plotApp = PlotApp(x_y_values=x_y_values, dap_worker=dap_worker)
plotApp = PlotApp(x_value=x_value, y_values=y_values, dap_worker=dap_worker)
# Connecting signals from bec_dispatcher
bec_dispatcher.connect_dap_slot(plotApp.on_dap_update, dap_worker)
bec_dispatcher.connect_slot(plotApp.on_dap_update, MessageEndpoints.processed_data(dap_worker))
bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment())
ctrl_c.setup(app)
window = plotApp
window.show()
app.exec_()
app.exec()

View File

@@ -0,0 +1,75 @@
<?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>547</width>
<height>653</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,2">
<item>
<layout class="QHBoxLayout" name="horizontalLayout" stretch="2,1">
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Cursor</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QTableWidget" name="tableWidget_crosshair">
<column>
<property name="text">
<string>Moved</string>
</property>
</column>
<column>
<property name="text">
<string>Clicked</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Fit</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="TableWidget" name="tableWidget_fit"/>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="GraphicsLayoutWidget" name="glw"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsLayoutWidget</class>
<extends>QGraphicsView</extends>
<header>pyqtgraph.h</header>
</customwidget>
<customwidget>
<class>TableWidget</class>
<extends>QTableWidget</extends>
<header>pyqtgraph.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,130 @@
plot_settings:
background_color: "black"
num_columns: 2
colormap: "plasma"
scan_types: False # True to show scan types
# example to use without scan_type -> only one general configuration
plot_data:
- plot_name: "BPM4i plots vs samy"
x:
label: 'Motor Y'
signals:
- name: "samy"
# entry: "samy" # here I also forgot to specify entry
y:
label: 'bpm4i'
signals:
- name: "bpm4i"
entry: "bpm4i"
# I will not specify entry, because I want to take hint from gauss_adc2
- plot_name: "BPM4i plots vs samx"
x:
label: 'Motor Y'
signals:
- name: "samy"
# entry: "samy" # here I also forgot to specify entry
y:
label: 'bpm4i'
signals:
- name: "bpm4i"
entry: "bpm4i"
# I will not specify entry, because I want to take hint from gauss_adc2
- plot_name: "MCS Channel 4 (Cyberstar) vs samx"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'mcs4 cyberstar'
signals:
- name: "mcs"
entry: "mca4"
- plot_name: "MCS Channel 4 (Cyberstar) vs samy"
x:
label: 'Motor X'
signals:
- name: "samy"
entry: "samy"
y:
label: 'mcs4 cyberstar'
signals:
- name: "mcs"
entry: "mca4"
# example to use with scan_type -> different configuration for different scan types
#plot_data:
# line_scan:
# - plot_name: "BPM plot"
# x:
# label: 'Motor X'
# signals:
# - name: "samx"
# # entry: "samx"
# y:
# label: 'BPM'
# signals:
# - name: "gauss_bpm"
# entry: "gauss_bpm"
# - name: "gauss_adc1"
# entry: "gauss_adc1"
# - name: "gauss_adc2"
# entry: "gauss_adc2"
#
# - plot_name: "Multi"
# x:
# label: 'Motor X'
# signals:
# - name: "samx"
# entry: "samx"
# y:
# label: 'Multi'
# signals:
# - name: "gauss_bpm"
# entry: "gauss_bpm"
# - name: "samx"
# entry: ["samx", "samx_setpoint"]
#
# grid_scan:
# - plot_name: "Grid plot 1"
# x:
# label: 'Motor X'
# signals:
# - name: "samx"
# entry: "samx"
# y:
# label: 'BPM'
# signals:
# - name: "gauss_bpm"
# entry: "gauss_bpm"
# - name: "gauss_adc1"
# entry: "gauss_adc1"
# - plot_name: "Grid plot 2"
# x:
# label: 'Motor X'
# signals:
# - name: "samx"
# entry: "samx"
# y:
# label: 'BPM'
# signals:
# - name: "gauss_bpm"
# entry: "gauss_bpm"
# - name: "gauss_adc1"
# entry: "gauss_adc1"
#
# - plot_name: "Grid plot 3"
# x:
# label: 'Motor Y'
# signals:
# - name: "samy"
# entry: "samy"
# y:
# label: 'BPM'
# signals:
# - name: "gauss_bpm"
# entry: "gauss_bpm"

View File

@@ -0,0 +1,91 @@
plot_settings:
background_color: "black"
num_columns: 2
colormap: "plasma"
scan_types: True # True to show scan types
# example to use with scan_type -> different configuration for different scan types
plot_data:
line_scan:
- plot_name: "BPM plot"
x:
label: 'Motor X'
signals:
- name: "samx"
# entry: "samx"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "gauss_adc1"
entry: "gauss_adc1"
- name: "gauss_adc2"
entry: "gauss_adc2"
- plot_name: "Multi"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'Multi'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "samx"
entry: ["samx", "samx_setpoint"]
grid_scan:
- plot_name: "Grid plot 1"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "gauss_adc1"
entry: "gauss_adc1"
- plot_name: "Grid plot 2"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "gauss_adc1"
entry: "gauss_adc1"
- plot_name: "Grid plot 3"
x:
label: 'Motor Y'
signals:
- name: "samy"
entry: "samy"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- plot_name: "Grid plot 4"
x:
label: 'Motor Y'
signals:
- name: "samy"
entry: "samy"
y:
label: 'BPM'
signals:
- name: "gauss_adc3"
entry: "gauss_adc3"

View File

@@ -0,0 +1,730 @@
import logging
import os
# import traceback
import pyqtgraph
import pyqtgraph as pg
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
from qtpy.QtWidgets import (
QApplication,
QWidget,
QTableWidgetItem,
QTableWidget,
QFileDialog,
QMessageBox,
)
from pyqtgraph import ColorButton
from pyqtgraph import mkBrush, mkPen
from pyqtgraph.Qt import QtCore, uic
from pyqtgraph.Qt import QtWidgets
from bec_lib import MessageEndpoints
from bec_widgets.utils import Crosshair, Colors
# TODO implement:
# - implement scanID database for visualizing previous scans
class PlotApp(QWidget):
"""
Main class for PlotApp, designed to plot multiple signals in a grid layout
based on a flexible YAML configuration.
Attributes:
update_signal (pyqtSignal): Signal to trigger plot updates.
plot_data (list of dict): List of dictionaries containing plot configurations.
Each dictionary specifies x and y signals, including their
name and entry, for a particular plot.
Args:
config (dict): Configuration dictionary containing all settings for the plotting app.
It should include the following keys:
- 'plot_settings': Dictionary containing global plot settings.
- 'plot_data': List of dictionaries specifying the signals to plot.
parent (QWidget, optional): Parent widget.
Example:
General Plot Configuration:
{
'plot_settings': {'background_color': 'black', 'num_columns': 2, 'colormap': 'plasma', 'scan_types': False},
'plot_data': [
{
'plot_name': 'Plot A',
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x', 'entry': 'entry_x'}]},
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y', 'entry': 'entry_y'}]}
}
]
}
Different Scans Mode Configuration:
{
'plot_settings': {'background_color': 'black', 'num_columns': 2, 'colormap': 'plasma', 'scan_types': True},
'plot_data': {
'scan_type_1': [
{
'plot_name': 'Plot 1',
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x1', 'entry': 'entry_x1'}]},
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y1', 'entry': 'entry_y1'}]}
}
],
'scan_type_2': [
{
'plot_name': 'Plot 2',
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x2', 'entry': 'entry_x2'}]},
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y2', 'entry': 'entry_y2'}]}
}
]
}
}
"""
update_signal = pyqtSignal()
update_dap_signal = pyqtSignal()
def __init__(self, config: dict, client=None, parent=None):
super(PlotApp, self).__init__(parent)
# Error handler
self.error_handler = ErrorHandler(parent=self)
# Client and device manager from BEC
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
# Loading UI
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "plot_app.ui"), self)
self.data = {}
self.crosshairs = None
self.plots = None
self.curves_data = None
self.grid_coordinates = None
self.scanID = None
self.user_colors = {} # key: (plot_name, y_name, y_entry), value: color
# Default config
self.config = config
# Validate the configuration before proceeding
self.load_config(self.config)
# Default splitter size
self.splitter.setSizes([400, 100])
# Buttons
self.pushButton_save.clicked.connect(self.save_settings_to_yaml)
self.pushButton_load.clicked.connect(self.load_settings_from_yaml)
# Connect the update signal to the update plot method
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self.update_plot
)
# Change layout of plots when the number of columns is changed in GUI
self.spinBox_N_columns.valueChanged.connect(lambda x: self.init_ui(x))
def load_config(self, config: dict) -> None:
"""
Load and validate the configuration, retrying until a valid configuration is provided or the user cancels.
Args:
config (dict): Configuration dictionary form .yaml file.
Returns:
None
"""
valid_config = False
self.error_handler.set_retry_action(self.load_settings_from_yaml)
while not valid_config:
if config is None:
self.config = (
self.load_settings_from_yaml()
) # Load config if it hasn't been loaded yet
try: # Validate loaded config file
self.error_handler.validate_config_file(config)
valid_config = True
except ValueError as e:
self.config = None # Reset config_to_test to force reloading configuration
self.config = self.error_handler.handle_error(str(e))
if valid_config is True: # Initialize config if validation succeeds
self.init_config(self.config)
def init_config(self, config: dict) -> None:
"""
Initializes or update the configuration settings for the PlotApp.
Args:
config (dict): Dictionary containing plot settings and data configurations.
"""
# YAML config
self.plot_settings = config.get("plot_settings", {})
self.plot_data_config = config.get("plot_data", {})
self.scan_types = self.plot_settings.get("scan_types", False)
if self.scan_types is False: # Device tracking mode
self.plot_data = self.plot_data_config # TODO logic has to be improved
else: # setup first line scan as default, then changed with different scan type
self.plot_data = self.plot_data_config[list(self.plot_data_config.keys())[0]]
# Setting global plot settings
self.init_plot_background(self.plot_settings["background_color"])
# Initialize the UI
self.init_ui(self.plot_settings["num_columns"])
self.spinBox_N_columns.setValue(
self.plot_settings["num_columns"]
) # TODO has to be checked if it will not setup more columns than plots
self.spinBox_N_columns.setMaximum(len(self.plot_data))
def init_plot_background(self, background_color: str) -> None:
"""
Initialize plot settings based on the background color.
Args:
background_color (str): The background color ('white' or 'black').
This method sets the background and foreground colors for pyqtgraph.
If the background is dark ('black'), the foreground will be set to 'white',
and vice versa.
"""
if background_color.lower() == "black":
pg.setConfigOption("background", "k")
pg.setConfigOption("foreground", "w")
elif background_color.lower() == "white":
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
else:
raise ValueError(
f"Invalid background color {background_color}. Allowed values are 'white' or 'black'."
)
# TODO simplify -> find way how to setup also foreground color
# if background_color.lower() not in ["black", "white"]:
# raise ValueError(
# f"Invalid background color {background_color}. Allowed values are 'white' or 'black'."
# )
# self.glw.setBackground(background_color.lower())
def init_ui(self, num_columns: int = 3) -> None:
"""
Initialize the UI components, create plots and store their grid positions.
Args:
num_columns (int): Number of columns to wrap the layout.
This method initializes a dictionary `self.plots` to store the plot objects
along with their corresponding x and y signal names. It dynamically arranges
the plots in a grid layout based on the given number of columns and dynamically
stretches the last plots to fit the remaining space.
"""
self.glw.clear()
self.plots = {}
self.grid_coordinates = []
num_plots = len(self.plot_data)
# Check if num_columns exceeds the number of plots
if num_columns >= num_plots:
num_columns = num_plots
self.plot_settings["num_columns"] = num_columns # Update the settings
print(
f"Warning: num_columns in the YAML file was greater than the number of plots. Resetting num_columns to number of plots:{num_columns}."
)
else:
self.plot_settings["num_columns"] = num_columns # Update the settings
num_rows = num_plots // num_columns
last_row_cols = num_plots % num_columns
remaining_space = num_columns - last_row_cols
for i, plot_config in enumerate(self.plot_data):
row, col = i // num_columns, i % num_columns
colspan = 1
if row == num_rows and remaining_space > 0:
if last_row_cols == 1:
colspan = num_columns
else:
colspan = remaining_space // last_row_cols + 1
remaining_space -= colspan - 1
last_row_cols -= 1
plot_name = plot_config.get("plot_name", "")
x_label = plot_config["x"].get("label", "")
y_label = plot_config["y"].get("label", "")
plot = self.glw.addPlot(row=row, col=col, colspan=colspan, title=plot_name)
plot.setLabel("bottom", x_label)
plot.setLabel("left", y_label)
plot.addLegend()
self.plots[plot_name] = plot
self.grid_coordinates.append((row, col))
self.init_curves()
def init_curves(self) -> None:
"""
Initialize curve data and properties, and update table row labels.
This method initializes a nested dictionary `self.curves_data` to store
the curve objects for each x and y signal pair. It also updates the row labels
in `self.tableWidget_crosshair` to include the grid position for each y-value.
"""
self.curves_data = {}
row_labels = []
for idx, plot_config in enumerate(self.plot_data):
plot_name = plot_config.get("plot_name", "")
plot = self.plots[plot_name]
plot.clear()
y_configs = plot_config["y"]["signals"]
colors_ys = Colors.golden_angle_color(
colormap=self.plot_settings["colormap"], num=len(y_configs)
)
curve_list = []
for i, (y_config, color) in enumerate(zip(y_configs, colors_ys)):
# print(y_config)
y_name = y_config["name"]
y_entries = y_config.get("entry", [y_name])
if not isinstance(y_entries, list):
y_entries = [y_entries]
for y_entry in y_entries:
user_color = self.user_colors.get((plot_name, y_name, y_entry), None)
color_to_use = user_color if user_color else color
pen_curve = mkPen(color=color_to_use, width=2, style=QtCore.Qt.DashLine)
brush_curve = mkBrush(color=color_to_use)
curve_data = pg.PlotDataItem(
symbolSize=5,
symbolBrush=brush_curve,
pen=pen_curve,
skipFiniteCheck=True,
name=f"{y_name} ({y_entry})",
)
curve_list.append((y_name, y_entry, curve_data))
plot.addItem(curve_data)
row_labels.append(f"{y_name} ({y_entry}) - {plot_name}")
# Create a ColorButton and set its color
color_btn = ColorButton()
color_btn.setColor(color_to_use)
color_btn.sigColorChanged.connect(
lambda btn=color_btn, plot=plot_name, yname=y_name, yentry=y_entry, curve=curve_data: self.change_curve_color(
btn, plot, yname, yentry, curve
)
)
# Add the ColorButton as a QWidget to the table
color_widget = QtWidgets.QWidget()
layout = QtWidgets.QHBoxLayout()
layout.addWidget(color_btn)
layout.setContentsMargins(0, 0, 0, 0)
color_widget.setLayout(layout)
row = len(row_labels) - 1 # The row index in the table
self.tableWidget_crosshair.setCellWidget(row, 2, color_widget)
self.curves_data[plot_name] = curve_list
self.tableWidget_crosshair.setRowCount(len(row_labels))
self.tableWidget_crosshair.setVerticalHeaderLabels(row_labels)
self.hook_crosshair()
def change_curve_color(
self,
btn: pyqtgraph.ColorButton,
plot_name: str,
y_name: str,
y_entry: str,
curve: pyqtgraph.PlotDataItem,
) -> None:
"""
Change the color of a curve and update the corresponding ColorButton.
Args:
btn (ColorButton): The ColorButton that was clicked.
plot_name (str): The name of the plot where the curve belongs.
y_name (str): The name of the y signal.
y_entry (str): The entry of the y signal.
curve (PlotDataItem): The curve to be changed.
"""
color = btn.color()
pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine)
brush_curve = mkBrush(color=color)
curve.setPen(pen_curve)
curve.setSymbolBrush(brush_curve)
self.user_colors[(plot_name, y_name, y_entry)] = color
def hook_crosshair(self) -> None:
"""Attach crosshairs to each plot and connect them to the update_table method."""
self.crosshairs = {}
for plot_name, plot in self.plots.items():
crosshair = Crosshair(plot, precision=3)
crosshair.coordinatesChanged1D.connect(
lambda x, y, plot=plot: self.update_table(
self.tableWidget_crosshair, x, y, column=0, plot=plot
)
)
crosshair.coordinatesClicked1D.connect(
lambda x, y, plot=plot: self.update_table(
self.tableWidget_crosshair, x, y, column=1, plot=plot
)
)
self.crosshairs[plot_name] = crosshair
def update_table(
self, table_widget: QTableWidget, x: float, y_values: list, column: int, plot: pg.PlotItem
) -> None:
"""
Update the table with coordinates based on cursor movements and clicks.
Args:
table_widget (QTableWidget): The table to be updated.
x (float): The x-coordinate from the plot.
y_values (list): The y-coordinates from the plot.
column (int): The column in the table to be updated.
plot (PlotItem): The plot from which the coordinates are taken.
This method calculates the correct row in the table for each y-value
and updates the cell at (row, column) with the new x and y coordinates.
"""
plot_name = [name for name, value in self.plots.items() if value == plot][0]
starting_row = 0
for plot_config in self.plot_data:
if plot_config.get("plot_name", "") == plot_name:
break
for y_config in plot_config.get("y", {}).get("signals", []):
y_entries = y_config.get("entry", [y_config.get("name", "")])
if not isinstance(y_entries, list):
y_entries = [y_entries]
starting_row += len(y_entries)
for i, y in enumerate(y_values):
row = starting_row + i
table_widget.setItem(row, column, QTableWidgetItem(f"({x}, {y})"))
table_widget.resizeColumnsToContents()
def update_plot(self) -> None:
"""Update the plot data based on the stored data dictionary."""
for plot_name, curve_list in self.curves_data.items():
for y_name, y_entry, curve in curve_list:
x_config = next(
(pc["x"] for pc in self.plot_data if pc.get("plot_name") == plot_name), {}
)
x_signal_config = x_config["signals"][0]
x_name = x_signal_config.get("name", "")
x_entry = x_signal_config.get("entry", x_name)
key = (x_name, x_entry, y_name, y_entry)
data_x = self.data.get(key, {}).get("x", [])
data_y = self.data.get(key, {}).get("y", [])
curve.setData(data_x, data_y)
@pyqtSlot(dict, dict)
def on_scan_segment(
self, msg, metadata
) -> None: # TODO the logic should be separated from GUI operation
"""
Handle new scan segments and saves data to a dictionary. Linked through bec_dispatcher.
Args:
msg (dict): Message received with scan data.
metadata (dict): Metadata of the scan.
"""
current_scanID = msg.get("scanID", None)
if current_scanID is None:
return
if current_scanID != self.scanID:
if self.scan_types is False:
self.plot_data = self.plot_data_config
elif self.scan_types is True:
currentName = metadata.get("scan_name")
if currentName is None:
raise ValueError(
f"Scan name not found in metadata. Please check the scan_name in the YAML config or in bec "
f"configuration."
)
self.plot_data = self.plot_data_config.get(currentName, [])
if self.plot_data == []:
raise ValueError(
f"Scan name {currentName} not found in the YAML config. Please check the scan_name in the "
f"YAML config or in bec configuration."
)
# Init UI
self.init_ui(self.plot_settings["num_columns"])
self.spinBox_N_columns.setValue(
self.plot_settings["num_columns"]
) # TODO has to be checked if it will not setup more columns than plots
self.spinBox_N_columns.setMaximum(len(self.plot_data))
self.scanID = current_scanID
self.data = {}
self.init_curves()
for plot_config in self.plot_data:
plot_name = plot_config.get("plot_name", "Unnamed Plot")
x_config = plot_config["x"]
x_signal_config = x_config["signals"][0] # Assuming there's at least one signal for x
x_name = x_signal_config.get("name", "")
if not x_name:
raise ValueError(f"Name for x signal must be specified in plot: {plot_name}.")
x_entry_list = x_signal_config.get("entry", [])
if not x_entry_list:
x_entry_list = (
self.dev[x_name]._hints if hasattr(self.dev[x_name], "_hints") else [x_name]
)
if not isinstance(x_entry_list, list):
x_entry_list = [x_entry_list]
y_configs = plot_config["y"]["signals"]
for x_entry in x_entry_list:
for y_config in y_configs:
y_name = y_config.get("name", "")
if not y_name:
raise ValueError(
f"Name for y signal must be specified in plot: {plot_name}."
)
y_entry_list = y_config.get("entry", [])
if not y_entry_list:
y_entry_list = (
self.dev[y_name]._hints
if hasattr(self.dev[y_name], "_hints")
else [y_name]
)
if not isinstance(y_entry_list, list):
y_entry_list = [y_entry_list]
for y_entry in y_entry_list:
key = (x_name, x_entry, y_name, y_entry)
data_x = msg["data"].get(x_name, {}).get(x_entry, {}).get("value", None)
data_y = msg["data"].get(y_name, {}).get(y_entry, {}).get("value", None)
if data_x is None:
raise ValueError(
f"Incorrect entry '{x_entry}' specified for x in plot: {plot_name}, x name: {x_name}"
)
if data_y is None:
if hasattr(self.dev[y_name], "_hints"):
raise ValueError(
f"Incorrect entry '{y_entry}' specified for y in plot: {plot_name}, y name: {y_name}"
)
else:
raise ValueError(
f"No hints available for y in plot: {plot_name}, and name '{y_name}' did not work as entry"
)
if data_x is not None:
self.data.setdefault(key, {}).setdefault("x", []).append(data_x)
if data_y is not None:
self.data.setdefault(key, {}).setdefault("y", []).append(data_y)
self.update_signal.emit()
def save_settings_to_yaml(self):
"""Save the current settings to a .yaml file using a file dialog."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getSaveFileName(
self, "Save Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options
)
if file_path:
try:
if not file_path.endswith(".yaml"):
file_path += ".yaml"
with open(file_path, "w") as file:
yaml.dump(
{"plot_settings": self.plot_settings, "plot_data": self.plot_data_config},
file,
)
print(f"Settings saved to {file_path}")
except Exception as e:
print(f"An error occurred while saving the settings to {file_path}: {e}")
def load_settings_from_yaml(self) -> dict: # TODO can be replace by the utils function
"""Load settings from a .yaml file using a file dialog and update the current settings."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getOpenFileName(
self, "Load Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options
)
if file_path:
try:
with open(file_path, "r") as file:
self.config = yaml.safe_load(file)
self.load_config(self.config) # validate new config
return config
except FileNotFoundError:
print(f"The file {file_path} was not found.")
except Exception as e:
print(f"An error occurred while loading the settings from {file_path}: {e}")
return None # Return None on exception to indicate failure
class ErrorHandler:
def __init__(self, parent=None):
self.parent = parent
self.errors = []
self.retry_action = None
logging.basicConfig(level=logging.ERROR) # Configure logging
def set_retry_action(self, action):
self.retry_action = action # Store a reference to the retry action
def handle_error(self, error_message: str):
# logging.error(f"{error_message}\n{traceback.format_exc()}") #TODO decide if useful
choice = QMessageBox.critical(
self.parent,
"Error",
f"{error_message}\n\nWould you like to retry?",
QMessageBox.Retry | QMessageBox.Cancel,
)
if choice == QMessageBox.Retry and self.retry_action is not None:
return self.retry_action()
else:
exit(1) # Exit the program if the user selects Cancel or if no retry_action is provided
def validate_config_file(self, config: dict) -> None:
"""
Validate the configuration dictionary.
Args:
config (dict): Configuration dictionary form .yaml file.
Returns:
None
"""
self.errors = []
# Validate common keys
required_top_level_keys = ["plot_settings", "plot_data"]
for key in required_top_level_keys:
if key not in config:
self.errors.append(f"Missing required key: {key}")
# Only continue if no errors so far
if not self.errors:
# Determine the configuration mode (device or scan)
plot_settings = config.get("plot_settings", {})
scan_types = plot_settings.get("scan_types", False)
plot_data = config.get("plot_data", [])
if scan_types:
# Validate scan mode configuration
for scan_type, plots in plot_data.items():
for i, plot_config in enumerate(plots):
self.validate_plot_config(plot_config, i)
else:
# Validate device mode configuration
for i, plot_config in enumerate(plot_data):
self.validate_plot_config(plot_config, i)
if self.errors != []:
self.handle_error("\n".join(self.errors))
def validate_plot_config(self, plot_config: dict, i: int):
"""
Validate individual plot configuration.
Args:
plot_config (dict): Individual plot configuration.
i (int): Index of the plot configuration.
Returns:
None
"""
for axis in ["x", "y"]:
axis_config = plot_config.get(axis)
plot_name = plot_config.get("plot_name", "")
if axis_config is None:
error_msg = f"Missing '{axis}' configuration in plot {i} - {plot_name}"
logging.error(error_msg) # Log the error
self.errors.append(error_msg)
signals_config = axis_config.get("signals")
if signals_config is None:
error_msg = (
f"Missing 'signals' configuration for {axis} axis in plot {i} - '{plot_name}'"
)
logging.error(error_msg) # Log the error
self.errors.append(error_msg)
elif not isinstance(signals_config, list) or len(signals_config) == 0:
error_msg = (
f"'signals' configuration for {axis} axis in plot {i} must be a non-empty list"
)
logging.error(error_msg) # Log the error
self.errors.append(error_msg)
# TODO add condition for name and entry
if __name__ == "__main__":
import yaml
import argparse
# from bec_widgets import ctrl_c
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
parser = argparse.ArgumentParser(description="Plotting App")
parser.add_argument(
"--config",
"-c",
help="Path to the .yaml configuration file",
default="config_example.yaml",
)
args = parser.parse_args()
try:
with open(args.config, "r") as file:
config = yaml.safe_load(file)
except FileNotFoundError:
print(f"The file {args.config} was not found.")
exit(1)
except Exception as e:
print(f"An error occurred while loading the config file: {e}")
exit(1)
# BECclient global variables
client = bec_dispatcher.client
client.start()
app = QApplication([])
plotApp = PlotApp(config=config, client=client)
# Connecting signals from bec_dispatcher
bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment())
# ctrl_c.setup(app)
window = plotApp
window.show()
app.exec()

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MultiWindow</class>
<widget class="QWidget" name="MultiWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1248</width>
<height>564</height>
</rect>
</property>
<property name="windowTitle">
<string>MultiWindow</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="GraphicsLayoutWidget" name="glw"/>
<widget class="QWidget" name="">
<layout class="QGridLayout" name="gridLayout_2">
<item row="1" column="0">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="0" colspan="3">
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Cursor</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QTableWidget" name="tableWidget_crosshair">
<column>
<property name="text">
<string>Moved</string>
</property>
</column>
<column>
<property name="text">
<string>Clicked</string>
</property>
</column>
<column>
<property name="text">
<string>Color</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="label">
<property name="text">
<string>Number of Columns:</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QSpinBox" name="spinBox_N_columns">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>10</number>
</property>
<property name="value">
<number>3</number>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QPushButton" name="pushButton_load">
<property name="text">
<string>Load Config</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QPushButton" name="pushButton_save">
<property name="text">
<string>Save Config</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsLayoutWidget</class>
<extends>QGraphicsView</extends>
<header>pyqtgraph.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,32 +1,28 @@
import os
import threading
import time
import warnings
from typing import Any
import numpy as np
import pyqtgraph
import pyqtgraph as pg
from bec_lib.core import BECMessage
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtWidgets import QCheckBox, QTableWidgetItem
from pyqtgraph import mkBrush, mkColor, mkPen
from bec_lib import messages, MessageEndpoints
from bec_lib.redis_connector import RedisConnector
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QTableWidgetItem
from pyqtgraph import mkBrush, mkPen
from pyqtgraph.Qt import QtCore, QtWidgets, uic
from pyqtgraph.Qt.QtCore import pyqtSignal
from bec_widgets.utils import Crosshair, Colors
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
from bec_widgets.bec_dispatcher import bec_dispatcher
from bec_lib.core.redis_connector import MessageObject, RedisConnector
from qt_utils import Crosshair
client = bec_dispatcher.client
# client = bec_dispatcher.client
class BasicPlot(QtWidgets.QWidget):
class StreamPlot(QtWidgets.QWidget):
update_signal = pyqtSignal()
roi_signal = pyqtSignal(tuple)
def __init__(self, name="", y_value_list=["gauss_bpm"]) -> None:
def __init__(self, name="", y_value_list=["gauss_bpm"], client=None, parent=None) -> None:
"""
Basic plot widget for displaying scan data.
@@ -35,7 +31,10 @@ class BasicPlot(QtWidgets.QWidget):
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
"""
super(BasicPlot, self).__init__()
# Client and device manager from BEC
self.client = bec_dispatcher.client if client is None else client
super(StreamPlot, self).__init__()
# Set style for pyqtgraph plots
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
@@ -60,20 +59,12 @@ class BasicPlot(QtWidgets.QWidget):
self.data_retriever = threading.Thread(target=self.on_projection, daemon=True)
self.data_retriever.start()
# self.comboBox.currentIndexChanged.connect(lambda : print(f'current comboText: {self.comboBox.currentText()}'))
# self.comboBox.currentIndexChanged.connect(lambda: print(f'current comboIndex: {self.comboBox.currentIndex()}'))
#
# self.doubleSpinBox.valueChanged.connect(lambda : print('Spin Changed'))
# self.splitterH_main.setSizes([1, 1])
##########################
# UI
##########################
self.init_ui()
self.init_curves()
self.hook_crosshair()
self.pushButton_generate.clicked.connect(self.generate_data)
def init_ui(self):
"""Setup all ui elements"""
@@ -153,9 +144,7 @@ class BasicPlot(QtWidgets.QWidget):
self.pens = []
self.brushs = []
self.color_list = BasicPlot.golden_angle_color(
colormap="CET-R2", num=len(self.y_value_list)
)
self.color_list = Colors.golden_angle_color(colormap="CET-R2", num=len(self.y_value_list))
for ii, y_value in enumerate(self.y_value_list):
pen = mkPen(color=self.color_list[ii], width=2, style=QtCore.Qt.DashLine)
@@ -209,34 +198,6 @@ class BasicPlot(QtWidgets.QWidget):
# ROI
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
def generate_data(self):
def gauss(x, mu, sigma):
return (1 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
self.plotter_data_x = np.linspace(0, 10, 1000)
self.plotter_data_y = [
gauss(self.plotter_data_x, 1, 1),
gauss(self.plotter_data_x, 1.5, 3),
np.sin(self.plotter_data_x),
np.cos(self.plotter_data_x),
np.sin(2 * self.plotter_data_x),
] # List of y-values for multiple curves
self.y_value_list = ["Gauss (1,1)", "Gauss (1.5,3)"] # ["Sine"]#, "Cosine", "Sine2x"]
# Curves
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
self.init_curves()
for ii in range(len(self.y_value_list)):
self.curves[ii].setData(self.plotter_data_x, self.plotter_data_y[ii])
self.data_2D = np.random.random((150, 30))
self.img.setImage(self.data_2D)
if self.roi_selector not in self.plot.items:
self.plot.addItem(self.roi_selector)
def get_roi_region(self):
"""For testing purpose now, get roi region and print it to self.label as tuple"""
region = self.roi_selector.getRegion()
@@ -247,7 +208,7 @@ class BasicPlot(QtWidgets.QWidget):
np.where(self.plotter_data_x[0] < 10 ** region[1])[0][-1],
]
}
msg = BECMessage.DeviceMessage(signals=return_dict).dumps()
msg = messages.DeviceMessage(signals=return_dict).dumps()
self.producer.set_and_publish("px_stream/gui_event", msg=msg)
self.roi_signal.emit(region)
@@ -274,6 +235,12 @@ class BasicPlot(QtWidgets.QWidget):
self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
@staticmethod
def flip_even_rows(arr):
arr_copy = np.copy(arr) # Create a writable copy
arr_copy[1::2, :] = arr_copy[1::2, ::-1]
return arr_copy
@staticmethod
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
@@ -292,59 +259,14 @@ class BasicPlot(QtWidgets.QWidget):
# else:
# return
@staticmethod
def golden_ratio(num: int) -> list:
"""Calculate the golden ratio for a given number of angles.
Args:
num (int): Number of angles
"""
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
angles = []
for ii in range(num):
x = np.cos(ii * phi)
y = np.sin(ii * phi)
angle = np.arctan2(y, x)
angles.append(angle)
return angles
@staticmethod
def golden_angle_color(colormap: str, num: int) -> list:
"""
Extract num colors for from the specified colormap following golden angle distribution.
Args:
colormap (str): Name of the colormap
num (int): Number of requested colors
Returns:
list: List of colors with length <num>
Raises:
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
"""
cmap = pg.colormap.get(colormap)
cmap_colors = cmap.color
if num > len(cmap_colors):
raise ValueError(
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
)
angles = BasicPlot.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = [
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
]
return colors
def on_projection(self):
while True:
if self._current_proj is None:
time.sleep(0.1)
continue
endpoint = f"px_stream/projection_{self._current_proj}/data"
msgs = client.producer.lrange(topic=endpoint, start=-1, end=-1)
data = [BECMessage.DeviceMessage.loads(msg) for msg in msgs]
msgs = self.client.producer.lrange(topic=endpoint, start=-1, end=-1)
data = [messages.DeviceMessage.loads(msg) for msg in msgs]
if not data:
continue
with np.errstate(divide="ignore", invalid="ignore"):
@@ -360,14 +282,16 @@ class BasicPlot(QtWidgets.QWidget):
@pyqtSlot(dict, dict)
def on_dap_update(self, data: dict, metadata: dict):
self.img.setImage(data["z"])
flipped_data = self.flip_even_rows(data["data"]["z"])
@pyqtSlot(dict)
def new_proj(self, data):
proj_nr = data["proj_nr"]
self.img.setImage(flipped_data)
@pyqtSlot(dict, dict)
def new_proj(self, content: dict, _metadata: dict):
proj_nr = content["signals"]["proj_nr"]
endpoint = f"px_stream/projection_{proj_nr}/metadata"
msg_raw = client.producer.get(topic=endpoint)
msg = BECMessage.DeviceMessage.loads(msg_raw)
msg_raw = self.client.producer.get(topic=endpoint)
msg = messages.DeviceMessage.loads(msg_raw)
self._current_q = msg.content["signals"]["q"]
self._current_norm = msg.content["signals"]["norm_sum"]
self._current_metadata = msg.content["signals"]["metadata"]
@@ -379,30 +303,27 @@ class BasicPlot(QtWidgets.QWidget):
if __name__ == "__main__":
import argparse
from bec_widgets import ctrl_c
from bec_widgets.bec_dispatcher import bec_dispatcher
# from bec_widgets import ctrl_c # TODO uncomment when ctrl_c is ready to be compatible with qtpy
parser = argparse.ArgumentParser()
parser.add_argument(
"--signals",
help="specify recorded signals",
nargs="+",
default=["gauss_bpm"],
"--signals", help="specify recorded signals", nargs="+", default=["gauss_bpm"]
)
# default = ["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
# dispatcher = bec_dispatcher
value = parser.parse_args()
print(f"Plotting signals for: {', '.join(value.signals)}")
# Client from dispatcher
client = bec_dispatcher.client
# client.start()
app = QtWidgets.QApplication([])
ctrl_c.setup(app)
plot = BasicPlot(y_value_list=value.signals)
# bec_dispatcher.connect(plot)
bec_dispatcher.connect_proj_id(plot.new_proj)
bec_dispatcher.connect_dap_slot(plot.on_dap_update, "px_dap_worker")
plot.roi_signal.connect(lambda x: print(f"signal from ROI {x}"))
plot.roi_signal.connect(lambda x: bec_dispatcher.getStuff(x))
# ctrl_c.setup(app) # TODO uncomment when ctrl_c is ready to be compatible with qtpy
plot = StreamPlot(y_value_list=value.signals, client=client)
bec_dispatcher.connect_slot(plot.new_proj, "px_stream/proj_nr")
bec_dispatcher.connect_slot(
plot.on_dap_update, MessageEndpoints.processed_data("px_dap_worker")
)
plot.show()
# client.callbacks.register("scan_segment", plot, sync=False)
app.exec_()
app.exec()

View File

@@ -1,360 +0,0 @@
import os
import warnings
from typing import Any
import numpy as np
import pyqtgraph
import pyqtgraph as pg
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtWidgets import QTableWidgetItem, QCheckBox
from bec_lib import BECClient
from pyqtgraph import mkBrush, mkColor, mkPen
from pyqtgraph.Qt import QtCore, QtWidgets, uic
from pyqtgraph.Qt.QtCore import pyqtSignal
class BasicPlot(QtWidgets.QWidget):
update_signal = pyqtSignal()
roi_signal = pyqtSignal(tuple)
def __init__(self, name="", y_value_list=["gauss_bpm"]) -> None:
"""
Basic plot widget for displaying scan data.
Args:
name (str, optional): Name of the plot. Defaults to "".
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
"""
super(BasicPlot, self).__init__()
# Set style for pyqtgraph plots
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "line_plot.ui"), self)
# Set splitter distribution of widgets
self.splitter.setSizes([5, 2])
self._idle_time = 100
self.title = ""
self.label_bottom = ""
self.label_left = ""
self.scan_motors = []
self.y_value_list = y_value_list
self.previous_y_value_list = None
self.plotter_data_x = []
self.plotter_data_y = []
self.curves = []
self.pens = []
self.brushs = []
self.plotter_scan_id = None
# TODO to be moved to utils function
plotstyles = {
"symbol": "o",
"symbolSize": 10,
}
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
color_list = BasicPlot.golden_angle_color(colormap="CET-R2", num=len(self.y_value_list))
# setup plots - GraphicsLayoutWidget
# LabelItem
self.label = pg.LabelItem(justify="center")
self.glw.addItem(self.label)
self.label.setText("test label")
# PlotItem - main window
self.glw.nextRow()
self.plot = pg.PlotItem()
self.glw.addItem(self.plot)
self.plot.addLegend()
# PlotItem - ROI window - disabled for now #TODO add 2D plot for ROI and 1D plot for mouse click
# self.glw.nextRow()
# self.plot_roi = pg.PlotItem()
# self.glw.addItem(self.plot_roi)
# ROI selector - so far from [-1,1] #TODO update to scale with xrange
self.roi_selector = pg.LinearRegionItem([-1, 1])
for ii, y_value in enumerate(self.y_value_list):
pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
brush = mkBrush(color=color_list[ii])
curve = pg.PlotDataItem(
**plotstyles, symbolBrush=brush, pen=pen, skipFiniteCheck=True, name=y_value
)
self.plot.addItem(curve)
self.curves.append(curve)
self.pens.append(pen)
self.brushs.append(brush)
self.crosshair_v = pg.InfiniteLine(angle=90, movable=False)
self.crosshair_h = pg.InfiniteLine(angle=0, movable=False)
self.plot.addItem(self.crosshair_v, ignoreBounds=True)
self.plot.addItem(self.crosshair_h, ignoreBounds=True)
# Add textItems
self.add_text_items()
# Manage signals
self.proxy = pg.SignalProxy(
self.plot.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved
)
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
self.pushButton_debug.clicked.connect(self.debug)
def debug(self):
"""
Debug button just for quick testing
"""
def get_roi_region(self):
"""For testing purpose now, get roi region and print it to self.label as tuple"""
region = self.roi_selector.getRegion()
self.label.setText(f"x = {region[0]:.4f}, y ={region[1]:.4f}")
self.roi_signal.emit(region)
def add_text_items(self): # TODO probably can be removed
"""Add text items to the plot"""
# self.mouse_box_data.setText("Mouse cursor")
# TODO Via StyleSheet, one may set the color of the full QLabel
# self.mouse_box_data.setStyleSheet(f"QLabel {{color : rgba{self.pens[0].color().getRgb()}}}")
def mouse_moved(self, event: tuple) -> None:
"""
Update the mouse table with the current mouse position and the corresponding data.
Args:
event (tuple): Mouse event containing the position of the mouse cursor.
The position is stored in first entry as horizontal, vertical pixel.
"""
pos = event[0]
if not self.plot.sceneBoundingRect().contains(pos):
return
mousePoint = self.plot.vb.mapSceneToView(pos)
self.crosshair_v.setPos(mousePoint.x())
self.crosshair_h.setPos(mousePoint.y())
if not self.plotter_data_x:
return
for ii, y_value in enumerate(self.y_value_list):
closest_point = self.closest_x_y_value(
mousePoint.x(), self.plotter_data_x, self.plotter_data_y[ii]
)
# TODO fix text wobble in plot, see plot when it crosses 0
x_data = f"{closest_point[0]:.{self.precision}f}"
y_data = f"{closest_point[1]:.{self.precision}f}"
# Write coordinate to QTable
self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
self.mouse_table.setItem(ii, 2, QTableWidgetItem(str(x_data)))
self.mouse_table.setItem(ii, 3, QTableWidgetItem(str(y_data)))
self.mouse_table.resizeColumnsToContents()
def closest_x_y_value(self, input_value, list_x, list_y) -> tuple:
"""
Find the closest x and y value to the input value.
Args:
input_value (float): Input value
list_x (list): List of x values
list_y (list): List of y values
Returns:
tuple: Closest x and y value
"""
arr = np.asarray(list_x)
i = (np.abs(arr - input_value)).argmin()
return list_x[i], list_y[i]
def update(self):
"""Update the plot with the new data."""
# check if roi selector is in the plot
if self.roi_selector not in self.plot.items:
self.plot.addItem(self.roi_selector)
# check if QTable was initialised and if list of devices was changed
if self.y_value_list != self.previous_y_value_list:
self.setup_cursor_table()
self.previous_y_value_list = self.y_value_list.copy() if self.y_value_list else None
if len(self.plotter_data_x) <= 1:
return
self.plot.setLabel("bottom", self.label_bottom)
self.plot.setLabel("left", self.label_left)
for ii in range(len(self.y_value_list)):
self.curves[ii].setData(self.plotter_data_x, self.plotter_data_y[ii])
@pyqtSlot(dict, dict)
def on_scan_segment(self, data: dict, metadata: dict) -> None:
"""Update function that is called during the scan callback. To avoid
too many renderings, the GUI is only processing events every <_idle_time> ms.
Args:
data (dict): Dictionary containing a new scan segment
metadata (dict): Scan metadata
"""
if metadata["scanID"] != self.plotter_scan_id:
self.plotter_scan_id = metadata["scanID"]
self._reset_plot_data()
self.title = f"Scan {metadata['scan_number']}"
self.scan_motors = scan_motors = metadata.get("scan_report_devices")
# client = BECClient()
remove_y_value_index = [
index
for index, y_value in enumerate(self.y_value_list)
if y_value not in client.device_manager.devices
]
if remove_y_value_index:
for ii in sorted(remove_y_value_index, reverse=True):
# TODO Use bec warning message??? to be discussed with Klaus
warnings.warn(
f"Warning: no matching signal for {self.y_value_list[ii]} found in list of devices. Removing from plot."
)
self.remove_curve_by_name(self.plot, self.y_value_list[ii])
self.y_value_list.pop(ii)
self.precision = client.device_manager.devices[scan_motors[0]]._info["describe"][
scan_motors[0]
]["precision"]
# TODO after update of bec_lib, this will be new way to access data
# self.precision = client.device_manager.devices[scan_motors[0]].precision
x = data["data"][scan_motors[0]][scan_motors[0]]["value"]
self.plotter_data_x.append(x)
for ii, y_value in enumerate(self.y_value_list):
y = data["data"][y_value][y_value]["value"]
self.plotter_data_y[ii].append(y)
self.label_bottom = scan_motors[0]
self.label_left = f"{', '.join(self.y_value_list)}"
# print(f'metadata scan N{metadata["scan_number"]}') #TODO put as label on top of plot
# print(f'Data point = {data["point_id"]}') #TODO can be used for progress bar
if len(self.plotter_data_x) <= 1:
return
self.update_signal.emit()
def _reset_plot_data(self):
"""Reset the plot data."""
self.plotter_data_x = []
self.plotter_data_y = []
for ii in range(len(self.y_value_list)):
self.curves[ii].setData([], [])
self.plotter_data_y.append([])
def setup_cursor_table(self):
"""QTable formatting according to N of devices displayed in plot."""
# Init number of rows in table according to n of devices
self.mouse_table.setRowCount(len(self.y_value_list))
for ii, y_value in enumerate(self.y_value_list):
checkbox = QCheckBox()
checkbox.setChecked(True)
# TODO just for testing, will be replaced by removing/adding curve
checkbox.stateChanged.connect(lambda: print("status Changed"))
# checkbox.stateChanged.connect(lambda: self.remove_curve_by_name(plot=self.plot, checkbox=checkbox, name=y_value))
self.mouse_table.setCellWidget(ii, 0, checkbox)
self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
self.mouse_table.resizeColumnsToContents()
@staticmethod
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
"""Removes a curve from the given plot by the specified name.
Args:
plot (pyqtgraph.PlotItem): The plot from which to remove the curve.
name (str): The name of the curve to remove.
"""
# if checkbox.isChecked():
for item in plot.items:
if isinstance(item, pg.PlotDataItem) and getattr(item, "opts", {}).get("name") == name:
plot.removeItem(item)
return
# else:
# return
@staticmethod
def golden_ratio(num: int) -> list:
"""Calculate the golden ratio for a given number of angles.
Args:
num (int): Number of angles
"""
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
angles = []
for ii in range(num):
x = np.cos(ii * phi)
y = np.sin(ii * phi)
angle = np.arctan2(y, x)
angles.append(angle)
return angles
@staticmethod
def golden_angle_color(colormap: str, num: int) -> list:
"""
Extract num colors for from the specified colormap following golden angle distribution.
Args:
colormap (str): Name of the colormap
num (int): Number of requested colors
Returns:
list: List of colors with length <num>
Raises:
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
"""
cmap = pg.colormap.get(colormap)
cmap_colors = cmap.color
if num > len(cmap_colors):
raise ValueError(
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
)
angles = BasicPlot.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = [
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
]
return colors
if __name__ == "__main__":
import argparse
from bec_widgets.bec_dispatcher import bec_dispatcher
from bec_widgets import ctrl_c
parser = argparse.ArgumentParser()
parser.add_argument(
"--signals",
help="specify recorded signals",
nargs="+",
default=["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
)
value = parser.parse_args()
print(f"Plotting signals for: {', '.join(value.signals)}")
client = bec_dispatcher.client
# client.start()
app = QtWidgets.QApplication([])
ctrl_c.setup(app)
plot = BasicPlot(y_value_list=value.signals)
bec_dispatcher.connect(plot)
plot.show()
# client.callbacks.register("scan_segment", plot, sync=False)
app.exec_()

View File

@@ -1 +0,0 @@
from .crosshair import Crosshair

View File

@@ -1,7 +1,7 @@
from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
from PyQt5.QtGui import QIcon
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
from qtpy.QtGui import QIcon
from bec_widgets.scan2d_plot import BECScanPlot2D
from bec_widgets.widgets.scan_plot.scan2d_plot import BECScanPlot2D
class BECScanPlot2DPlugin(QPyDesignerCustomWidgetPlugin):

View File

@@ -1,7 +1,7 @@
from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
from PyQt5.QtGui import QIcon
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
from qtpy.QtGui import QIcon
from bec_widgets.scan_plot import BECScanPlot
from bec_widgets.widgets.scan_plot.scan_plot import BECScanPlot
class BECScanPlotPlugin(QPyDesignerCustomWidgetPlugin):

View File

@@ -1,12 +0,0 @@
Add/modify the path in the following variable to make the plugin avaiable in Qt Designer:
```
$ export PYQTDESIGNERPATH=/<path to repo>/bec_widgets/qtdesigner_plugins
```
It can be done when activating a conda environment (run with the corresponding env already activated):
```
$ conda env config vars set PYQTDESIGNERPATH=/<path to repo>/bec_widgets/qtdesigner_plugins
```
All the available conda-forge `pyqt >=5.15` packages don't seem to support loading Qt Designer
python plugins at the time of writing. Use `pyqt =5.12` to solve the issue for now.

View File

View File

@@ -0,0 +1,4 @@
from .crosshair import Crosshair
from .colors import Colors
from .validator_delegate import DoubleValidationDelegate
from .bec_table import BECTable

View File

@@ -0,0 +1,151 @@
# TODO last backup
# todo super last refactor
from __future__ import annotations
import argparse
import itertools
import os
from collections.abc import Callable
from typing import Union
from bec_lib import BECClient, messages, ServiceConfig
from bec_lib.redis_connector import RedisConsumerThreaded
from qtpy.QtCore import QObject, Signal as pyqtSignal
# Adding a new pyqt signal requires a class factory, as they must be part of the class definition
# and cannot be dynamically added as class attributes after the class has been defined.
_signal_class_factory = (
type(f"Signal{i}", (QObject,), dict(signal=pyqtSignal(dict, dict))) for i in itertools.count()
)
class _Connection:
"""Utility class to keep track of slots connected to a particular redis consumer"""
def __init__(self, consumer) -> None:
self.consumer: RedisConsumerThreaded = consumer
self.slots = set()
# keep a reference to a new signal class, so it is not gc'ed
self._signal_container = next(_signal_class_factory)()
self.signal: pyqtSignal = self._signal_container.signal
class _BECDispatcher(QObject):
"""Utility class to keep track of slots connected to a particular redis consumer"""
def __init__(self, bec_config=None):
super().__init__()
self.client = BECClient()
# TODO: this is a workaround for now to provide service config within qtdesigner, but is
# it possible to provide config via a cli arg?
if bec_config is None and os.path.isfile("bec_config.yaml"):
bec_config = "bec_config.yaml"
self.client.initialize(config=ServiceConfig(config_path=bec_config))
self._connections = {}
def connect_slot(
self, slot: Callable, topics: Union[str, list], single_callback_for_all_topics=False
) -> None:
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message.
Args:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
the corresponding pub/sub message
topics (str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
single_callback_for_all_topics (bool): If True, use the same callback for all topics, otherwise use
separate callbacks.
"""
if isinstance(topics, str):
topics = [topics]
# Ensure topics_key is a tuple, whether single_callback_for_all_topics is True or False.
topics_key = tuple(sorted(topics)) if single_callback_for_all_topics else tuple(topics)
if topics_key not in self._connections:
self._connections[topics_key] = self._create_connection(topics)
connection = self._connections[topics_key]
if slot not in connection.slots:
connection.signal.connect(slot)
connection.slots.add(slot)
def _create_connection(self, topics: list) -> _Connection:
"""Creates a new connection for given topics."""
def cb(msg):
msg = messages.MessageReader.loads(msg.value)
if not isinstance(msg, list):
msg = [msg]
for msg_i in msg:
for connection_key, connection in self._connections.items():
if set(topics).intersection(
connection_key if isinstance(connection_key, tuple) else [connection_key]
):
connection.signal.emit(msg_i.content, msg_i.metadata)
consumer = self.client.connector.consumer(topics=topics, cb=cb)
consumer.start()
return _Connection(consumer)
def _disconnect_slot_from_topic(self, slot: Callable, topic: str) -> None:
"""A helper method to disconnect a slot from a specific topic.
Args:
slot (Callable): A slot to be disconnected
topic (str): A corresponding topic that can typically be acquired via
bec_lib.MessageEndpoints
"""
connection = self._connections.get(topic)
if connection and slot in connection.slots:
connection.signal.disconnect(slot)
connection.slots.remove(slot)
if not connection.slots:
connection.consumer.shutdown()
del self._connections[topic]
def disconnect_slot(self, slot: Callable, topics: Union[str, list]) -> None:
"""Disconnect widget's pyqt slot from pub/sub updates on a topic.
Args:
slot (Callable): A slot to be disconnected
topics (str | list): A corresponding topic or list of topics that can typically be acquired via
bec_lib.MessageEndpoints
"""
if isinstance(topics, str):
topics = [topics]
for key, connection in list(self._connections.items()):
if slot in connection.slots:
common_topics = set(topics).intersection(key)
if common_topics:
remaining_topics = set(key) - set(topics)
# Disconnect slot from common topics
connection.signal.disconnect(slot)
connection.slots.remove(slot)
if not connection.slots:
print(f"{connection.consumer} is shutting down")
connection.consumer.shutdown()
del self._connections[key]
# Reconnect slot to remaining topics if any
if remaining_topics:
self.connect_slot(slot, list(remaining_topics), True)
def disconnect_all(self):
"""Disconnect all slots from all topics."""
for key, connection in list(self._connections.items()):
for slot in list(connection.slots):
self._disconnect_slot_from_topic(slot, key)
if key in self._connections and not connection.slots:
connection.consumer.shutdown()
del self._connections[key]
parser = argparse.ArgumentParser()
parser.add_argument("--bec-config", default=None)
args, _ = parser.parse_known_args()
bec_dispatcher = _BECDispatcher(args.bec_config)

View File

@@ -0,0 +1,20 @@
from qtpy.QtWidgets import QTableWidget
from qtpy.QtCore import Qt
class BECTable(QTableWidget):
"""Table widget with custom keyPressEvent to delete rows with backspace or delete key"""
def keyPressEvent(self, event) -> None:
"""
Delete selected rows with backspace or delete key
Args:
event: keyPressEvent
"""
if event.key() in (Qt.Key_Backspace, Qt.Key_Delete):
selected_ranges = self.selectedRanges()
for selected_range in selected_ranges:
for row in range(selected_range.topRow(), selected_range.bottomRow() + 1):
self.removeRow(row)
else:
super().keyPressEvent(event)

View File

@@ -0,0 +1,50 @@
import numpy as np
import pyqtgraph as pg
from pyqtgraph import mkColor
class Colors:
@staticmethod
def golden_ratio(num: int) -> list:
"""Calculate the golden ratio for a given number of angles.
Args:
num (int): Number of angles
"""
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
angles = []
for ii in range(num):
x = np.cos(ii * phi)
y = np.sin(ii * phi)
angle = np.arctan2(y, x)
angles.append(angle)
return angles
@staticmethod
def golden_angle_color(colormap: str, num: int) -> list:
"""
Extract num colors for from the specified colormap following golden angle distribution.
Args:
colormap (str): Name of the colormap
num (int): Number of requested colors
Returns:
list: List of colors with length <num>
Raises:
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
"""
cmap = pg.colormap.get(colormap)
cmap_colors = cmap.color
if num > len(cmap_colors):
raise ValueError(
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
)
angles = Colors.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = [
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
]
return colors

View File

@@ -1,6 +1,8 @@
import numpy as np
import pyqtgraph as pg
from PyQt5.QtCore import QObject, pyqtSignal
# from qtpy.QtCore import QObject, pyqtSignal
from qtpy.QtCore import QObject, Signal as pyqtSignal
class Crosshair(QObject):

View File

@@ -0,0 +1,39 @@
# TODO haven't found yet how to deal with QAbstractSocket in qtpy
# import signal
# import socket
# from PyQt5.QtNetwork import QAbstractSocket
#
#
# def setup(app):
# app.signalwatchdog = SignalWatchdog() # need to store to keep socket pair alive
# signal.signal(signal.SIGINT, make_quit_handler(app))
#
#
# def make_quit_handler(app):
# def handler(*args):
# print() # make ^C appear on its own line
# app.quit()
#
# return handler
#
#
# class SignalWatchdog(QAbstractSocket):
# def __init__(self):
# """
# Propagates system signals from Python to QEventLoop
# adapted from https://stackoverflow.com/a/65802260/655404
# """
# super().__init__(QAbstractSocket.SctpSocket, None)
#
# self.writer, self.reader = writer, reader = socket.socketpair()
# writer.setblocking(False)
#
# fd_writer = writer.fileno()
# fd_reader = reader.fileno()
#
# signal.set_wakeup_fd(fd_writer) # Python hook
# self.setSocketDescriptor(fd_reader) # Qt hook
#
# self.readyRead.connect(
# lambda: None
# ) # dummy function call that lets the Python interpreter run

View File

@@ -0,0 +1,13 @@
# from qtpy.QtGui import QDoubleValidator
# from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit
class DoubleValidationDelegate(QStyledItemDelegate):
def createEditor(self, parent, option, index):
editor = QLineEdit(parent)
validator = QDoubleValidator()
editor.setValidator(validator)
return editor

View File

@@ -0,0 +1,320 @@
# pylint: disable=no-name-in-module
from abc import ABC, abstractmethod
from qtpy.QtWidgets import (
QApplication,
QWidget,
QLineEdit,
QComboBox,
QTableWidget,
QSpinBox,
QDoubleSpinBox,
QTableWidgetItem,
QVBoxLayout,
QCheckBox,
QLabel,
)
class WidgetHandler(ABC):
"""Abstract base class for all widget handlers."""
@abstractmethod
def get_value(self, widget: QWidget):
"""Retrieve value from the widget instance."""
@abstractmethod
def set_value(self, widget: QWidget, value):
"""Set a value on the widget instance."""
class LineEditHandler(WidgetHandler):
"""Handler for QLineEdit widgets."""
def get_value(self, widget: QLineEdit) -> str:
return widget.text()
def set_value(self, widget: QLineEdit, value: str) -> None:
widget.setText(value)
class ComboBoxHandler(WidgetHandler):
"""Handler for QComboBox widgets."""
def get_value(self, widget: QComboBox) -> int:
return widget.currentIndex()
def set_value(self, widget: QComboBox, value: int) -> None:
widget.setCurrentIndex(value)
class TableWidgetHandler(WidgetHandler):
"""Handler for QTableWidget widgets."""
def get_value(self, widget: QTableWidget) -> list:
return [
[
widget.item(row, col).text() if widget.item(row, col) else ""
for col in range(widget.columnCount())
]
for row in range(widget.rowCount())
]
def set_value(self, widget: QTableWidget, value) -> None:
for row, row_values in enumerate(value):
for col, cell_value in enumerate(row_values):
item = QTableWidgetItem(str(cell_value))
widget.setItem(row, col, item)
class SpinBoxHandler(WidgetHandler):
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
def get_value(self, widget):
return widget.value()
def set_value(self, widget, value):
widget.setValue(value)
class CheckBoxHandler(WidgetHandler):
"""Handler for QCheckBox widgets."""
def get_value(self, widget):
return widget.isChecked()
def set_value(self, widget, value):
widget.setChecked(value)
class LabelHandler(WidgetHandler):
"""Handler for QLabel widgets."""
def get_value(self, widget):
return widget.text()
def set_value(self, widget, value):
widget.setText(value)
class WidgetIO:
"""Public interface for getting and setting values using handler mapping"""
_handlers = {
QLineEdit: LineEditHandler,
QComboBox: ComboBoxHandler,
QTableWidget: TableWidgetHandler,
QSpinBox: SpinBoxHandler,
QDoubleSpinBox: SpinBoxHandler,
QCheckBox: CheckBoxHandler,
QLabel: LabelHandler,
}
@staticmethod
def get_value(widget, ignore_errors=False):
"""
Retrieve value from the widget instance.
Args:
widget: Widget instance.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = WidgetIO._handlers.get(type(widget))
if handler_class:
return handler_class().get_value(widget) # Instantiate the handler
if not ignore_errors:
raise ValueError(f"No handler for widget type: {type(widget)}")
return None
@staticmethod
def set_value(widget, value, ignore_errors=False):
"""
Set a value on the widget instance.
Args:
widget: Widget instance.
value: Value to set.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = WidgetIO._handlers.get(type(widget))
if handler_class:
handler_class().set_value(widget, value) # Instantiate the handler
elif not ignore_errors:
raise ValueError(f"No handler for widget type: {type(widget)}")
################## for exporting and importing widget hierarchies ##################
class WidgetHierarchy:
@staticmethod
def print_widget_hierarchy(
widget,
indent: int = 0,
grab_values: bool = False,
prefix: str = "",
exclude_internal_widgets: bool = True,
) -> None:
"""
Print the widget hierarchy to the console.
Args:
widget: Widget to print the hierarchy of
indent(int, optional): Level of indentation.
grab_values(bool,optional): Whether to grab the values of the widgets.
prefix(stc,optional): Custom string prefix for indentation.
exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6).
"""
widget_info = f"{widget.__class__.__name__} ({widget.objectName()})"
if grab_values:
value = WidgetIO.get_value(widget, ignore_errors=True)
value_str = f" [value: {value}]" if value is not None else ""
widget_info += value_str
print(prefix + widget_info)
children = widget.children()
for child in children:
if (
exclude_internal_widgets
and isinstance(widget, QComboBox)
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
):
continue
child_prefix = prefix + " "
arrow = "├─ " if child != children[-1] else "└─ "
WidgetHierarchy.print_widget_hierarchy(
child, indent + 1, grab_values, prefix=child_prefix + arrow
)
@staticmethod
def export_config_to_dict(
widget: QWidget,
config: dict = None,
indent: int = 0,
grab_values: bool = False,
print_hierarchy: bool = False,
save_all: bool = True,
exclude_internal_widgets: bool = True,
) -> dict:
"""
Export the widget hierarchy to a dictionary.
Args:
widget: Widget to print the hierarchy of.
config(dict,optional): Dictionary to export the hierarchy to.
indent(int,optional): Level of indentation.
grab_values(bool,optional): Whether to grab the values of the widgets.
print_hierarchy(bool,optional): Whether to print the hierarchy to the console.
save_all(bool,optional): Whether to save all widgets or only those with values.
exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6).
Returns:
config(dict): Dictionary containing the widget hierarchy.
"""
if config is None:
config = {}
widget_info = f"{widget.__class__.__name__} ({widget.objectName()})"
# if grab_values and type(widget) in WidgetIO._handlers:
if grab_values:
value = WidgetIO.get_value(widget, ignore_errors=True)
if value is not None or save_all:
if widget_info not in config:
config[widget_info] = {}
if value is not None:
config[widget_info]["value"] = value
if print_hierarchy:
WidgetHierarchy.print_widget_hierarchy(widget, indent, grab_values)
for child in widget.children():
# Skip internal widgets of QComboBox in PyQt6
if (
exclude_internal_widgets
and isinstance(widget, QComboBox)
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
):
continue
child_config = WidgetHierarchy.export_config_to_dict(
child, None, indent + 1, grab_values, print_hierarchy, save_all
)
if child_config or save_all:
if widget_info not in config:
config[widget_info] = {}
config[widget_info].update(child_config)
return config
@staticmethod
def import_config_from_dict(widget, config: dict, set_values: bool = False) -> None:
"""
Import the widget hierarchy from a dictionary.
Args:
widget: Widget to import the hierarchy to.
config:
set_values:
"""
widget_name = f"{widget.__class__.__name__} ({widget.objectName()})"
widget_config = config.get(widget_name, {})
for child in widget.children():
child_name = f"{child.__class__.__name__} ({child.objectName()})"
child_config = widget_config.get(child_name)
if child_config is not None:
value = child_config.get("value")
if set_values and value is not None:
WidgetIO.set_value(child, value)
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
# Example application to demonstrate the usage of the functions
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
# Create instance of WidgetHierarchy
widget_hierarchy = WidgetHierarchy()
# Create a simple widget hierarchy for demonstration purposes
main_widget = QWidget()
layout = QVBoxLayout(main_widget)
line_edit = QLineEdit(main_widget)
combo_box = QComboBox(main_widget)
table_widget = QTableWidget(2, 2, main_widget)
spin_box = QSpinBox(main_widget)
layout.addWidget(line_edit)
layout.addWidget(combo_box)
layout.addWidget(table_widget)
layout.addWidget(spin_box)
# Add text items to the combo box
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
main_widget.show()
# Hierarchy of original widget
print(30 * "#")
print(f"Widget hierarchy for {main_widget.objectName()}:")
print(30 * "#")
config_dict = widget_hierarchy.export_config_to_dict(
main_widget, grab_values=True, print_hierarchy=True
)
print(30 * "#")
print(f"Config dict: {config_dict}")
# Hierarchy of new widget and set values
new_config_dict = {
"QWidget ()": {
"QLineEdit ()": {"value": "New Text"},
"QComboBox ()": {"value": 1},
"QTableWidget ()": {"value": [["a", "b"], ["c", "d"]]},
"QSpinBox ()": {"value": 10},
}
}
widget_hierarchy.import_config_from_dict(main_widget, new_config_dict, set_values=True)
print(30 * "#")
config_dict_new = widget_hierarchy.export_config_to_dict(
main_widget, grab_values=True, print_hierarchy=True
)
config_dict_new_reduced = widget_hierarchy.export_config_to_dict(
main_widget, grab_values=True, print_hierarchy=True, save_all=False
)
print(30 * "#")
print(f"Config dict new FULL: {config_dict_new}")
print(f"Config dict new REDUCED: {config_dict_new_reduced}")
app.exec()

View File

@@ -0,0 +1,61 @@
# pylint: disable=no-name-in-module
from typing import Union
import yaml
from qtpy.QtWidgets import QFileDialog
def load_yaml(instance) -> Union[dict, None]:
"""
Load YAML file from disk.
Args:
instance: Instance of the calling widget.
Returns:
dict: Configuration data loaded from the YAML file.
"""
options = QFileDialog.Options()
file_path, _ = QFileDialog.getOpenFileName(
instance, "Load Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
)
if not file_path:
return None
try:
with open(file_path, "r") as file:
config = yaml.safe_load(file)
return config
except FileNotFoundError:
print(f"The file {file_path} was not found.")
except PermissionError:
print(f"Permission denied for file {file_path}.")
except yaml.YAMLError as e:
print(f"Error parsing YAML file {file_path}: {e}")
except Exception as e:
print(f"An error occurred while loading the settings from {file_path}: {e}")
def save_yaml(instance, config: dict) -> None:
"""
Save YAML file to disk.
Args:
instance: Instance of the calling widget.
config: Configuration data to be saved.
"""
options = QFileDialog.Options()
file_path, _ = QFileDialog.getSaveFileName(
instance, "Save Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
)
if not file_path:
return
try:
if not (file_path.endswith(".yaml") or file_path.endswith(".yml")):
file_path += ".yaml"
with open(file_path, "w") as file:
yaml.dump(config, file)
print(f"Settings saved to {file_path}")
except Exception as e:
print(f"An error occurred while saving the settings to {file_path}: {e}")

View File

@@ -0,0 +1,2 @@
# from .monitor_config import validate_monitor_config, ValidationError
from .monitor_config_validator import MonitorConfigValidator

View File

@@ -0,0 +1,257 @@
from typing import Optional, Union, Literal
from pydantic import BaseModel, Field, field_validator, model_validator, ValidationError
from pydantic_core import PydanticCustomError
class Signal(BaseModel):
"""
Represents a signal in a plot configuration.
Attributes:
name (str): The name of the signal.
entry (Optional[str]): The entry point of the signal, optional.
"""
name: str
entry: Optional[str] = Field(None, validate_default=True)
@model_validator(mode="before")
@classmethod
def validate_fields(cls, values):
"""Validate the fields of the model.
First validate the 'name' field, then validate the 'entry' field.
Args:
values (dict): The values to be validated."""
devices = MonitorConfigValidator.devices
# Validate 'name'
name = values.get("name")
# Check if device name provided
if name is None:
raise PydanticCustomError(
"no_device_name", "Device name must be provided", {"wrong_value": name}
)
# Check if device exists in BEC
if name not in devices:
raise PydanticCustomError(
"no_device_bec",
'Device "{wrong_value}" not found in current BEC session',
{"wrong_value": name},
)
device = devices[name] # get the device to check if it has signals
# Get device description
description = device.describe()
# Validate 'entry'
entry = values.get("entry")
# Set entry based on hints if not provided
if entry is None:
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
raise PydanticCustomError(
"no_entry_for_device",
'Entry "{wrong_value}" not found in device "{device_name}" signals',
{"wrong_value": entry, "device_name": name},
)
values["entry"] = entry
return values
class AxisSignal(BaseModel):
"""
Configuration signal axis for a single plot.
Attributes:
x (list): Signal for the X axis.
y (list): Signals for the Y axis.
"""
x: list[Signal] = Field(default_factory=list)
y: list[Signal] = Field(default_factory=list)
@field_validator("x")
@classmethod
def validate_x_signals(cls, v):
"""Ensure that there is only one signal for x-axis."""
if len(v) != 1:
raise PydanticCustomError(
"x_axis_multiple_signals",
'There must be exactly one signal for x axis. Number of x signals: "{wrong_value}"',
{"wrong_value": v},
)
return v
class SourceHistoryValidator(BaseModel):
"""History source validator
Attributes:
type (str): type of source - history
scanID (str): Scan ID for history source.
signals (list): Signal for the source.
"""
type: Literal["history"]
scanID: str # TODO can be validated if it is a valid scanID
signals: AxisSignal
class SourceSegmentValidator(BaseModel):
"""Scan Segment source validator
Attributes:
type (str): type of source - scan_segment
signals (AxisSignal): Signal for the source.
"""
type: Literal["scan_segment"]
signals: AxisSignal
class SourceRedisValidator(BaseModel):
"""Scan Segment source validator
Attributes:
type (str): type of source - scan_segment
endpoint (str): Endpoint reference in redis.
update (str): Update type.
"""
type: Literal["redis"]
endpoint: str
update: str
signals: dict
class Source(BaseModel): # TODO decide if it should stay for general Source validation
"""
General source validation, includes all Optional arguments of all other sources.
Attributes:
type (list): type of source (scan_segment, history)
scanID (Optional[str]): Scan ID for history source.
signals (Optional[AxisSignal]): Signal for the source.
"""
type: Literal["scan_segment", "history", "redis"]
scanID: Optional[str] = None
signals: Optional[dict] = None
class PlotConfig(BaseModel):
"""
Configuration for a single plot.
Attributes:
plot_name (Optional[str]): Name of the plot.
x_label (Optional[str]): The label for the x-axis.
y_label (Optional[str]): The label for the y-axis.
sources (list): A list of sources to be plotted on this axis.
"""
plot_name: Optional[str] = None
x_label: Optional[str] = None
y_label: Optional[str] = None
sources: list = Field(default_factory=list)
@field_validator("sources")
@classmethod
def validate_sources(cls, values):
"""Validate the sources of the plot configuration, based on the type of source."""
validated_sources = []
for source in values:
# Check if source type is supported
Source(**source)
source_type = source.get("type", None)
# Validate source based on type
if source_type == "scan_segment":
validated_sources.append(SourceSegmentValidator(**source))
elif source_type == "history":
validated_sources.append(SourceHistoryValidator(**source))
elif source_type == "redis":
validated_sources.append(SourceRedisValidator(**source))
return validated_sources
class PlotSettings(BaseModel):
"""
Global settings for plotting affecting mostly visuals.
Attributes:
background_color (str): Color of the plot background. Default is black.
axis_width (Optional[int]): Width of the plot axes. Default is 2.
axis_color (Optional[str]): Color of the plot axes. Default is None.
num_columns (int): Number of columns in the plot layout. Default is 1.
colormap (str): Colormap to be used. Default is magma.
scan_types (bool): Indicates if the configuration is for different scan types. Default is False.
"""
background_color: Literal["black", "white"] = "black"
axis_width: Optional[int] = 2
axis_color: Optional[str] = None
num_columns: Optional[int] = 1
colormap: Optional[str] = "magma"
scan_types: Optional[bool] = False
class DeviceMonitorConfig(BaseModel):
"""
Configuration model for the device monitor mode.
Attributes:
plot_settings (PlotSettings): Global settings for plotting.
plot_data (list[PlotConfig]): List of plot configurations.
"""
plot_settings: PlotSettings
plot_data: list[PlotConfig]
class ScanModeConfig(BaseModel):
"""
Configuration model for scan mode.
Attributes:
plot_settings (PlotSettings): Global settings for plotting.
plot_data (dict[str, list[PlotConfig]]): Dictionary of plot configurations,
keyed by scan type.
"""
plot_settings: PlotSettings
plot_data: dict[str, list[PlotConfig]]
class MonitorConfigValidator:
"""Validates the configuration data for the BECMonitor."""
devices = None
def __init__(self, devices):
# self.device_manager = device_manager
MonitorConfigValidator.devices = devices
def validate_monitor_config(
self, config_data: dict
) -> Union[DeviceMonitorConfig, ScanModeConfig]:
"""
Validates the configuration data based on the provided schema.
Args:
config_data (dict): Configuration data to be validated.
Returns:
Union[DeviceMonitorConfig, ScanModeConfig]: Validated configuration object.
Raises:
ValidationError: If the configuration data does not conform to the schema.
"""
config_type = config_data.get("plot_settings", {}).get("scan_types", False)
if config_type:
validated_config = ScanModeConfig(**config_data)
else:
validated_config = DeviceMonitorConfig(**config_data)
return validated_config

View File

@@ -0,0 +1,5 @@
from .monitor import BECMonitor, ConfigDialog
from .motor_map import MotorMap
from .scan_control import ScanControl
from .toolbar import ModularToolBar
from .editor import BECEditor

View File

@@ -0,0 +1 @@
from .editor import BECEditor

View File

@@ -0,0 +1,415 @@
import subprocess
import qdarktheme
from jedi import Script
from jedi.api import Completion
# pylint: disable=no-name-in-module
from qtpy.Qsci import QsciScintilla, QsciLexerPython, QsciAPIs
from qtpy.QtCore import Qt
from qtpy.QtCore import Signal, QThread
from qtpy.QtGui import QColor, QFont
from qtpy.QtWidgets import (
QApplication,
QFileDialog,
QTextEdit,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QSplitter
from qtconsole.manager import QtKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from bec_widgets.widgets import ModularToolBar
class AutoCompleter(QThread):
"""Initializes the AutoCompleter thread for handling autocompletion and signature help.
Args:
file_path (str): The path to the file for which autocompletion is required.
api (QsciAPIs): The QScintilla API instance used for managing autocompletions.
enable_docstring (bool, optional): Flag to determine if docstrings should be included in the signatures.
"""
def __init__(self, file_path: str, api: QsciAPIs, enable_docstring: bool = False):
super().__init__(None)
self.file_path = file_path
self.script: Script = None
self.api: QsciAPIs = api
self.completions: list[Completion] = None
self.line = 0
self.index = 0
self.text = ""
# TODO so far disabled, quite buggy, docstring extraction has to be generalised
self.enable_docstring = enable_docstring
def update_script(self, text: str):
"""Updates the script for Jedi completion based on the current editor text.
Args:
text (str): The current text of the editor.
"""
if self.script is None or self.script.path != text:
self.script = Script(text, path=self.file_path)
def run(self):
"""Runs the thread for generating autocompletions. Overrides QThread.run."""
self.update_script(self.text)
try:
self.completions = self.script.complete(self.line, self.index)
self.load_autocomplete(self.completions)
except Exception as err:
print(err)
self.finished.emit()
def get_function_signature(self, line: int, index: int, text: str) -> str:
"""Fetches the function signature for a given position in the text.
Args:
line (int): The line number in the editor.
index (int): The index (column number) in the line.
text (str): The current text of the editor.
Returns:
str: A string containing the function signature or an empty string if not available.
"""
self.update_script(text)
try:
signatures = self.script.get_signatures(line, index)
if signatures and self.enable_docstring is True:
full_docstring = signatures[0].docstring(raw=True)
compact_docstring = self.get_compact_docstring(full_docstring)
return compact_docstring
if signatures and self.enable_docstring is False:
return signatures[0].to_string()
except Exception as err:
print(f"Signature Error:{err}")
return ""
def load_autocomplete(self, completions: list):
"""Loads the autocomplete suggestions into the QScintilla API.
Args:
completions (list[Completion]): A list of Completion objects to be added to the API.
"""
self.api.clear()
for i in completions:
self.api.add(i.name)
self.api.prepare()
def get_completions(self, line: int, index: int, text: str):
"""Starts the autocompletion process for a given position in the text.
Args:
line (int): The line number in the editor.
index (int): The index (column number) in the line.
text (str): The current text of the editor.
"""
self.line = line
self.index = index
self.text = text
self.start()
def get_compact_docstring(self, full_docstring):
"""Generates a compact version of a function's docstring.
Args:
full_docstring (str): The full docstring of a function.
Returns:
str: A compact version of the docstring.
"""
lines = full_docstring.split("\n")
# TODO make it also for different docstring styles, now it is only for numpy style
cutoff_indices = [
i
for i, line in enumerate(lines)
if line.strip().lower() in ["parameters", "returns", "examples", "see also", "warnings"]
]
if cutoff_indices:
lines = lines[: cutoff_indices[0]]
compact_docstring = "\n".join(lines).strip()
return compact_docstring
class ScriptRunnerThread(QThread):
"""Initializes the thread for running a Python script.
Args:
script (str): The script to be executed.
"""
outputSignal = Signal(str)
def __init__(self, script):
super().__init__()
self.script = script
def run(self):
"""Executes the script in a subprocess and emits output through a signal. Overrides QThread.run."""
process = subprocess.Popen(
["python", "-u", "-c", self.script],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
universal_newlines=True,
text=True,
)
while True:
output = process.stdout.readline()
if output == "" and process.poll() is not None:
break
if output:
self.outputSignal.emit(output)
error = process.communicate()[1]
if error:
self.outputSignal.emit(error)
class BECEditor(QWidget):
"""Initializes the BEC Editor widget.
Args:
toolbar_enabled (bool, optional): Determines if the toolbar should be enabled. Defaults to True.
"""
def __init__(
self, toolbar_enabled=True, jupyter_terminal_enabled=False, docstring_tooltip=False
):
super().__init__()
self.script_runner_thread = None
self.file_path = None
self.docstring_tooltip = docstring_tooltip
self.jupyter_terminal_enabled = jupyter_terminal_enabled
# TODO just temporary solution, could be extended to other languages
self.is_python_file = True
# Initialize the editor and terminal
self.editor = QsciScintilla()
if self.jupyter_terminal_enabled:
self.terminal = self.make_jupyter_widget_with_kernel()
else:
self.terminal = QTextEdit()
self.terminal.setReadOnly(True)
# Layout
self.layout = QVBoxLayout()
# Initialize and add the toolbar if enabled
if toolbar_enabled:
self.toolbar = ModularToolBar(self)
self.layout.addWidget(self.toolbar)
# Initialize the splitter
self.splitter = QSplitter(Qt.Orientation.Vertical, self)
self.splitter.addWidget(self.editor)
self.splitter.addWidget(self.terminal)
self.splitter.setSizes([400, 200])
# Add Splitter to layout
self.layout.addWidget(self.splitter)
self.setLayout(self.layout)
self.setup_editor()
def setup_editor(self):
"""Sets up the editor with necessary configurations like lexer, auto indentation, and line numbers."""
# Set the lexer for Python
self.lexer = QsciLexerPython()
self.editor.setLexer(self.lexer)
# Enable auto indentation and competition within the editor
self.editor.setAutoIndent(True)
self.editor.setIndentationsUseTabs(False)
self.editor.setIndentationWidth(4)
self.editor.setAutoCompletionSource(QsciScintilla.AutoCompletionSource.AcsAll)
self.editor.setAutoCompletionThreshold(1)
# Autocomplete for python file
# Connect cursor position change signal for autocompletion
self.editor.cursorPositionChanged.connect(self.on_cursor_position_changed)
# if self.is_python_file: #TODO can be changed depending on supported languages
self.__api = QsciAPIs(self.lexer)
self.auto_completer = AutoCompleter(
self.editor.text(), self.__api, enable_docstring=self.docstring_tooltip
)
self.auto_completer.finished.connect(self.loaded_autocomplete)
# Enable line numbers in the margin
self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin)
self.editor.setMarginWidth(0, "0000") # Adjust the width as needed
# Additional UI elements like menu for load/save can be added here
self.set_editor_style()
@staticmethod
def make_jupyter_widget_with_kernel() -> object:
"""Start a kernel, connect to it, and create a RichJupyterWidget to use it"""
kernel_manager = QtKernelManager(kernel_name="python3")
kernel_manager.start_kernel()
kernel_client = kernel_manager.client()
kernel_client.start_channels()
jupyter_widget = RichJupyterWidget()
jupyter_widget.set_default_style("linux")
jupyter_widget.kernel_manager = kernel_manager
jupyter_widget.kernel_client = kernel_client
return jupyter_widget
def show_call_tip(self, position):
"""Shows a call tip at the given position in the editor.
Args:
position (int): The position in the editor where the call tip should be shown.
"""
line, index = self.editor.lineIndexFromPosition(position)
signature = self.auto_completer.get_function_signature(line + 1, index, self.editor.text())
if signature:
self.editor.showUserList(1, [signature])
def on_cursor_position_changed(self, line, index):
"""Handles the event of cursor position change in the editor.
Args:
line (int): The current line number where the cursor is.
index (int): The current column index where the cursor is.
"""
# if self.is_python_file: #TODO can be changed depending on supported languages
# Get completions
self.auto_completer.get_completions(line + 1, index, self.editor.text())
self.editor.autoCompleteFromAPIs()
# Show call tip - signature
position = self.editor.positionFromLineIndex(line, index)
self.show_call_tip(position)
def loaded_autocomplete(self):
"""Placeholder method for actions after autocompletion data is loaded."""
def set_editor_style(self):
"""Sets the style and color scheme for the editor."""
# Dracula Theme Colors
background_color = QColor("#282a36")
text_color = QColor("#f8f8f2")
keyword_color = QColor("#8be9fd")
string_color = QColor("#f1fa8c")
comment_color = QColor("#6272a4")
class_function_color = QColor("#50fa7b")
# Set Font
font = QFont()
font.setFamily("Consolas")
font.setPointSize(10)
self.editor.setFont(font)
self.editor.setMarginsFont(font)
# Set Editor Colors
self.editor.setMarginsBackgroundColor(background_color)
self.editor.setMarginsForegroundColor(text_color)
self.editor.setCaretForegroundColor(text_color)
self.editor.setCaretLineBackgroundColor(QColor("#44475a"))
self.editor.setPaper(background_color) # Set the background color for the entire paper
self.editor.setColor(text_color)
# Set editor
# Syntax Highlighting Colors
lexer = self.editor.lexer()
if lexer:
lexer.setDefaultPaper(background_color) # Set the background color for the text area
lexer.setDefaultColor(text_color)
lexer.setColor(keyword_color, QsciLexerPython.Keyword)
lexer.setColor(string_color, QsciLexerPython.DoubleQuotedString)
lexer.setColor(string_color, QsciLexerPython.SingleQuotedString)
lexer.setColor(comment_color, QsciLexerPython.Comment)
lexer.setColor(class_function_color, QsciLexerPython.ClassName)
lexer.setColor(class_function_color, QsciLexerPython.FunctionMethodName)
# Set the style for all text to have a transparent background
# TODO find better way how to do it!
for style in range(
128
): # QsciScintilla supports 128 styles by default, this set all to transparent background
self.lexer.setPaper(background_color, style)
def run_script(self):
"""Runs the current script in the editor."""
if self.jupyter_terminal_enabled:
script = self.editor.text()
self.terminal.execute(script)
else:
script = self.editor.text()
self.script_runner_thread = ScriptRunnerThread(script)
self.script_runner_thread.outputSignal.connect(self.update_terminal)
self.script_runner_thread.start()
def update_terminal(self, text):
"""Updates the terminal with new text.
Args:
text (str): The text to be appended to the terminal.
"""
self.terminal.append(text)
def enable_docstring_tooltip(self):
"""Enables the docstring tooltip."""
self.docstring_tooltip = True
self.auto_completer.enable_docstring = True
def open_file(self):
"""Opens a file dialog for selecting and opening a Python file in the editor."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getOpenFileName(
self, "Open file", "", "Python files (*.py);;All Files (*)", options=options
)
if not file_path:
return
try:
with open(file_path, "r") as file:
text = file.read()
self.editor.setText(text)
except FileNotFoundError:
print(f"The file {file_path} was not found.")
except Exception as e:
print(f"An error occurred while opening the file {file_path}: {e}")
def save_file(self):
"""Opens a save file dialog for saving the current script in the editor."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getSaveFileName(
self, "Save file", "", "Python files (*.py);;All Files (*)", options=options
)
if not file_path:
return
try:
if not file_path.endswith(".py"):
file_path += ".py"
with open(file_path, "w") as file:
text = self.editor.text()
file.write(text)
print(f"File saved to {file_path}")
except Exception as e:
print(f"An error occurred while saving the file to {file_path}: {e}")
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
qdarktheme.setup_theme("auto")
mainWin = BECEditor(jupyter_terminal_enabled=True)
mainWin.show()
app.exec()

View File

@@ -0,0 +1,2 @@
from .monitor import BECMonitor
from .config_dialog import ConfigDialog

View File

@@ -0,0 +1,534 @@
import os
from qtpy import uic
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import (
QApplication,
QWidget,
QVBoxLayout,
QTableWidget,
QTabWidget,
QTableWidgetItem,
QLineEdit,
)
from bec_widgets.utils.yaml_dialog import load_yaml, save_yaml
current_path = os.path.dirname(__file__)
Ui_Form, BaseClass = uic.loadUiType(os.path.join(current_path, "config_dialog.ui"))
Tab_Ui_Form, Tab_BaseClass = uic.loadUiType(os.path.join(current_path, "tab_template.ui"))
# test configs for demonstration purpose
# Configuration for default mode when only devices are monitored
CONFIG_DEFAULT = {
"plot_settings": {
"background_color": "black",
"num_columns": 1,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor Y",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [
{"name": "gauss_bpm"},
{"name": "gauss_adc1"},
{"name": "gauss_adc2"},
],
},
}
],
},
],
}
# Configuration which is dynamically changing depending on the scan type
CONFIG_SCAN_MODE = {
"plot_settings": {
"background_color": "white",
"num_columns": 3,
"colormap": "plasma",
"scan_types": True,
},
"plot_data": {
"grid_scan": [
{
"plot_name": "Grid plot 1",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}],
},
}
],
},
{
"plot_name": "Grid plot 2",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_adc1"}],
},
}
],
},
{
"plot_name": "Grid plot 3",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "gauss_adc2"}],
},
}
],
},
{
"plot_name": "Grid plot 4",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "gauss_adc3"}],
},
}
],
},
],
"line_scan": [
{
"plot_name": "BPM plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
},
}
class ConfigDialog(QWidget, Ui_Form):
config_updated = pyqtSignal(dict)
def __init__(self, default_config=None):
super(ConfigDialog, self).__init__()
self.setupUi(self)
# Connect the Ok/Apply/Cancel buttons
self.pushButton_ok.clicked.connect(self.apply_and_close)
self.pushButton_apply.clicked.connect(self.apply_config)
self.pushButton_cancel.clicked.connect(self.close)
# Hook signals top level
self.pushButton_new_scan_type.clicked.connect(
lambda: self.generate_empty_scan_tab(
self.tabWidget_scan_types, self.lineEdit_scan_type.text()
)
)
# Load/save yaml file buttons
self.pushButton_import.clicked.connect(self.load_config_from_yaml)
self.pushButton_export.clicked.connect(self.save_config_to_yaml)
# Scan Types changed
self.comboBox_scanTypes.currentIndexChanged.connect(self._init_default)
# Make scan tabs closable
self.tabWidget_scan_types.tabCloseRequested.connect(self.handle_tab_close_request)
# Init functions to make a default dialog
if default_config is None:
self._init_default()
else:
self.load_config(default_config)
def _init_default(self):
"""Init default dialog"""
if self.comboBox_scanTypes.currentText() == "Disabled": # Default mode
self.add_new_scan_tab(self.tabWidget_scan_types, "Default")
self.add_new_plot_tab(self.tabWidget_scan_types.widget(0))
self.pushButton_new_scan_type.setEnabled(False)
self.lineEdit_scan_type.setEnabled(False)
else: # Scan mode with clear tab
self.pushButton_new_scan_type.setEnabled(True)
self.lineEdit_scan_type.setEnabled(True)
self.tabWidget_scan_types.clear()
def add_new_scan_tab(
self, parent_tab: QTabWidget, scan_name: str, closable: bool = False
) -> QWidget:
"""
Add a new scan tab to the parent tab widget
Args:
parent_tab(QTabWidget): Parent tab widget, where to add scan tab
scan_name(str): Scan name
closable(bool): If True, the scan tab will be closable
Returns:
scan_tab(QWidget): Scan tab widget
"""
# Check for an existing tab with the same name
for index in range(parent_tab.count()):
if parent_tab.tabText(index) == scan_name:
print(f'Scan name "{scan_name}" already exists.')
return None # or return the existing tab: return parent_tab.widget(index)
# Create a new scan tab
scan_tab = QWidget()
scan_tab_layout = QVBoxLayout(scan_tab)
# Set a tab widget for plots
tabWidget_plots = QTabWidget()
tabWidget_plots.setObjectName("tabWidget_plots") # TODO decide if needed to give a name
tabWidget_plots.setTabsClosable(True)
tabWidget_plots.tabCloseRequested.connect(self.handle_tab_close_request)
scan_tab_layout.addWidget(tabWidget_plots)
# Add scan tab
parent_tab.addTab(scan_tab, scan_name)
# Make tabs closable
if closable:
parent_tab.setTabsClosable(closable)
return scan_tab
def add_new_plot_tab(self, scan_tab: QWidget) -> QWidget:
"""
Add a new plot tab to the scan tab
Args:
scan_tab (QWidget): Scan tab widget
Returns:
plot_tab (QWidget): Plot tab
"""
# Create a new plot tab from .ui template
plot_tab = QWidget()
plot_tab_ui = Tab_Ui_Form()
plot_tab_ui.setupUi(plot_tab)
plot_tab.ui = plot_tab_ui
# Add plot to current scan tab
tabWidget_plots = scan_tab.findChild(
QTabWidget, "tabWidget_plots"
) # TODO decide if putting name is needed
plot_name = f"Plot {tabWidget_plots.count() + 1}"
tabWidget_plots.addTab(plot_tab, plot_name)
# Hook signal
self.hook_plot_tab_signals(scan_tab=scan_tab, plot_tab=plot_tab.ui)
return plot_tab
def hook_plot_tab_signals(self, scan_tab: QTabWidget, plot_tab: Tab_Ui_Form) -> None:
"""
Hook signals of the plot tab
Args:
scan_tab(QTabWidget): Scan tab widget
plot_tab(Tab_Ui_Form): Plot tab widget
"""
plot_tab.pushButton_add_new_plot.clicked.connect(
lambda: self.add_new_plot_tab(scan_tab=scan_tab)
)
plot_tab.pushButton_y_new.clicked.connect(
lambda: self.add_new_signal(plot_tab.tableWidget_y_signals)
)
def add_new_signal(self, table: QTableWidget) -> None:
"""
Add a new signal to the table
Args:
table(QTableWidget): Table widget
"""
row_position = table.rowCount()
table.insertRow(row_position)
table.setItem(row_position, 0, QTableWidgetItem(""))
table.setItem(row_position, 1, QTableWidgetItem(""))
def handle_tab_close_request(self, index: int) -> None:
"""
Handle tab close request
Args:
index(int): Index of the tab to be closed
"""
parent_tab = self.sender()
if parent_tab.count() > 1: # ensure there is at least one tab
parent_tab.removeTab(index)
def generate_empty_scan_tab(self, parent_tab: QTabWidget, scan_name: str):
"""
Generate an empty scan tab
Args:
parent_tab (QTabWidget): Parent tab widget where to add the scan tab
scan_name(str): name of the scan tab
"""
scan_tab = self.add_new_scan_tab(parent_tab, scan_name, closable=True)
if scan_tab is not None:
self.add_new_plot_tab(scan_tab)
def get_plot_config(self, plot_tab: QWidget) -> dict:
"""
Get plot configuration from the plot tab adn send it as dict
Args:
plot_tab(QWidget): Plot tab widget
Returns:
dict: Plot configuration
"""
ui = plot_tab.ui
table = ui.tableWidget_y_signals
x_signals = [
{
"name": self.safe_text(ui.lineEdit_x_name),
"entry": self.safe_text(ui.lineEdit_x_entry),
}
]
y_signals = [
{
"name": self.safe_text(table.item(row, 0)),
"entry": self.safe_text(table.item(row, 1)),
}
for row in range(table.rowCount())
]
plot_data = {
"plot_name": self.safe_text(ui.lineEdit_plot_title),
"x_label": self.safe_text(ui.lineEdit_x_label),
"y_label": self.safe_text(ui.lineEdit_y_label),
"sources": [
{
"type": "scan_segment",
"signals": {
"x": x_signals,
"y": y_signals,
},
}
],
}
return plot_data
def apply_config(self) -> dict:
"""
Apply configuration from the whole configuration window
Returns:
dict: Current configuration
"""
# General settings
config = {
"plot_settings": {
"background_color": self.comboBox_appearance.currentText(),
"num_columns": self.spinBox_n_column.value(),
"colormap": self.comboBox_colormap.currentText(),
"scan_types": True if self.comboBox_scanTypes.currentText() == "Enabled" else False,
},
"plot_data": {} if self.comboBox_scanTypes.currentText() == "Enabled" else [],
}
# Iterate through the plot tabs - Device monitor mode
if config["plot_settings"]["scan_types"] == False:
plot_tab = self.tabWidget_scan_types.widget(0).findChild(QTabWidget)
for index in range(plot_tab.count()):
plot_data = self.get_plot_config(plot_tab.widget(index))
config["plot_data"].append(plot_data)
# Iterate through the scan tabs - Scan mode
elif config["plot_settings"]["scan_types"] == True:
# Iterate through the scan tabs
for index in range(self.tabWidget_scan_types.count()):
scan_tab = self.tabWidget_scan_types.widget(index)
scan_name = self.tabWidget_scan_types.tabText(index)
plot_tab = scan_tab.findChild(QTabWidget)
config["plot_data"][scan_name] = []
# Iterate through the plot tabs
for index in range(plot_tab.count()):
plot_data = self.get_plot_config(plot_tab.widget(index))
config["plot_data"][scan_name].append(plot_data)
return config
def load_config(self, config: dict) -> None:
"""
Load configuration to the configuration window
Args:
config(dict): Configuration to be loaded
"""
# Plot setting General box
plot_settings = config.get("plot_settings", {})
self.comboBox_appearance.setCurrentText(plot_settings.get("background_color", "black"))
self.spinBox_n_column.setValue(plot_settings.get("num_columns", 1))
self.comboBox_colormap.setCurrentText(
plot_settings.get("colormap", "magma")
) # TODO make logic to allow also different colormaps -> validation of incoming dict
self.comboBox_scanTypes.setCurrentText(
"Enabled" if plot_settings.get("scan_types", False) else "Disabled"
)
# Clear exiting scan tabs
self.tabWidget_scan_types.clear()
# Get what mode is active - scan vs default device monitor
scan_mode = plot_settings.get("scan_types", False)
if scan_mode is False: # default mode:
plot_data = config.get("plot_data", [])
self.add_new_scan_tab(self.tabWidget_scan_types, "Default")
for plot_config in plot_data: # Create plot tab for each plot and populate GUI
plot = self.add_new_plot_tab(self.tabWidget_scan_types.widget(0))
self.load_plot_setting(plot, plot_config)
elif scan_mode is True: # scan mode
plot_data = config.get("plot_data", {})
for scan_name, scan_config in plot_data.items():
scan_tab = self.add_new_scan_tab(self.tabWidget_scan_types, scan_name)
for plot_config in scan_config:
plot = self.add_new_plot_tab(scan_tab)
self.load_plot_setting(plot, plot_config)
def load_plot_setting(self, plot: QWidget, plot_config: dict) -> None:
"""
Load plot setting from config
Args:
plot (QWidget): plot tab widget
plot_config (dict): config for single plot tab
"""
sources = plot_config.get("sources", [{}])[0]
x_signals = sources.get("signals", {}).get("x", [{}])[0]
y_signals = sources.get("signals", {}).get("y", [])
# LabelBox
plot.ui.lineEdit_plot_title.setText(plot_config.get("plot_name", ""))
plot.ui.lineEdit_x_label.setText(plot_config.get("x_label", ""))
plot.ui.lineEdit_y_label.setText(plot_config.get("y_label", ""))
# X axis
plot.ui.lineEdit_x_name.setText(x_signals.get("name", ""))
plot.ui.lineEdit_x_entry.setText(x_signals.get("entry", ""))
# Y axis
for y_signal in y_signals:
row_position = plot.ui.tableWidget_y_signals.rowCount()
plot.ui.tableWidget_y_signals.insertRow(row_position)
plot.ui.tableWidget_y_signals.setItem(
row_position, 0, QTableWidgetItem(y_signal.get("name", ""))
)
plot.ui.tableWidget_y_signals.setItem(
row_position, 1, QTableWidgetItem(y_signal.get("entry", ""))
)
def load_config_from_yaml(self):
"""
Load configuration from yaml file
"""
config = load_yaml(self)
self.load_config(config)
def save_config_to_yaml(self):
"""
Save configuration to yaml file
"""
config = self.apply_config()
save_yaml(self, config)
@staticmethod
def safe_text(line_edit: QLineEdit) -> str:
"""
Get text from a line edit, if it is None, return empty string
Args:
line_edit(QLineEdit): Line edit widget
Returns:
str: Text from the line edit
"""
return "" if line_edit is None else line_edit.text()
def apply_and_close(self):
new_config = self.apply_config()
self.config_updated.emit(new_config)
self.close()
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
main_app = ConfigDialog()
main_app.show()
main_app.load_config(CONFIG_SCAN_MODE)
app.exec()

View File

@@ -0,0 +1,210 @@
<?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>597</width>
<height>769</height>
</rect>
</property>
<property name="windowTitle">
<string>Plot Configuration</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QGroupBox" name="groupBox_plot_setting">
<property name="title">
<string>Plot Layout Settings</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Number of Columns</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Scan Types</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="pushButton_new_scan_type">
<property name="text">
<string>New Scan Type</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="comboBox_scanTypes">
<item>
<property name="text">
<string>Disabled</string>
</property>
</item>
<item>
<property name="text">
<string>Enabled</string>
</property>
</item>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="spinBox_n_column">
<property name="minimum">
<number>1</number>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="lineEdit_scan_type"/>
</item>
</layout>
</item>
<item>
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLabel" name="label_4">
<property name="text">
<string>Appearance</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox_appearance">
<property name="enabled">
<bool>false</bool>
</property>
<item>
<property name="text">
<string>black</string>
</property>
</item>
<item>
<property name="text">
<string>white</string>
</property>
</item>
</widget>
</item>
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>Default Color Palette</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox_colormap">
<item>
<property name="text">
<string>magma</string>
</property>
</item>
<item>
<property name="text">
<string>plasma</string>
</property>
</item>
<item>
<property name="text">
<string>viridis</string>
</property>
</item>
<item>
<property name="text">
<string>reds</string>
</property>
</item>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_config">
<property name="title">
<string>Configuration</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="pushButton_import">
<property name="text">
<string>Import</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_export">
<property name="text">
<string>Export</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTabWidget" name="tabWidget_scan_types">
<property name="tabPosition">
<enum>QTabWidget::West</enum>
</property>
<property name="tabShape">
<enum>QTabWidget::Rounded</enum>
</property>
<property name="currentIndex">
<number>-1</number>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="confirm_layout">
<item>
<widget class="QPushButton" name="pushButton_cancel">
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_apply">
<property name="text">
<string>Apply</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_ok">
<property name="text">
<string>OK</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,60 @@
plot_settings:
background_color: "black"
num_columns: 2
colormap: "viridis"
scan_types: False
plot_data:
- plot_name: "BPM4i plots vs samy"
x:
label: 'Motor Y'
signals:
- name: "samy"
entry: "samy"
y:
label: 'bpm4i'
signals:
- name: "bpm4i"
entry: "bpm4i"
- plot_name: "BPM4i plots vs samx"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'bpm6b'
signals:
- name: "bpm6b"
entry: "bpm6b"
- name: "samy"
entry: "samy"
- plot_name: "Multiple Gaussian"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'Gauss ADC'
signals:
- name: "gauss_adc1"
entry: "gauss_adc1"
- name: "gauss_adc2"
entry: "gauss_adc2"
- name: "gauss_adc3"
entry: "gauss_adc3"
- plot_name: "Linear Signals"
x:
label: 'Motor X'
signals:
- name: "samy"
entry: "samy"
y:
label: 'Motor Y'
signals:
- name: "samy"
entry: "samy"

View File

@@ -0,0 +1,92 @@
plot_settings:
background_color: "black"
num_columns: 2
colormap: "plasma"
scan_types: True
plot_data:
line_scan:
- plot_name: "BPM plot"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "gauss_adc1"
entry: "gauss_adc1"
- name: "gauss_adc2"
entry: "gauss_adc2"
- plot_name: "Multi"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'Multi'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "samx"
entry: "samx"
grid_scan:
- plot_name: "Grid plot 1"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "gauss_adc1"
entry: "gauss_adc1"
- plot_name: "Grid plot 2"
x:
label: 'Motor X'
signals:
- name: "samx"
entry: "samx"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "gauss_adc1"
entry: "gauss_adc1"
- plot_name: "Grid plot 3"
x:
label: 'Motor Y'
signals:
- name: "samy"
entry: "samy"
y:
label: 'BPM'
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- plot_name: "Grid plot 4"
x:
label: 'Motor Y'
signals:
- name: "samy"
entry: "samy"
y:
label: 'BPM'
signals:
- name: "gauss_adc3"
entry: "gauss_adc3"

View File

@@ -0,0 +1,842 @@
# pylint: disable = no-name-in-module,missing-module-docstring
import time
import pyqtgraph as pg
from bec_lib import MessageEndpoints
from pydantic import ValidationError
from pyqtgraph import mkBrush, mkPen
from qtpy import QtCore
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QApplication, QMessageBox
from bec_widgets.utils import Colors, Crosshair, yaml_dialog
from bec_widgets.validation import MonitorConfigValidator
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
# just for demonstration purposes if script run directly
CONFIG_SCAN_MODE = {
"plot_settings": {
"background_color": "white",
"num_columns": 3,
"colormap": "plasma",
"scan_types": True,
},
"plot_data": {
"grid_scan": [
{
"plot_name": "Grid plot 1",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}],
},
}
],
},
{
"plot_name": "Grid plot 2",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_adc1"}],
},
}
],
},
{
"plot_name": "Grid plot 3",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "gauss_adc2"}],
},
}
],
},
{
"plot_name": "Grid plot 4",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "gauss_adc3"}],
},
}
],
},
],
"line_scan": [
{
"plot_name": "BPM plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
},
}
CONFIG_WRONG = {
"plot_settings": {
"background_color": "black",
"num_columns": 2,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor Y",
"y_label": "bpm4i",
"sources": [
{
"type": "non_existing_source",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
{
"type": "history",
"scanID": "<scanID>",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "non_sense_entry"}],
"y": [
{"name": "non_existing_name"},
{"name": "samy", "entry": "non_existing_entry"},
],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [
{"name": "samx"},
{"name": "samy", "entry": "samx"},
],
},
}
],
},
],
}
CONFIG_SIMPLE = {
"plot_settings": {
"background_color": "black",
"num_columns": 2,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor X",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
# {
# "type": "history",
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
# {
# "type": "dap",
# 'worker':'some_worker',
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
}
CONFIG_REDIS = {
"plot_settings": {
"background_color": "white",
"axis_width": 2,
"num_columns": 5,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor Y",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {"x": [{"name": "samx"}], "y": [{"name": "gauss_bpm"}]},
},
{
"type": "redis",
"endpoint": "public/gui/data/6cd5ea3f-a9a9-4736-b4ed-74ab9edfb996",
"update": "append",
"signals": {"x": [{"name": "x_default_tag"}], "y": [{"name": "y_default_tag"}]},
},
],
}
],
}
class BECMonitor(pg.GraphicsLayoutWidget):
update_signal = pyqtSignal()
def __init__(
self,
parent=None,
client=None,
config: dict = None,
enable_crosshair: bool = True,
gui_id=None,
skip_validation: bool = False,
):
super().__init__(parent=parent)
# Client and device manager from BEC
self.plot_data = None
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
self.queue = self.client.queue
self.validator = MonitorConfigValidator(self.dev)
self.gui_id = gui_id
if self.gui_id is None:
self.gui_id = self.__class__.__name__ + str(time.time())
# Connect slots dispatcher
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
bec_dispatcher.connect_slot(self.on_config_update, MessageEndpoints.gui_config(self.gui_id))
bec_dispatcher.connect_slot(
self.on_instruction, MessageEndpoints.gui_instructions(self.gui_id)
)
bec_dispatcher.connect_slot(self.on_data_from_redis, MessageEndpoints.gui_data(self.gui_id))
# Current configuration
self.config = config
self.skip_validation = skip_validation
# Enable crosshair
self.enable_crosshair = enable_crosshair
# Displayed Data
self.database = {}
self.crosshairs = None
self.plots = None
self.curves_data = None
self.grid_coordinates = None
self.scanID = None
# TODO make colors accessible to users
self.user_colors = {} # key: (plot_name, y_name, y_entry), value: color
# Connect the update signal to the update plot method
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self.update_scan_segment_plot
)
# Init UI
if self.config is None:
print("No initial config found for BECDeviceMonitor")
else:
self.on_config_update(self.config)
def _init_config(self):
"""
Initializes or update the configuration settings for the PlotApp.
"""
# Separate configs
self.plot_settings = self.config.get("plot_settings", {})
self.plot_data_config = self.config.get("plot_data", {})
self.scan_types = self.plot_settings.get("scan_types", False)
if self.scan_types is False: # Device tracking mode
self.plot_data = self.plot_data_config # TODO logic has to be improved
else: # without incoming data setup the first configuration to the first scan type sorted alphabetically by name
self.plot_data = self.plot_data_config[min(list(self.plot_data_config.keys()))]
# Initialize the database
self.database = self._init_database(self.plot_data)
# Initialize the UI
self._init_ui(self.plot_settings["num_columns"])
def _init_database(self, plot_data_config: dict, source_type_to_init=None) -> dict:
"""
Initializes or updates the database for the PlotApp.
Args:
plot_data_config(dict): Configuration settings for plots.
source_type_to_init(str, optional): Specific source type to initialize. If None, initialize all.
Returns:
dict: Updated or new database dictionary.
"""
database = {} if source_type_to_init is None else self.database.copy()
for plot in plot_data_config:
for source in plot["sources"]:
source_type = source["type"]
if source_type_to_init and source_type != source_type_to_init:
continue # Skip if not the specified source type
if source_type not in database:
database[source_type] = {}
for axis, signals in source["signals"].items():
for signal in signals:
name = signal["name"]
entry = signal.get("entry", name)
if name not in database[source_type]:
database[source_type][name] = {}
if entry not in database[source_type][name]:
database[source_type][name][entry] = []
return database
def _init_ui(self, num_columns: int = 3) -> None:
"""
Initialize the UI components, create plots and store their grid positions.
Args:
num_columns (int): Number of columns to wrap the layout.
This method initializes a dictionary `self.plots` to store the plot objects
along with their corresponding x and y signal names. It dynamically arranges
the plots in a grid layout based on the given number of columns and dynamically
stretches the last plots to fit the remaining space.
"""
self.clear()
self.plots = {}
self.grid_coordinates = []
num_plots = len(self.plot_data)
# Check if num_columns exceeds the number of plots
if num_columns >= num_plots:
num_columns = num_plots
self.plot_settings["num_columns"] = num_columns # Update the settings
print(
"Warning: num_columns in the YAML file was greater than the number of plots."
f" Resetting num_columns to number of plots:{num_columns}."
)
else:
self.plot_settings["num_columns"] = num_columns # Update the settings
num_rows = num_plots // num_columns
last_row_cols = num_plots % num_columns
remaining_space = num_columns - last_row_cols
for i, plot_config in enumerate(self.plot_data):
row, col = i // num_columns, i % num_columns
colspan = 1
if row == num_rows and remaining_space > 0:
if last_row_cols == 1:
colspan = num_columns
else:
colspan = remaining_space // last_row_cols + 1
remaining_space -= colspan - 1
last_row_cols -= 1
plot_name = plot_config.get("plot_name", "")
x_label = plot_config.get("x_label", "")
y_label = plot_config.get("y_label", "")
plot = self.addPlot(row=row, col=col, colspan=colspan, title=plot_name)
plot.setLabel("bottom", x_label)
plot.setLabel("left", y_label)
plot.addLegend()
self._set_plot_colors(plot, self.plot_settings)
self.plots[plot_name] = plot
self.grid_coordinates.append((row, col))
# Initialize curves
self.init_curves()
def _set_plot_colors(self, plot: pg.PlotItem, plot_settings: dict) -> None:
"""
Set the plot colors based on the plot config.
Args:
plot (pg.PlotItem): Plot object to set the colors.
plot_settings (dict): Plot settings dictionary.
"""
if plot_settings.get("show_grid", False):
plot.showGrid(x=True, y=True, alpha=0.5)
pen_width = plot_settings.get("axis_width")
color = plot_settings.get("axis_color")
if color is None:
if plot_settings["background_color"].lower() == "black":
color = "w"
self.setBackground("k")
elif plot_settings["background_color"].lower() == "white":
color = "k"
self.setBackground("w")
else:
raise ValueError(
f"Invalid background color {plot_settings['background_color']}. Allowed values"
" are 'white' or 'black'."
)
pen = pg.mkPen(color=color, width=pen_width)
x_axis = plot.getAxis("bottom") # 'bottom' corresponds to the x-axis
x_axis.setPen(pen)
x_axis.setTextPen(pen)
x_axis.setTickPen(pen)
y_axis = plot.getAxis("left") # 'left' corresponds to the y-axis
y_axis.setPen(pen)
y_axis.setTextPen(pen)
y_axis.setTickPen(pen)
def init_curves(self) -> None:
"""
Initialize curve data and properties for each plot and data source.
"""
self.curves_data = {}
for idx, plot_config in enumerate(self.plot_data):
plot_name = plot_config.get("plot_name", "")
plot = self.plots[plot_name]
plot.clear()
for source in plot_config["sources"]:
source_type = source["type"]
y_signals = source["signals"].get("y", [])
colors_ys = Colors.golden_angle_color(
colormap=self.plot_settings["colormap"], num=len(y_signals)
)
if source_type not in self.curves_data:
self.curves_data[source_type] = {}
if plot_name not in self.curves_data[source_type]:
self.curves_data[source_type][plot_name] = []
for i, (y_signal, color) in enumerate(zip(y_signals, colors_ys)):
y_name = y_signal["name"]
y_entry = y_signal.get("entry", y_name)
curve_name = f"{y_name} ({y_entry})-{source_type[0].upper()}"
curve_data = self.create_curve(curve_name, color)
plot.addItem(curve_data)
self.curves_data[source_type][plot_name].append((y_name, y_entry, curve_data))
# Render static plot elements
self.update_plot()
# # Hook Crosshair #TODO enable later, currently not working
if self.enable_crosshair is True:
self.hook_crosshair()
def create_curve(self, curve_name: str, color: str) -> pg.PlotDataItem:
"""
Create
Args:
curve_name: Name of the curve
color(str): Color of the curve
Returns:
pg.PlotDataItem: Assigned curve object
"""
user_color = self.user_colors.get(curve_name, None)
color_to_use = user_color if user_color else color
pen_curve = mkPen(color=color_to_use, width=2, style=QtCore.Qt.DashLine)
brush_curve = mkBrush(color=color_to_use)
return pg.PlotDataItem(
symbolSize=5,
symbolBrush=brush_curve,
pen=pen_curve,
skipFiniteCheck=True,
name=curve_name,
)
def hook_crosshair(self) -> None:
"""Hook the crosshair to all plots."""
# TODO can be extended to hook crosshair signal for mouse move/clicked
self.crosshairs = {}
for plot_name, plot in self.plots.items():
crosshair = Crosshair(plot, precision=3)
self.crosshairs[plot_name] = crosshair
def update_scan_segment_plot(self):
"""
Update the plot with the latest scan segment data.
"""
self.update_plot(source_type="scan_segment")
def update_plot(self, source_type=None) -> None:
"""
Update the plot data based on the stored data dictionary.
Only updates data for the specified source_type if provided.
"""
for src_type, plots in self.curves_data.items():
if source_type and src_type != source_type:
continue
for plot_name, curve_list in plots.items():
plot_config = next(
(pc for pc in self.plot_data if pc.get("plot_name") == plot_name), None
)
if not plot_config:
continue
x_name, x_entry = self.extract_x_config(plot_config, src_type)
for y_name, y_entry, curve in curve_list:
data_x = self.database.get(src_type, {}).get(x_name, {}).get(x_entry, [])
data_y = self.database.get(src_type, {}).get(y_name, {}).get(y_entry, [])
curve.setData(data_x, data_y)
def extract_x_config(self, plot_config: dict, source_type: str) -> tuple:
"""Extract the signal configurations for x and y axes from plot_config.
Args:
plot_config (dict): Plot configuration.
Returns:
tuple: Tuple containing the x name and x entry.
"""
x_name, x_entry = None, None
for source in plot_config["sources"]:
if source["type"] == source_type and "x" in source["signals"]:
x_signal = source["signals"]["x"][0]
x_name = x_signal.get("name")
x_entry = x_signal.get("entry", x_name)
return x_name, x_entry
def get_config(self):
"""Return the current configuration settings."""
return self.config
def show_config_dialog(self):
"""Show the configuration dialog."""
from bec_widgets.widgets import ConfigDialog
dialog = ConfigDialog(default_config=self.config)
dialog.config_updated.connect(self.on_config_update)
dialog.show()
def update_client(self, client) -> None:
"""Update the client and device manager from BEC.
Args:
client: BEC client
"""
self.client = client
self.dev = self.client.device_manager.devices
def _close_all_plots(self):
"""Close all plots."""
for plot in self.plots.values():
plot.clear()
@pyqtSlot(dict)
def on_instruction(self, msg_content: dict) -> None:
"""
Handle instructions sent to the GUI.
Possible actions are:
- clear: Clear the plots
- close: Close the GUI
- config_dialog: Open the configuration dialog
Args:
msg_content (dict): Message content with the instruction and parameters.
"""
action = msg_content.get("action", None)
parameters = msg_content.get("parameters", None)
if action == "clear":
self.flush()
self._close_all_plots()
elif action == "close":
self.close()
elif action == "config_dialog":
self.show_config_dialog()
else:
print(f"Unknown instruction received: {msg_content}")
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""
Validate and update the configuration settings for the PlotApp.
Args:
config(dict): Configuration settings
"""
# convert config from BEC CLI to correct formatting
config_tag = config.get("config", None)
if config_tag is not None:
config = config["config"]
if self.skip_validation is True:
self.config = config
self._init_config()
else:
try:
validated_config = self.validator.validate_monitor_config(config)
self.config = validated_config.model_dump()
self._init_config()
except ValidationError as e:
error_str = str(e)
formatted_error_message = BECMonitor.format_validation_error(error_str)
# Display the formatted error message in a popup
QMessageBox.critical(self, "Configuration Error", formatted_error_message)
@staticmethod
def format_validation_error(error_str: str) -> str:
"""
Format the validation error string to be displayed in a popup.
Args:
error_str(str): Error string from the validation error.
"""
error_lines = error_str.split("\n")
# The first line contains the number of errors.
error_header = f"<p><b>{error_lines[0]}</b></p><hr>"
formatted_error_message = error_header
# Skip the first line as it's the header.
error_details = error_lines[1:]
# Iterate through pairs of lines (each error's two lines).
for i in range(0, len(error_details), 2):
location = error_details[i]
message = error_details[i + 1] if i + 1 < len(error_details) else ""
formatted_error_message += f"<p><b>{location}</b><br>{message}</p><hr>"
return formatted_error_message
def flush(self, flush_all=False, source_type_to_flush=None) -> None:
"""Update or reset the database to match the current configuration.
Args:
flush_all (bool): If True, reset the entire database.
source_type_to_flush (str): Specific source type to reset. Ignored if flush_all is True.
"""
if flush_all:
self.database = self._init_database(self.plot_data)
self.init_curves()
else:
if source_type_to_flush in self.database:
# TODO maybe reinit the database from config again instead of cycle through names/entries
# Reset only the specified source type
for name in self.database[source_type_to_flush]:
for entry in self.database[source_type_to_flush][name]:
self.database[source_type_to_flush][name][entry] = []
# Reset curves for the specified source type
if source_type_to_flush in self.curves_data:
self.init_curves()
@pyqtSlot(dict, dict)
def on_scan_segment(self, msg: dict, metadata: dict):
"""
Handle new scan segments and saves data to a dictionary. Linked through bec_dispatcher.
Args:
msg (dict): Message received with scan data.
metadata (dict): Metadata of the scan.
"""
current_scanID = msg.get("scanID", None)
if current_scanID is None:
return
if current_scanID != self.scanID:
if self.scan_types is False:
self.plot_data = self.plot_data_config
elif self.scan_types is True:
current_name = metadata.get("scan_name")
if current_name is None:
raise ValueError(
"Scan name not found in metadata. Please check the scan_name in the YAML"
" config or in bec configuration."
)
self.plot_data = self.plot_data_config.get(current_name, None)
if not self.plot_data:
raise ValueError(
f"Scan name {current_name} not found in the YAML config. Please check the scan_name in the "
"YAML config or in bec configuration."
)
# Init UI
self._init_ui(self.plot_settings["num_columns"])
self.scanID = current_scanID
self.scan_data = self.queue.scan_storage.find_scan_by_ID(self.scanID)
if not self.scan_data:
print(f"No data found for scanID: {self.scanID}") # TODO better error
return
self.flush(source_type_to_flush="scan_segment")
self.scan_segment_update()
self.update_signal.emit()
def scan_segment_update(self):
"""
Update the database with data from scan storage based on the provided scanID.
"""
scan_data = self.scan_data.data
for device_name, device_entries in self.database.get("scan_segment", {}).items():
for entry in device_entries.keys():
dataset = scan_data[device_name][entry].val
if dataset:
self.database["scan_segment"][device_name][entry] = dataset
else:
print(f"No data found for {device_name} {entry}")
@pyqtSlot(dict)
def on_data_from_redis(self, msg) -> None:
"""
Handle new data sent from redis.
Args:
msg (dict): Message received with data.
"""
# wait until new config is loaded
while "redis" not in self.database:
time.sleep(0.1)
self._init_database(
self.plot_data, source_type_to_init="redis"
) # add database entry for redis dataset
data = msg.get("data", {})
x_data = data.get("x", {})
y_data = data.get("y", {})
# Update x data
if x_data:
x_tag = x_data.get("tag")
self.database["redis"][x_tag][x_tag] = x_data["data"]
# Update y data
for y_tag, y_info in y_data.items():
self.database["redis"][y_tag][y_tag] = y_info["data"]
# Trigger plot update
self.update_plot(source_type="redis")
print(f"database after: {self.database}")
if __name__ == "__main__": # pragma: no cover
import argparse
import json
import sys
parser = argparse.ArgumentParser()
parser.add_argument("--config_file", help="Path to the config file.")
parser.add_argument("--config", help="Path to the config file.")
parser.add_argument("--id", help="GUI ID.")
args = parser.parse_args()
if args.config is not None:
# Load config from file
config = json.loads(args.config)
elif args.config_file is not None:
# Load config from file
config = yaml_dialog.load_yaml(args.config_file)
else:
config = CONFIG_SIMPLE
client = bec_dispatcher.client
client.start()
app = QApplication(sys.argv)
monitor = BECMonitor(
config=config,
gui_id=args.id,
skip_validation=False,
)
monitor.show()
# just to test redis data
# redis_data = {
# "x": {"data": [1, 2, 3], "tag": "x_default_tag"},
# "y": {"y_default_tag": {"data": [1, 2, 3]}},
# }
# monitor.on_data_from_redis({"data": redis_data})
sys.exit(app.exec())

View File

@@ -0,0 +1,180 @@
<?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>506</width>
<height>592</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QGroupBox" name="groupBox_general">
<property name="title">
<string>General</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="2" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>X Label</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="5">
<widget class="Line" name="line_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item row="2" column="4">
<widget class="QLineEdit" name="lineEdit_y_label"/>
</item>
<item row="2" column="3">
<widget class="QLabel" name="label_11">
<property name="text">
<string>Y Label</string>
</property>
</widget>
</item>
<item row="0" column="1" colspan="4">
<widget class="QLineEdit" name="lineEdit_plot_title"/>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="lineEdit_x_label"/>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="Line" name="line_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="groupBox_x_axis">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="label_6">
<property name="text">
<string>Name:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="lineEdit_x_name"/>
</item>
<item>
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_7">
<property name="text">
<string>Entry:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="lineEdit_x_entry"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_y_signals">
<property name="title">
<string>Y Signals</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="BECTable" name="tableWidget_y_signals">
<column>
<property name="text">
<string>Name</string>
</property>
</column>
<column>
<property name="text">
<string>Entries</string>
</property>
</column>
<column>
<property name="text">
<string>Color</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="pushButton_add_new_plot">
<property name="text">
<string>Add New Plot</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="pushButton_y_new">
<property name="text">
<string>Add New Signal</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECTable</class>
<extends>QTableWidget</extends>
<header>bec_widgets.utils.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

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

View File

@@ -0,0 +1,554 @@
# pylint: disable = no-name-in-module,missing-module-docstring
from __future__ import annotations
import time
from typing import Any, Union
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints
from qtpy import QtCore
from qtpy import QtGui
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.yaml_dialog import load_yaml
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
CONFIG_DEFAULT = {
"plot_settings": {
"colormap": "Greys",
"scatter_size": 5,
"max_points": 1000,
"num_dim_points": 100,
"precision": 2,
"num_columns": 1,
"background_value": 25,
},
"motors": [
{
"plot_name": "Motor Map",
"x_label": "Motor X",
"y_label": "Motor Y",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "samy", "entry": "samy"}],
},
},
{
"plot_name": "Motor Map 2 ",
"x_label": "Motor X",
"y_label": "Motor Y",
"signals": {
"x": [{"name": "aptrx", "entry": "aptrx"}],
"y": [{"name": "aptry", "entry": "aptry"}],
},
},
],
}
class MotorMap(pg.GraphicsLayoutWidget):
update_signal = pyqtSignal()
def __init__(
self,
parent=None,
client=None,
config: dict = None,
gui_id=None,
skip_validation: bool = True,
):
super().__init__(parent=parent)
# Import BEC related stuff
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
# TODO import validator when prepared
self.gui_id = gui_id
if self.gui_id is None:
self.gui_id = self.__class__.__name__ + str(time.time())
# Current configuration
self.config = config
self.skip_validation = skip_validation # TODO implement validation when validator is ready
# Connect the update signal to the update plot method
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self._update_plots
)
# Config related variables
self.plot_data = None
self.plot_settings = None
self.max_points = None
self.num_dim_points = None
self.scatter_size = None
self.precision = None
self.background_value = None
self.database = {}
self.device_mapping = {}
self.plots = {}
self.grid_coordinates = []
self.curves_data = {}
# Init UI with config
if self.config is None:
print("No initial config found for MotorMap. Using default config.")
else:
self.on_config_update(self.config)
@pyqtSlot(dict)
def on_config_update(self, config: dict) -> None:
"""
Validate and update the configuration settings for the PlotApp.
Args:
config(dict): Configuration settings
"""
# TODO implement BEC CLI commands similar to BECPlotter
# convert config from BEC CLI to correct formatting
config_tag = config.get("config", None)
if config_tag is not None:
config = config["config"]
if self.skip_validation is True:
self.config = config
self._init_config()
else: # TODO implement validator
print("Do validation")
def _init_config(self):
"""Initiate the configuration."""
# Global widget settings
self._get_global_settings()
# Motor settings
self.plot_data = self.config.get("motors", {})
# Include motor limits into the config
self._add_limits_to_plot_data()
# Initialize the database
self.database = self._init_database()
# Create device mapping for x/y motor pairs
self.device_mapping = self._create_device_mapping()
# Initialize the plot UI
self._init_ui()
# Connect motors to slots
self._connect_motors_to_slots()
def _get_global_settings(self):
"""Get global settings from the config."""
self.plot_settings = self.config.get("plot_settings", {})
self.max_points = self.plot_settings.get("max_points", 5000)
self.num_dim_points = self.plot_settings.get("num_dim_points", 100)
self.scatter_size = self.plot_settings.get("scatter_size", 5)
self.precision = self.plot_settings.get("precision", 2)
self.background_value = self.plot_settings.get("background_value", 25)
def _create_device_mapping(self):
"""
Create a mapping of device names to their corresponding x/y devices.
"""
mapping = {}
for motor in self.config.get("motors", []):
for axis in ["x", "y"]:
for signal in motor["signals"][axis]:
other_axis = "y" if axis == "x" else "x"
corresponding_device = motor["signals"][other_axis][0]["name"]
mapping[signal["name"]] = corresponding_device
return mapping
def _connect_motors_to_slots(self):
"""Connect motors to slots."""
# Disconnect all slots before connecting a new ones
bec_dispatcher.disconnect_all()
# Get list of all unique motors
unique_motors = []
for motor_config in self.plot_data:
for axis in ["x", "y"]:
for signal in motor_config["signals"][axis]:
unique_motors.append(signal["name"])
unique_motors = list(set(unique_motors))
# Create list of endpoint
endpoints = []
for motor in unique_motors:
endpoints.append(MessageEndpoints.device_readback(motor))
# Connect all topics to a single slot
bec_dispatcher.connect_slot(
self.on_device_readback,
endpoints,
single_callback_for_all_topics=True,
)
def _add_limits_to_plot_data(self):
"""
Add limits to each motor signal in the plot_data.
"""
for motor_config in self.plot_data:
for axis in ["x", "y"]:
for signal in motor_config["signals"][axis]:
motor_name = signal["name"]
motor_limits = self._get_motor_limit(motor_name)
signal["limits"] = motor_limits
def _get_motor_limit(self, motor: str) -> Union[list | None]:
"""
Get the motor limit from the config.
Args:
motor(str): Motor name.
Returns:
float: Motor limit.
"""
try:
limits = self.dev[motor].limits
if limits == [0, 0]:
return None
return limits
except AttributeError: # TODO maybe not needed, if no limits it returns [0,0]
# If the motor doesn't have a 'limits' attribute, return a default value or raise a custom exception
print(f"The device '{motor}' does not have defined limits.")
return None
def _init_database(self):
"""Initiate the database according the config."""
database = {}
for plot in self.plot_data:
for axis, signals in plot["signals"].items():
for signal in signals:
name = signal["name"]
entry = signal.get("entry", name)
if name not in database:
database[name] = {}
if entry not in database[name]:
database[name][entry] = [self.get_coordinate(name, entry)]
return database
def get_coordinate(self, name, entry):
"""Get the initial coordinate value for a motor."""
try:
return self.dev[name].read()[entry]["value"]
except Exception as e:
print(f"Error getting initial value for {name}: {e}")
return None
def _init_ui(self, num_columns: int = 3) -> None:
"""
Initialize the UI components, create plots and store their grid positions.
Args:
num_columns (int): Number of columns to wrap the layout.
This method initializes a dictionary `self.plots` to store the plot objects
along with their corresponding x and y signal names. It dynamically arranges
the plots in a grid layout based on the given number of columns and dynamically
stretches the last plots to fit the remaining space.
"""
self.clear()
self.plots = {}
self.grid_coordinates = []
self.curves_data = {} # TODO moved from init_curves
num_plots = len(self.plot_data)
# Check if num_columns exceeds the number of plots
if num_columns >= num_plots:
num_columns = num_plots
self.plot_settings["num_columns"] = num_columns # Update the settings
print(
"Warning: num_columns in the YAML file was greater than the number of plots."
f" Resetting num_columns to number of plots:{num_columns}."
)
else:
self.plot_settings["num_columns"] = num_columns # Update the settings
num_rows = num_plots // num_columns
last_row_cols = num_plots % num_columns
remaining_space = num_columns - last_row_cols
for i, plot_config in enumerate(self.plot_data):
row, col = i // num_columns, i % num_columns
colspan = 1
if row == num_rows and remaining_space > 0:
if last_row_cols == 1:
colspan = num_columns
else:
colspan = remaining_space // last_row_cols + 1
remaining_space -= colspan - 1
last_row_cols -= 1
if "plot_name" not in plot_config:
plot_name = f"Plot ({row}, {col})"
plot_config["plot_name"] = plot_name
else:
plot_name = plot_config["plot_name"]
x_label = plot_config.get("x_label", "")
y_label = plot_config.get("y_label", "")
plot = self.addPlot(row=row, col=col, colspan=colspan, title="Motor position: (X, Y)")
plot.setLabel("bottom", f"{x_label} ({plot_config['signals']['x'][0]['name']})")
plot.setLabel("left", f"{y_label} ({plot_config['signals']['y'][0]['name']})")
plot.addLegend()
# self._set_plot_colors(plot, self.plot_settings) #TODO implement colors
self.plots[plot_name] = plot
self.grid_coordinates.append((row, col))
self._init_motor_map(plot_config)
def _init_motor_map(self, plot_config: dict) -> None:
"""
Initialize the motor map.
Args:
plot_config(dict): Plot configuration.
"""
# Get plot name to find appropriate plot
plot_name = plot_config.get("plot_name", "")
# Reset the curves data
plot = self.plots[plot_name]
plot.clear()
limits_x, limits_y = plot_config["signals"]["x"][0].get("limits", None), plot_config[
"signals"
]["y"][0].get("limits", None)
if limits_x is not None and limits_y is not None:
self._make_limit_map(plot, [limits_x, limits_y])
# Initiate ScatterPlotItem for motor coordinates
self.curves_data[plot_name] = {
"pos": pg.ScatterPlotItem(
size=self.scatter_size, pen=pg.mkPen(None), brush=pg.mkBrush(255, 255, 255, 255)
)
}
# Add the scatter plot to the plot
plot.addItem(self.curves_data[plot_name]["pos"])
# Set the point map to be always on the top
self.curves_data[plot_name]["pos"].setZValue(0)
# Add all layers to the plot
plot.showGrid(x=True, y=True)
# Add the crosshair for motor coordinates
init_position_x = self._get_motor_init_position(
plot_config["signals"]["x"][0]["name"], plot_config["signals"]["x"][0]["entry"]
)
init_position_y = self._get_motor_init_position(
plot_config["signals"]["y"][0]["name"], plot_config["signals"]["y"][0]["entry"]
)
self._add_coordinantes_crosshair(plot_name, init_position_x, init_position_y)
def _add_coordinantes_crosshair(self, plot_name: str, x: float, y: float) -> None:
"""
Add crosshair to the plot to highlight the current position.
Args:
plot_name(str): Name of the plot.
x(float): X coordinate.
y(float): Y coordinate.
"""
# find the current plot
plot = self.plots[plot_name]
# Crosshair to highlight the current position
highlight_H = pg.InfiniteLine(
angle=0, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
)
highlight_V = pg.InfiniteLine(
angle=90, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
)
# Add crosshair to the curve list for future referencing
self.curves_data[plot_name]["highlight_H"] = highlight_H
self.curves_data[plot_name]["highlight_V"] = highlight_V
# Add crosshair to the plot
plot.addItem(highlight_H)
plot.addItem(highlight_V)
highlight_H.setPos(x)
highlight_V.setPos(y)
def _make_limit_map(self, plot: pg.PlotItem, limits: list):
"""
Make a limit map from the limits list.
Args:
plot(pg.PlotItem): Plot to add the limit map to.
limits(list): List of limits.
"""
# Define the size of the image map based on the motor's limits
limit_x_min, limit_x_max = limits[0]
limit_y_min, limit_y_max = limits[1]
map_width = int(limit_x_max - limit_x_min + 1)
map_height = int(limit_y_max - limit_y_min + 1)
limit_map_data = np.full((map_width, map_height), self.background_value, dtype=np.float32)
# Create the image map
limit_map = pg.ImageItem()
limit_map.setImage(limit_map_data)
plot.addItem(limit_map)
# Translate and scale the image item to match the motor coordinates
tr = QtGui.QTransform()
tr.translate(limit_x_min, limit_y_min)
limit_map.setTransform(tr)
def _get_motor_init_position(self, name: str, entry: str) -> float:
"""
Get the motor initial position from the config.
Args:
name(str): Motor name.
entry(str): Motor entry.
Returns:
float: Motor initial position.
"""
init_position = round(self.dev[name].read()[entry]["value"], self.precision)
return init_position
def _update_plots(self):
"""Update the motor position on plots."""
for plot_name, curve_list in self.curves_data.items():
plot_config = next(
(pc for pc in self.plot_data if pc.get("plot_name") == plot_name), None
)
if not plot_config:
continue
# Get the motor coordinates
x_motor_name = plot_config["signals"]["x"][0]["name"]
x_motor_entry = plot_config["signals"]["x"][0]["entry"]
y_motor_name = plot_config["signals"]["y"][0]["name"]
y_motor_entry = plot_config["signals"]["y"][0]["entry"]
# update motor position only if there is data
if (
len(self.database[x_motor_name][x_motor_entry]) >= 1
and len(self.database[y_motor_name][y_motor_entry]) >= 1
):
# Relevant data for the plot
motor_x_data = self.database[x_motor_name][x_motor_entry]
motor_y_data = self.database[y_motor_name][y_motor_entry]
# Setup gradient brush for history
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(motor_x_data)
# Calculate the decrement step based on self.num_dim_points
decrement_step = (255 - 50) / self.num_dim_points
for i in range(1, min(self.num_dim_points + 1, len(motor_x_data) + 1)):
brightness = max(60, 255 - decrement_step * (i - 1))
brushes[-i] = pg.mkBrush(brightness, brightness, brightness, 255)
brushes[-1] = pg.mkBrush(
255, 255, 255, 255
) # Newest point is always full brightness
# Update the scatter plot
self.curves_data[plot_name]["pos"].setData(
x=motor_x_data,
y=motor_y_data,
brush=brushes,
pen=None,
size=self.scatter_size,
)
# Get last know position for crosshair
current_x = motor_x_data[-1]
current_y = motor_y_data[-1]
# Update plot title
self.plots[plot_name].setTitle(
f"Motor position: ({round(current_x,self.precision)}, {round(current_y,self.precision)})"
)
# Update the crosshair
self.curves_data[plot_name]["highlight_V"].setPos(current_x)
self.curves_data[plot_name]["highlight_H"].setPos(current_y)
@pyqtSlot(dict)
def on_device_readback(self, msg: dict):
"""
Update the motor coordinates on the plots.
Args:
msg (dict): Message received with device readback data.
"""
for device_name, device_info in msg["signals"].items():
# Check if the device is relevant to our current context
if device_name in self.device_mapping:
self._update_device_data(device_name, device_info["value"])
self.update_signal.emit()
def _update_device_data(self, device_name: str, value: float):
"""
Update the device data.
Args:
device_name (str): Device name.
value (float): Device value.
"""
if device_name in self.database:
self.database[device_name][device_name].append(value)
corresponding_device = self.device_mapping.get(device_name)
if corresponding_device and corresponding_device in self.database:
last_value = (
self.database[corresponding_device][corresponding_device][-1]
if self.database[corresponding_device][corresponding_device]
else None
)
self.database[corresponding_device][corresponding_device].append(last_value)
if __name__ == "__main__": # pragma: no cover
import argparse
import json
import sys
parser = argparse.ArgumentParser()
parser.add_argument("--config_file", help="Path to the config file.")
parser.add_argument("--config", help="Path to the config file.")
parser.add_argument("--id", help="GUI ID.")
args = parser.parse_args()
if args.config is not None:
# Load config from file
config = json.loads(args.config)
elif args.config_file is not None:
# Load config from file
config = load_yaml(args.config_file)
else:
config = CONFIG_DEFAULT
client = bec_dispatcher.client
client.start()
app = QApplication(sys.argv)
motor_map = MotorMap(
config=config,
gui_id=args.id,
skip_validation=True,
)
motor_map.show()
sys.exit(app.exec())

View File

@@ -0,0 +1 @@
from .scan_control import ScanControl

View File

@@ -0,0 +1,441 @@
import msgpack
from qtpy.QtWidgets import (
QApplication,
QWidget,
QComboBox,
QPushButton,
QVBoxLayout,
QGroupBox,
QLabel,
QLineEdit,
QDoubleSpinBox,
QSpinBox,
QCheckBox,
QFrame,
QHBoxLayout,
QLayout,
QGridLayout,
QTableWidget,
QTableWidgetItem,
QHeaderView,
)
from bec_lib import MessageEndpoints
from bec_widgets.utils.widget_io import WidgetIO
class ScanArgType:
DEVICE = "device"
FLOAT = "float"
INT = "int"
BOOL = "bool"
STR = "str"
class ScanControl(QWidget):
WIDGET_HANDLER = {
ScanArgType.DEVICE: QLineEdit,
ScanArgType.FLOAT: QDoubleSpinBox,
ScanArgType.INT: QSpinBox,
ScanArgType.BOOL: QCheckBox,
ScanArgType.STR: QLineEdit,
}
def __init__(self, parent=None, client=None, allowed_scans=None):
super().__init__(parent)
# Client from BEC + shortcuts to device manager and scans
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
self.scans = self.client.scans
# Scan list - allowed scans for the GUI
self.allowed_scans = allowed_scans
# Create and set main layout
self._init_UI()
def _init_UI(self):
self.verticalLayout = QVBoxLayout(self)
# Scan selection group box
self.scan_selection_group = QGroupBox("Scan Selection", self)
self.scan_selection_layout = QVBoxLayout(self.scan_selection_group)
self.comboBox_scan_selection = QComboBox(self.scan_selection_group)
self.button_run_scan = QPushButton("Run Scan", self.scan_selection_group)
self.scan_selection_layout.addWidget(self.comboBox_scan_selection)
self.scan_selection_layout.addWidget(self.button_run_scan)
self.verticalLayout.addWidget(self.scan_selection_group)
# Scan control group box
self.scan_control_group = QGroupBox("Scan Control", self)
self.scan_control_layout = QVBoxLayout(self.scan_control_group)
self.verticalLayout.addWidget(self.scan_control_group)
# Kwargs layout - just placeholder
self.kwargs_layout = QGridLayout()
self.scan_control_layout.addLayout(self.kwargs_layout)
# 1st Separator
self.add_horizontal_separator(self.scan_control_layout)
# Buttons
self.button_layout = QHBoxLayout()
self.pushButton_add_bundle = QPushButton("Add Bundle", self.scan_control_group)
self.pushButton_add_bundle.clicked.connect(self.add_bundle)
self.pushButton_remove_bundle = QPushButton("Remove Bundle", self.scan_control_group)
self.pushButton_remove_bundle.clicked.connect(self.remove_bundle)
self.button_layout.addWidget(self.pushButton_add_bundle)
self.button_layout.addWidget(self.pushButton_remove_bundle)
self.scan_control_layout.addLayout(self.button_layout)
# 2nd Separator
self.add_horizontal_separator(self.scan_control_layout)
# Initialize the QTableWidget for args
self.args_table = QTableWidget()
self.args_table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
self.scan_control_layout.addWidget(self.args_table)
# Connect signals
self.comboBox_scan_selection.currentIndexChanged.connect(self.on_scan_selected)
self.button_run_scan.clicked.connect(self.run_scan)
# Initialize scan selection
self.populate_scans()
def add_horizontal_separator(self, layout) -> None:
"""
Adds a horizontal separator to the given layout
Args:
layout: Layout to add the separator to
"""
separator = QFrame(self.scan_control_group)
separator.setFrameShape(QFrame.HLine)
separator.setFrameShadow(QFrame.Sunken)
layout.addWidget(separator)
def populate_scans(self):
"""Populates the scan selection combo box with available scans"""
msg = self.client.producer.get(MessageEndpoints.available_scans())
self.available_scans = msgpack.loads(msg)
if self.allowed_scans is None:
allowed_scans = self.available_scans.keys()
else:
allowed_scans = self.allowed_scans
# TODO check parent class is ScanBase -> filter out the scans not relevant for GUI
self.comboBox_scan_selection.addItems(allowed_scans)
def on_scan_selected(self):
"""Callback for scan selection combo box"""
selected_scan_name = self.comboBox_scan_selection.currentText()
selected_scan_info = self.available_scans.get(selected_scan_name, {})
print(selected_scan_info) # TODO remove when widget will be more mature
# Generate kwargs input
self.generate_kwargs_input_fields(selected_scan_info)
# Args section
self.generate_args_input_fields(selected_scan_info)
def add_labels_to_layout(self, labels: list, grid_layout: QGridLayout) -> None:
"""
Adds labels to the given grid layout as a separate row.
Args:
labels (list): List of label names to add.
grid_layout (QGridLayout): The grid layout to which labels will be added.
"""
row_index = grid_layout.rowCount() # Get the next available row
for column_index, label_name in enumerate(labels):
label = QLabel(label_name.capitalize(), self.scan_control_group)
# Add the label to the grid layout at the calculated row and current column
grid_layout.addWidget(label, row_index, column_index)
def add_labels_to_table(
self, labels: list, table: QTableWidget
) -> None: # TODO could be moved to BECTable
"""
Adds labels to the given table widget as a header row.
Args:
labels(list): List of label names to add.
table(QTableWidget): The table widget to which labels will be added.
"""
table.setColumnCount(len(labels))
table.setHorizontalHeaderLabels(labels)
def generate_args_input_fields(self, scan_info: dict) -> None:
"""
Generates input fields for args
Args:
scan_info(dict): Scan signature dictionary from BEC.
"""
# Setup args table limits
self.set_args_table_limits(self.args_table, scan_info)
# Get arg_input from selected scan
self.arg_input = scan_info.get("arg_input", {})
# Generate labels for table
self.add_labels_to_table(list(self.arg_input.keys()), self.args_table)
# add minimum number of args rows
if self.arg_size_min is not None:
for i in range(self.arg_size_min):
self.add_bundle()
def generate_kwargs_input_fields(self, scan_info: dict) -> None:
"""
Generates input fields for kwargs
Args:
scan_info(dict): Scan signature dictionary from BEC.
"""
# Create a new kwarg layout to replace the old one - this is necessary because otherwise row count is not reseted
self.clear_and_delete_layout(self.kwargs_layout)
self.kwargs_layout = self.create_new_grid_layout() # Create new grid layout
self.scan_control_layout.insertLayout(0, self.kwargs_layout)
# Get signature
signature = scan_info.get("signature", [])
# Extract kwargs from the converted signature
kwargs = [param["name"] for param in signature if param["kind"] == "KEYWORD_ONLY"]
# Add labels
self.add_labels_to_layout(kwargs, self.kwargs_layout)
# Add widgets
widgets = self.generate_widgets_from_signature(kwargs, signature)
self.add_widgets_row_to_layout(self.kwargs_layout, widgets)
def generate_widgets_from_signature(self, items: list, signature: dict = None) -> list:
"""
Generates widgets from the given list of items.
Args:
items(list): List of items to create widgets for.
signature(dict, optional): Scan signature dictionary from BEC.
Returns:
"""
widgets = [] # Initialize an empty list to hold the widgets
for item in items:
if signature:
# If a signature is provided, extract type and name from it
kwarg_info = next((info for info in signature if info["name"] == item), None)
if kwarg_info:
item_type = kwarg_info.get("annotation", "_empty")
item_name = item
else:
# If no signature is provided, assume the item is a tuple of (name, type)
item_name, item_type = item
widget_class = self.WIDGET_HANDLER.get(item_type, None)
if widget_class is None:
print(f"Unsupported annotation '{item_type}' for parameter '{item_name}'")
continue
# Instantiate the widget and set some properties if necessary
widget = widget_class()
# set high default range for spin boxes #TODO can be linked to motor/device limits from BEC
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
widget.setRange(-9999, 9999)
widget.setValue(0)
# Add the widget to the list
widgets.append(widget)
return widgets
def set_args_table_limits(self, table: QTableWidget, scan_info: dict) -> None:
# Get bundle info
arg_bundle_size = scan_info.get("arg_bundle_size", {})
self.arg_size_min = arg_bundle_size.get("min", 1)
self.arg_size_max = arg_bundle_size.get("max", None)
# Clear the previous input fields
table.setRowCount(0) # Wipe table
def add_widgets_row_to_layout(
self, grid_layout: QGridLayout, widgets: list, row_index: int = None
) -> None:
"""
Adds a row of widgets to the given grid layout.
Args:
grid_layout (QGridLayout): The grid layout to which widgets will be added.
items (list): List of parameter names to create widgets for.
row_index (int): The row index where the widgets should be added.
"""
# If row_index is not specified, add to the next available row
if row_index is None:
row_index = grid_layout.rowCount()
for column_index, widget in enumerate(widgets):
# Add the widget to the grid layout at the specified row and column
grid_layout.addWidget(widget, row_index, column_index)
def add_widgets_row_to_table(
self, table_widget: QTableWidget, widgets: list, row_index: int = None
) -> None:
"""
Adds a row of widgets to the given QTableWidget.
Args:
table_widget (QTableWidget): The table widget to which widgets will be added.
widgets (list): List of widgets to add to the table.
row_index (int): The row index where the widgets should be added. If None, add to the end.
"""
# If row_index is not specified, add to the end of the table
if row_index is None or row_index > table_widget.rowCount():
row_index = table_widget.rowCount()
if self.arg_size_max is not None: # ensure the max args size is not exceeded
if row_index >= self.arg_size_max:
return
table_widget.insertRow(row_index)
for column_index, widget in enumerate(widgets):
# If the widget is a subclass of QWidget, use setCellWidget
if issubclass(type(widget), QWidget):
table_widget.setCellWidget(row_index, column_index, widget)
else:
# Otherwise, assume it's a string or some other value that should be displayed as text
item = QTableWidgetItem(str(widget))
table_widget.setItem(row_index, column_index, item)
# Optionally, adjust the row height based on the content #TODO decide if needed
table_widget.setRowHeight(
row_index,
max(widget.sizeHint().height() for widget in widgets if isinstance(widget, QWidget)),
)
def remove_last_row_from_table(self, table_widget: QTableWidget) -> None:
"""
Removes the last row from the given QTableWidget until only one row is left.
Args:
table_widget (QTableWidget): The table widget from which the last row will be removed.
"""
row_count = table_widget.rowCount()
if (
row_count > self.arg_size_min
): # Check to ensure there is a minimum number of rows remaining
table_widget.removeRow(row_count - 1)
def create_new_grid_layout(self):
new_layout = QGridLayout()
# TODO maybe setup other layouts properties here?
return new_layout
def clear_and_delete_layout(self, layout: QLayout):
"""
Clears and deletes the given layout and all its child widgets.
Args:
layout(QLayout): Layout to clear and delete
"""
if layout is not None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
else:
sub_layout = item.layout()
if sub_layout:
self.clear_and_delete_layout(sub_layout)
layout.deleteLater()
def add_bundle(self) -> None:
"""Adds a new bundle to the scan control layout"""
# Get widgets used for particular scan and save them to be able to use for adding bundles
args_widgets = self.generate_widgets_from_signature(
self.arg_input.items()
) # TODO decide if make sense to put widget list into method parameters
# Add first widgets row to the table
self.add_widgets_row_to_table(self.args_table, args_widgets)
def remove_bundle(self) -> None:
"""Removes the last bundle from the scan control layout"""
self.remove_last_row_from_table(self.args_table)
def extract_kwargs_from_grid_row(self, grid_layout: QGridLayout, row: int) -> dict:
kwargs = {}
for column in range(grid_layout.columnCount()):
label_item = grid_layout.itemAtPosition(row, column)
if label_item is not None:
label_widget = label_item.widget()
if isinstance(label_widget, QLabel):
key = label_widget.text()
# The corresponding value widget is in the next row
value_item = grid_layout.itemAtPosition(row + 1, column)
if value_item is not None:
value_widget = value_item.widget()
# Use WidgetIO.get_value to extract the value
value = WidgetIO.get_value(value_widget)
kwargs[key] = value
return kwargs
def extract_args_from_table(self, table: QTableWidget) -> list:
"""
Extracts the arguments from the given table widget.
Args:
table(QTableWidget): Table widget from which to extract the arguments
"""
args = []
for row in range(table.rowCount()):
row_args = []
for column in range(table.columnCount()):
widget = table.cellWidget(row, column)
if widget:
if isinstance(widget, QLineEdit): # special case for QLineEdit for Devices
value = widget.text().lower()
if value in self.dev:
value = getattr(self.dev, value)
else:
raise ValueError(f"The device '{value}' is not recognized.")
else:
value = WidgetIO.get_value(widget)
row_args.append(value)
args.extend(row_args)
return args
def run_scan(self):
# Extract kwargs for the scan
kwargs = {
k.lower(): v
for k, v in self.extract_kwargs_from_grid_row(self.kwargs_layout, 1).items()
}
# Extract args from the table
args = self.extract_args_from_table(self.args_table)
# Convert args to lowercase if they are strings
args = [arg.lower() if isinstance(arg, str) else arg for arg in args]
# Execute the scan
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
if callable(scan_function):
scan_function(*args, **kwargs)
# Application example
if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
# BECclient global variables
client = bec_dispatcher.client
client.start()
app = QApplication([])
scan_control = ScanControl(
client=client,
) # allowed_scans=["line_scan", "grid_scan"])
window = scan_control
window.show()
app.exec()

View File

@@ -1,9 +1,12 @@
from threading import RLock
import numpy as np
import pyqtgraph as pg
from bec_lib.core.logger import bec_logger
from PyQt5.QtCore import pyqtProperty, pyqtSlot
from bec_lib import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property as pyqtProperty, Slot as pyqtSlot
from bec_widgets.bec_dispatcher import bec_dispatcher
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
logger = bec_logger.logger
@@ -14,7 +17,10 @@ pg.setConfigOptions(background="w", foreground="k", antialias=True)
class BECScanPlot2D(pg.GraphicsView):
def __init__(self, parent=None, background="default"):
super().__init__(parent, background)
bec_dispatcher.connect(self)
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
self._scanID = None
self._scanID_lock = RLock()
self._x_channel = ""
self._y_channel = ""
@@ -33,8 +39,7 @@ class BECScanPlot2D(pg.GraphicsView):
self.imageItem = pg.ImageItem()
self.plot_item.addItem(self.imageItem)
@pyqtSlot(dict, dict)
def on_new_scan(self, _scan_segment, metadata):
def reset_plots(self, _scan_segment, metadata):
# TODO: Do we reset in case of a scan type change?
self.imageItem.clear()
@@ -79,6 +84,13 @@ class BECScanPlot2D(pg.GraphicsView):
@pyqtSlot(dict, dict)
def on_scan_segment(self, scan_segment, metadata):
# reset plots on scanID change
with self._scanID_lock:
scan_id = scan_segment["scanID"]
if self._scanID != scan_id:
self._scanID = scan_id
self.reset_plots(scan_segment, metadata)
if not self.z_channel or metadata["scan_name"] != "grid_scan":
return
@@ -127,7 +139,7 @@ class BECScanPlot2D(pg.GraphicsView):
if __name__ == "__main__":
import sys
from PyQt5.QtWidgets import QApplication
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
@@ -137,4 +149,4 @@ if __name__ == "__main__":
plot.show()
sys.exit(app.exec_())
sys.exit(app.exec())

View File

@@ -1,10 +1,12 @@
import itertools
from threading import RLock
import pyqtgraph as pg
from bec_lib.core.logger import bec_logger
from PyQt5.QtCore import pyqtProperty, pyqtSlot
from bec_lib import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property as pyqtProperty, Slot as pyqtSlot
from bec_widgets.bec_dispatcher import bec_dispatcher
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
logger = bec_logger.logger
@@ -16,24 +18,33 @@ COLORS = ["#fd7f6f", "#7eb0d5", "#b2e061", "#bd7ebe", "#ffb55a"]
class BECScanPlot(pg.GraphicsView):
def __init__(self, parent=None, background="default"):
super().__init__(parent, background)
bec_dispatcher.connect(self)
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
self.view = pg.PlotItem()
self.setCentralItem(self.view)
self._scanID = None
self._scanID_lock = RLock()
self._x_channel = ""
self._y_channel_list = []
self.scan_curves = {}
self.dap_curves = {}
@pyqtSlot(dict, dict)
def on_new_scan(self, _scan_segment, _metadata):
def reset_plots(self, _scan_segment, _metadata):
for plot_curve in {**self.scan_curves, **self.dap_curves}.values():
plot_curve.setData(x=[], y=[])
@pyqtSlot(dict, dict)
def on_scan_segment(self, scan_segment, _metadata):
def on_scan_segment(self, scan_segment, metadata):
# reset plots on scanID change
with self._scanID_lock:
scan_id = scan_segment["scanID"]
if self._scanID != scan_id:
self._scanID = scan_id
self.reset_plots(scan_segment, metadata)
if not self.x_channel:
return
@@ -62,7 +73,8 @@ class BECScanPlot(pg.GraphicsView):
plot_curve.setData(x=[*x, x_new], y=[*y, y_new])
@pyqtSlot(dict, dict)
def redraw_dap(self, data, _metadata):
def redraw_dap(self, content, _metadata):
data = content["data"]
for chan, plot_curve in self.dap_curves.items():
if not chan:
continue
@@ -86,7 +98,8 @@ class BECScanPlot(pg.GraphicsView):
chan_removed = [chan for chan in self._y_channel_list if chan not in new_list]
if chan_removed and chan_removed[0].startswith("dap."):
chan_removed = chan_removed[0].partition("dap.")[-1]
bec_dispatcher.disconnect_dap_slot(self.redraw_dap, chan_removed)
chan_removed_ep = MessageEndpoints.processed_data(chan_removed)
bec_dispatcher.disconnect_slot(self.redraw_dap, chan_removed_ep)
self._y_channel_list = new_list
@@ -100,7 +113,8 @@ class BECScanPlot(pg.GraphicsView):
if y_chan.startswith("dap."):
y_chan = y_chan.partition("dap.")[-1]
curves = self.dap_curves
bec_dispatcher.connect_dap_slot(self.redraw_dap, y_chan)
y_chan_ep = MessageEndpoints.processed_data(y_chan)
bec_dispatcher.connect_slot(self.redraw_dap, y_chan_ep)
else:
curves = self.scan_curves
@@ -124,7 +138,7 @@ class BECScanPlot(pg.GraphicsView):
if __name__ == "__main__":
import sys
from PyQt5.QtWidgets import QApplication
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
@@ -134,4 +148,4 @@ if __name__ == "__main__":
plot.show()
sys.exit(app.exec_())
sys.exit(app.exec())

View File

@@ -0,0 +1 @@
from .toolbar import ModularToolBar

View File

@@ -0,0 +1,145 @@
from abc import ABC, abstractmethod
# pylint: disable=no-name-in-module
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QToolBar, QStyle, QApplication
from qtpy.QtCore import QTimer
from qtpy.QtGui import QAction
from qtpy.QtWidgets import QWidget
class ToolBarAction(ABC):
"""Abstract base class for action creators for the toolbar."""
@abstractmethod
def create(self, target: QWidget):
"""Creates and returns an action to be added to a toolbar.
This method must be implemented by subclasses.
Args:
target (QWidget): The widget that the action will target.
Returns:
QAction: The action created for the toolbar.
"""
class OpenFileAction: # (ToolBarAction):
"""Action creator for the 'Open File' action in the toolbar."""
def create(self, target: QWidget):
"""Creates an 'Open File' action for the toolbar.
Args:
target (QWidget): The widget that the 'Open File' action will be targeted.
Returns:
QAction: The 'Open File' action created for the toolbar.
"""
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_DialogOpenButton)
action = QAction(icon, "Open File", target)
# action = QAction("Open File", target)
action.triggered.connect(target.open_file)
return action
class SaveFileAction:
"""Action creator for the 'Save File' action in the toolbar."""
def create(self, target):
"""Creates a 'Save File' action for the toolbar.
Args:
target (QWidget): The widget that the 'Save File' action will be targeted.
Returns:
QAction: The 'Save File' action created for the toolbar.
"""
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton)
action = QAction(icon, "Save File", target)
# action = QAction("Save File", target)
action.triggered.connect(target.save_file)
return action
class RunScriptAction:
"""Action creator for the 'Run Script' action in the toolbar."""
def create(self, target):
"""Creates a 'Run Script' action for the toolbar.
Args:
target (QWidget): The widget that the 'Run Script' action will be targeted.
Returns:
QAction: The 'Run Script' action created for the toolbar.
"""
icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)
action = QAction(icon, "Run Script", target)
# action = QAction("Run Script", target)
action.triggered.connect(target.run_script)
return action
class ModularToolBar(QToolBar):
"""Modular toolbar with optional automatic initialization.
Args:
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
auto_init (bool, optional): If True, automatically populates the toolbar based on the parent widget.
"""
def __init__(self, parent=None, auto_init=True):
super().__init__(parent)
self.auto_init = auto_init
self.handler = {
"BECEditor": [OpenFileAction(), SaveFileAction(), RunScriptAction()],
# BECMonitor: [SomeOtherAction(), AnotherAction()], # Example for another widget
}
self.setStyleSheet("QToolBar { background: transparent; }")
# Set the icon size for the toolbar
self.setIconSize(QSize(20, 20))
if self.auto_init:
QTimer.singleShot(0, self.auto_detect_and_populate)
def auto_detect_and_populate(self):
"""Automatically detects the parent widget and populates the toolbar with relevant actions."""
if not self.auto_init:
return
parent_widget = self.parent()
if parent_widget is None:
return
parent_widget_class_name = type(parent_widget).__name__
for widget_type_name, actions in self.handler.items():
if parent_widget_class_name == widget_type_name:
self.populate_toolbar(actions, parent_widget)
return
def populate_toolbar(self, actions, target_widget):
"""Populates the toolbar with a set of actions.
Args:
actions (list[ToolBarAction]): A list of action creators to populate the toolbar.
target_widget (QWidget): The widget that the actions will target.
"""
self.clear()
for action_creator in actions:
action = action_creator.create(target_widget)
self.addAction(action)
def set_manual_actions(self, actions, target_widget):
"""Manually sets the actions for the toolbar.
Args:
actions (list[QAction or ToolBarAction]): A list of actions or action creators to populate the toolbar.
target_widget (QWidget): The widget that the actions will target.
"""
self.clear()
for action in actions:
if isinstance(action, QAction):
self.addAction(action)
elif isinstance(action, ToolBarAction):
self.addAction(action.create(target_widget))

20
docs/Makefile Normal file
View File

@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View File

@@ -0,0 +1,34 @@
{{ fullname | escape | underline}}
.. currentmodule:: {{ module }}
.. autoclass:: {{ objname }}
:members:
:show-inheritance:
:inherited-members:
:special-members: __call__, __add__, __mul__
{% block methods %}
{% if methods %}
.. rubric:: {{ _('Methods') }}
.. autosummary::
:nosignatures:
{% for item in methods %}
{%- if not item.startswith('_') %}
~{{ name }}.{{ item }}
{%- endif -%}
{%- endfor %}
{% endif %}
{% endblock %}
{% block attributes %}
{% if attributes %}
.. rubric:: {{ _('Attributes') }}
.. autosummary::
{% for item in attributes %}
~{{ name }}.{{ item }}
{%- endfor %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,66 @@
{{ fullname | escape | underline}}
.. automodule:: {{ fullname }}
{% block attributes %}
{% if attributes %}
.. rubric:: Module attributes
.. autosummary::
:toctree:
{% for item in attributes %}
{{ item }}
{%- endfor %}
{% endif %}
{% endblock %}
{% block functions %}
{% if functions %}
.. rubric:: {{ _('Functions') }}
.. autosummary::
:toctree:
:nosignatures:
{% for item in functions %}
{{ item }}
{%- endfor %}
{% endif %}
{% endblock %}
{% block classes %}
{% if classes %}
.. rubric:: {{ _('Classes') }}
.. autosummary::
:toctree:
:template: custom-class-template.rst
:nosignatures:
{% for item in classes %}
{{ item }}
{%- endfor %}
{% endif %}
{% endblock %}
{% block exceptions %}
{% if exceptions %}
.. rubric:: {{ _('Exceptions') }}
.. autosummary::
:toctree:
{% for item in exceptions %}
{{ item }}
{%- endfor %}
{% endif %}
{% endblock %}
{% block modules %}
{% if modules %}
.. autosummary::
:toctree:
:template: custom-module-template.rst
:recursive:
{% for item in modules %}
{{ item }}
{%- endfor %}
{% endif %}
{% endblock %}

85
docs/conf.py Normal file
View File

@@ -0,0 +1,85 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import os
import pathlib
project = "BEC Widgets"
copyright = "2023, Paul Scherrer Institute"
author = "Paul Scherrer Institute"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
current_path = pathlib.Path(__file__).parent.parent.resolve()
def get_version():
"""load the version from the version file"""
version_file = os.path.join(current_path, "setup.py")
with open(version_file, "r", encoding="utf-8") as file:
res = file.readline()
while not res.startswith("__version__"):
res = file.readline()
version = res.split("=")[1]
return version.strip().strip('"')
release = get_version()
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
# "sphinx.ext.coverage",
"sphinx.ext.viewcode",
"sphinx.ext.napoleon",
"sphinx_toolbox.collapse",
"sphinx_copybutton",
"myst_parser",
"sphinx_design",
]
myst_enable_extensions = [
"amsmath",
"attrs_inline",
"colon_fence",
"deflist",
"dollarmath",
"fieldlist",
"html_admonition",
"html_image",
"replacements",
"smartquotes",
"strikethrough",
"substitution",
"tasklist",
]
autosummary_generate = True # Turn on sphinx.ext.autosummary
add_module_names = False # Remove namespaces from class/method signatures
autodoc_inherit_docstrings = True # If no docstring, inherit from base class
set_type_checking_flag = True # Enable 'expensive' imports for sphinx_autodoc_typehints
autoclass_content = "both" # Include both class docstring and __init__
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
language = "Python"
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = "pydata_sphinx_theme"
html_static_path = ["_static"]
html_css_files = ["css/custom.css"]
html_logo = "_static/bec.png"
html_theme_options = {
"show_nav_level": 1,
"navbar_align": "content",
}

View File

@@ -0,0 +1,16 @@
(developer)=
# Development
To contribute to the development of BEC Widgets, start by setting up the development environment:
1. **Clone the Repository**:
```bash
git clone https://gitlab.psi.ch/bec/bec-widgets
cd bec-widgets
```
2. **Install in Editable Mode**:
Installing the package in editable mode allows you to make changes to the code and test them in real-time.
```bash
pip install -e .[dev]
```

39
docs/index.md Normal file
View File

@@ -0,0 +1,39 @@
# BEC Widgets documentation
<br><br>
````{grid} 3
:gutter: 5
```{grid-item-card} Introduction
:link: introduction
:link-type: ref
General information.
```
```{grid-item-card} User
:link: user
:link-type: ref
Information for users.
```
```{grid-item-card} Developer
:link: developer
:link-type: ref
Information for developers.
```
````
```{toctree}
---
numbered: true
maxdepth: 1
---
introduction/introduction
user/user
developer/developer

View File

@@ -0,0 +1,18 @@
(introduction)=
# Introduction
## Overview
BEC Widgets is a GUI framework developed with beamline scientists in mind, aiming to provide a modern and modular environment for interacting with experiments. This package offers a suite of widgets specifically designed to enhance the workflow of beamline experiments, including features for running scans and data visualization.
Targeting the unique needs of beamline scientists, BEC Widgets stands out with its modular approach to widget design and high customizability. This flexibility allows for tailored solutions that meet the specific requirements of each beamline.
**Key Features**:
- **Integration:** Seamlessly integrates with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec), ensuring a cohesive and efficient experiment control experience.
- **Support for PyQt5 and PyQt6:** Provides compatibility with both PyQt5 and PyQt6, offering versatility in your development environment.
- **Widget Modularity:** Features modular widgets that can be easily combined to create customized applications, perfectly aligning with the diverse needs of beamline experiments.
## Getting Started
For detailed usage instructions and examples showcasing the practical applications of BEC Widgets, please refer to the [User](#user) section. Developers interested in contributing or customizing BEC Widgets can find more information in the [Developer](#developer) section.

35
docs/make.bat Normal file
View File

@@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

9
docs/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
sphinx
sphinx_copybutton
recommonmark
sphinx-toolbox
pydata-sphinx-theme
sphinx-copybutton
myst-parser
sphinx-design
bec-widgets

39
docs/user/apps.md Normal file
View File

@@ -0,0 +1,39 @@
(user.apps)=
# Applications
In the `bec_widgets/examples` directory, you will find practical applications that demonstrate the capabilities of BEC Widgets in real-world scenarios. These applications showcase the adaptability and functionality of the framework for various beamline experiment needs.
**Motor Alignment Tool**
This tool assists in aligning motors with samples during experiments. It enables users to move motors, visually track their movement, and record positions for precise alignment.
- **Location:** `bec_widgets/examples/motor_movement`
- **Further Details:** [Motor Alignment Tool Documentation](#user.apps.motor_app)
**General Plotting Live Acquisition Tool**
This application is designed for live data visualization. It allows users to view real-time signals from detectors in a multi-grid layout, facilitating immediate analysis during experiments.
- **Location:** `bec_widgets/examples/plot_app`
- **Further Details:** [General Plotting Live Acquisition Tool Documentation](#user.apps.plot_app)
**Modular Application**
A bespoke application built entirely using BEC Widgets' modular components. This example illustrates the framework's flexibility in creating customized GUIs tailored to specific experimental setups.
- **Location:** `bec_widgets/examples/modular_app`
- **Further Details:** [Modular Application](#user.apps.modular_app)
---
Note: The documentation for these applications is currently under development. The provided links will direct you to their respective pages once the documentation is complete.
```{toctree}
---
maxdepth: 1
hidden: true
---
apps/motor_app
apps/plot_app
apps/modular_app

View File

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

View File

@@ -0,0 +1,4 @@
(user.apps.motor_app)=
# Motor Alignment
_to be added..._

View File

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

View File

@@ -0,0 +1,13 @@
(user.customisation)=
# Customisation
BEC Widgets are designed to be used with QtDesigner to quicly design GUI.
## Example of promoting widgets in Qt Designer
_Work in progress_
## Implementation of plugins into Qt Designer
_Work in progress_

46
docs/user/installation.md Normal file
View File

@@ -0,0 +1,46 @@
(user.installation)=
# Installation
**Prerequisites**
Before installing BEC Widgets, please ensure the following requirements are met:
1. **Python Version:** BEC Widgets requires Python version 3.9 or higher. Verify your Python version to ensure compatibility.
2. **BEC Installation:** BEC Widgets works in conjunction with BEC. While BEC is a dependency and will be installed automatically, you can find more information about BEC and its installation process in the [BEC documentation](https://beamline-experiment-control.readthedocs.io/en/latest/).
**Standard Installation**
Install BEC Widgets using the pip package manager. Open your terminal and execute:
```bash
pip install bec-widgets
```
This command installs BEC Widgets along with its dependencies, including the default PyQt6.
**Selecting a PyQt Version**
BEC Widgets supports both PyQt5 and PyQt6. To install a specific version, use:
For PyQt6:
```bash
pip install bec-widgets[pyqt6]
```
For PyQt5:
```bash
pip install bec-widgets[pyqt5]
```
**Troubleshooting**
If you encounter issues during installation, particularly with PyQt, try purging the pip cache:
```bash
pip cache purge
```
This can resolve conflicts or issues with package installations.

38
docs/user/user.md Normal file
View File

@@ -0,0 +1,38 @@
(user)=
# User
**Overview**
Welcome to the User section of the BEC Widgets documentation! BEC Widgets is a versatile GUI framework tailored for beamline scientists, enabling efficient and intuitive interaction with beamline experiments. This section is designed to guide both new and experienced users through the essential aspects of utilizing BEC Widgets.
**Key Topics**
- [Installing BEC Widgets](#user.installation): Instructions for installing BEC Widgets on your system.
- [Example Applications](#user.apps): Overview of bespoke applications and demonstrations of BEC Widgets in action, showcasing its use in real-world beamline scenarios.
- [Widgets Overview](#user.widgets): Detailed information on the variety of widgets available, their functions, and how to use them effectively.
- [Customization and Configuration](#user.customisation): Tips on customizing and configuring BEC Widgets to suit your specific experimental needs using Qt Designer.
**Bug Reports and Feature Requests**
We value your feedback and contributions to improving BEC Widgets. If you encounter any issues or have ideas for new features, we encourage you to report them.
- **Bug Reports:** If you find a bug or an issue, please report it on our repository's [Issues page](https://gitlab.psi.ch/bec/bec-widgets/-/issues?sort=created_date&state=opened). We have a template for bug reporting to help you provide all necessary information.
- **Feature Requests:** Have an idea for a new feature or an enhancement? Share it with us on the [Issues page](https://gitlab.psi.ch/bec/bec-widgets/-/issues?sort=created_date&state=opened) of our repository. We have a feature request template that you can use to describe your proposal.
**Development**
For advanced details about BEC Widgets internal architecture, development contributions, or customization techniques, please explore the [Developer](#developer) section.
```{toctree}
---
maxdepth: 3
hidden: true
---
installation
apps
widgets
customisation

6
docs/user/widgets.md Normal file
View File

@@ -0,0 +1,6 @@
(user.widgets)=
# Widgets
Guide to use widgets.
_work in progress_

View File

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

View File

@@ -1,10 +1,40 @@
# pylint: disable= missing-module-docstring
from setuptools import setup
__version__ = "0.8.0"
__version__ = "0.37.0"
# Default to PyQt6 if no other Qt binding is installed
QT_DEPENDENCY = "PyQt6>=6.0"
QSCINTILLA_DEPENDENCY = "PyQt6-QScintilla"
# pylint: disable=unused-import
try:
import PyQt5
except ImportError:
pass
else:
QT_DEPENDENCY = "PyQt5>=5.9"
QSCINTILLA_DEPENDENCY = "QScintilla"
if __name__ == "__main__":
setup(
install_requires=["pyqt5", "pyqtgraph", "bec_lib"],
extras_require={"dev": ["pytest", "pytest-random-order", "coverage", "pytest-qt", "black"]},
install_requires=[
"pydantic",
"qtconsole",
QT_DEPENDENCY,
QSCINTILLA_DEPENDENCY,
"jedi",
"qtpy",
"pyqtgraph",
"bec_lib",
"zmq",
"h5py",
"pyqtdarktheme",
],
extras_require={
"dev": ["pytest", "pytest-random-order", "coverage", "pytest-qt", "black"],
"pyqt5": ["PyQt5>=5.9"],
"pyqt6": ["PyQt6>=6.0"],
},
version=__version__,
)

Some files were not shown because too many files have changed in this diff Show More