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

Compare commits

..

185 Commits

Author SHA1 Message Date
semantic-release
06d0331dee 0.44.4
Automatically generated by python-semantic-release
2024-03-22 15:04:58 +00:00
e6b065767c fix(cli/server): thread heartbeat replaced with QTimer 2024-03-22 15:59:53 +01:00
f3a96dedd7 fix(cli/server): removed BECFigure.start(), the QApplication event loop is started by server.py 2024-03-21 15:51:30 +01:00
semantic-release
016324e71c 0.44.3
Automatically generated by python-semantic-release
2024-03-21 13:12:40 +00:00
a92aead769 fix(cli): don't call user script if gui is not alive 2024-03-20 22:11:09 +01:00
882cf55fc5 fix(cli): added gui heartbeat 2024-03-20 22:02:02 +01:00
semantic-release
ee02c13d5d 0.44.2
Automatically generated by python-semantic-release
2024-03-20 11:50:29 +00:00
wyzula-jan
9ccd4ea235 fix(utils/bec_dispatcher): try/except to start client, to avoid crash when redis is not running 2024-03-20 12:06:22 +01:00
wyzula-jan
86416d50cb fix(utils/bec_dispatcher): bec_dispatcher adjusted to the new BECClient; dropped support to inject bec_config.yaml, instead BECClient can be passed as arg 2024-03-20 11:12:16 +01:00
1d5442ac08 ci: now testing against master branches of bec_lib and ophyd_devices 2024-03-20 11:11:49 +01:00
semantic-release
f3c7196921 0.44.1
Automatically generated by python-semantic-release
2024-03-19 09:05:40 +00:00
wyzula-jan
14f901f1be fix(examples/motor_compilation): motor_control_compilations.py do not have any hardcoded config anymore 2024-03-18 18:18:48 +01:00
semantic-release
9f93c01ff7 0.44.0
Automatically generated by python-semantic-release
2024-03-18 08:05:04 +00:00
203ae09606 fix(cli): removed hard-coded signal 2024-03-18 07:43:32 +01:00
2d39c5e4d1 fix(cli): fixed cleanup procedure 2024-03-18 07:43:32 +01:00
9049e0d27f feat(cli): added update script to BECFigure 2024-03-18 07:43:32 +01:00
semantic-release
d5d41fc759 0.43.2
Automatically generated by python-semantic-release
2024-03-18 06:38:10 +00:00
wyzula-jan
d0f9bf1733 fix(cli/server): added QApplications to enter separate QT event loop ensuring that QT objects are not deleted 2024-03-17 18:22:21 +01:00
semantic-release
7d46d1160d 0.43.1
Automatically generated by python-semantic-release
2024-03-15 16:22:33 +00:00
wyzula-jan
b8d4e697ac fix(plots/image): same access pattern for image and image_item for setting up parameters, autorange of z scale disabled by default 2024-03-15 16:44:32 +01:00
wyzula-jan
4664661cfb fix(widget/various): corrected USER_ACCESS methods for children widgets to include inherited methods to RPC 2024-03-15 16:44:32 +01:00
wyzula-jan
d99fd76c0b refactor(widget/figure): changed add_plot and add_image to specify what should be content of the widget, instead of widget id 2024-03-15 16:44:32 +01:00
wyzula-jan
fcf918c488 fix(widgets/figure): added widgets can be accessed as a list (fig.axes) or as a dictionary (fig.widgets) 2024-03-15 16:44:32 +01:00
wyzula-jan
32747baa27 refactor(cli): commented debug CLI messages 2024-03-14 15:17:11 +01:00
semantic-release
9e974eda27 0.43.0
Automatically generated by python-semantic-release
2024-03-14 13:54:29 +00:00
wyzula-jan
598479bb55 fix(plots/waveform1d): curves_data access disabled 2024-03-14 14:42:41 +01:00
wyzula-jan
4ef6ae90f2 fix(cli): find_widget_by_id for BECImageShow changed to be compatible with RPC logic 2024-03-14 14:42:41 +01:00
wyzula-jan
4865b10ced feat(plots/image): image processor can run in threaded or non-threaded version 2024-03-14 14:42:41 +01:00
wyzula-jan
3362fabed7 fix(plots/image): access pattern for ImageItems in BECImageShow 2024-03-14 14:42:41 +01:00
wyzula-jan
4076698530 fix(cli): fix cli connector.send to set_and_publish for gui_instruction_response 2024-03-14 14:42:41 +01:00
wyzula-jan
a21bfec3d9 refactor(plots/image): image logic moved to BECImageItem, image updated from bec_dispatcher with register_stream fetching data from dispatcher 2024-03-14 14:42:41 +01:00
wyzula-jan
7ffedd9ceb feat(plots/image): change stream processor to QThread with connector.get_last; cleanup method for BECFigure to kill all threads if App is closed during acquisition 2024-03-14 14:39:44 +01:00
wyzula-jan
9ad0055336 feat(plots/image): basic image visualisation, getting data are based on stream_connector (deprecated) 2024-03-14 14:39:44 +01:00
wyzula-jan
70c4e9bc5e refactor(plots/plot_base): BECPlotBase inherits from pg.GraphicalLayout instead of pg.PlotItem, this will allow us to add multiple plots into each coordinate of BECFigure. 2024-03-14 14:35:26 +01:00
semantic-release
43770e2967 0.42.1
Automatically generated by python-semantic-release
2024-03-10 18:33:42 +00:00
wyzula-jan
f3b3c2f526 fix(various): repo cleanup, removed - [plot_app, one_plot, scan_plot, scan2d_plot, crosshair_example, qtplugins], tests adjusted 2024-03-10 19:27:43 +01:00
semantic-release
279ac03dc3 0.42.0
Automatically generated by python-semantic-release
2024-03-07 19:33:46 +00:00
4c0a7bbec7 feat(utils/bec_dispatcher): BECDispatcher can register redis stream 2024-03-07 20:31:24 +01:00
semantic-release
f5f9158779 0.41.4
Automatically generated by python-semantic-release
2024-03-07 12:49:09 +00:00
c319dacb24 fix(utils/bec_dispatcher): BECDispatcher can accept new EndpointInfo dataclass. 2024-03-07 13:46:51 +01:00
814768525f ci: drop python/3.9 2024-03-05 12:46:49 +01:00
semantic-release
38d056570f 0.41.3
Automatically generated by python-semantic-release
2024-03-01 12:23:03 +00:00
wyzula-jan
f386563aa1 fix(cli/generate_cli): typing.get_overloads are only used if the python version is higher than 3.11 2024-03-01 12:35:12 +01:00
wyzula-jan
110506c9a9 test(cli/generate_cli): import from future 2024-02-29 15:17:20 +01:00
wyzula-jan
7e0058a611 test(cli/generate_cli): test added 2024-02-29 14:58:58 +01:00
wyzula-jan
d89f596a5d fix(cli/generate_cli): added automatic black formatting; added black as a dependency 2024-02-29 14:08:00 +01:00
semantic-release
5de2dfefcb 0.41.2
Automatically generated by python-semantic-release
2024-02-28 06:09:16 +00:00
wyzula-jan
bb1f066c3c fix(utils/bec_dispatcher): msg is unp[acked from dict before accessing .content 2024-02-27 16:46:19 +01:00
semantic-release
44b451e66b 0.41.1
Automatically generated by python-semantic-release
2024-02-26 20:04:48 +00:00
a2ed2ebe00 fix(bec_dispatcher): handle redis connection errors more gracefully 2024-02-26 20:58:46 +01:00
8127fc2960 fix(bec_dispatcher): adapt code to redis connector refactoring 2024-02-26 19:26:15 +01:00
semantic-release
6171790f66 0.41.0
Automatically generated by python-semantic-release
2024-02-26 14:40:20 +00:00
wyzula-jan
ebb36f62dd fix(cli/client_utils): "__rpc__" pop from msg_results 2024-02-26 15:30:43 +01:00
wyzula-jan
644f1031f6 fix(tests): BECDispatcher fixture putted back 2024-02-26 14:27:22 +01:00
wyzula-jan
fd711b475f fix(cli/rpc): rpc client can return any type of object + config dict of the widgets 2024-02-26 14:06:36 +01:00
wyzula-jan
57132a4721 fix(cli/rpc): server access children widget.find_widget_by_id(gui_id) 2024-02-26 13:26:55 +01:00
f71dc5c5ab fix(cli): fixed property access, rebased 2024-02-26 10:29:15 +01:00
4630d78fc2 fix(rpc_server): fixed gui_id lookup 2024-02-26 10:25:02 +01:00
da640e888d fix(cli): fixed rpc construction of nested widgets 2024-02-26 10:25:02 +01:00
wyzula-jan
35cd4fd6f1 fix(plots/waveform1d): pandas import clean up, export curves with none skipped 2024-02-25 18:06:33 +01:00
wyzula-jan
f06e652b82 test(plots/waveform1d): tests added 2024-02-25 17:52:11 +01:00
wyzula-jan
5fc8047c8f feat(widgets/waveform1d): data can be exported from rendered curve 2024-02-25 12:52:36 +01:00
wyzula-jan
0363fd5194 feat(widgets/figure): clear_all method for BECFigure 2024-02-23 15:27:09 +01:00
wyzula-jan
826a5e9874 test(test_plot_base): BECPlotBase tests added 2024-02-23 13:37:25 +01:00
wyzula-jan
f668eb8b9b test(test_bec_figure): tests for BECFigure added 2024-02-23 13:06:18 +01:00
wyzula-jan
5964778a64 refactor(widgets/BECCurve): set kwargs for curve style while adding curve 2024-02-23 11:05:01 +01:00
wyzula-jan
8135f68230 test(tests/test_bec_connector): test_bec_connector.py added 2024-02-23 10:53:10 +01:00
wyzula-jan
24c77376b2 fix(widgets/plots): added placeholder for cleanup method to BECPlotBase 2024-02-23 10:53:10 +01:00
wyzula-jan
f364afcb42 refactor(widgets/figure: fixed wrong references to debug jupyter console 2024-02-23 10:53:10 +01:00
wyzula-jan
4051902f09 test(tests/client_mocks): added general mock_client with container for fake devices for testing 2024-02-23 10:53:10 +01:00
wyzula-jan
a28b9c8981 fix(widget/figure): add cleanup method to disconnect all slots before removing Waveform1D from layout 2024-02-23 10:53:10 +01:00
wyzula-jan
9a5c86ea35 feat(widgets/Waveform1D): Waveform1D can be fully constructed by config 2024-02-23 10:53:10 +01:00
wyzula-jan
08534a4739 feat(widgets/figure.py): dark/light theme changer 2024-02-23 10:53:10 +01:00
wyzula-jan
1db77b969b feat(utils/entry_validator): possibility to validate add_scan_curve with current BEC session 2024-02-23 10:53:10 +01:00
wyzula-jan
99dce077c4 refactor(plot/Waveform1D,plot/BECCurve): BECCurve inherits from BECConnector and can refer to parent_id (Waveform1D) and has its own gui_id 2024-02-23 10:53:10 +01:00
wyzula-jan
402adc44e8 refactor(rpc/client): changed path to client.py to relative one 2024-02-23 10:53:10 +01:00
wyzula-jan
c6bdf0b6a5 fix(rpc): added annotations to pass py3.9 tests 2024-02-23 10:53:10 +01:00
wyzula-jan
1c2fb8b972 fix(rpc): connection to on_rpc_update done through bec_dispatcher 2024-02-23 10:53:10 +01:00
a61bf36df5 feat(cli): added cli interface, rebased 2024-02-23 10:53:10 +01:00
wyzula-jan
d678a85957 fix: after removing plot from BECFigure, the coordinates are correctly resigned 2024-02-23 10:53:10 +01:00
wyzula-jan
684592ae37 feat: curve can be modified after adding to the plot 2024-02-23 10:53:10 +01:00
wyzula-jan
f0ed243c91 feat: waveform1d.py curves can be removed by identifier by order(int) or by curve_id(str) 2024-02-23 10:53:10 +01:00
wyzula-jan
cba3863e5a feat: waveform1d.py curves can be stylised; access scan history by index or scanID 2024-02-23 10:53:10 +01:00
wyzula-jan
1d26b23221 feat: start method for BECFigure, jupyter console .ui added to git 2024-02-23 10:53:10 +01:00
wyzula-jan
b827e9eaa7 feat: added @user_access from bec_lib.utils 2024-02-23 10:53:10 +01:00
wyzula-jan
60d150a411 feat: plot can be removed from BECFigure 2024-02-23 10:53:10 +01:00
wyzula-jan
c781b1b4e4 feat: figure.py create widget factory 2024-02-23 10:53:10 +01:00
wyzula-jan
565e475ace feat: waveform1d.py draft 2024-02-23 10:53:10 +01:00
wyzula-jan
7c15d75011 fix: removed DI references, fixed set when adding plot by fig 2024-02-23 10:53:10 +01:00
wyzula-jan
b676877242 feat: rpc decorator to add methods to USER_ACCESS 2024-02-23 10:53:10 +01:00
wyzula-jan
7768e594b5 refactor: BECFigure, BECPlotBase changed back to pyqtgraph classes inheritance 2024-02-23 10:53:10 +01:00
wyzula-jan
9ef331c272 feat: BECFigure and BECPlotBase created 2024-02-23 10:53:10 +01:00
wyzula-jan
4a1792c209 refactor: BECConnector changed config structure 2024-02-23 10:53:10 +01:00
wyzula-jan
91447a2d62 feat: BECConnector -> mixin class for all BEC Widget to hook them to BEC client 2024-02-23 10:53:10 +01:00
semantic-release
ed5bdd99e6 0.40.1
Automatically generated by python-semantic-release
2024-02-23 09:51:25 +00:00
wyzula-jan
feca7a3dcd fix(utils/bec_dispatcher): _do_disconnect_slot will shutdown consumer of slots/signals which were already disconnected 2024-02-22 13:35:57 +01:00
semantic-release
2d9020358d 0.40.0
Automatically generated by python-semantic-release
2024-02-16 20:51:02 +00:00
wyzula-jan
51259097fa feat(utils.colors): golden_angle_color utility can return colors as a list of QColor, RGB or HEC 2024-02-16 20:16:19 +01:00
semantic-release
8a4aeb8dfe 0.39.0
Automatically generated by python-semantic-release
2024-02-12 13:01:47 +00:00
wyzula-jan
4b0542a513 refactor: pylint ignore for tests 2024-02-12 13:53:52 +01:00
wyzula-jan
bf04a4e04a test: motor_control_compilations.py and motor_control.py tests added 2024-02-12 13:53:52 +01:00
wyzula-jan
fa4ca935bb feat: added full app with all motor movement related widgets into motor_control_compilations.py 2024-02-12 13:53:52 +01:00
wyzula-jan
b52e22d81f refactor: motor_control.py clean up 2024-02-12 13:53:52 +01:00
wyzula-jan
2f96e10b9d feat: MotorCoordinateTable mode_switch added for "Individual" and "Start/Stop" modes 2024-02-12 13:53:52 +01:00
wyzula-jan
031cb094e7 feat: motor_control.py MotorCoordinateTable added basic version to store coordinates and show them in motor_map.py 2024-02-12 13:53:52 +01:00
wyzula-jan
8afc5f0c0c refactor: motor_control_compilations.py moved to example part of repository 2024-02-12 13:53:52 +01:00
wyzula-jan
17f14581d7 feat: active motors from motor_map.py can be changed by slot without changing the whole config 2024-02-12 13:53:52 +01:00
wyzula-jan
8361736679 feat: control panels compilations 2024-02-12 13:53:52 +01:00
wyzula-jan
0b9927fcf5 feat: comboboxes of motor selection are changed to orange if the motors are not connected yet 2024-02-12 13:53:52 +01:00
wyzula-jan
8139e271de refactor: base class for motor_control.py widgets 2024-02-12 13:53:52 +01:00
wyzula-jan
6fe08e6b82 feat: motor_control.py MotorControl widgets - Absolute + Relative movement, MotorSelection, ErrorMessage popups 2024-02-12 13:53:52 +01:00
wyzula-jan
968da6f558 build: added all .ui and .yaml files to pypi install; removed gauss_bpm from default config from monitor.py 2024-02-08 10:59:48 +01:00
semantic-release
11ae0b1054 0.38.2
Automatically generated by python-semantic-release
2024-02-07 16:26:49 +00:00
5ebfd2a3c2 test: fixed import in test_validator_errors.py 2024-02-07 17:23:03 +01:00
b36131eed5 fix: adapt code to BEC 1.0 2024-02-07 17:16:43 +01:00
semantic-release
a7bfcc12b9 0.38.1
Automatically generated by python-semantic-release
2024-01-26 15:45:41 +00:00
wyzula-jan
ab275b8e5f fix: monitor.py replots last scan after changing config with new signals; config_dialog.py checks if the new config is valid with BEC 2024-01-26 16:42:08 +01:00
wyzula-jan
d211b47f4c refactor: black v24 formatting 2024-01-26 15:17:59 +01:00
wyzula-jan
812ffaf8ea docs: 2D waveform scatter plot changed to 2D scatter plot 2024-01-24 10:50:51 +01:00
wyzula-jan
f7a496723c docs: documentation for example apps and widgets updated 2024-01-24 10:36:50 +01:00
semantic-release
48847a19c7 0.38.0
Automatically generated by python-semantic-release
2024-01-23 13:48:08 +00:00
wyzula-jan
8d0083c4aa test: fix test_bec_monitor_scatter2D.py database init test change to check defaultdict 2024-01-23 14:42:11 +01:00
wyzula-jan
3c143274c5 refactor: monitor_scatter_2D.py _init_database replaced with defaultdict 2024-01-23 14:31:20 +01:00
wyzula-jan
747e97e0c9 fix: monitor_scatter_2D.py changed to new BECDispatcher definition 2024-01-23 13:51:23 +01:00
wyzula-jan
c6fe9d2026 test: test_bec_monitor_scatter2D.py added 2024-01-23 13:51:23 +01:00
wyzula-jan
75090b8575 feat: BECMonitor2DScatter for plotting x/y/z signal as a mesh of scatter plot 2024-01-23 13:51:23 +01:00
semantic-release
8f76c789cf 0.37.1
Automatically generated by python-semantic-release
2024-01-23 12:42:02 +00:00
4664568672 fix(tests): ensure BEC service is shutdown after bec dispatcher test 2024-01-20 23:04:51 +01:00
3fb6644543 fix(tests): ensure threads started during plot tests are properly stopped 2024-01-20 23:01:41 +01:00
d909673071 refactor(tests): ensure BEC dispatcher singleton object is renewed at each test
and add a check for dangling threads
2024-01-19 19:40:21 +01:00
semantic-release
d281d6576c 0.37.0
Automatically generated by python-semantic-release
2024-01-17 14:09:10 +00:00
wyzula-jan
8bebc4f692 refactor: pylint improvement 2024-01-17 14:59:53 +01:00
wyzula-jan
1cd273c375 test: test_motor_map.py added 2024-01-17 14:59:53 +01:00
wyzula-jan
249170ea30 refactor: motor_map.py clean up 2024-01-17 14:59:53 +01:00
wyzula-jan
1a429b3024 feat: independent motor_map widget 2024-01-17 14:59:53 +01:00
semantic-release
e05cab812a 0.36.2
Automatically generated by python-semantic-release
2024-01-17 13:56:53 +00:00
wyzula-jan
7607d7a3b6 fix: bec_dispatcher.py can partially disconnect topics from slot 2024-01-16 16:02:31 +01:00
wyzula-jan
e51be04b95 fix: bec_dispatcher.py can connect multiple topics to one callback slot 2024-01-16 16:02:31 +01:00
semantic-release
de1f5c968a 0.36.1
Automatically generated by python-semantic-release
2024-01-15 16:04:22 +00:00
wyzula-jan
bf819bcf48 refactor: motor_example.py get coordinates by .readback.get() method 2024-01-15 16:14:15 +01:00
wyzula-jan
6f26e5cc3d refactor: using motor.readback.read() to access motor coordinates 2024-01-12 16:44:55 +01:00
wyzula-jan
f9c5c82381 fix: motor_example.py fix to the new .read() structure from bec_lib 2024-01-12 16:44:55 +01:00
semantic-release
79487dbec2 0.36.0
Automatically generated by python-semantic-release
2024-01-12 13:23:34 +00:00
58721bea1a feat: bec_dispatcher can link multiple endpoints topics for one qt slot 2024-01-12 14:22:29 +01:00
semantic-release
03e96669da 0.35.0
Automatically generated by python-semantic-release
2024-01-12 09:33:49 +00:00
wyzula-jan
eb529d24d2 refactor: review response for MR !31 2024-01-11 16:58:53 +01:00
wyzula-jan
ebd4fccda2 fix: monitor.py clear command from BECPlotter CLI clear now flush database and clear the plots 2024-01-10 17:59:36 +01:00
wyzula-jan
97dcc5ac76 fix: monitor.py crosshair enabled by default 2024-01-09 12:51:22 +01:00
wyzula-jan
9c7a189beb ci: fix cobertura for gitlab/16 2024-01-09 12:45:40 +01:00
wyzula-jan
6061b3150e fix: monitor.py change import of ConfigDialog from relative to absolute in order to make BECPlotter be able to open it 2024-01-09 12:32:45 +01:00
wyzula-jan
3982c5d498 refactor: config_dialog.py refactored to accept new config formatting 2024-01-08 16:31:56 +01:00
wyzula-jan
404ca49821 refactor: modular_app.py configs changed to new format 2024-01-08 16:17:14 +01:00
wyzula-jan
6e4775a124 feat: monitor.py can access custom data send through redis 2024-01-08 15:23:39 +01:00
wyzula-jan
5ab82bc133 fix: monitor_config_validator.py changed to check .describe() instead of signals 2023-12-21 16:59:02 +01:00
wyzula-jan
00ef3ae925 fix: monitor.py fixed not updating config changes after receiving refresh from BECPlotter 2023-12-18 15:42:10 +01:00
wyzula-jan
90d8069cc3 test: test_validator_errors.py fixed 2023-12-15 19:05:38 +01:00
wyzula-jan
457567ef74 test: test_bec_monitor.py fixed 2023-12-15 18:28:29 +01:00
wyzula-jan
1128ca5252 refactor: monitor.py clean up 2023-12-15 13:49:08 +01:00
wyzula-jan
86c5f25205 fix: monitor_config_validator.py valid color is Literal['black','white'] 2023-12-15 13:19:16 +01:00
wyzula-jan
a706da2490 fix: monitor.py fixed scan mode 2023-12-15 13:12:05 +01:00
wyzula-jan
d67bdd2616 fix: motor_config_validation changed to new monitor config structure 2023-12-14 14:01:28 +01:00
wyzula-jan
c3f2ad45c3 refactor: monitor.py data for scan segment are only accessed through queue.scan_storage 2023-12-13 10:30:58 +01:00
wyzula-jan
26c07c3205 feat: monitor.py access data directly from scan storage 2023-12-13 09:37:45 +01:00
wyzula-jan
c995e0d235 refactor: monitor.py config hierarchy refactor for source (can be 'scan_segment','history', 'redis') 2023-12-13 09:37:45 +01:00
wyzula-jan
463a60a99c refactor: monitor.py on_scan_segment old logic separated from on_scan_segment function 2023-12-13 09:37:21 +01:00
semantic-release
98a46a85b2 0.34.1
Automatically generated by python-semantic-release
2023-12-12 19:20:19 +00:00
wyzula-jan
186c42d667 fix: formatter and tests fixed 2023-12-12 18:24:38 +01:00
wyzula-jan
f3a47a5b08 refactor: repo reorganisation 2023-12-12 17:26:28 +01:00
wyzula-jan
af995a74f3 docs: readdocs updated 2023-12-12 17:26:28 +01:00
wyzula-jan
3abd955465 refactor: repo reorganization 2023-12-12 17:26:22 +01:00
wyzula-jan
cba8131367 docs: readme.md updated 2023-12-12 17:25:25 +01:00
wyzula-jan
831eddc136 docs: gitlab templates for issues and merge requests from main bec repo 2023-12-12 17:25:25 +01:00
9e852d1afc refactor: replace deprecated imports from typing
https://peps.python.org/pep-0585/#implementation
2023-12-12 15:22:59 +01:00
3ec9caae09 build: fix python requirement 2023-12-12 15:14:18 +01:00
11281fef53 ci: added rtd update job 2023-12-11 19:22:01 +01:00
semantic-release
9d497b70bf 0.34.0
Automatically generated by python-semantic-release
2023-12-08 09:57:26 +00:00
wyzula-jan
2a334156a8 test: validation errors tests 2023-12-07 19:32:21 +01:00
wyzula-jan
086804780d fix: monitor_config_validator.py - Signal validation changed from field_validator to model_validator to check first name and then entry 2023-12-07 19:32:21 +01:00
wyzula-jan
731fba55ec refactor: monitor.py pylint improvement 2023-12-07 19:32:21 +01:00
wyzula-jan
a3b24f9242 feat: monitor.py error message popup 2023-12-07 19:32:20 +01:00
wyzula-jan
af71e35e73 fix: monitor_config_validator.py fix entry validation executed only if name validator is successful 2023-12-07 19:20:26 +01:00
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
128 changed files with 13324 additions and 3789 deletions

View File

@@ -1,10 +1,12 @@
# This file is a template, and might need editing before it works on your project.
# Official language image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/python/tags/
image: $CI_DOCKER_REGISTRY/python:3.9
image: $CI_DOCKER_REGISTRY/python:3.10
#commands to run in the Docker container before starting each job.
variables:
DOCKER_TLS_CERTDIR: ""
BEC_CORE_BRANCH: "master"
OPHYD_DEVICES_BRANCH: "master"
include:
- template: Security/Secret-Detection.gitlab-ci.yml
@@ -77,9 +79,13 @@ tests:
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install .[dev]
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests
- coverage report
- coverage xml
@@ -87,25 +93,20 @@ 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
#tests-3.10-pyqt5: #todo enable when we decide what qt distributions we want to support
# extends: "tests"
# stage: AdditionalTests
# image: $CI_DOCKER_REGISTRY/python:3.9
# image: $CI_DOCKER_REGISTRY/python:3.10
# 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
@@ -139,25 +140,23 @@ semver:
- export REPOSITORY_USERNAME=__token__
- export REPOSITORY_PASSWORD=$CI_PYPI_TOKEN
- >
semantic-release publish -v DEBUG
-D version_variable=./setup.py:__version__
-D hvcs=gitlab
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.

View File

@@ -52,7 +52,7 @@ persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.9
py-version=3.10
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.

View File

@@ -9,7 +9,7 @@ version: 2
build:
os: ubuntu-20.04
tools:
python: "3.9"
python: "3.10"
# Build documentation in the docs/ directory with Sphinx
sphinx:

View File

@@ -2,6 +2,285 @@
<!--next-version-placeholder-->
## v0.44.4 (2024-03-22)
### Fix
* **cli/server:** Thread heartbeat replaced with QTimer ([`e6b0657`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e6b065767c8605aaef6ed6032ba893d3900b552c))
* **cli/server:** Removed BECFigure.start(), the QApplication event loop is started by server.py ([`f3a96de`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f3a96dedd7ba49f9a1b713f6a5565f2b3dbb141e))
## v0.44.3 (2024-03-21)
### Fix
* **cli:** Don't call user script if gui is not alive ([`a92aead`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a92aead7698fa98d6f7f582d030845d0b940ea2d))
* **cli:** Added gui heartbeat ([`882cf55`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/882cf55fc5266a2cfb610702e834badff3ad0428))
## v0.44.2 (2024-03-20)
### Fix
* **utils/bec_dispatcher:** Try/except to start client, to avoid crash when redis is not running ([`9ccd4ea`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9ccd4ea235be4c4332045b7a7f09d6cc6291f7ff))
* **utils/bec_dispatcher:** Bec_dispatcher adjusted to the new BECClient; dropped support to inject bec_config.yaml, instead BECClient can be passed as arg ([`86416d5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/86416d50cb850b42d312fe17fc46f0b4743dc940))
## v0.44.1 (2024-03-19)
### Fix
* **examples/motor_compilation:** Motor_control_compilations.py do not have any hardcoded config anymore ([`14f901f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/14f901f1bea2ba7b79903c4743e37384e11533d3))
## v0.44.0 (2024-03-18)
### Feature
* **cli:** Added update script to BECFigure ([`9049e0d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9049e0d27fe9a3860e21ffc3b350eb69e567b71c))
### Fix
* **cli:** Removed hard-coded signal ([`203ae09`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/203ae0960688608fb609a742a23e5994bfe9805c))
* **cli:** Fixed cleanup procedure ([`2d39c5e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2d39c5e4d18bbb66a5f3340fce7f8944dd4ba19f))
## v0.43.2 (2024-03-18)
### Fix
* **cli/server:** Added QApplications to enter separate QT event loop ensuring that QT objects are not deleted ([`d0f9bf1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d0f9bf17339296a60301e5e6ffe602db369c6c7c))
## v0.43.1 (2024-03-15)
### Fix
* **plots/image:** Same access pattern for image and image_item for setting up parameters, autorange of z scale disabled by default ([`b8d4e69`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b8d4e697ac2a5929a1374ce1778046efc3f8187a))
* **widget/various:** Corrected USER_ACCESS methods for children widgets to include inherited methods to RPC ([`4664661`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4664661cfb4e8bd4a6adb71f2050b25d0b4f3d36))
* **widgets/figure:** Added widgets can be accessed as a list (fig.axes) or as a dictionary (fig.widgets) ([`fcf918c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fcf918c48862d069b9fe69cbba7dbecbe7429790))
## v0.43.0 (2024-03-14)
### Feature
* **plots/image:** Image processor can run in threaded or non-threaded version ([`4865b10`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4865b10ced6b321974e7b4b4db12786fe21fd916))
* **plots/image:** Change stream processor to QThread with connector.get_last; cleanup method for BECFigure to kill all threads if App is closed during acquisition ([`7ffedd9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7ffedd9cebb382fc22f24a6b0b46823db6378d89))
* **plots/image:** Basic image visualisation, getting data are based on stream_connector (deprecated) ([`9ad0055`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9ad0055336dba50886504a616db6f9f63b23beb3))
### Fix
* **plots/waveform1d:** Curves_data access disabled ([`598479b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/598479bb555a6cd077d5a137052d91314e5af6b7))
* **cli:** Find_widget_by_id for BECImageShow changed to be compatible with RPC logic ([`4ef6ae9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4ef6ae90f2afd5e2442465c11ce5165517cd4218))
* **plots/image:** Access pattern for ImageItems in BECImageShow ([`3362fab`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3362fabed7ccd611b35f524c1970aeefbf3a9faf))
* **cli:** Fix cli connector.send to set_and_publish for gui_instruction_response ([`4076698`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/407669853097b40e6fba7d43da001f083140ad74))
## v0.42.1 (2024-03-10)
### Fix
* **various:** Repo cleanup, removed - [plot_app, one_plot, scan_plot, scan2d_plot, crosshair_example, qtplugins], tests adjusted ([`f3b3c2f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f3b3c2f526d66687b3cc596a5877921953dd0803))
## v0.42.0 (2024-03-07)
### Feature
* **utils/bec_dispatcher:** BECDispatcher can register redis stream ([`4c0a7bb`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4c0a7bbec7abafc7d04a8aaf10dabd7e668fa908))
## v0.41.4 (2024-03-07)
### Fix
* **utils/bec_dispatcher:** BECDispatcher can accept new EndpointInfo dataclass. ([`c319dac`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c319dacb24e64930af258a81484feeadcb1bc341))
## v0.41.3 (2024-03-01)
### Fix
* **cli/generate_cli:** Typing.get_overloads are only used if the python version is higher than 3.11 ([`f386563`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f386563aa162eaca9202af16574860bf3eb5a092))
* **cli/generate_cli:** Added automatic black formatting; added black as a dependency ([`d89f596`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d89f596a5d0f0674b1ef3268a9cfee5e32b64ba5))
## v0.41.2 (2024-02-28)
### Fix
* **utils/bec_dispatcher:** Msg is unp[acked from dict before accessing .content ([`bb1f066`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bb1f066c3c5e5076a8906e309030cfb47a6cad12))
## v0.41.1 (2024-02-26)
### Fix
* **bec_dispatcher:** Handle redis connection errors more gracefully ([`a2ed2eb`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a2ed2ebe00c623eb183b03f8182ffd672fbf9e1e))
* **bec_dispatcher:** Adapt code to redis connector refactoring ([`8127fc2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8127fc2960bebd3e862dbe55ac9401af4a6dccb6))
## v0.41.0 (2024-02-26)
### Feature
* **widgets/waveform1d:** Data can be exported from rendered curve ([`5fc8047`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5fc8047c8ff971cdc2807d02743eae56d288f4d7))
* **widgets/figure:** Clear_all method for BECFigure ([`0363fd5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0363fd5194320a7ea868ef883f8022ea464d0298))
* **widgets/Waveform1D:** Waveform1D can be fully constructed by config ([`9a5c86e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9a5c86ea35178b9cab270fc35e668dd22f3ec8da))
* **widgets/figure.py:** Dark/light theme changer ([`08534a4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/08534a4739ec8e85d82a00ab639411dd0198e9d8))
* **utils/entry_validator:** Possibility to validate add_scan_curve with current BEC session ([`1db77b9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1db77b969bcf9b38716ae3d38bf4695b2b8c1f37))
* **cli:** Added cli interface, rebased ([`a61bf36`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a61bf36df5d54ad44f78479c2474c4e38e68ed26))
* Curve can be modified after adding to the plot ([`684592a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/684592ae37e9dd5328a96018c78ca242e10395b2))
* Waveform1d.py curves can be removed by identifier by order(int) or by curve_id(str) ([`f0ed243`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f0ed243c9197b7d1aab0d99a15e9ba175708ec90))
* Waveform1d.py curves can be stylised; access scan history by index or scanID ([`cba3863`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cba3863e5a9ac1187ea643be67db6cfc36b44ee2))
* Start method for BECFigure, jupyter console .ui added to git ([`1d26b23`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1d26b2322147d9ea5a6a245e1648c00986f80881))
* Added @user_access from bec_lib.utils ([`b827e9e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b827e9eaa77f8b64433bb7a54e40ab5ccd86f4b6))
* Plot can be removed from BECFigure ([`60d150a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/60d150a41193aa7659285cf3612965f1a3c57244))
* Figure.py create widget factory ([`c781b1b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c781b1b4e4121c4ec6fc8871a4cdf6f494913138))
* Waveform1d.py draft ([`565e475`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/565e475ace72ccc103d71ea98af1dcaf04f37861))
* Rpc decorator to add methods to USER_ACCESS ([`b676877`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b6768772424a3ad5ee7e271de19131f8065eef09))
* BECFigure and BECPlotBase created ([`9ef331c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9ef331c272b88f725de9b8497fdf906056c0738b))
* BECConnector -> mixin class for all BEC Widget to hook them to BEC client ([`91447a2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/91447a2d6234de1e8f2bac792e822bfda556abba))
### Fix
* **cli/client_utils:** "__rpc__" pop from msg_results ([`ebb36f6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ebb36f62ddc1c5013435f9e7727648b977b6b732))
* **tests:** BECDispatcher fixture putted back ([`644f103`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/644f1031f6ff27064111565b0882cb8b2544aa2f))
* **cli/rpc:** Rpc client can return any type of object + config dict of the widgets ([`fd711b4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fd711b475f268fbdb59739da0a428f0355b25bac))
* **cli/rpc:** Server access children widget.find_widget_by_id(gui_id) ([`57132a4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/57132a472165c55bf99e1994d09f5fe3586c24da))
* **cli:** Fixed property access, rebased ([`f71dc5c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f71dc5c5abdd6b8b585cb9b502b11ef513d7813e))
* **rpc_server:** Fixed gui_id lookup ([`4630d78`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4630d78fc28109da7daf53e49dd3cdb9b8084941))
* **cli:** Fixed rpc construction of nested widgets ([`da640e8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/da640e888d575b536fdd5d7adbf1df3eda802219))
* **plots/waveform1d:** Pandas import clean up, export curves with none skipped ([`35cd4fd`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/35cd4fd6f176ba670fad5d9fec44b305094280d6))
* **widgets/plots:** Added placeholder for cleanup method to BECPlotBase ([`24c7737`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/24c77376b232c3846a1d6be360ec46acc077b48d))
* **widget/figure:** Add cleanup method to disconnect all slots before removing Waveform1D from layout ([`a28b9c8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a28b9c8981d1058e4dc4146463f16c53413e8db9))
* **rpc:** Added annotations to pass py3.9 tests ([`c6bdf0b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c6bdf0b6a5b12c054863b101a3944efc366686cb))
* **rpc:** Connection to on_rpc_update done through bec_dispatcher ([`1c2fb8b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1c2fb8b972d4cb28cead11989461aea010c4571d))
* After removing plot from BECFigure, the coordinates are correctly resigned ([`d678a85`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d678a85957c13c1fda2b52692c0d3b9b7ff40834))
* Removed DI references, fixed set when adding plot by fig ([`7c15d75`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7c15d750117aec9e75111853074630a44dca87ae))
## v0.40.1 (2024-02-23)
### Fix
* **utils/bec_dispatcher:** _do_disconnect_slot will shutdown consumer of slots/signals which were already disconnected ([`feca7a3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/feca7a3dcde6d0befa415db64fc8f9bbf0c06e52))
## v0.40.0 (2024-02-16)
### Feature
* **utils.colors:** Golden_angle_color utility can return colors as a list of QColor, RGB or HEC ([`5125909`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/51259097fa23ff861eac3f7c63624ea591bf1bd3))
## v0.39.0 (2024-02-12)
### Feature
* Added full app with all motor movement related widgets into motor_control_compilations.py ([`fa4ca93`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/fa4ca935bb39fdba4c6500ce9569d47400190e65))
* MotorCoordinateTable mode_switch added for "Individual" and "Start/Stop" modes ([`2f96e10`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2f96e10b9deb76eedd8f6b6e201ba3b0e526a6f0))
* Motor_control.py MotorCoordinateTable added basic version to store coordinates and show them in motor_map.py ([`031cb09`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/031cb094e7f8a7be4a295bea99b7ca8e095db8d7))
* Active motors from motor_map.py can be changed by slot without changing the whole config ([`17f1458`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/17f14581d7c4662a2f5814ea477dfae8ef6de555))
* Control panels compilations ([`8361736`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/83617366796ce2926650e38a1a9cec296befd3c6))
* Comboboxes of motor selection are changed to orange if the motors are not connected yet ([`0b9927f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0b9927fcf5f46410d05187b2e5a83f97a6ca9246))
* Motor_control.py MotorControl widgets - Absolute + Relative movement, MotorSelection, ErrorMessage popups ([`6fe08e6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6fe08e6b8206bcaaa292b7ff0e6b0d32b883f24f))
## v0.38.2 (2024-02-07)
### Fix
* Adapt code to BEC 1.0 ([`b36131e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b36131eed5c3a3ea58c0fa4d083e63a3717cdf22))
## v0.38.1 (2024-01-26)
### Fix
* Monitor.py replots last scan after changing config with new signals; config_dialog.py checks if the new config is valid with BEC ([`ab275b8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ab275b8e5f226d6c5d22a844c4c0fae0fdc66108))
### Documentation
* 2D waveform scatter plot changed to 2D scatter plot ([`812ffaf`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/812ffaf8eafc3f8c3a6973717149e4befba2c395))
* Documentation for example apps and widgets updated ([`f7a4967`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f7a496723c3fd113867a712928e06636e3212e1a))
## v0.38.0 (2024-01-23)
### Feature
* BECMonitor2DScatter for plotting x/y/z signal as a mesh of scatter plot ([`75090b8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/75090b857526fa642218986806d0daeb1dec0914))
### Fix
* Monitor_scatter_2D.py changed to new BECDispatcher definition ([`747e97e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/747e97e0c924cdedb85e9fe7d47512002b791b10))
## v0.37.1 (2024-01-23)
### Fix
* **tests:** Ensure BEC service is shutdown after bec dispatcher test ([`4664568`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/46645686725a2acb7196dbd1a504c98dbf2e4b5d))
* **tests:** Ensure threads started during plot tests are properly stopped ([`3fb6644`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3fb6644543b4065236216b70a583641956a09a60))
## v0.37.0 (2024-01-17)
### Feature
* Independent motor_map widget ([`1a429b3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1a429b3024e76446ed530bee71ed797c20843fba))
## v0.36.2 (2024-01-17)
### Fix
* Bec_dispatcher.py can partially disconnect topics from slot ([`7607d7a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7607d7a3b64b3861f4833c9b8f5afc360f31b38d))
* Bec_dispatcher.py can connect multiple topics to one callback slot ([`e51be04`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e51be04b95f1a9549a4a3b00d76944aa58b0526a))
## v0.36.1 (2024-01-15)
### Fix
* Motor_example.py fix to the new .read() structure from bec_lib ([`f9c5c82`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f9c5c82381907a19582bf9132740fe27b48d48cc))
## v0.36.0 (2024-01-12)
### Feature
* Bec_dispatcher can link multiple endpoints topics for one qt slot ([`58721be`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/58721bea1a2b4b06220ef0e3b2dcec8c1656213d))
## v0.35.0 (2024-01-12)
### Feature
* Monitor.py can access custom data send through redis ([`6e4775a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6e4775a1248153f6027be754054f3f43c18514d1))
* Monitor.py access data directly from scan storage ([`26c07c3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/26c07c3205debaf88a346410a8ebab0a3ab7a5d9))
### Fix
* Monitor.py clear command from BECPlotter CLI clear now flush database and clear the plots ([`ebd4fcc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ebd4fccda2321aa0dc108a5436fb4cc717911d4b))
* Monitor.py crosshair enabled by default ([`97dcc5a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/97dcc5ac768cc4f0122382591238fd5a9d035270))
* Monitor.py change import of ConfigDialog from relative to absolute in order to make BECPlotter be able to open it ([`6061b31`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6061b3150e990141eafb8d5b17c7e931c7bf8631))
* Monitor_config_validator.py changed to check .describe() instead of signals ([`5ab82bc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5ab82bc13340adb992c921a7211e8e2265861f7a))
* Monitor.py fixed not updating config changes after receiving refresh from BECPlotter ([`00ef3ae`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/00ef3ae9256a368f4842c1dc38a407131181ec1d))
* Monitor_config_validator.py valid color is Literal['black','white'] ([`86c5f25`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/86c5f25205dbaa45b7b2efd255f3a3cb2d3eb0b1))
* Monitor.py fixed scan mode ([`a706da2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a706da2490f4cce80e9515633e8437b3667b0db0))
* Motor_config_validation changed to new monitor config structure ([`d67bdd2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d67bdd26167dca6c65627192dbd098af08355d06))
## v0.34.1 (2023-12-12)
### Fix
* Formatter and tests fixed ([`186c42d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/186c42d6676a495bc2f66d8b7ed37dbf7d0be747))
### Documentation
* Readdocs updated ([`af995a7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/af995a74f34d59eeaff5d9100117f103ec79765d))
* Readme.md updated ([`cba8131`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cba81313671acfee0a40410753c1974008316d07))
* Gitlab templates for issues and merge requests from main bec repo ([`831eddc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/831eddc13600cc06b67de92d39509af37bb05002))
## v0.34.0 (2023-12-08)
### Feature
* Monitor.py error message popup ([`a3b24f9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a3b24f92420420c8968ef4793342c3857c826e57))
### Fix
* Monitor_config_validator.py - Signal validation changed from field_validator to model_validator to check first name and then entry ([`0868047`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/086804780d19956331d8385381d2f7f9c181e77c))
* Monitor_config_validator.py fix entry validation executed only if name validator is successful ([`af71e35`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/af71e35e73733472228c4be0061faefaf655b769))
## v0.33.0 (2023-12-07)
### Feature
* 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

View File

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

View File

@@ -1,99 +0,0 @@
import argparse
import itertools
import os
from typing import Callable
from bec_lib import BECClient, messages, ServiceConfig
from bec_lib.redis_connector import RedisConsumerThreaded
from qtpy.QtCore import QObject, Signal as pyqtSignal
# Adding a new pyqt signal requres a class factory, as they must be part of the class definition
# and cannot be dynamically added as class attributes after the class has been defined.
_signal_class_factory = (
type(f"Signal{i}", (QObject,), dict(signal=pyqtSignal(dict, dict))) for i in itertools.count()
)
class _Connection:
"""Utility class to keep track of slots connected to a particular redis consumer"""
def __init__(self, consumer) -> None:
self.consumer: RedisConsumerThreaded = consumer
self.slots = set()
# keep a reference to a new signal class, so it is not gc'ed
self._signal_container = next(_signal_class_factory)()
self.signal: pyqtSignal = self._signal_container.signal
class _BECDispatcher(QObject):
def __init__(self, bec_config=None):
super().__init__()
self.client = BECClient()
# TODO: this is a workaround for now to provide service config within qtdesigner, but is
# it possible to provide config via a cli arg?
if bec_config is None and os.path.isfile("bec_config.yaml"):
bec_config = "bec_config.yaml"
self.client.initialize(config=ServiceConfig(config_path=bec_config))
self._connections = {}
def connect_slot(self, slot: Callable, topic: str) -> None:
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message
Args:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
the corresponding pub/sub message
topic (str): A topic that can typically be acquired via bec_lib.MessageEndpoints
"""
# create new connection for topic if it doesn't exist
if topic not in self._connections:
def cb(msg):
msg = messages.MessageReader.loads(msg.value)
# TODO: this can could be replaced with a simple
# self._connections[topic].signal.emit(msg.content, msg.metadata)
# once all dispatcher.connect_slot calls are made with a single topic only
if not isinstance(msg, list):
msg = [msg]
for msg_i in msg:
self._connections[topic].signal.emit(msg_i.content, msg_i.metadata)
consumer = self.client.connector.consumer(topics=topic, cb=cb)
consumer.start()
self._connections[topic] = _Connection(consumer)
# connect slot if it's not connected
if slot not in self._connections[topic].slots:
self._connections[topic].signal.connect(slot)
self._connections[topic].slots.add(slot)
def disconnect_slot(self, slot: Callable, topic: str) -> None:
"""Disconnect widget's pyqt slot from pub/sub updates on a topic.
Args:
slot (Callable): A slot to be disconnected
topic (str): A corresponding topic that can typically be acquired via
bec_lib.MessageEndpoints
"""
if topic not in self._connections:
return
if slot not in self._connections[topic].slots:
return
self._connections[topic].signal.disconnect(slot)
self._connections[topic].slots.remove(slot)
if not self._connections[topic].slots:
# shutdown consumer if there are no more connected slots
self._connections[topic].consumer.shutdown()
del self._connections[topic]
parser = argparse.ArgumentParser()
parser.add_argument("--bec-config", default=None)
args, _ = parser.parse_known_args()
bec_dispatcher = _BECDispatcher(args.bec_config)

View File

@@ -0,0 +1 @@
from .client import BECFigure

1092
bec_widgets/cli/client.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,289 @@
from __future__ import annotations
import importlib
import select
import subprocess
import time
import uuid
from functools import wraps
from typing import TYPE_CHECKING
from qtpy.QtCore import QCoreApplication
import bec_widgets.cli.client as client
from bec_lib import MessageEndpoints, messages
from bec_lib.connector import MessageObject
from bec_lib.device import DeviceBase
from bec_widgets.utils.bec_dispatcher import BECDispatcher
if TYPE_CHECKING:
from bec_widgets.cli.client import BECFigure
def rpc_call(func):
"""
A decorator for calling a function on the server.
Args:
func: The function to call.
Returns:
The result of the function call.
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
if not self.gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
return wrapper
def get_selected_device(monitored_devices, selected_device):
"""
Get the selected device for the plot. If no device is selected, the first
device in the monitored devices list is selected.
"""
if selected_device:
return selected_device
if len(monitored_devices) > 0:
sel_device = monitored_devices[0]
return sel_device
return None
def update_script(figure: BECFigure, msg):
"""
Update the script with the given data.
"""
info = msg.info
status = msg.status
scan_id = msg.scanID
scan_number = info.get("scan_number", 0)
scan_name = info.get("scan_name", "Unknown")
scan_report_devices = info.get("scan_report_devices", [])
monitored_devices = info.get("readout_priority", {}).get("monitored", [])
monitored_devices = [dev for dev in monitored_devices if dev not in scan_report_devices]
if scan_name == "line_scan" and scan_report_devices:
dev_x = scan_report_devices[0]
dev_y = get_selected_device(monitored_devices, figure.selected_device)
print(f"Selected device: {dev_y}")
if not dev_y:
return
figure.clear_all()
plt = figure.plot(dev_x, dev_y)
plt.set(title=f"Scan {scan_number}", x_label=dev_x, y_label=dev_y)
elif scan_name == "grid_scan" and scan_report_devices:
print(f"Scan {scan_number} is running")
dev_x = scan_report_devices[0]
dev_y = scan_report_devices[1]
figure.clear_all()
plt = figure.plot(dev_x, dev_y, label=f"Scan {scan_number}")
plt.set(title=f"Scan {scan_number}", x_label=dev_x, y_label=dev_y)
elif scan_report_devices:
dev_x = scan_report_devices[0]
dev_y = get_selected_device(monitored_devices, figure.selected_device)
if not dev_y:
return
figure.clear_all()
plt = figure.plot(dev_x, dev_y, label=f"Scan {scan_number}")
plt.set(title=f"Scan {scan_number}", x_label=dev_x, y_label=dev_y)
class BECFigureClientMixin:
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._process = None
self.update_script = update_script
self._target_endpoint = MessageEndpoints.scan_status()
self._selected_device = None
@property
def selected_device(self):
"""
Selected device for the plot.
"""
return self._selected_device
@selected_device.setter
def selected_device(self, device: str | DeviceBase):
if isinstance(device, DeviceBase):
self._selected_device = device.name
elif isinstance(device, str):
self._selected_device = device
else:
raise ValueError("Device must be a string or a device object")
def _start_update_script(self) -> None:
self._client.connector.register(
self._target_endpoint, cb=self._handle_msg_update, parent=self
)
@staticmethod
def _handle_msg_update(msg: MessageObject, parent: BECFigureClientMixin) -> None:
if parent.update_script is not None:
# pylint: disable=protected-access
parent._update_script_msg_parser(msg.value)
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
if isinstance(msg, messages.ScanStatusMessage):
if not self.gui_is_alive():
return
if msg.status == "open":
self.update_script(self, msg)
def show(self) -> None:
"""
Show the figure.
"""
if self._process is None or self._process.poll() is not None:
self._start_plot_process()
while not self.gui_is_alive():
print("Waiting for GUI to start...")
time.sleep(1)
def close(self) -> None:
"""
Close the figure.
"""
if self._process is None:
return
self._run_rpc("close", (), wait_for_rpc_response=False)
self._process.kill()
self._process = None
def _start_plot_process(self) -> None:
"""
Start the plot in a new process.
"""
self._start_update_script()
# pylint: disable=subprocess-run-check
monitor_module = importlib.import_module("bec_widgets.cli.server")
monitor_path = monitor_module.__file__
command = f"python {monitor_path} --id {self._gui_id}"
self._process = subprocess.Popen(
command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
def print_log(self) -> None:
"""
Print the log of the plot process.
"""
if self._process is None:
return
print(self._get_stderr_output())
def _get_stderr_output(self) -> str:
stderr_output = []
while self._process.poll() is not None:
readylist, _, _ = select.select([self._process.stderr], [], [], 0.1)
if not readylist:
break
line = self._process.stderr.readline()
if not line:
break
stderr_output.append(line.decode("utf-8"))
return "".join(stderr_output)
class RPCBase:
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
self._client = BECDispatcher().client
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())
self._parent = parent
super().__init__()
# print(f"RPCBase: {self._gui_id}")
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} object at {hex(id(self))}>"
@property
def _root(self):
"""
Get the root widget. This is the BECFigure widget that holds
the anchor gui_id.
"""
parent = self
# pylint: disable=protected-access
while parent._parent is not None:
parent = parent._parent
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, **kwargs):
"""
Run the RPC call.
Args:
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
kwargs: The keyword arguments to pass to the method.
Returns:
The result of the RPC call.
"""
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
metadata={"request_id": request_id},
)
# print(f"RPCBase: {rpc_msg}")
# pylint: disable=protected-access
receiver = self._root._gui_id
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
if not wait_for_rpc_response:
return None
response = self._wait_for_response(request_id)
# get class name
if not response.content["accepted"]:
raise ValueError(response.content["message"]["error"])
msg_result = response.content["message"].get("result")
return self._create_widget_from_msg_result(msg_result)
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
return None
if isinstance(msg_result, list):
return [self._create_widget_from_msg_result(res) for res in msg_result]
if isinstance(msg_result, dict):
if "__rpc__" not in msg_result:
return {
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
}
cls = msg_result.pop("widget_class", None)
msg_result.pop("__rpc__", None)
if not cls:
return msg_result
cls = getattr(client, cls)
# print(msg_result)
return cls(parent=self, **msg_result)
return msg_result
def _wait_for_response(self, request_id):
"""
Wait for the response from the server.
"""
response = None
while response is None and self.gui_is_alive():
response = self._client.connector.get(
MessageEndpoints.gui_instruction_response(request_id)
)
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
return response
def gui_is_alive(self):
"""
Check if the GUI is alive.
"""
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
return heart is not None

View File

@@ -0,0 +1,128 @@
# pylint: disable=missing-module-docstring
from __future__ import annotations
import inspect
import black
import sys
if sys.version_info >= (3, 11):
from typing import get_overloads
else:
print(
"Python version is less than 3.11, using dummy function for get_overloads. "
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
)
def get_overloads(obj):
# Dummy function for Python versions before 3.11
return []
class ClientGenerator:
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py\n
from bec_widgets.cli.client_utils import rpc_call, RPCBase, BECFigureClientMixin
from typing import Literal, Optional, overload"""
self.content = ""
def generate_client(self, published_classes: list):
"""
Generate the client for the published classes.
Args:
published_classes(list): The list of published classes (e.g. [BECWaveform1D, BECFigure]).
"""
for cls in published_classes:
self.content += "\n\n"
self.generate_content_for_class(cls)
def generate_content_for_class(self, cls):
"""
Generate the content for the class.
Args:
cls: The class for which to generate the content.
"""
class_name = cls.__name__
module = cls.__module__
# Generate the header
# self.header += f"""
# from {module} import {class_name}"""
# Generate the content
if cls.__name__ == "BECFigure":
self.content += f"""
class {class_name}(RPCBase, BECFigureClientMixin):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
for method in cls.USER_ACCESS:
obj = getattr(cls, method)
if isinstance(obj, property):
self.content += """
@property
@rpc_call"""
sig = str(inspect.signature(obj.fget))
doc = inspect.getdoc(obj.fget)
else:
sig = str(inspect.signature(obj))
doc = inspect.getdoc(obj)
overloads = get_overloads(obj)
for overload in overloads:
sig_overload = str(inspect.signature(overload))
self.content += f"""
@overload
def {method}{str(sig_overload)}: ...
"""
self.content += """
@rpc_call"""
self.content += f"""
def {method}{str(sig)}:
\"\"\"
{doc}
\"\"\""""
def write(self, file_name: str):
"""
Write the content to a file, automatically formatted with black.
Args:
file_name(str): The name of the file to write to.
"""
# Combine header and content, then format with black
full_content = self.header + "\n" + self.content
try:
formatted_content = black.format_str(full_content, mode=black.FileMode(line_length=100))
except black.NothingChanged:
formatted_content = full_content
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
if __name__ == "__main__": # pragma: no cover
import os
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.plots import BECPlotBase, BECWaveform1D, BECImageShow
from bec_widgets.widgets.plots.waveform1d import BECCurve
from bec_widgets.widgets.plots.image import BECImageItem
from bec_widgets.utils import BECConnector
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
clss = [
BECPlotBase,
BECWaveform1D,
BECFigure,
BECCurve,
BECImageShow,
BECConnector,
BECImageItem,
]
generator = ClientGenerator()
generator.generate_client(clss)
generator.write(client_path)

138
bec_widgets/cli/server.py Normal file
View File

@@ -0,0 +1,138 @@
import inspect
from qtpy.QtCore import QTimer
from bec_lib import MessageEndpoints, messages
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.plots import BECCurve, BECImageShow, BECWaveform1D
class BECWidgetsCLIServer:
WIDGETS = [BECWaveform1D, BECFigure, BECCurve, BECImageShow]
def __init__(self, gui_id: str = None, dispatcher: BECDispatcher = None) -> None:
self.dispatcher = BECDispatcher() if dispatcher is None else dispatcher
self.client = self.dispatcher.client
self.client.start()
self.gui_id = gui_id
self.fig = BECFigure(gui_id=self.gui_id)
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
# Setup QTimer for heartbeat
self._shutdown_event = False
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(1000) # Emit heartbeat every 1 seconds
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
try:
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
obj = self.get_object_from_config(msg["parameter"])
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
print(e)
self.send_response(request_id, False, {"error": str(e)})
else:
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
MessageEndpoints.gui_instruction_response(request_id),
messages.RequestResponseMessage(accepted=accepted, message=msg),
expire=60,
)
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
# check if the object is the figure
if gui_id == self.fig.gui_id:
return self.fig
# check if the object is a widget
if gui_id in self.fig._widgets:
obj = self.fig._widgets[config["gui_id"]]
return obj
if self.fig._widgets:
for widget in self.fig._widgets.values():
item = widget.find_widget_by_id(gui_id)
if item:
return item
raise ValueError(f"Object with gui_id {gui_id} not found")
def run_rpc(self, obj, method, args, kwargs):
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
res = method_obj
else:
sig = inspect.signature(method_obj)
if sig.parameters:
res = method_obj(*args, **kwargs)
else:
res = method_obj()
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
elif isinstance(res, dict):
res = {key: self.serialize_object(val) for key, val in res.items()}
else:
res = self.serialize_object(res)
return res
def serialize_object(self, obj):
if isinstance(obj, BECConnector):
return {
"gui_id": obj.gui_id,
"widget_class": obj.__class__.__name__,
"config": obj.config.model_dump(),
"__rpc__": True,
}
return obj
def emit_heartbeat(self):
if self._shutdown_event is False:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=1, info={}),
expire=10,
)
print("Heartbeat emitted")
def shutdown(self):
self._shutdown_event = True
self._heartbeat_timer.stop()
if __name__ == "__main__": # pragma: no cover
import argparse
import sys
from qtpy.QtWidgets import QApplication
from qtpy.QtWidgets import QMainWindow
app = QApplication(sys.argv)
app.setApplicationName("BEC Figure")
win = QMainWindow()
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, help="The id of the server")
args = parser.parse_args()
server = BECWidgetsCLIServer(gui_id=args.id)
# server = BECWidgetsCLIServer(gui_id="test")
fig = server.fig
win.setCentralWidget(fig)
win.show()
app.aboutToQuit.connect(server.shutdown)
sys.exit(app.exec())

View File

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

View File

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

View File

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

View File

@@ -47,7 +47,13 @@ class EigerPlot(QWidget):
self.key_bindings()
# ZMQ Consumer
self.start_zmq_consumer()
self._zmq_consumer_exit_event = threading.Event()
self._zmq_consumer_thread = self.start_zmq_consumer()
def close(self):
super().close()
self._zmq_consumer_exit_event.set()
self._zmq_consumer_thread.join()
def init_ui(self):
# Create Plot and add ImageItem
@@ -182,25 +188,36 @@ class EigerPlot(QWidget):
###############################
def start_zmq_consumer(self):
consumer_thread = threading.Thread(target=self.zmq_consumer, daemon=True).start()
consumer_thread = threading.Thread(
target=self.zmq_consumer, args=(self._zmq_consumer_exit_event,), daemon=True
)
consumer_thread.start()
return consumer_thread
def zmq_consumer(self):
try:
print("starting consumer")
live_stream_url = "tcp://129.129.95.38:20000"
receiver = zmq.Context().socket(zmq.SUB)
receiver.connect(live_stream_url)
receiver.setsockopt_string(zmq.SUBSCRIBE, "")
def zmq_consumer(self, exit_event):
print("starting consumer")
live_stream_url = "tcp://129.129.95.38:20000"
receiver = zmq.Context().socket(zmq.SUB)
receiver.connect(live_stream_url)
receiver.setsockopt_string(zmq.SUBSCRIBE, "")
poller = zmq.Poller()
poller.register(receiver, zmq.POLLIN)
# code could be a bit simpler here, testing exit_event in
# 'while' condition, but like this it is easier for the
# 'test_zmq_consumer' test
while True:
if poller.poll(1000): # 1s timeout
raw_meta, raw_data = receiver.recv_multipart(zmq.NOBLOCK)
while True:
raw_meta, raw_data = receiver.recv_multipart()
meta = json.loads(raw_meta.decode("utf-8"))
self.image = np.frombuffer(raw_data, dtype=meta["type"]).reshape(meta["shape"])
self.update_signal.emit()
if exit_event.is_set():
break
finally:
receiver.disconnect(live_stream_url)
receiver.context.term()
receiver.disconnect(live_stream_url)
###############################
# just simulations from here

View File

@@ -1,130 +0,0 @@
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

@@ -1,91 +0,0 @@
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

@@ -1,730 +0,0 @@
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.qt_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, "extreme.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 qt_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.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

@@ -1,115 +0,0 @@
<?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

@@ -102,7 +102,7 @@ class StreamApp(QWidget):
@staticmethod
def _streamer_cb(msg, *, parent, **_kwargs) -> None:
msgMCS = messages.DeviceMessage.loads(msg.value)
msgMCS = msg.value
print(msgMCS)
row = msgMCS.content["signals"][parent.sub_device]
metadata = msgMCS.metadata
@@ -123,7 +123,7 @@ class StreamApp(QWidget):
def _device_cv(msg, *, parent, **_kwargs) -> None:
print("Getting ScanID")
msgDEV = messages.ScanStatusMessage.loads(msg.value)
msgDEV = msg.value
current_scanID = msgDEV.content["scanID"]

View File

@@ -26,7 +26,7 @@ for ii in range(20):
topic=MessageEndpoints.device_async_readback(
scanID=scanID, device="mca"
), # scanID will be different for each scan
msg={"data": msg},
msg={"data": msg}, # TODO should be msg_dict
expire=1800,
)

View File

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

View File

@@ -1,45 +1,13 @@
import os
from qtpy import uic
from qtpy.QtWidgets import QMainWindow, QApplication, QVBoxLayout
from qtpy.QtWidgets import QMainWindow, QApplication
from bec_widgets.widgets.monitor import BECMonitor
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets import BECMonitor
# some default configs for demonstration purposes
config_1 = {
"plot_settings": {
"background_color": "black",
"num_columns": 1,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {
"label": "Motor Y",
"signals": [{"name": "samx", "entry": "samx"}],
},
"y": {
"label": "bpm4i",
"signals": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
{
"plot_name": "Gauss plots vs samx",
"x": {
"label": "Motor X",
"signals": [{"name": "samx", "entry": "samx"}],
},
"y": {
"label": "Gauss",
"signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
},
},
],
}
config_2 = {
CONFIG_SIMPLE = {
"plot_settings": {
"background_color": "black",
"num_columns": 2,
@@ -49,41 +17,52 @@ config_2 = {
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {
"label": "Motor Y",
"signals": [{"name": "samx", "entry": "samx"}],
},
"y": {
"label": "bpm4i",
"signals": [{"name": "samy", "entry": "samy"}],
},
"x_label": "Motor X",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
# {
# "type": "history",
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
# {
# "type": "dap",
# 'worker':'some_worker',
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
],
},
{
"plot_name": "Gauss plots vs samx",
"x": {
"label": "Motor X",
"signals": [{"name": "samx", "entry": "samx"}],
},
"y": {
"label": "Gauss ADC",
"signals": [{"name": "gauss_adc1", "entry": "gauss_adc1"}],
},
},
{
"plot_name": "Plot 3",
"x": {
"label": "Motor X",
"signals": [{"name": "samx", "entry": "samx"}],
},
"y": {
"label": "Gauss ADC",
"signals": [{"name": "gauss_adc3", "entry": "gauss_adc3"}],
},
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
}
config_scan_mode = {
CONFIG_SCAN_MODE = {
"plot_settings": {
"background_color": "white",
"num_columns": 3,
@@ -94,77 +73,89 @@ config_scan_mode = {
"grid_scan": [
{
"plot_name": "Grid plot 1",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "gauss_adc1", "entry": "gauss_adc1"},
],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}],
},
}
],
},
{
"plot_name": "Grid plot 2",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "gauss_adc1", "entry": "gauss_adc1"},
],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_adc1"}],
},
}
],
},
{
"plot_name": "Grid plot 3",
"x": {"label": "Motor Y", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "gauss_adc2"}],
},
}
],
},
{
"plot_name": "Grid plot 4",
"x": {"label": "Motor Y", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [{"name": "gauss_adc3", "entry": "gauss_adc3"}],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "gauss_adc3"}],
},
}
],
},
],
"line_scan": [
{
"plot_name": "BPM plot",
"x": {"label": "Motor X", "signals": [{"name": "samx"}]},
"y": {
"label": "BPM",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "gauss_adc1", "entry": "gauss_adc1"},
{"name": "gauss_adc2", "entry": "gauss_adc2"},
],
},
"plot_name": "BPM plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Multi",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "Multi",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "samx", "entry": "samx"},
],
},
},
{
"plot_name": "Multi",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "Multi",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "samx", "entry": "samx"},
],
},
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
},
@@ -176,7 +167,7 @@ class ModularApp(QMainWindow):
super(ModularApp, self).__init__(parent)
# Client and device manager from BEC
self.client = bec_dispatcher.client if client is None else client
self.client = BECDispatcher().client if client is None else client
# Loading UI
current_path = os.path.dirname(__file__)
@@ -187,7 +178,7 @@ class ModularApp(QMainWindow):
def _init_plots(self):
"""Initialize plots and connect the buttons to the config dialogs"""
plots = [self.plot_1, self.plot_2, self.plot_3]
configs = [config_1, config_2, config_scan_mode]
configs = [CONFIG_SIMPLE, CONFIG_SCAN_MODE, CONFIG_SCAN_MODE]
buttons = [self.pushButton_setting_1, self.pushButton_setting_2, self.pushButton_setting_3]
# hook plots, configs and buttons together
@@ -197,10 +188,8 @@ class ModularApp(QMainWindow):
if __name__ == "__main__":
from bec_widgets.bec_dispatcher import bec_dispatcher
# BECclient global variables
client = bec_dispatcher.client
client = BECDispatcher().client
client.start()
app = QApplication([])

View File

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

View File

@@ -0,0 +1,260 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import qdarktheme
from qtpy.QtWidgets import QApplication
from qtpy.QtWidgets import QVBoxLayout
from qtpy.QtWidgets import (
QWidget,
QSplitter,
)
from qtpy.QtCore import Qt
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets import (
MotorControlAbsolute,
MotorControlRelative,
MotorControlSelection,
MotorThread,
MotorMap,
MotorCoordinateTable,
)
CONFIG_DEFAULT = {
"motor_control": {
"motor_x": "samx",
"motor_y": "samy",
"step_size_x": 3,
"step_size_y": 3,
"precision": 4,
"step_x_y_same": False,
"move_with_arrows": False,
},
"plot_settings": {
"colormap": "Greys",
"scatter_size": 5,
"max_points": 1000,
"num_dim_points": 100,
"precision": 2,
"num_columns": 1,
"background_value": 25,
},
"motors": [
{
"plot_name": "Motor Map",
"x_label": "Motor X",
"y_label": "Motor Y",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "samy", "entry": "samy"}],
},
},
],
}
class MotorControlApp(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
# Widgets
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
# Create MotorMap
self.motion_map = MotorMap(client=self.client, config=self.config)
# Create MotorCoordinateTable
self.motor_table = MotorCoordinateTable(client=self.client, config=self.config)
# Create the splitter and add MotorMap and MotorControlPanel
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(self.motion_map)
splitter.addWidget(self.motor_control_panel)
splitter.addWidget(self.motor_table)
# Set the main layout
layout = QVBoxLayout(self)
layout.addWidget(splitter)
self.setLayout(layout)
# Connecting signals and slots
self.motor_control_panel.selection_widget.selected_motors_signal.connect(
lambda x, y: self.motion_map.change_motors(x, y, 0)
)
self.motor_control_panel.absolute_widget.coordinates_signal.connect(
self.motor_table.add_coordinate
)
self.motor_control_panel.relative_widget.precision_signal.connect(
self.motor_table.set_precision
)
self.motor_control_panel.relative_widget.precision_signal.connect(
self.motor_control_panel.absolute_widget.set_precision
)
self.motor_table.plot_coordinates_signal.connect(self.motion_map.plot_saved_coordinates)
class MotorControlMap(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
# Widgets
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
# Create MotorMap
self.motion_map = MotorMap(client=self.client, config=self.config)
# Create the splitter and add MotorMap and MotorControlPanel
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(self.motion_map)
splitter.addWidget(self.motor_control_panel)
# Set the main layout
layout = QVBoxLayout(self)
layout.addWidget(splitter)
self.setLayout(layout)
# Connecting signals and slots
self.motor_control_panel.selection_widget.selected_motors_signal.connect(
lambda x, y: self.motion_map.change_motors(x, y, 0)
)
class MotorControlPanel(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
self.relative_widget = MotorControlRelative(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
self.absolute_widget = MotorControlAbsolute(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.relative_widget)
layout.addWidget(self.absolute_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
# Set the window to a fixed size based on its contents
self.layout().setSizeConstraint(layout.SetFixedSize)
class MotorControlPanelAbsolute(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=client, config=config, motor_thread=self.motor_thread
)
self.absolute_widget = MotorControlAbsolute(
client=client, config=config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.absolute_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
# Set the window to a fixed size based on its contents
self.layout().setSizeConstraint(layout.SetFixedSize)
class MotorControlPanelRelative(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=client, config=config, motor_thread=self.motor_thread
)
self.relative_widget = MotorControlRelative(
client=client, config=config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.relative_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
# Set the window to a fixed size based on its contents
self.layout().setSizeConstraint(layout.SetFixedSize)
if __name__ == "__main__": # pragma: no cover
import argparse
import sys
parser = argparse.ArgumentParser(description="Run various Motor Control Widgets compositions.")
parser.add_argument(
"-v",
"--variant",
type=str,
choices=["app", "map", "panel", "panel_abs", "panel_rel"],
help="Select the variant of the motor control to run. "
"'app' for the full application, "
"'map' for MotorMap, "
"'panel' for the MotorControlPanel, "
"'panel_abs' for MotorControlPanel with absolute control, "
"'panel_rel' for MotorControlPanel with relative control.",
)
args = parser.parse_args()
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication([])
qdarktheme.setup_theme("auto")
if args.variant == "app":
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "map":
window = MotorControlMap(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel":
window = MotorControlPanel(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel_abs":
window = MotorControlPanelAbsolute(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel_rel":
window = MotorControlPanelRelative(client=client) # , config=CONFIG_DEFAULT)
else:
print("Please specify a valid variant to run. Use -h for help.")
print("Running the full application by default.")
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
window.show()
sys.exit(app.exec())

View File

@@ -25,7 +25,7 @@ from qtpy.QtWidgets import QShortcut
from pyqtgraph.Qt import QtWidgets, uic, QtCore
from bec_lib import MessageEndpoints, messages
from bec_widgets.qt_utils import DoubleValidationDelegate
from bec_widgets.utils import DoubleValidationDelegate
# TODO - General features
@@ -1129,10 +1129,12 @@ class MotorControl(QThread):
motor_x_name (str): The name of the motor for the x-axis.
motor_y_name (str): The name of the motor for the y-axis.
"""
self.motor_x_name = motor_x_name
self.motor_y_name = motor_y_name
self.motor_x, self.motor_y = (
dev[motor_x_name],
dev[motor_y_name],
dev[self.motor_x_name],
dev[self.motor_y_name],
)
(self.current_x, self.current_y) = self.get_coordinates()
@@ -1179,8 +1181,8 @@ class MotorControl(QThread):
def get_coordinates(self) -> tuple:
"""Get current motor position"""
x = self.motor_x.read(cached=True)["value"]
y = self.motor_y.read(cached=True)["value"]
x = self.motor_x.readback.get()
y = self.motor_y.readback.get()
return x, y
def retrieve_coordinates(self) -> tuple:
@@ -1295,7 +1297,7 @@ class MotorControl(QThread):
@staticmethod
def _device_status_callback_motors(msg, *, parent, **_kwargs) -> None:
deviceMSG = messages.DeviceMessage.loads(msg.value)
deviceMSG = msg.value
if parent.motor_x.name in deviceMSG.content["signals"]:
parent.current_x = deviceMSG.content["signals"][parent.motor_x.name]["value"]
elif parent.motor_y.name in deviceMSG.content["signals"]:

View File

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

View File

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

View File

@@ -1,271 +0,0 @@
import os
import numpy as np
import qtpy.QtWidgets
import pyqtgraph as pg
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
# 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):
"""
Main class for the PlotApp used to plot two signals from the BEC.
Attributes:
update_signal (pyqtSignal): Signal to trigger plot updates.
update_dap_signal (pyqtSignal): Signal to trigger DAP updates.
Args:
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.
"""
update_signal = pyqtSignal()
update_dap_signal = pyqtSignal()
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_value = x_value
self.y_values = y_values
self.dap_worker = dap_worker
self.scanID = None
self.data_x = []
self.data_y = []
self.dap_x = np.array([])
self.dap_y = np.array([])
self.fit = None
self.init_ui()
self.init_curves()
self.hook_crosshair()
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self.update_plot
)
self.proxy_update_fit = pg.SignalProxy(
self.update_dap_signal, rateLimit=25, slot=self.update_fit_table
)
def init_ui(self) -> None:
"""Initialize the UI components."""
self.plot = pg.PlotItem()
self.glw.addItem(self.plot)
self.plot.setLabel("bottom", self.x_value)
self.plot.setLabel("left", ", ".join(self.y_values))
self.plot.addLegend()
def init_curves(self) -> None:
"""Initialize curve data and properties."""
self.plot.clear()
self.curves_data = []
self.curves_dap = []
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
# 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,
name=f"{signal}",
)
self.curves_data.append(curve_data)
self.plot.addItem(curve_data)
# 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)
self.hook_crosshair()
def hook_crosshair(self) -> None:
"""Attach the crosshair to the plot."""
self.crosshair_1d = Crosshair(self.plot, precision=3)
self.crosshair_1d.coordinatesChanged1D.connect(
lambda x, y: self.update_table(self.tableWidget_crosshair, x, y, column=0)
)
self.crosshair_1d.coordinatesClicked1D.connect(
lambda x, y: self.update_table(self.tableWidget_crosshair, x, y, column=1)
)
def update_table(
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})"))
table_widget.resizeColumnsToContents()
def update_plot(self) -> None:
"""Update the plot data."""
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):
"""Update the table for fit data."""
self.tableWidget_fit.setData(self.fit)
@pyqtSlot(dict, dict)
def on_dap_update(self, msg: dict, metadata: dict) -> None:
"""
Update DAP related data.
Args:
msg (dict): Message received with data.
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"]
self.fit = metadata["fit_parameters"]
self.update_dap_signal.emit()
@pyqtSlot(dict, dict)
def on_scan_segment(self, msg: dict, metadata: dict):
"""
Handle new scan segments.
Args:
msg (dict): Message received with scan data.
metadata (dict): Metadata of the scan.
"""
current_scanID = msg["scanID"]
if current_scanID != self.scanID:
self.scanID = current_scanID
self.data_x = []
self.data_y = [[] for _ in self.y_values]
self.init_curves()
dev_x = self.x_value
data_x = msg["data"][dev_x][dev[dev_x]._hints[0]]["value"]
self.data_x.append(data_x)
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 yaml
from bec_widgets import ctrl_c
from bec_widgets.bec_dispatcher import bec_dispatcher
with open("config_noworker.yaml", "r") as file:
config = yaml.safe_load(file)
x_value = config["x_value"]
y_values = config["y_values"]
dap_worker = config["dap_worker"]
dap_worker = None if dap_worker == "None" else dap_worker
# BECclient global variables
client = bec_dispatcher.client
client.start()
dev = client.device_manager.devices
scans = client.scans
queue = client.queue
app = QApplication([])
plotApp = PlotApp(x_value=x_value, y_values=y_values, dap_worker=dap_worker)
# Connecting signals from bec_dispatcher
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()

View File

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

@@ -1,168 +0,0 @@
import numpy as np
import pyqtgraph as pg
from qtpy.QtWidgets import (
QApplication,
QVBoxLayout,
QLabel,
QWidget,
QHBoxLayout,
QTableWidget,
QTableWidgetItem,
QSpinBox,
)
from pyqtgraph import mkPen
from pyqtgraph.Qt import QtCore
from bec_widgets.qt_utils import Crosshair
class ExampleApp(QWidget):
def __init__(self):
super().__init__()
# Layout
self.layout = QHBoxLayout()
self.setLayout(self.layout)
##########################
# 1D Plot
##########################
# PlotWidget
self.plot_widget_1d = pg.PlotWidget(title="1D PlotWidget with multiple curves")
self.plot_item_1d = self.plot_widget_1d.getPlotItem()
self.plot_item_1d.setLogMode(True, True)
# 1D Datasets
self.x_data = np.linspace(0, 10, 1000)
def gauss(x, mu, sigma):
return (1 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
# same convention as in line_plot.py
self.y_value_list = [
gauss(self.x_data, 1, 1),
gauss(self.x_data, 1.5, 3),
abs(np.sin(self.x_data)),
abs(np.cos(self.x_data)),
abs(np.sin(2 * self.x_data)),
] # List of y-values for multiple curves
self.curve_names = ["Gauss(1,1)", "Gauss(1.5,3)", "Abs(Sine)", "Abs(Cosine)", "Abs(Sine2x)"]
self.curves = []
##########################
# 2D Plot
##########################
self.plot_widget_2d = pg.PlotWidget(title="2D plot with crosshair and ROI square")
self.data_2D = np.random.random((100, 200))
self.plot_item_2d = self.plot_widget_2d.getPlotItem()
self.image_item = pg.ImageItem(self.data_2D)
self.plot_item_2d.addItem(self.image_item)
##########################
# Table
##########################
self.table = QTableWidget(len(self.curve_names), 2)
self.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"])
self.table.setVerticalHeaderLabels(self.curve_names)
self.table.resizeColumnsToContents()
##########################
# Spinbox for N curves
##########################
self.spin_box = QSpinBox()
self.spin_box.setMinimum(0)
self.spin_box.setMaximum(len(self.y_value_list))
self.spin_box.setValue(2)
self.spin_box.valueChanged.connect(lambda: self.update_curves(self.spin_box.value()))
##########################
# Adding widgets to layout
##########################
##### left side #####
self.column1 = QVBoxLayout()
self.layout.addLayout(self.column1)
# SpinBox
self.spin_row = QHBoxLayout()
self.column1.addLayout(self.spin_row)
self.spin_row.addWidget(QLabel("Number of curves:"))
self.spin_row.addWidget(self.spin_box)
# label
self.clicked_label_1d = QLabel("Clicked Coordinates (1D):")
self.column1.addWidget(self.clicked_label_1d)
# table
self.column1.addWidget(self.table)
# 1D plot
self.column1.addWidget(self.plot_widget_1d)
##### left side #####
self.column2 = QVBoxLayout()
self.layout.addLayout(self.column2)
# labels
self.clicked_label_2d = QLabel("Clicked Coordinates (2D):")
self.moved_label_2d = QLabel("Moved Coordinates (2D):")
self.column2.addWidget(self.clicked_label_2d)
self.column2.addWidget(self.moved_label_2d)
# 2D plot
self.column2.addWidget(self.plot_widget_2d)
self.update_curves(2) # just Gaussian curves
def hook_crosshair(self):
self.crosshair_1d = Crosshair(self.plot_item_1d, precision=10)
self.crosshair_1d.coordinatesChanged1D.connect(
lambda x, y: self.update_table(self.table, x, y, column=0)
)
self.crosshair_1d.coordinatesClicked1D.connect(
lambda x, y: self.update_table(self.table, x, y, column=1)
)
# 2D
self.crosshair_2d = Crosshair(self.plot_item_2d)
self.crosshair_2d.coordinatesChanged2D.connect(
lambda x, y: self.moved_label_2d.setText(f"Mouse Moved Coordinates (2D): x={x}, y={y}")
)
self.crosshair_2d.coordinatesClicked2D.connect(
lambda x, y: self.clicked_label_2d.setText(f"Clicked Coordinates (2D): x={x}, y={y}")
)
def update_table(self, table_widget, x, y_values, column):
"""Update the table with the new coordinates"""
for i, y in enumerate(y_values):
table_widget.setItem(i, column, QTableWidgetItem(f"({x}, {y})"))
table_widget.resizeColumnsToContents()
def update_curves(self, num_curves):
"""Update the number of curves"""
self.plot_item_1d.clear()
# Curves
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
self.plot_item_1d.addLegend()
self.curves = []
y_value_list = self.y_value_list[:num_curves]
for ii, y_value in enumerate(y_value_list):
pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
curve = pg.PlotDataItem(
self.x_data, y_value, pen=pen, skipFiniteCheck=True, name=self.curve_names[ii]
)
self.plot_item_1d.addItem(curve)
self.curves.append(curve)
self.hook_crosshair()
if __name__ == "__main__":
app = QApplication([])
window = ExampleApp()
window.show()
app.exec()

View File

@@ -1,23 +1,19 @@
import os
import threading
import time
import warnings
from typing import Any
import numpy as np
import pyqtgraph
import pyqtgraph as pg
from bec_lib import messages, MessageEndpoints
from bec_lib.redis_connector import MessageObject, RedisConnector
from bec_lib.redis_connector import RedisConnector
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QCheckBox, QTableWidgetItem
from pyqtgraph import mkBrush, mkColor, mkPen
from qtpy.QtWidgets import QTableWidgetItem
from pyqtgraph import mkBrush, mkPen
from pyqtgraph.Qt import QtCore, QtWidgets, uic
from pyqtgraph.Qt.QtCore import pyqtSignal
from bec_widgets.qt_utils import Crosshair, Colors
from bec_widgets.bec_dispatcher import bec_dispatcher
# client = bec_dispatcher.client
from bec_widgets.utils import Crosshair, Colors
from bec_widgets.utils.bec_dispatcher import BECDispatcher
class StreamPlot(QtWidgets.QWidget):
@@ -34,7 +30,7 @@ class StreamPlot(QtWidgets.QWidget):
"""
# Client and device manager from BEC
self.client = bec_dispatcher.client if client is None else client
self.client = BECDispatcher().client if client is None else client
super(StreamPlot, self).__init__()
# Set style for pyqtgraph plots
@@ -58,7 +54,10 @@ class StreamPlot(QtWidgets.QWidget):
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
self.data_retriever = threading.Thread(target=self.on_projection, daemon=True)
self._data_retriever_thread_exit_event = threading.Event()
self.data_retriever = threading.Thread(
target=self.on_projection, args=(self._data_retriever_thread_exit_event,), daemon=True
)
self.data_retriever.start()
##########################
@@ -68,6 +67,11 @@ class StreamPlot(QtWidgets.QWidget):
self.init_curves()
self.hook_crosshair()
def close(self):
super().close()
self._data_retriever_thread_exit_event.set()
self.data_retriever.join()
def init_ui(self):
"""Setup all ui elements"""
##########################
@@ -171,8 +175,7 @@ class StreamPlot(QtWidgets.QWidget):
self.hook_crosshair()
self.init_table()
def splitter_sizes(self):
...
def splitter_sizes(self): ...
def hook_crosshair(self):
self.crosshair_1d = Crosshair(self.plot, precision=4)
@@ -261,14 +264,14 @@ class StreamPlot(QtWidgets.QWidget):
# else:
# return
def on_projection(self):
while True:
def on_projection(self, exit_event):
while not exit_event.is_set():
if self._current_proj is None:
time.sleep(0.1)
continue
endpoint = f"px_stream/projection_{self._current_proj}/data"
msgs = self.client.producer.lrange(topic=endpoint, start=-1, end=-1)
data = [messages.DeviceMessage.loads(msg) for msg in msgs]
data = msgs
if not data:
continue
with np.errstate(divide="ignore", invalid="ignore"):
@@ -316,6 +319,7 @@ if __name__ == "__main__":
print(f"Plotting signals for: {', '.join(value.signals)}")
# Client from dispatcher
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
app = QtWidgets.QApplication([])

View File

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

View File

@@ -1,50 +0,0 @@
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,56 +0,0 @@
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
from qtpy.QtGui import QIcon
from bec_widgets.scan2d_plot import BECScanPlot2D
class BECScanPlot2DPlugin(QPyDesignerCustomWidgetPlugin):
def __init__(self, parent=None):
super().__init__(parent)
self._initialized = False
def initialize(self, formEditor):
if self._initialized:
return
self._initialized = True
def isInitialized(self):
return self._initialized
def createWidget(self, parent):
return BECScanPlot2D(parent)
def name(self):
return "BECScanPlot2D"
def group(self):
return "BEC widgets"
def icon(self):
return QIcon()
def toolTip(self):
return "BEC plot for 2D scans"
def whatsThis(self):
return "BEC plot for 2D scans"
def isContainer(self):
return False
def domXml(self):
return (
'<widget class="BECScanPlot2D" name="BECScanPlot2D">\n'
' <property name="toolTip" >\n'
" <string>BEC plot for 2D scans</string>\n"
" </property>\n"
' <property name="whatsThis" >\n'
" <string>BEC plot for 2D scans in Python using PyQt.</string>\n"
" </property>\n"
"</widget>\n"
)
def includeFile(self):
return "scan2d_plot"

View File

@@ -1,56 +0,0 @@
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
from qtpy.QtGui import QIcon
from bec_widgets.scan_plot import BECScanPlot
class BECScanPlotPlugin(QPyDesignerCustomWidgetPlugin):
def __init__(self, parent=None):
super().__init__(parent)
self._initialized = False
def initialize(self, formEditor):
if self._initialized:
return
self._initialized = True
def isInitialized(self):
return self._initialized
def createWidget(self, parent):
return BECScanPlot(parent)
def name(self):
return "BECScanPlot"
def group(self):
return "BEC widgets"
def icon(self):
return QIcon()
def toolTip(self):
return "BEC plot for scans"
def whatsThis(self):
return "BEC plot for scans"
def isContainer(self):
return False
def domXml(self):
return (
'<widget class="BECScanPlot" name="BECScanPlot">\n'
' <property name="toolTip" >\n'
" <string>BEC plot for scans</string>\n"
" </property>\n"
' <property name="whatsThis" >\n'
" <string>BEC plot for scans in Python using PyQt.</string>\n"
" </property>\n"
"</widget>\n"
)
def includeFile(self):
return "scan_plot"

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

@@ -1,152 +0,0 @@
from threading import RLock
import numpy as np
import pyqtgraph as pg
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
logger = bec_logger.logger
pg.setConfigOptions(background="w", foreground="k", antialias=True)
class BECScanPlot2D(pg.GraphicsView):
def __init__(self, parent=None, background="default"):
super().__init__(parent, background)
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
self._scanID = None
self._scanID_lock = RLock()
self._x_channel = ""
self._y_channel = ""
self._z_channel = ""
self._xpos = []
self._ypos = []
self._x_ind = None
self._y_ind = None
self.plot_item = pg.PlotItem()
self.setCentralItem(self.plot_item)
self.plot_item.setAspectLocked(True)
self.imageItem = pg.ImageItem()
self.plot_item.addItem(self.imageItem)
def reset_plots(self, _scan_segment, metadata):
# TODO: Do we reset in case of a scan type change?
self.imageItem.clear()
# TODO: better to check the number of coordinates in metadata["positions"]?
if metadata["scan_name"] != "grid_scan":
return
positions = [sorted(set(pos)) for pos in zip(*metadata["positions"])]
motors = metadata["scan_motors"]
if self.x_channel and self.y_channel:
self._x_ind = motors.index(self.x_channel) if self.x_channel in motors else None
self._y_ind = motors.index(self.y_channel) if self.y_channel in motors else None
elif not self.x_channel and not self.y_channel:
# Plot the first and second motors along x and y axes respectively
self._x_ind = 0
self._y_ind = 1
else:
logger.warning(
f"X and Y channels should be either both empty or both set in {self.objectName()}"
)
if self._x_ind is None or self._y_ind is None:
return
xpos = positions[self._x_ind]
ypos = positions[self._y_ind]
self._xpos = xpos
self._ypos = ypos
self.imageItem.setImage(np.zeros(shape=(len(xpos), len(ypos))))
w = max(xpos) - min(xpos)
h = max(ypos) - min(ypos)
w_pix = w / (len(xpos) - 1)
h_pix = h / (len(ypos) - 1)
self.imageItem.setRect(min(xpos) - w_pix / 2, min(ypos) - h_pix / 2, w + w_pix, h + h_pix)
self.plot_item.setLabel("bottom", motors[self._x_ind])
self.plot_item.setLabel("left", motors[self._y_ind])
@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
if self._x_ind is None or self._y_ind is None:
return
point_coord = metadata["positions"][scan_segment["point_id"]]
x_coord_ind = self._xpos.index(point_coord[self._x_ind])
y_coord_ind = self._ypos.index(point_coord[self._y_ind])
data = scan_segment["data"]
z_new = data[self.z_channel][self.z_channel]["value"]
image = self.imageItem.image
image[x_coord_ind, y_coord_ind] = z_new
self.imageItem.setImage()
@pyqtProperty(str)
def x_channel(self):
return self._x_channel
@x_channel.setter
def x_channel(self, new_val):
self._x_channel = new_val
self.plot_item.setLabel("bottom", new_val)
@pyqtProperty(str)
def y_channel(self):
return self._y_channel
@y_channel.setter
def y_channel(self, new_val):
self._y_channel = new_val
self.plot_item.setLabel("left", new_val)
@pyqtProperty(str)
def z_channel(self):
return self._z_channel
@z_channel.setter
def z_channel(self, new_val):
self._z_channel = new_val
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
plot = BECScanPlot2D()
# If x_channel and y_channel are both omitted, they will be inferred from each running grid scan
plot.z_channel = "bpm3y"
plot.show()
sys.exit(app.exec())

View File

@@ -1,151 +0,0 @@
import itertools
from threading import RLock
import pyqtgraph as pg
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
logger = bec_logger.logger
pg.setConfigOptions(background="w", foreground="k", antialias=True)
COLORS = ["#fd7f6f", "#7eb0d5", "#b2e061", "#bd7ebe", "#ffb55a"]
class BECScanPlot(pg.GraphicsView):
def __init__(self, parent=None, background="default"):
super().__init__(parent, background)
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
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 = {}
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):
# 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
data = scan_segment["data"]
if self.x_channel not in data:
logger.warning(f"Unknown channel `{self.x_channel}` for X data in {self.objectName()}")
return
x_new = data[self.x_channel][self.x_channel]["value"]
for chan, plot_curve in self.scan_curves.items():
if not chan:
continue
if chan not in data:
logger.warning(f"Unknown channel `{chan}` for Y data in {self.objectName()}")
continue
y_new = data[chan][chan]["value"]
x, y = plot_curve.getData() # TODO: is it a good approach?
if x is None:
x = []
if y is None:
y = []
plot_curve.setData(x=[*x, x_new], y=[*y, y_new])
@pyqtSlot(dict, dict)
def redraw_dap(self, content, _metadata):
data = content["data"]
for chan, plot_curve in self.dap_curves.items():
if not chan:
continue
if chan not in data:
logger.warning(f"Unknown channel `{chan}` for DAP data in {self.objectName()}")
continue
x_new = data[chan]["x"]
y_new = data[chan]["y"]
plot_curve.setData(x=x_new, y=y_new)
@pyqtProperty("QStringList")
def y_channel_list(self):
return self._y_channel_list
@y_channel_list.setter
def y_channel_list(self, new_list):
# TODO: do we want to care about dap/not dap here?
chan_removed = [chan for chan in self._y_channel_list if chan not in new_list]
if chan_removed and chan_removed[0].startswith("dap."):
chan_removed = chan_removed[0].partition("dap.")[-1]
chan_removed_ep = MessageEndpoints.processed_data(chan_removed)
bec_dispatcher.disconnect_slot(self.redraw_dap, chan_removed_ep)
self._y_channel_list = new_list
# Prepare plot for a potentially different list of y channels
self.view.clear()
self.view.addLegend()
colors = itertools.cycle(COLORS)
for y_chan in new_list:
if y_chan.startswith("dap."):
y_chan = y_chan.partition("dap.")[-1]
curves = self.dap_curves
y_chan_ep = MessageEndpoints.processed_data(y_chan)
bec_dispatcher.connect_slot(self.redraw_dap, y_chan_ep)
else:
curves = self.scan_curves
curves[y_chan] = self.view.plot(
x=[], y=[], pen=pg.mkPen(color=next(colors), width=2), name=y_chan
)
if len(new_list) == 1:
self.view.setLabel("left", new_list[0])
@pyqtProperty(str)
def x_channel(self):
return self._x_channel
@x_channel.setter
def x_channel(self, new_val):
self._x_channel = new_val
self.view.setLabel("bottom", new_val)
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
plot = BECScanPlot()
plot.x_channel = "samx"
plot.y_channel_list = ["bpm3y", "bpm6y"]
plot.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,8 @@
from .crosshair import Crosshair
from .colors import Colors
from .validator_delegate import DoubleValidationDelegate
from .bec_table import BECTable
from .bec_connector import BECConnector, ConnectionConfig
from .bec_dispatcher import BECDispatcher
from .rpc_decorator import rpc_public, register_rpc_methods
from .entry_validator import EntryValidator

View File

@@ -0,0 +1,111 @@
# pylint: disable = no-name-in-module,missing-module-docstring
from __future__ import annotations
import time
from typing import Type, Optional
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import Slot as pyqtSlot
from bec_widgets.utils.bec_dispatcher import BECDispatcher
class ConnectionConfig(BaseModel):
"""Configuration for BECConnector mixin class"""
widget_class: str = Field(default="NonSpecifiedWidget", description="The class of the widget.")
gui_id: Optional[str] = Field(
default=None, validate_default=True, description="The GUI ID of the widget."
)
@field_validator("gui_id")
def generate_gui_id(cls, v, values):
"""Generate a GUI ID if none is provided."""
if v is None:
widget_class = values.data["widget_class"]
v = f"{widget_class}_{str(time.time())}"
return v
return v
class BECConnector:
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
USER_ACCESS = ["get_config"]
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
# BEC related connections
self.bec_dispatcher = BECDispatcher()
self.client = self.bec_dispatcher.client if client is None else client
if config:
self.config = config
self.config.widget_class = self.__class__.__name__
else:
print(
f"No initial config found for {self.__class__.__name__}.\n"
f"Initializing with default config."
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
if gui_id:
self.config.gui_id = gui_id
self.gui_id = gui_id
else:
self.gui_id = self.config.gui_id
@pyqtSlot(str)
def set_gui_id(self, gui_id: str) -> None:
"""
Set the GUI ID for the widget.
Args:
gui_id(str): GUI ID
"""
self.config.gui_id = gui_id
self.gui_id = gui_id
def get_obj_by_id(self, obj_id: str):
if obj_id == self.gui_id:
return self
def get_bec_shortcuts(self):
"""Get BEC shortcuts for the widget."""
self.dev = self.client.device_manager.devices
self.scans = self.client.scans
self.queue = self.client.queue
self.scan_storage = self.queue.scan_storage
self.dap = self.client.dap
def update_client(self, client) -> None:
"""Update the client and device manager from BEC and create object for BEC shortcuts.
Args:
client: BEC client
"""
self.client = client
self.get_bec_shortcuts()
@pyqtSlot(ConnectionConfig) # TODO can be also dict
def on_config_update(self, config: ConnectionConfig | dict) -> None:
"""
Update the configuration for the widget.
Args:
config(ConnectionConfig): Configuration settings.
"""
if isinstance(config, dict):
config = ConnectionConfig(**config)
# TODO add error handler
self.config = config
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
"""
Get the configuration of the widget.
Args:
dict_output(bool): If True, return the configuration as a dictionary. If False, return the configuration as a pydantic model.
Returns:
dict: The configuration of the plot widget.
"""
if dict_output:
return self.config.model_dump()
else:
return self.config

View File

@@ -0,0 +1,188 @@
from __future__ import annotations
import argparse
import itertools
import os
from collections.abc import Callable
from typing import Union
import redis
from bec_lib import BECClient, ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
from bec_lib.endpoints import EndpointInfo
# 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 connector"""
def __init__(self, callback) -> None:
self.callback = callback
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 connector"""
def __init__(self, client=None):
super().__init__()
self.client = BECClient() if client is None else client
try:
self.client.start()
except redis.exceptions.ConnectionError:
print("Could not connect to Redis, skipping start of BECClient.")
self._connections = {}
def connect_slot(
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
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 (EndpointInfo | 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.
"""
# Normalise the topics input
if isinstance(topics, (str, EndpointInfo)):
topics = [topics]
endpoint_to_consumer_type = {
(topic.endpoint if isinstance(topic, EndpointInfo) else topic): (
topic.message_op.name if isinstance(topic, EndpointInfo) else "SEND"
)
for topic in topics
}
# Group topics by consumer type
consumer_type_to_endpoints = {}
for endpoint, consumer_type in endpoint_to_consumer_type.items():
if consumer_type not in consumer_type_to_endpoints:
consumer_type_to_endpoints[consumer_type] = []
consumer_type_to_endpoints[consumer_type].append(endpoint)
for consumer_type, endpoints in consumer_type_to_endpoints.items():
topics_key = (
tuple(sorted(endpoints)) if single_callback_for_all_topics else tuple(endpoints)
)
if topics_key not in self._connections:
self._connections[topics_key] = self._create_connection(endpoints, consumer_type)
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, consumer_type: str) -> _Connection:
"""Creates a new connection for given topics."""
def cb(msg):
if isinstance(msg, dict):
msg = msg["data"]
else:
msg = msg.value
for connection_key, connection in self._connections.items():
if set(topics).intersection(connection_key):
if isinstance(msg, list):
msg = msg[0]
connection.signal.emit(msg.content, msg.metadata)
try:
if consumer_type == "STREAM":
self.client.connector.register_stream(topics=topics, cb=cb, newest_only=True)
else:
self.client.connector.register(topics=topics, cb=cb)
except redis.exceptions.ConnectionError:
print("Could not connect to Redis, skipping registration of topics.")
return _Connection(cb)
def _do_disconnect_slot(self, topic, slot):
print(f"Disconnecting {slot} from {topic}")
connection = self._connections[topic]
try:
connection.signal.disconnect(slot)
except TypeError:
print(f"Could not disconnect slot:'{slot}' from topic:'{topic}'")
print("Continue to remove slot:'{slot}' from 'connection.slots'.")
connection.slots.remove(slot)
if not connection.slots:
del self._connections[topic]
def _disconnect_slot_from_topic(self, slot: Callable, topic: str) -> None:
"""A helper method to disconnect a slot from a specific topic.
Args:
slot (Callable): A slot to be disconnected
topic (str): A corresponding topic that can typically be acquired via
bec_lib.MessageEndpoints
"""
connection = self._connections.get(topic)
if connection and slot in connection.slots:
self._do_disconnect_slot(topic, slot)
def disconnect_slot(self, slot: Callable, topics: Union[str, list]) -> None:
"""Disconnect widget's pyqt slot from pub/sub updates on a topic.
Args:
slot (Callable): A slot to be disconnected
topics (str | list): A corresponding topic or list of topics that can typically be acquired via
bec_lib.MessageEndpoints
"""
# Normalise the topics input
if isinstance(topics, (str, EndpointInfo)):
topics = [topics]
endpoints = [
topic.endpoint if isinstance(topic, EndpointInfo) else topic for topic in topics
]
for key, connection in list(self._connections.items()):
if slot in connection.slots:
common_topics = set(endpoints).intersection(key)
if common_topics:
remaining_topics = set(key) - set(endpoints)
# Disconnect slot from common topics
self._do_disconnect_slot(key, slot)
# Reconnect slot to remaining topics if any
if remaining_topics:
self.connect_slot(slot, list(remaining_topics), True)
def disconnect_all(self):
"""Disconnect all slots from all topics."""
for key, connection in list(self._connections.items()):
for slot in list(connection.slots):
self._disconnect_slot_from_topic(slot, key)
# variable holding the Singleton instance of BECDispatcher
_bec_dispatcher = None
def BECDispatcher():
global _bec_dispatcher
if _bec_dispatcher is None:
parser = argparse.ArgumentParser()
parser.add_argument("--bec-client", default=None)
args, _ = parser.parse_known_args()
_bec_dispatcher = _BECDispatcher(args.bec_client)
return _bec_dispatcher

View File

@@ -0,0 +1,65 @@
from typing import Literal
import numpy as np
import pyqtgraph as pg
from qtpy.QtGui import QColor
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
Returns:
list: List of angles calculated using the golden ratio.
"""
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, format: Literal["QColor", "HEX", "RGB"] = "QColor"
) -> list:
"""
Extract num colors from the specified colormap following golden angle distribution and return them in the specified format.
Args:
colormap (str): Name of the colormap.
num (int): Number of requested colors.
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
Returns:
list: List of colors in the specified format.
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.getColors(mode="float")
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 = []
for ii in color_selection[:num]:
color = cmap_colors[int(ii)]
if format.upper() == "HEX":
colors.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
colors.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
colors.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return colors

View File

@@ -0,0 +1,17 @@
class EntryValidator:
def __init__(self, devices):
self.devices = devices
def validate_signal(self, name: str, entry: str = None) -> str:
if name not in self.devices:
raise ValueError(f"Device '{name}' not found in current BEC session")
device = self.devices[name]
description = device.describe()
if entry is None:
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
return entry

View File

@@ -0,0 +1,15 @@
def rpc_public(func):
func.rpc_public = True # Mark the function for later processing by the class decorator
return func
def register_rpc_methods(cls):
"""
Class decorator to scan for rpc_public methods and add them to USER_ACCESS.
"""
if not hasattr(cls, "USER_ACCESS"):
cls.USER_ACCESS = set()
for name, method in cls.__dict__.items():
if getattr(method, "rpc_public", False):
cls.USER_ACCESS.add(name)
return cls

View File

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

View File

@@ -1,4 +1,15 @@
from .monitor import BECMonitor, ConfigDialog
from .motor_map import MotorMap
from .scan_control import ScanControl
from .toolbar import ModularToolBar
from .editor import BECEditor
from .monitor_scatter_2D import BECMonitor2DScatter
from .motor_control import (
MotorControlRelative,
MotorControlAbsolute,
MotorControlSelection,
MotorThread,
MotorCoordinateTable,
)
from .figure import FigureConfig, BECFigure
from .plots import BECWaveform1D, BECCurve, BECPlotBase

View File

@@ -0,0 +1 @@
from .figure import FigureConfig, BECFigure

View File

@@ -0,0 +1,828 @@
# pylint: disable = no-name-in-module,missing-module-docstring
from __future__ import annotations
import itertools
import os
from collections import defaultdict
from typing import Literal, Optional, Type
import numpy as np
import pyqtgraph as pg
import qdarktheme
from pydantic import Field
from pyqtgraph.Qt import uic
from qtpy.QtWidgets import QApplication, QWidget
from qtpy.QtWidgets import QVBoxLayout, QMainWindow
from qtpy.QtCore import Signal as pyqtSignal
from bec_widgets.utils import BECConnector, BECDispatcher, ConnectionConfig
from bec_widgets.widgets.plots import (
BECPlotBase,
BECWaveform1D,
Waveform1DConfig,
WidgetConfig,
BECImageShow,
)
from bec_widgets.widgets.plots.image import ImageConfig
class FigureConfig(ConnectionConfig):
"""Configuration for BECFigure. Inheriting from ConnectionConfig widget_class and gui_id"""
theme: Literal["dark", "light"] = Field("dark", description="The theme of the figure widget.")
num_cols: int = Field(1, description="The number of columns in the figure widget.")
num_rows: int = Field(1, description="The number of rows in the figure widget.")
widgets: dict[str, WidgetConfig] = Field(
{}, description="The list of widgets to be added to the figure widget."
)
class WidgetHandler:
"""Factory for creating and configuring BEC widgets for BECFigure."""
def __init__(self):
self.widget_factory = {
"PlotBase": (BECPlotBase, WidgetConfig),
"Waveform1D": (BECWaveform1D, Waveform1DConfig),
"ImShow": (BECImageShow, ImageConfig),
}
def create_widget(
self,
widget_type: str,
widget_id: str,
parent_figure,
parent_id: str,
config: dict = None,
**axis_kwargs,
) -> BECPlotBase:
"""
Create and configure a widget based on its type.
Args:
widget_type (str): The type of the widget to create.
widget_id (str): Unique identifier for the widget.
parent_id (str): Identifier of the parent figure.
config (dict, optional): Additional configuration for the widget.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECPlotBase: The created and configured widget instance.
"""
entry = self.widget_factory.get(widget_type)
if not entry:
raise ValueError(f"Unsupported widget type: {widget_type}")
widget_class, config_class = entry
if config is not None and isinstance(config, config_class):
config = config.model_dump()
widget_config_dict = {
"widget_class": widget_class.__name__,
"parent_id": parent_id,
"gui_id": widget_id,
**(config if config is not None else {}),
}
widget_config = config_class(**widget_config_dict)
widget = widget_class(
config=widget_config, parent_figure=parent_figure, client=parent_figure.client
)
if axis_kwargs:
widget.set(**axis_kwargs)
return widget
class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
USER_ACCESS = [
"axes",
"widgets",
"add_plot",
"add_image",
"plot",
"image",
"remove",
"change_layout",
"change_theme",
"clear_all",
"get_config",
]
clean_signal = pyqtSignal()
def __init__(
self,
parent: Optional[QWidget] = None,
config: Optional[FigureConfig] = None,
client=None,
gui_id: Optional[str] = None,
) -> None:
if config is None:
config = FigureConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = FigureConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
pg.GraphicsLayoutWidget.__init__(self, parent)
self.widget_handler = WidgetHandler()
# Widget container to reference widgets by 'widget_id'
self._widgets = defaultdict(dict)
# Container to keep track of the grid
self.grid = []
@property
def axes(self) -> list[BECPlotBase]:
"""
Access all widget in BECFigure as a list
Returns:
list[BECPlotBase]: List of all widgets in the figure.
"""
axes = [value for value in self._widgets.values() if isinstance(value, BECPlotBase)]
return axes
@axes.setter
def axes(self, value: list[BECPlotBase]):
self._axes = value
@property
def widgets(self) -> dict:
return self._widgets
@widgets.setter
def widgets(self, value: dict):
self._widgets = value
def add_plot(
self,
x_name: str = None,
y_name: str = None,
x_entry: str = None,
y_entry: str = None,
x: list | np.ndarray = None,
y: list | np.ndarray = None,
color: Optional[str] = None,
label: Optional[str] = None,
validate: bool = True,
row: int = None,
col: int = None,
config=None,
**axis_kwargs,
) -> BECWaveform1D:
"""
Add a Waveform1D plot to the figure at the specified position.
Args:
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
"""
widget_id = self._generate_unique_widget_id()
waveform = self.add_widget(
widget_type="Waveform1D",
widget_id=widget_id,
row=row,
col=col,
config=config,
**axis_kwargs,
)
# TODO remove repetition from .plot method
# User wants to add scan curve
if x_name is not None and y_name is not None and x is None and y is None:
waveform.add_curve_scan(
x_name=x_name,
y_name=y_name,
x_entry=x_entry,
y_entry=y_entry,
validate=validate,
color=color,
label=label,
)
# User wants to add custom curve
elif x is not None and y is not None and x_name is None and y_name is None:
waveform.add_curve_custom(
x=x,
y=y,
color=color,
label=label,
)
return waveform
def plot(
self,
x_name: str = None,
y_name: str = None,
x_entry: str = None,
y_entry: str = None,
x: list | np.ndarray = None,
y: list | np.ndarray = None,
color: Optional[str] = None,
label: Optional[str] = None,
validate: bool = True,
**axis_kwargs,
) -> BECWaveform1D:
"""
Add a 1D waveform plot to the figure. Always access the first waveform widget in the figure.
Args:
x_name(str): The name of the device for the x-axis.
y_name(str): The name of the device for the y-axis.
x_entry(str): The name of the entry for the x-axis.
y_entry(str): The name of the entry for the y-axis.
x(list | np.ndarray): Custom x data to plot.
y(list | np.ndarray): Custom y data to plot.
color(str): The color of the curve.
label(str): The label of the curve.
validate(bool): If True, validate the device names and entries.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECWaveform1D: The waveform plot widget.
"""
waveform = self._find_first_widget_by_class(BECWaveform1D, can_fail=True)
if waveform is not None:
if axis_kwargs:
waveform.set(**axis_kwargs)
else:
waveform = self.add_plot(**axis_kwargs)
# User wants to add scan curve
if x_name is not None and y_name is not None and x is None and y is None:
waveform.add_curve_scan(
x_name=x_name,
y_name=y_name,
x_entry=x_entry,
y_entry=y_entry,
validate=validate,
color=color,
label=label,
)
# User wants to add custom curve
elif x is not None and y is not None and x_name is None and y_name is None:
waveform.add_curve_custom(
x=x,
y=y,
color=color,
label=label,
)
else:
raise ValueError(
"Invalid input. Provide either device names (x_name, y_name) or custom data."
)
return waveform
def image(
self,
monitor: str = None,
color_bar: Literal["simple", "full"] = "full",
color_map: str = "magma",
data: np.ndarray = None,
vrange: tuple[float, float] = None,
**axis_kwargs,
) -> BECImageShow:
"""
Add an image to the figure. Always access the first image widget in the figure.
Args:
monitor(str): The name of the monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
color_map(str): The color map to use for the image.
data(np.ndarray): Custom data to display.
vrange(tuple[float, float]): The range of values to display.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECImageShow: The image widget.
"""
image = self._find_first_widget_by_class(BECImageShow, can_fail=True)
if image is not None:
if axis_kwargs:
image.set(**axis_kwargs)
else:
image = self.add_image(color_bar=color_bar, **axis_kwargs)
# Setting data #TODO check logic if monitor or data are already created
if monitor is not None and data is None:
image.add_monitor_image(
monitor=monitor, color_map=color_map, vrange=vrange, color_bar=color_bar
)
elif data is not None and monitor is None:
image.add_custom_image(
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
)
elif data is None and monitor is None:
# Setting appearance
if vrange is not None:
image.set_vrange(vmin=vrange[0], vmax=vrange[1])
if color_map is not None:
image.set_color_map(color_map)
else:
raise ValueError("Invalid input. Provide either monitor name or custom data.")
return image
def add_image(
self,
monitor: str = None,
color_bar: Literal["simple", "full"] = "full",
color_map: str = "magma",
data: np.ndarray = None,
vrange: tuple[float, float] = None,
row: int = None,
col: int = None,
config=None,
**axis_kwargs,
) -> BECImageShow:
"""
Add an image to the figure at the specified position.
Args:
monitor(str): The name of the monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
color_map(str): The color map to use for the image.
data(np.ndarray): Custom data to display.
vrange(tuple[float, float]): The range of values to display.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs:
Returns:
BECImageShow: The image widget.
"""
widget_id = self._generate_unique_widget_id()
if config is None:
config = ImageConfig(
widget_class="BECImageShow",
gui_id=widget_id,
parent_id=self.gui_id,
color_map=color_map,
color_bar=color_bar,
vrange=vrange,
)
image = self.add_widget(
widget_type="ImShow",
widget_id=widget_id,
row=row,
col=col,
config=config,
**axis_kwargs,
)
# TODO remove repetition from .image method
if monitor is not None and data is None:
image.add_monitor_image(
monitor=monitor, color_map=color_map, vrange=vrange, color_bar=color_bar
)
elif data is not None and monitor is None:
image.add_custom_image(
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
)
elif data is None and monitor is None:
# Setting appearance
if vrange is not None:
image.set_vrange(vmin=vrange[0], vmax=vrange[1])
if color_map is not None:
image.set_color_map(color_map)
return image
def add_widget(
self,
widget_type: Literal["PlotBase", "Waveform1D", "ImShow"] = "PlotBase",
widget_id: str = None,
row: int = None,
col: int = None,
config=None,
**axis_kwargs,
) -> BECPlotBase:
"""
Add a widget to the figure at the specified position.
Args:
widget_type(Literal["PlotBase","Waveform1D"]): The type of the widget to add.
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
"""
if not widget_id:
widget_id = self._generate_unique_widget_id()
if widget_id in self._widgets:
raise ValueError(f"Widget with ID '{widget_id}' already exists.")
widget = self.widget_handler.create_widget(
widget_type=widget_type,
widget_id=widget_id,
parent_figure=self,
parent_id=self.gui_id,
config=config,
**axis_kwargs,
)
# Check if position is occupied
if row is not None and col is not None:
if self.getItem(row, col):
raise ValueError(f"Position at row {row} and column {col} is already occupied.")
else:
widget.config.row = row
widget.config.col = col
# Add widget to the figure
self.addItem(widget, row=row, col=col)
else:
row, col = self._find_next_empty_position()
widget.config.row = row
widget.config.col = col
# Add widget to the figure
self.addItem(widget, row=row, col=col)
# Update num_cols and num_rows based on the added widget
self.config.num_rows = max(self.config.num_rows, row + 1)
self.config.num_cols = max(self.config.num_cols, col + 1)
# Saving config for future referencing
self.config.widgets[widget_id] = widget.config
self._widgets[widget_id] = widget
# Reflect the grid coordinates
self._change_grid(widget_id, row, col)
return widget
def remove(
self,
row: int = None,
col: int = None,
widget_id: str = None,
coordinates: tuple[int, int] = None,
) -> None:
"""
Remove a widget from the figure. Can be removed by its unique identifier or by its coordinates.
Args:
row(int): The row coordinate of the widget to remove.
col(int): The column coordinate of the widget to remove.
widget_id(str): The unique identifier of the widget to remove.
coordinates(tuple[int, int], optional): The coordinates of the widget to remove.
"""
if widget_id:
self._remove_by_id(widget_id)
elif row is not None and col is not None:
self._remove_by_coordinates(row, col)
elif coordinates:
self._remove_by_coordinates(*coordinates)
else:
raise ValueError("Must provide either widget_id or coordinates for removal.")
def change_theme(self, theme: Literal["dark", "light"]) -> None:
"""
Change the theme of the figure widget.
Args:
theme(Literal["dark","light"]): The theme to set for the figure widget.
"""
qdarktheme.setup_theme(theme)
self.setBackground("k" if theme == "dark" else "w")
self.config.theme = theme
def _find_first_widget_by_class(
self, widget_class: Type[BECPlotBase], can_fail: bool = True
) -> BECPlotBase | None:
"""
Find the first widget of a given class in the figure.
Args:
widget_class(Type[BECPlotBase]): The class of the widget to find.
can_fail(bool): If True, the method will return None if no widget is found. If False, it will raise an error.
Returns:
BECPlotBase: The widget of the given class.
"""
for widget_id, widget in self._widgets.items():
if isinstance(widget, widget_class):
return widget
if can_fail:
return None
else:
raise ValueError(f"No widget of class {widget_class} found.")
def _remove_by_coordinates(self, row: int, col: int) -> None:
"""
Remove a widget from the figure by its coordinates.
Args:
row(int): The row coordinate of the widget to remove.
col(int): The column coordinate of the widget to remove.
"""
widget = self._get_widget_by_coordinates(row, col)
if widget:
widget_id = widget.config.gui_id
if widget_id in self._widgets:
self._remove_by_id(widget_id)
def _remove_by_id(self, widget_id: str) -> None:
"""
Remove a widget from the figure by its unique identifier.
Args:
widget_id(str): The unique identifier of the widget to remove.
"""
if widget_id in self._widgets:
widget = self._widgets.pop(widget_id)
widget.cleanup()
self.removeItem(widget)
self.grid[widget.config.row][widget.config.col] = None
self._reindex_grid()
if widget_id in self.config.widgets:
self.config.widgets.pop(widget_id)
print(f"Removed widget {widget_id}.")
else:
raise ValueError(f"Widget with ID '{widget_id}' does not exist.")
def __getitem__(self, key: tuple | str):
if isinstance(key, tuple) and len(key) == 2:
return self._get_widget_by_coordinates(*key)
elif isinstance(key, str):
widget = self._widgets.get(key)
if widget is None:
raise KeyError(f"No widget with ID {key}")
return self._widgets.get(key)
else:
raise TypeError(
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
)
def _get_widget_by_coordinates(self, row: int, col: int) -> BECPlotBase:
"""
Get widget by its coordinates in the figure.
Args:
row(int): the row coordinate
col(int): the column coordinate
Returns:
BECPlotBase: the widget at the given coordinates
"""
widget = self.getItem(row, col)
if widget is None:
raise ValueError(f"No widget at coordinates ({row}, {col})")
return widget
def _find_next_empty_position(self):
"""Find the next empty position (new row) in the figure."""
row, col = 0, 0
while self.getItem(row, col):
row += 1
return row, col
def _generate_unique_widget_id(self):
"""Generate a unique widget ID."""
existing_ids = set(self._widgets.keys())
for i in itertools.count(1):
widget_id = f"widget_{i}"
if widget_id not in existing_ids:
return widget_id
def _change_grid(self, widget_id: str, row: int, col: int):
"""
Change the grid to reflect the new position of the widget.
Args:
widget_id(str): The unique identifier of the widget.
row(int): The new row coordinate of the widget in the figure.
col(int): The new column coordinate of the widget in the figure.
"""
while len(self.grid) <= row:
self.grid.append([])
row = self.grid[row]
while len(row) <= col:
row.append(None)
row[col] = widget_id
def _reindex_grid(self):
"""Reindex the grid to remove empty rows and columns."""
print(f"old grid: {self.grid}")
new_grid = []
for row in self.grid:
new_row = [widget for widget in row if widget is not None]
if new_row:
new_grid.append(new_row)
#
# Update the config of each object to reflect its new position
for row_idx, row in enumerate(new_grid):
for col_idx, widget in enumerate(row):
self._widgets[widget].config.row, self._widgets[widget].config.col = (
row_idx,
col_idx,
)
self.grid = new_grid
self._replot_layout()
def _replot_layout(self):
"""Replot the layout based on the current grid configuration."""
self.clear()
for row_idx, row in enumerate(self.grid):
for col_idx, widget in enumerate(row):
self.addItem(self._widgets[widget], row=row_idx, col=col_idx)
def change_layout(self, max_columns=None, max_rows=None):
"""
Reshuffle the layout of the figure to adjust to a new number of max_columns or max_rows.
If both max_columns and max_rows are provided, max_rows is ignored.
Args:
max_columns (Optional[int]): The new maximum number of columns in the figure.
max_rows (Optional[int]): The new maximum number of rows in the figure.
"""
# Calculate total number of widgets
total_widgets = len(self._widgets)
if max_columns:
# Calculate the required number of rows based on max_columns
required_rows = (total_widgets + max_columns - 1) // max_columns
new_grid = [[None for _ in range(max_columns)] for _ in range(required_rows)]
elif max_rows:
# Calculate the required number of columns based on max_rows
required_columns = (total_widgets + max_rows - 1) // max_rows
new_grid = [[None for _ in range(required_columns)] for _ in range(max_rows)]
else:
# If neither max_columns nor max_rows is specified, just return without changing the layout
return
# Populate the new grid with widgets' IDs
current_idx = 0
for widget_id, widget in self._widgets.items():
row = current_idx // len(new_grid[0])
col = current_idx % len(new_grid[0])
new_grid[row][col] = widget_id
current_idx += 1
self.config.num_rows = row
self.config.num_cols = col
# Update widgets' positions and replot them according to the new grid
self.grid = new_grid
self._reindex_grid() # This method should be updated to handle reshuffling correctly
self._replot_layout() # Assumes this method re-adds widgets to the layout based on self.grid
def clear_all(self):
"""Clear all widgets from the figure and reset to default state"""
for widget in self._widgets.values():
widget.cleanup()
self.clear()
self._widgets = defaultdict(dict)
self.grid = []
theme = self.config.theme
self.config = FigureConfig(
widget_class=self.__class__.__name__, gui_id=self.gui_id, theme=theme
)
##################################################
##################################################
# Debug window
##################################################
##################################################
from qtconsole.inprocess import QtInProcessKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
def __init__(self):
super().__init__()
self.kernel_manager = QtInProcessKernelManager()
self.kernel_manager.start_kernel(show_banner=False)
self.kernel_client = self.kernel_manager.client()
self.kernel_client.start_channels()
self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
# self.set_console_font_size(70)
def shutdown_kernel(self):
self.kernel_client.stop_channels()
self.kernel_manager.shutdown_kernel()
class DebugWindow(QWidget): # pragma: no cover:
"""Debug window for BEC widgets"""
def __init__(self, parent=None):
super().__init__(parent)
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "figure_debug_minimal.ui"), self)
self._init_ui()
self.splitter.setSizes([200, 100])
self.safe_close = False
# self.figure.clean_signal.connect(self.confirm_close)
# console push
self.console.kernel_manager.kernel.shell.push(
{
"fig": self.figure,
"w1": self.w1,
"w2": self.w2,
"w3": self.w3,
"w4": self.w4,
"bec": self.figure.client,
"scans": self.figure.client.scans,
"dev": self.figure.client.device_manager.devices,
}
)
def _init_ui(self):
# Plotting window
self.glw_1_layout = QVBoxLayout(self.glw) # Create a new QVBoxLayout
self.figure = BECFigure(parent=self, gui_id="remote") # Create a new BECDeviceMonitor
self.glw_1_layout.addWidget(self.figure) # Add BECDeviceMonitor to the layout
# add stuff to figure
self._init_figure()
self.console_layout = QVBoxLayout(self.widget_console)
self.console = JupyterConsoleWidget()
self.console_layout.addWidget(self.console)
self.console.set_default_style("linux")
def _init_figure(self):
self.figure.add_widget(widget_type="Waveform1D", row=0, col=0, title="Widget 1")
self.figure.add_widget(widget_type="Waveform1D", row=0, col=1, title="Widget 2")
self.figure.add_image(
title="Image", row=1, col=0, color_map="viridis", color_bar="simple", vrange=(0, 100)
)
self.figure.add_image(title="Image", row=1, col=1, vrange=(0, 100))
self.w1 = self.figure[0, 0]
self.w2 = self.figure[0, 1]
self.w3 = self.figure[1, 0]
self.w4 = self.figure[1, 1]
# curves for w1
self.w1.add_curve_scan("samx", "bpm4i", pen_style="dash")
self.w1.add_curve_custom(
x=[1, 2, 3, 4, 5],
y=[1, 2, 3, 4, 5],
label="curve-custom",
color="blue",
pen_style="dashdot",
)
self.c1 = self.w1.get_config()
# curves for w2
self.w2.add_curve_scan("samx", "bpm3a", pen_style="solid")
self.w2.add_curve_scan("samx", "bpm4d", pen_style="dot")
self.w2.add_curve_custom(
x=[1, 2, 3, 4, 5], y=[5, 4, 3, 2, 1], color="red", pen_style="dashdot"
)
# curves for w3
# self.w3.add_curve_scan("samx", "bpm4i", pen_style="dash")
# self.w3.add_curve_custom(
# x=[1, 2, 3, 4, 5],
# y=[1, 2, 3, 4, 5],
# label="curve-custom",
# color="blue",
# pen_style="dashdot",
# )
# curves for w4
# self.w4.add_curve_scan("samx", "bpm4i", pen_style="dash")
# self.w4.add_curve_custom(
# x=[1, 2, 3, 4, 5],
# y=[1, 2, 3, 4, 5],
# label="curve-custom",
# color="blue",
# pen_style="dashdot",
# )
# Image setting for w3
self.w3.add_monitor_image("eiger", vrange=(0, 100), color_bar="full")
# Image setting for w4
self.w4.add_monitor_image("eiger", vrange=(0, 100), color_map="viridis")
# def confirm_close(self):
# self.safe_close = True
#
# def closeEvent(self, event):
# self.figure.cleanup()
# if self.safe_close == True:
# print("Safe close")
# event.accept()
if __name__ == "__main__": # pragma: no cover
import sys
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication(sys.argv)
win = DebugWindow()
win.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,30 @@
<?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>2104</width>
<height>966</height>
</rect>
</property>
<property name="windowTitle">
<string>Plotting Console</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QWidget" name="glw" native="true"/>
<widget class="QWidget" name="widget_console" native="true"/>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -12,7 +12,12 @@ from qtpy.QtWidgets import (
QLineEdit,
)
from bec_widgets.qt_utils.yaml_dialog import load_yaml, save_yaml
from bec_widgets.utils.yaml_dialog import load_yaml, save_yaml
from bec_widgets.validation import MonitorConfigValidator
from pydantic import ValidationError
from qtpy.QtWidgets import QApplication, QMessageBox
from bec_widgets.utils.bec_dispatcher import BECDispatcher
current_path = os.path.dirname(__file__)
Ui_Form, BaseClass = uic.loadUiType(os.path.join(current_path, "config_dialog.ui"))
@@ -21,7 +26,7 @@ Tab_Ui_Form, Tab_BaseClass = uic.loadUiType(os.path.join(current_path, "tab_temp
# test configs for demonstration purpose
# Configuration for default mode when only devices are monitored
config_default = {
CONFIG_DEFAULT = {
"plot_settings": {
"background_color": "black",
"num_columns": 1,
@@ -31,35 +36,41 @@ config_default = {
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {
"label": "Motor Y",
"signals": [{"name": "samx", "entry": "samx"}],
},
"y": {
"label": "bpm4i",
"signals": [{"name": "bpm4i", "entry": "bpm4i"}],
},
"x_label": "Motor Y",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x": {
"label": "Motor X",
"signals": [{"name": "samx", "entry": "samx"}],
},
"y": {
"label": "Gauss",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "gauss_acd1", "entry": "gauss_adc1"},
{"name": "gauss_acd2", "entry": "gauss_adc2"},
],
},
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [
{"name": "gauss_bpm"},
{"name": "gauss_adc1"},
{"name": "gauss_adc2"},
],
},
}
],
},
],
}
# Configuration which is dynamically changing depending on the scan type
config_scan = {
CONFIG_SCAN_MODE = {
"plot_settings": {
"background_color": "white",
"num_columns": 3,
@@ -70,77 +81,89 @@ config_scan = {
"grid_scan": [
{
"plot_name": "Grid plot 1",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "gauss_adc1", "entry": "gauss_adc1"},
],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}],
},
}
],
},
{
"plot_name": "Grid plot 2",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "gauss_adc1", "entry": "gauss_adc1"},
],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_adc1"}],
},
}
],
},
{
"plot_name": "Grid plot 3",
"x": {"label": "Motor Y", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "gauss_adc2"}],
},
}
],
},
{
"plot_name": "Grid plot 4",
"x": {"label": "Motor Y", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [{"name": "gauss_adc3", "entry": "gauss_adc3"}],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "gauss_adc3"}],
},
}
],
},
],
"line_scan": [
{
"plot_name": "BPM plot",
"x": {"label": "Motor X", "signals": [{"name": "samx"}]},
"y": {
"label": "BPM",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "gauss_adc1", "entry": "gauss_adc1"},
{"name": "gauss_adc2", "entry": "gauss_adc2"},
],
},
"plot_name": "BPM plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Multi",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "Multi",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "samx", "entry": "samx"},
],
},
},
{
"plot_name": "Multi",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "Multi",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "samx", "entry": "samx"},
],
},
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
},
@@ -150,10 +173,25 @@ config_scan = {
class ConfigDialog(QWidget, Ui_Form):
config_updated = pyqtSignal(dict)
def __init__(self, default_config=None):
def __init__(
self,
client=None,
default_config=None,
skip_validation: bool = False,
):
super(ConfigDialog, self).__init__()
self.setupUi(self)
# Client
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
# Init validator
self.skip_validation = skip_validation
if self.skip_validation is False:
self.validator = MonitorConfigValidator(self.dev)
# Connect the Ok/Apply/Cancel buttons
self.pushButton_ok.clicked.connect(self.apply_and_close)
self.pushButton_apply.clicked.connect(self.apply_config)
@@ -329,7 +367,15 @@ class ConfigDialog(QWidget, Ui_Form):
ui = plot_tab.ui
table = ui.tableWidget_y_signals
signals = [
x_signals = [
{
"name": self.safe_text(ui.lineEdit_x_name),
"entry": self.safe_text(ui.lineEdit_x_entry),
}
]
y_signals = [
{
"name": self.safe_text(table.item(row, 0)),
"entry": self.safe_text(table.item(row, 1)),
@@ -339,19 +385,17 @@ class ConfigDialog(QWidget, Ui_Form):
plot_data = {
"plot_name": self.safe_text(ui.lineEdit_plot_title),
"x": {
"label": self.safe_text(ui.lineEdit_x_label),
"signals": [
{
"name": self.safe_text(ui.lineEdit_x_name),
"entry": self.safe_text(ui.lineEdit_x_entry),
}
],
},
"y": {
"label": self.safe_text(ui.lineEdit_y_label),
"signals": signals,
},
"x_label": self.safe_text(ui.lineEdit_x_label),
"y_label": self.safe_text(ui.lineEdit_y_label),
"sources": [
{
"type": "scan_segment",
"signals": {
"x": x_signals,
"y": y_signals,
},
}
],
}
return plot_data
@@ -446,15 +490,14 @@ class ConfigDialog(QWidget, Ui_Form):
plot (QWidget): plot tab widget
plot_config (dict): config for single plot tab
"""
x_config = plot_config.get("x", {})
x_signals = x_config.get("signals", [{}])[0] # Assuming at least one x signal
y_config = plot_config.get("y", {})
y_signals = y_config.get("signals", [])
sources = plot_config.get("sources", [{}])[0]
x_signals = sources.get("signals", {}).get("x", [{}])[0]
y_signals = sources.get("signals", {}).get("y", [])
# LabelBox
plot.ui.lineEdit_plot_title.setText(plot_config.get("plot_name", ""))
plot.ui.lineEdit_x_label.setText(x_config.get("label", ""))
plot.ui.lineEdit_y_label.setText(y_config.get("label", ""))
plot.ui.lineEdit_x_label.setText(plot_config.get("x_label", ""))
plot.ui.lineEdit_y_label.setText(plot_config.get("y_label", ""))
# X axis
plot.ui.lineEdit_x_name.setText(x_signals.get("name", ""))
@@ -499,12 +542,50 @@ class ConfigDialog(QWidget, Ui_Form):
def apply_and_close(self):
new_config = self.apply_config()
self.config_updated.emit(new_config)
self.close()
if self.skip_validation is True:
self.config_updated.emit(new_config)
self.close()
else:
try:
validated_config = self.validator.validate_monitor_config(new_config)
approved_config = validated_config.model_dump()
self.config_updated.emit(approved_config)
self.close()
except ValidationError as e:
error_str = str(e)
formatted_error_message = ConfigDialog.format_validation_error(error_str)
# Display the formatted error message in a popup
QMessageBox.critical(self, "Configuration Error", formatted_error_message)
@staticmethod
def format_validation_error(error_str: str) -> str:
"""
Format the validation error string to be displayed in a popup.
Args:
error_str(str): Error string from the validation error.
"""
error_lines = error_str.split("\n")
# The first line contains the number of errors.
error_header = f"<p><b>{error_lines[0]}</b></p><hr>"
formatted_error_message = error_header
# Skip the first line as it's the header.
error_details = error_lines[1:]
# Iterate through pairs of lines (each error's two lines).
for i in range(0, len(error_details), 2):
location = error_details[i]
message = error_details[i + 1] if i + 1 < len(error_details) else ""
formatted_error_message += f"<p><b>{location}</b><br>{message}</p><hr>"
return formatted_error_message
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
main_app = ConfigDialog()
main_app.show()
main_app.load_config(CONFIG_SCAN_MODE)
app.exec()

View File

@@ -1,23 +1,21 @@
import os
# pylint: disable = no-name-in-module,missing-module-docstring
import time
import pyqtgraph as pg
from pydantic import ValidationError
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, Slot as pyqtSlot
from qtpy.QtWidgets import QApplication, QTableWidgetItem, QWidget, QMessageBox
from pyqtgraph import mkPen, mkBrush
from qtpy import uic
from bec_widgets.bec_dispatcher import bec_dispatcher
from bec_widgets.qt_utils import Crosshair, Colors
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 BECDispatcher
# just for demonstration purposes if script run directly
config_scan_mode = {
CONFIG_SCAN_MODE = {
"plot_settings": {
"background_color": "white",
"num_columns": 3,
@@ -28,72 +26,96 @@ config_scan_mode = {
"grid_scan": [
{
"plot_name": "Grid plot 1",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "gauss_adc1", "entry": "gauss_adc1"},
],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Grid plot 2",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "gauss_adc1", "entry": "gauss_adc1"},
],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Grid plot 3",
"x": {"label": "Motor Y", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Grid plot 4",
"x": {"label": "Motor Y", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [{"name": "gauss_adc3", "entry": "gauss_adc3"}],
},
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
],
"line_scan": [
{
"plot_name": "BPM plot",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "gauss_adc1"},
{"name": "gauss_adc2", "entry": "gauss_adc2"},
],
},
"plot_name": "BPM plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Multi",
"x": {"label": "Motor X", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "Multi",
"signals": [
{"name": "gauss_bpm", "entry": "gauss_bpm"},
{"name": "samx", "entry": ["samx", "samx_setpoint"]},
],
},
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}, {"name": "bpm4i"}],
},
}
],
},
],
},
}
config_simple = {
CONFIG_WRONG = {
"plot_settings": {
"background_color": "black",
"num_columns": 2,
@@ -103,37 +125,121 @@ config_simple = {
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {
"label": "Motor Y",
# "signals": [{"name": "samx", "entry": "samx"}],
"signals": [{"name": "samy"}],
},
"y": {
"label": "bpm4i",
"signals": [{"name": "bpm4i", "entry": "bpm4i"}],
},
"x_label": "Motor Y",
"y_label": "bpm4i",
"sources": [
{
"type": "non_existing_source",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
{
"type": "history",
"scanID": "<scanID>",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
],
},
{
"plot_name": "Gauss plots vs samx",
"x": {
"label": "Motor X",
"signals": [{"name": "samx", "entry": "samx"}],
},
"y": {
"label": "Gauss",
# "signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
"signals": [
{"name": "gauss_bpm"},
{"name": "samy", "entry": "samy"},
],
},
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "non_sense_entry"}],
"y": [
{"name": "non_existing_name"},
{"name": "samy", "entry": "non_existing_entry"},
],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [
{"name": "samx"},
{"name": "samy", "entry": "samx"},
],
},
}
],
},
],
}
config_no_entry = {
CONFIG_SIMPLE = {
"plot_settings": {
"background_color": "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": "bpm4i"}, {"name": "bpm4i"}],
},
}
],
},
],
}
CONFIG_REDIS = {
"plot_settings": {
"background_color": "white",
"axis_width": 2,
"num_columns": 5,
"colormap": "plasma",
"scan_types": False,
@@ -141,14 +247,21 @@ config_no_entry = {
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {"label": "Motor Y", "signals": [{"name": "samx"}]},
"y": {"label": "bpm4i", "signals": [{"name": "bpm4i"}]},
},
{
"plot_name": "Gauss plots vs samx",
"x": {"label": "Motor X", "signals": [{"name": "samx"}]},
"y": {"label": "Gauss", "signals": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}]},
},
"x_label": "Motor 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"}]},
},
],
}
],
}
@@ -161,33 +274,42 @@ class BECMonitor(pg.GraphicsLayoutWidget):
parent=None,
client=None,
config: dict = None,
enable_crosshair: bool = False,
enable_crosshair: bool = True,
gui_id=None,
skip_validation: bool = False,
):
super(BECMonitor, self).__init__(parent=parent)
super().__init__(parent=parent)
# Client and device manager from BEC
self.plot_data = None
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
self.queue = self.client.queue
self.validator = MonitorConfigValidator(self.dev)
self.gui_id = gui_id
if gui_id is None:
self.gui_id = self.__class__.__name__ + str(time.time()) # TODO still in discussion
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)) #TODO connect when ready
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.data = {}
self.database = None
self.crosshairs = None
self.plots = None
@@ -198,9 +320,9 @@ class BECMonitor(pg.GraphicsLayoutWidget):
# TODO make colors accessible to users
self.user_colors = {} # key: (plot_name, y_name, y_entry), value: color
# Connect the update signal to the update plot method #TODO enable when update is fixed
# Connect the update signal to the update plot method
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self.update_plot
self.update_signal, rateLimit=25, slot=self.update_scan_segment_plot
)
# Init UI
@@ -224,11 +346,46 @@ class BECMonitor(pg.GraphicsLayoutWidget):
else: # without incoming data setup the first configuration to the first scan type sorted alphabetically by name
self.plot_data = self.plot_data_config[min(list(self.plot_data_config.keys()))]
# TODO init plot background -> so far not used, I don't like how it is done in extreme.py
# Initialize the database
self.database = self._init_database(self.plot_data)
# Initialize the UI
self._init_ui(self.plot_settings["num_columns"])
if self.scanID is not None:
self.replot_last_scan()
def _init_database(self, plot_data_config: dict, source_type_to_init=None) -> dict:
"""
Initializes or updates the database for the PlotApp.
Args:
plot_data_config(dict): Configuration settings for plots.
source_type_to_init(str, optional): Specific source type to initialize. If None, initialize all.
Returns:
dict: Updated or new database dictionary.
"""
database = {} if source_type_to_init is None else self.database.copy()
for plot in plot_data_config:
for source in plot["sources"]:
source_type = source["type"]
if source_type_to_init and source_type != source_type_to_init:
continue # Skip if not the specified source type
if source_type not in database:
database[source_type] = {}
for axis, signals in source["signals"].items():
for signal in signals:
name = signal["name"]
entry = signal.get("entry", name)
if name not in database[source_type]:
database[source_type][name] = {}
if entry not in database[source_type][name]:
database[source_type][name][entry] = []
return database
def _init_ui(self, num_columns: int = 3) -> None:
"""
Initialize the UI components, create plots and store their grid positions.
@@ -252,7 +409,8 @@ class BECMonitor(pg.GraphicsLayoutWidget):
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}."
"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
@@ -274,69 +432,117 @@ class BECMonitor(pg.GraphicsLayoutWidget):
last_row_cols -= 1
plot_name = plot_config.get("plot_name", "")
x_label = plot_config["x"].get("label", "")
y_label = plot_config["y"].get("label", "")
x_label = plot_config.get("x_label", "")
y_label = plot_config.get("y_label", "")
plot = self.addPlot(row=row, col=col, colspan=colspan, title=plot_name)
plot.setLabel("bottom", x_label)
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, and update table row labels.
This method initializes a nested dictionary `self.curves_data` to store
the curve objects for each x and y signal pair. It also updates the row labels
in `self.tableWidget_crosshair` to include the grid position for each y-value.
Initialize curve data and properties for each plot and data source.
"""
self.curves_data = {}
row_labels = []
for idx, plot_config in enumerate(self.plot_data):
plot_name = plot_config.get("plot_name", "")
plot = self.plots[plot_name]
plot.clear()
y_configs = plot_config["y"]["signals"]
colors_ys = Colors.golden_angle_color(
colormap=self.plot_settings["colormap"], num=len(y_configs)
)
curve_list = []
for i, (y_config, color) in enumerate(zip(y_configs, colors_ys)):
y_name = y_config["name"]
y_entry = y_config["entry"]
user_color = self.user_colors.get((plot_name, y_name, y_entry), None)
color_to_use = user_color if user_color else color
pen_curve = mkPen(color=color_to_use, width=2, style=QtCore.Qt.DashLine)
brush_curve = mkBrush(color=color_to_use)
curve_data = pg.PlotDataItem(
symbolSize=5,
symbolBrush=brush_curve,
pen=pen_curve,
skipFiniteCheck=True,
name=f"{y_name} ({y_entry})",
for source in plot_config["sources"]:
source_type = source["type"]
y_signals = source["signals"].get("y", [])
colors_ys = Colors.golden_angle_color(
colormap=self.plot_settings["colormap"], num=len(y_signals)
)
curve_list.append((y_name, y_entry, curve_data))
plot.addItem(curve_data)
row_labels.append(f"{y_name} ({y_entry}) - {plot_name}")
if source_type not in self.curves_data:
self.curves_data[source_type] = {}
if plot_name not in self.curves_data[source_type]:
self.curves_data[source_type][plot_name] = []
self.curves_data[plot_name] = curve_list
for i, (y_signal, color) in enumerate(zip(y_signals, colors_ys)):
y_name = y_signal["name"]
y_entry = y_signal.get("entry", y_name)
curve_name = f"{y_name} ({y_entry})-{source_type[0].upper()}"
curve_data = self.create_curve(curve_name, color)
plot.addItem(curve_data)
self.curves_data[source_type][plot_name].append((y_name, y_entry, curve_data))
# Hook Crosshair
if self.enable_crosshair == True:
# Render static plot elements
self.update_plot()
# # Hook Crosshair #TODO enable later, currently not working
if self.enable_crosshair is True:
self.hook_crosshair()
def create_curve(self, curve_name: str, color: str) -> pg.PlotDataItem:
"""
Create
Args:
curve_name: Name of the curve
color(str): Color of the curve
Returns:
pg.PlotDataItem: Assigned curve object
"""
user_color = self.user_colors.get(curve_name, None)
color_to_use = user_color if user_color else color
pen_curve = mkPen(color=color_to_use, width=2, style=QtCore.Qt.DashLine)
brush_curve = mkBrush(color=color_to_use)
return pg.PlotDataItem(
symbolSize=5,
symbolBrush=brush_curve,
pen=pen_curve,
skipFiniteCheck=True,
name=curve_name,
)
def hook_crosshair(self) -> None:
"""Hook the crosshair to all plots."""
# TODO can be extended to hook crosshair signal for mouse move/clicked
@@ -345,22 +551,50 @@ class BECMonitor(pg.GraphicsLayoutWidget):
crosshair = Crosshair(plot, precision=3)
self.crosshairs[plot_name] = crosshair
def update_plot(self) -> None:
"""Update the plot data based on the stored data dictionary."""
for plot_name, curve_list in self.curves_data.items():
for y_name, y_entry, curve in curve_list:
x_config = next(
(pc["x"] for pc in self.plot_data if pc.get("plot_name") == plot_name), {}
def update_scan_segment_plot(self):
"""
Update the plot with the latest scan segment data.
"""
self.update_plot(source_type="scan_segment")
def update_plot(self, source_type=None) -> None:
"""
Update the plot data based on the stored data dictionary.
Only updates data for the specified source_type if provided.
"""
for src_type, plots in self.curves_data.items():
if source_type and src_type != source_type:
continue
for plot_name, curve_list in plots.items():
plot_config = next(
(pc for pc in self.plot_data if pc.get("plot_name") == plot_name), None
)
x_signal_config = x_config["signals"][0]
x_name = x_signal_config.get("name", "")
x_entry = x_signal_config.get("entry", x_name)
if not plot_config:
continue
key = (x_name, x_entry, y_name, y_entry)
data_x = self.data.get(key, {}).get("x", [])
data_y = self.data.get(key, {}).get("y", [])
x_name, x_entry = self.extract_x_config(plot_config, src_type)
curve.setData(data_x, data_y)
for y_name, y_entry, curve in curve_list:
data_x = self.database.get(src_type, {}).get(x_name, {}).get(x_entry, [])
data_y = self.database.get(src_type, {}).get(y_name, {}).get(y_entry, [])
curve.setData(data_x, data_y)
def extract_x_config(self, plot_config: dict, source_type: str) -> tuple:
"""Extract the signal configurations for x and y axes from plot_config.
Args:
plot_config (dict): Plot configuration.
Returns:
tuple: Tuple containing the x name and x entry.
"""
x_name, x_entry = None, None
for source in plot_config["sources"]:
if source["type"] == source_type and "x" in source["signals"]:
x_signal = source["signals"]["x"][0]
x_name = x_signal.get("name")
x_entry = x_signal.get("entry", x_name)
return x_name, x_entry
def get_config(self):
"""Return the current configuration settings."""
@@ -368,9 +602,11 @@ class BECMonitor(pg.GraphicsLayoutWidget):
def show_config_dialog(self):
"""Show the configuration dialog."""
from .config_dialog import ConfigDialog
from bec_widgets.widgets import ConfigDialog
dialog = ConfigDialog(default_config=self.config)
dialog = ConfigDialog(
client=self.client, default_config=self.config, skip_validation=self.skip_validation
)
dialog.config_updated.connect(self.on_config_update)
dialog.show()
@@ -382,6 +618,36 @@ class BECMonitor(pg.GraphicsLayoutWidget):
self.client = client
self.dev = self.client.device_manager.devices
def _close_all_plots(self):
"""Close all plots."""
for plot in self.plots.values():
plot.clear()
@pyqtSlot(dict)
def on_instruction(self, msg_content: dict) -> None:
"""
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:
"""
@@ -389,18 +655,73 @@ class BECMonitor(pg.GraphicsLayoutWidget):
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"]
try:
validated_config = self.validator.validate_monitor_config(config)
self.config = validated_config.model_dump()
if self.skip_validation is True:
self.config = config
self._init_config()
except ValidationError as e:
error_message = f"Monitor configuration validation error: {e}"
print(error_message)
# QMessageBox.critical(self, "Configuration Error", error_message) #TODO do better error popups
else:
try:
validated_config = self.validator.validate_monitor_config(config)
self.config = validated_config.model_dump()
self._init_config()
except ValidationError as e:
error_str = str(e)
formatted_error_message = BECMonitor.format_validation_error(error_str)
# Display the formatted error message in a popup
QMessageBox.critical(self, "Configuration Error", formatted_error_message)
@staticmethod
def format_validation_error(error_str: str) -> str:
"""
Format the validation error string to be displayed in a popup.
Args:
error_str(str): Error string from the validation error.
"""
error_lines = error_str.split("\n")
# The first line contains the number of errors.
error_header = f"<p><b>{error_lines[0]}</b></p><hr>"
formatted_error_message = error_header
# Skip the first line as it's the header.
error_details = error_lines[1:]
# Iterate through pairs of lines (each error's two lines).
for i in range(0, len(error_details), 2):
location = error_details[i]
message = error_details[i + 1] if i + 1 < len(error_details) else ""
formatted_error_message += f"<p><b>{location}</b><br>{message}</p><hr>"
return formatted_error_message
def flush(self, flush_all=False, source_type_to_flush=None) -> None:
"""Update or reset the database to match the current configuration.
Args:
flush_all (bool): If True, reset the entire database.
source_type_to_flush (str): Specific source type to reset. Ignored if flush_all is True.
"""
if flush_all:
self.database = self._init_database(self.plot_data)
self.init_curves()
else:
if source_type_to_flush in self.database:
# TODO maybe reinit the database from config again instead of cycle through names/entries
# Reset only the specified source type
for name in self.database[source_type_to_flush]:
for entry in self.database[source_type_to_flush][name]:
self.database[source_type_to_flush][name][entry] = []
# Reset curves for the specified source type
if source_type_to_flush in self.curves_data:
self.init_curves()
@pyqtSlot(dict, dict)
def on_scan_segment(self, msg, metadata):
def on_scan_segment(self, msg: dict, metadata: dict):
"""
Handle new scan segments and saves data to a dictionary. Linked through bec_dispatcher.
@@ -408,7 +729,6 @@ class BECMonitor(pg.GraphicsLayoutWidget):
msg (dict): Message received with scan data.
metadata (dict): Metadata of the scan.
"""
# TODO for scan mode, if there are same names for different plots, the data are assigned multiple times
current_scanID = msg.get("scanID", None)
if current_scanID is None:
return
@@ -417,60 +737,119 @@ class BECMonitor(pg.GraphicsLayoutWidget):
if self.scan_types is False:
self.plot_data = self.plot_data_config
elif self.scan_types is True:
currentName = metadata.get("scan_name")
if currentName is None:
current_name = metadata.get("scan_name")
if current_name is None:
raise ValueError(
f"Scan name not found in metadata. Please check the scan_name in the YAML config or in bec "
f"configuration."
"Scan name not found in metadata. Please check the scan_name in the YAML"
" config or in bec configuration."
)
self.plot_data = self.plot_data_config.get(currentName, [])
if self.plot_data == []:
self.plot_data = self.plot_data_config.get(current_name, None)
if not self.plot_data:
raise ValueError(
f"Scan name {currentName} not found in the YAML config. Please check the scan_name in the "
f"YAML config or in bec configuration."
f"Scan name {current_name} not found in the YAML config. Please check the scan_name in the "
"YAML config or in bec configuration."
)
# Init UI
self._init_ui(self.plot_settings["num_columns"])
self.scanID = current_scanID
self.data = {}
self.init_curves()
self.scan_data = self.queue.scan_storage.find_scan_by_ID(self.scanID)
if not self.scan_data:
print(f"No data found for scanID: {self.scanID}") # TODO better error
return
self.flush(source_type_to_flush="scan_segment")
for plot_config in self.plot_data:
x_config = plot_config["x"]
x_signal_config = x_config["signals"][0] # There is exactly 1 config for x signals
x_name = x_signal_config.get("name", "")
x_entry = x_signal_config.get("entry", [])
y_configs = plot_config["y"]["signals"]
for y_config in y_configs:
y_name = y_config.get("name", "")
y_entry = y_config.get("entry", [])
key = (x_name, x_entry, y_name, y_entry)
data_x = msg["data"].get(x_name, {}).get(x_entry, {}).get("value", None)
data_y = msg["data"].get(y_name, {}).get(y_entry, {}).get("value", None)
if data_x is not None:
self.data.setdefault(key, {}).setdefault("x", []).append(data_x)
if data_y is not None:
self.data.setdefault(key, {}).setdefault("y", []).append(data_y)
self.scan_segment_update()
self.update_signal.emit()
def scan_segment_update(self):
"""
Update the database with data from scan storage based on the provided scanID.
"""
scan_data = self.scan_data.data
for device_name, device_entries in self.database.get("scan_segment", {}).items():
for entry in device_entries.keys():
dataset = scan_data[device_name][entry].val
if dataset:
self.database["scan_segment"][device_name][entry] = dataset
else:
print(f"No data found for {device_name} {entry}")
def replot_last_scan(self):
"""
Replot the last scan.
"""
self.scan_segment_update()
self.update_plot(source_type="scan_segment")
@pyqtSlot(dict)
def on_data_from_redis(self, msg) -> None:
"""
Handle new data sent from redis.
Args:
msg (dict): Message received with data.
"""
# wait until new config is loaded
while "redis" not in self.database:
time.sleep(0.1)
self._init_database(
self.plot_data, source_type_to_init="redis"
) # add database entry for redis dataset
data = msg.get("data", {})
x_data = data.get("x", {})
y_data = data.get("y", {})
# Update x data
if x_data:
x_tag = x_data.get("tag")
self.database["redis"][x_tag][x_tag] = x_data["data"]
# Update y data
for y_tag, y_info in y_data.items():
self.database["redis"][y_tag][y_tag] = y_info["data"]
# Trigger plot update
self.update_plot(source_type="redis")
print(f"database after: {self.database}")
if __name__ == "__main__": # pragma: no cover
import argparse
import json
import sys
from bec_widgets.bec_dispatcher import bec_dispatcher
client = bec_dispatcher.client
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 = BECDispatcher().client
client.start()
app = QApplication(sys.argv)
monitor = BECMonitor(config=config_simple)
monitor = BECMonitor(
config=config,
gui_id=args.id,
skip_validation=False,
)
monitor.show()
# just to test redis data
# redis_data = {
# "x": {"data": [1, 2, 3], "tag": "x_default_tag"},
# "y": {"y_default_tag": {"data": [1, 2, 3]}},
# }
# monitor.on_data_from_redis({"data": redis_data})
sys.exit(app.exec())

View File

@@ -172,7 +172,7 @@
<customwidget>
<class>BECTable</class>
<extends>QTableWidget</extends>
<header>bec_widgets.qt_utils.h</header>
<header>bec_widgets.utils.h</header>
</customwidget>
</customwidgets>
<resources/>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>285</width>
<height>220</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>285</width>
<height>220</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>285</width>
<height>220</height>
</size>
</property>
<property name="windowTitle">
<string>Move Movement Absolute</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QGroupBox" name="motorControl_absolute">
<property name="minimumSize">
<size>
<width>261</width>
<height>195</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>261</width>
<height>195</height>
</size>
</property>
<property name="title">
<string>Move Movement Absolute</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QCheckBox" name="checkBox_save_with_go">
<property name="text">
<string>Save position with Go</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout_3">
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_7">
<property name="text">
<string>Y</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="pushButton_save">
<property name="text">
<string>Save</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_set">
<property name="text">
<string>Set</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_go_absolute">
<property name="text">
<string>Go</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPushButton" name="pushButton_stop">
<property name="text">
<string>Stop Movement</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,298 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>285</width>
<height>405</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>285</width>
<height>405</height>
</size>
</property>
<property name="windowTitle">
<string>Motor Control Relative</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="motorControl">
<property name="minimumSize">
<size>
<width>261</width>
<height>394</height>
</size>
</property>
<property name="title">
<string>Motor Control Relative</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<widget class="QCheckBox" name="checkBox_enableArrows">
<property name="text">
<string>Move with arrow keys</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBox_same_xy">
<property name="text">
<string>Step [X] = Step [Y]</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="step_grid">
<item row="2" column="0">
<widget class="QLabel" name="label_step_y">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Step [Y]</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Decimal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_step_x">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>99.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_step_x">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Step [X]</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QDoubleSpinBox" name="spinBox_step_y">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>99.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="spinBox_precision">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="maximum">
<number>8</number>
</property>
<property name="value">
<number>2</number>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QGridLayout" name="direction_grid">
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<item row="1" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
<widget class="QToolButton" name="toolButton_up">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::UpArrow</enum>
</property>
</widget>
</item>
<item row="2" column="4">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
<widget class="QToolButton" name="toolButton_down">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::DownArrow</enum>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QToolButton" name="toolButton_left">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::LeftArrow</enum>
</property>
</widget>
</item>
<item row="0" column="2">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="3">
<widget class="QToolButton" name="toolButton_right">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::RightArrow</enum>
</property>
</widget>
</item>
<item row="2" column="0">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="2">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QPushButton" name="pushButton_stop">
<property name="text">
<string>Stop Movement</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>285</width>
<height>156</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>285</width>
<height>156</height>
</size>
</property>
<property name="windowTitle">
<string>Motor Control Selection</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="motorSelection">
<property name="minimumSize">
<size>
<width>261</width>
<height>145</height>
</size>
</property>
<property name="title">
<string>Motor Selection</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="1" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Motor X</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="comboBox_motor_x"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Motor Y</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="QPushButton" name="pushButton_connecMotors">
<property name="text">
<string>Connect Motors</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="comboBox_motor_y"/>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>676</width>
<height>667</height>
</rect>
</property>
<property name="windowTitle">
<string>Motor Coordinates Table</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QCheckBox" name="checkBox_resize_auto">
<property name="text">
<string>Resize Auto</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_editColumns">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Edit Custom Column</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Entries Mode:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox_mode">
<property name="enabled">
<bool>true</bool>
</property>
<item>
<property name="text">
<string>Individual</string>
</property>
</item>
<item>
<property name="text">
<string>Start/Stop</string>
</property>
</item>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTableWidget" name="table">
<property name="gridStyle">
<enum>Qt::SolidLine</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="pushButton_importCSV">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Import CSV</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_exportCSV">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Export CSV</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
from .plot_base import AxisConfig, WidgetConfig, BECPlotBase
from .waveform1d import Waveform1DConfig, BECWaveform1D, BECCurve
from .image import BECImageShow, ImageItemConfig, BECImageItem

View File

@@ -0,0 +1,902 @@
from __future__ import annotations
from collections import defaultdict
from typing import Literal, Optional, Any
import numpy as np
import pyqtgraph as pg
from pydantic import Field, BaseModel, ValidationError
from qtpy.QtCore import QThread, QObject
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QWidget
from bec_lib import MessageEndpoints
from bec_widgets.utils import ConnectionConfig, BECConnector
from bec_widgets.widgets.plots import BECPlotBase, WidgetConfig
class ProcessingConfig(BaseModel):
fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.")
log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.")
center_of_mass: Optional[bool] = Field(
False, description="Whether to calculate the center of mass of the monitor data."
)
transpose: Optional[bool] = Field(
False, description="Whether to transpose the monitor data before displaying."
)
rotation: Optional[int] = Field(
None, description="The rotation angle of the monitor data before displaying."
)
class ImageItemConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the image.")
monitor: Optional[str] = Field(None, description="The name of the monitor.")
source: Optional[str] = Field(None, description="The source of the curve.")
color_map: Optional[str] = Field("magma", description="The color map of the image.")
downsample: Optional[bool] = Field(True, description="Whether to downsample the image.")
opacity: Optional[float] = Field(1.0, description="The opacity of the image.")
vrange: Optional[tuple[int, int]] = Field(
None, description="The range of the color bar. If None, the range is automatically set."
)
color_bar: Optional[Literal["simple", "full"]] = Field(
"simple", description="The type of the color bar."
)
autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.")
processing: ProcessingConfig = Field(
default_factory=ProcessingConfig, description="The post processing of the image."
)
class ImageConfig(WidgetConfig):
images: dict[str, ImageItemConfig] = Field(
{},
description="The configuration of the images. The key is the name of the image (source).",
)
class BECImageItem(BECConnector, pg.ImageItem):
USER_ACCESS = [
"set",
"set_fft",
"set_log",
"set_rotation",
"set_transpose",
"set_opacity",
"set_autorange",
"set_color_map",
"set_auto_downsample",
"set_monitor",
"set_vrange",
"get_config",
]
def __init__(
self,
config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None,
parent_image: Optional[BECImageItem] = None,
**kwargs,
):
if config is None:
config = ImageItemConfig(widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
super().__init__(config=config, gui_id=gui_id)
pg.ImageItem.__init__(self)
self.parent_image = parent_image
self.colorbar_bar = None
self._add_color_bar(
self.config.color_bar, self.config.vrange
) # TODO can also support None to not have any colorbar
self.apply_config()
if kwargs:
self.set(**kwargs)
def apply_config(self):
"""
Apply current configuration.
"""
self.set_color_map(self.config.color_map)
self.set_auto_downsample(self.config.downsample)
if self.config.vrange is not None:
self.set_vrange(vrange=self.config.vrange)
def set(self, **kwargs):
"""
Set the properties of the image.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- downsample
- color_map
- monitor
- opacity
- vrange
- fft
- log
- rot
- transpose
"""
method_map = {
"downsample": self.set_auto_downsample,
"color_map": self.set_color_map,
"monitor": self.set_monitor,
"opacity": self.set_opacity,
"vrange": self.set_vrange,
"fft": self.set_fft,
"log": self.set_log,
"rot": self.set_rotation,
"transpose": self.set_transpose,
}
for key, value in kwargs.items():
if key in method_map:
method_map[key](value)
else:
print(f"Warning: '{key}' is not a recognized property.")
def set_fft(self, enable: bool = False):
"""
Set the FFT of the image.
Args:
enable(bool): Whether to perform FFT on the monitor data.
"""
self.config.processing.fft = enable
def set_log(self, enable: bool = False):
"""
Set the log of the image.
Args:
enable(bool): Whether to perform log on the monitor data.
"""
self.config.processing.log = enable
if enable and self.color_bar and self.config.color_bar == "full":
self.color_bar.autoHistogramRange()
def set_rotation(self, deg_90: int = 0):
"""
Set the rotation of the image.
Args:
deg_90(int): The rotation angle of the monitor data before displaying.
"""
self.config.processing.rotation = deg_90
def set_transpose(self, enable: bool = False):
"""
Set the transpose of the image.
Args:
enable(bool): Whether to transpose the image.
"""
self.config.processing.transpose = enable
def set_opacity(self, opacity: float = 1.0):
"""
Set the opacity of the image.
Args:
opacity(float): The opacity of the image.
"""
self.setOpacity(opacity)
self.config.opacity = opacity
def set_autorange(self, autorange: bool = False):
"""
Set the autorange of the color bar.
Args:
autorange(bool): Whether to autorange the color bar.
"""
self.config.autorange = autorange
if self.color_bar is not None:
self.color_bar.autoHistogramRange()
def set_color_map(self, cmap: str = "magma"):
"""
Set the color map of the image.
Args:
cmap(str): The color map of the image.
"""
self.setColorMap(cmap)
if self.color_bar is not None:
if self.config.color_bar == "simple":
self.color_bar.setColorMap(cmap)
elif self.config.color_bar == "full":
self.color_bar.gradient.loadPreset(cmap)
self.config.color_map = cmap
def set_auto_downsample(self, auto: bool = True):
"""
Set the auto downsample of the image.
Args:
auto(bool): Whether to downsample the image.
"""
self.setAutoDownsample(auto)
self.config.downsample = auto
def set_monitor(self, monitor: str):
"""
Set the monitor of the image.
Args:
monitor(str): The name of the monitor.
"""
self.config.monitor = monitor
def set_vrange(self, vmin: float = None, vmax: float = None, vrange: tuple[int, int] = None):
"""
Set the range of the color bar.
Args:
vmin(float): Minimum value of the color bar.
vmax(float): Maximum value of the color bar.
"""
if vrange is not None:
vmin, vmax = vrange
self.setLevels([vmin, vmax])
self.config.vrange = (vmin, vmax)
self.config.autorange = False
if self.color_bar is not None:
if self.config.color_bar == "simple":
self.color_bar.setLevels(low=vmin, high=vmax)
elif self.config.color_bar == "full":
self.color_bar.setLevels(min=vmin, max=vmax)
self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
def _add_color_bar(
self, color_bar_style: str = "simple", vrange: Optional[tuple[int, int]] = None
):
"""
Add color bar to the layout.
Args:
style(Literal["simple,full"]): The style of the color bar.
vrange(tuple[int,int]): The range of the color bar.
"""
if color_bar_style == "simple":
self.color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
if vrange is not None:
self.color_bar.setLevels(low=vrange[0], high=vrange[1])
self.color_bar.setImageItem(self)
self.parent_image.addItem(self.color_bar) # , row=0, col=1)
self.config.color_bar = "simple"
elif color_bar_style == "full":
# Setting histogram
self.color_bar = pg.HistogramLUTItem()
self.color_bar.setImageItem(self)
self.color_bar.gradient.loadPreset(self.config.color_map)
if vrange is not None:
self.color_bar.setLevels(min=vrange[0], max=vrange[1])
self.color_bar.setHistogramRange(
vrange[0] - 0.1 * vrange[0], vrange[1] + 0.1 * vrange[1]
)
# Adding histogram to the layout
self.parent_image.addItem(self.color_bar) # , row=0, col=1)
# save settings
self.config.color_bar = "full"
else:
raise ValueError("style should be 'simple' or 'full'")
class BECImageShow(BECPlotBase):
USER_ACCESS = [
"add_image_by_config",
"get_image_config",
"get_image_list",
"get_image_dict",
"add_monitor_image",
"add_custom_image",
"set_vrange",
"set_color_map",
"set_autorange",
"set_monitor",
"set_processing",
"set_image_properties",
"set_fft",
"set_log",
"set_rotation",
"set_transpose",
"toggle_threading",
"get_config",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"lock_aspect_ratio",
"plot",
"remove",
]
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[ImageConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = ImageConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
self._images = defaultdict(dict)
self.apply_config(self.config)
self.processor = ImageProcessor()
self.use_threading = False # TODO WILL be moved to the init method and to figure method
def _create_thread_worker(self, device: str, image: np.ndarray):
thread = QThread()
worker = ProcessorWorker(self.processor)
worker.moveToThread(thread)
# Connect signals and slots
thread.started.connect(lambda: worker.process_image(device, image))
worker.processed.connect(self.update_image)
worker.finished.connect(thread.quit)
worker.finished.connect(thread.wait)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.start()
def find_widget_by_id(self, item_id: str) -> BECImageItem:
"""
Find the widget by its gui_id.
Args:
item_id(str): The gui_id of the widget.
Returns:
BECImageItem: The widget with the given gui_id.
"""
for source, images in self._images.items():
for monitor, image_item in images.items():
if image_item.gui_id == item_id:
return image_item
def find_image_by_monitor(self, item_id: str) -> BECImageItem:
"""
Find the widget by its gui_id.
Args:
item_id(str): The gui_id of the widget.
Returns:
BECImageItem: The widget with the given gui_id.
"""
for source, images in self._images.items():
for key, value in images.items():
if key == item_id and isinstance(value, BECImageItem):
return value
elif isinstance(value, dict):
result = self.find_image_by_monitor(item_id)
if result is not None:
return result
def apply_config(self, config: dict | WidgetConfig):
"""
Apply the configuration to the 1D waveform widget.
Args:
config(dict|WidgetConfig): Configuration settings.
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
"""
if isinstance(config, dict):
try:
config = ImageConfig(**config)
except ValidationError as e:
print(f"Validation error when applying config to BECImageShow: {e}")
return
self.config = config
self.plot_item.clear()
self.apply_axis_config()
self._images = defaultdict(dict)
# TODO extend by adding image by config
def change_gui_id(self, new_gui_id: str):
"""
Change the GUI ID of the image widget and update the parent_id in all associated curves.
Args:
new_gui_id (str): The new GUI ID to be set for the image widget.
"""
self.gui_id = new_gui_id
self.config.gui_id = new_gui_id
for source, images in self._images.items():
for id, image_item in images.items():
image_item.config.parent_id = new_gui_id
def add_image_by_config(self, config: ImageItemConfig | dict) -> BECImageItem:
"""
Add an image to the widget by configuration.
Args:
config(ImageItemConfig|dict): The configuration of the image.
Returns:
BECImageItem: The image object.
"""
if isinstance(config, dict):
config = ImageItemConfig(**config)
config.parent_id = self.gui_id
name = config.monitor if config.monitor is not None else config.gui_id
image = self._add_image_object(source=config.source, name=name, config=config)
return image
def get_image_config(self, image_id, dict_output: bool = True) -> ImageItemConfig | dict:
"""
Get the configuration of the image.
Args:
image_id(str): The ID of the image.
dict_output(bool): Whether to return the configuration as a dictionary. Defaults to True.
Returns:
ImageItemConfig|dict: The configuration of the image.
"""
for source, images in self._images.items():
for id, image in images.items():
if id == image_id:
if dict_output:
return image.config.dict()
else:
return image.config # TODO check if this works
def get_image_list(self) -> list[BECImageItem]:
"""
Get the list of images.
Returns:
list[BECImageItem]: The list of images.
"""
images = []
for source, images_dict in self._images.items():
for id, image in images_dict.items():
images.append(image)
return images
def get_image_dict(self) -> dict[str, dict[str, BECImageItem]]:
"""
Get all images.
Returns:
dict[str, dict[str, BECImageItem]]: The dictionary of images.
"""
return self._images
def add_monitor_image(
self,
monitor: str,
color_map: Optional[str] = "magma",
color_bar: Optional[Literal["simple", "full"]] = "simple",
downsample: Optional[bool] = True,
opacity: Optional[float] = 1.0,
vrange: Optional[tuple[int, int]] = None,
# post_processing: Optional[PostProcessingConfig] = None,
**kwargs,
) -> BECImageItem:
image_source = "device_monitor"
image_exits = self._check_image_id(monitor, self._images)
if image_exits:
raise ValueError(
f"Monitor with ID '{monitor}' already exists in widget '{self.gui_id}'."
)
image_config = ImageItemConfig(
widget_class="BECImageItem",
parent_id=self.gui_id,
color_map=color_map,
color_bar=color_bar,
downsample=downsample,
opacity=opacity,
vrange=vrange,
# post_processing=post_processing,
**kwargs,
)
image = self._add_image_object(source=image_source, name=monitor, config=image_config)
self._connect_device_monitor(monitor)
return image
def add_custom_image(
self,
name: str,
data: Optional[np.ndarray] = None,
color_map: Optional[str] = "magma",
color_bar: Optional[Literal["simple", "full"]] = "simple",
downsample: Optional[bool] = True,
opacity: Optional[float] = 1.0,
vrange: Optional[tuple[int, int]] = None,
# post_processing: Optional[PostProcessingConfig] = None,
**kwargs,
):
image_source = "device_monitor"
image_exits = self._check_curve_id(name, self._images)
if image_exits:
raise ValueError(f"Monitor with ID '{name}' already exists in widget '{self.gui_id}'.")
image_config = ImageItemConfig(
widget_class="BECImageItem",
parent_id=self.gui_id,
monitor=name,
color_map=color_map,
color_bar=color_bar,
downsample=downsample,
opacity=opacity,
vrange=vrange,
# post_processing=post_processing,
**kwargs,
)
image = self._add_image_object(source=image_source, config=image_config, data=data)
return image
def apply_setting_to_images(
self, setting_method_name: str, args: list, kwargs: dict, image_id: str = None
):
"""
Apply a setting to all images or a specific image by its ID.
Args:
setting_method_name (str): The name of the method to apply (e.g., 'set_color_map').
args (list): Positional arguments for the setting method.
kwargs (dict): Keyword arguments for the setting method.
image_id (str, optional): The ID of the specific image to apply the setting to. If None, applies to all images.
"""
if image_id:
image = self.find_image_by_monitor(image_id)
if image:
getattr(image, setting_method_name)(*args, **kwargs)
else:
for source, images in self._images.items():
for _, image in images.items():
getattr(image, setting_method_name)(*args, **kwargs)
def set_vrange(self, vmin: float, vmax: float, name: str = None):
"""
Set the range of the color bar.
If name is not specified, then set vrange for all images.
Args:
vmin(float): Minimum value of the color bar.
vmax(float): Maximum value of the color bar.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_vrange", args=[vmin, vmax], kwargs={}, image_id=name)
def set_color_map(self, cmap: str, name: str = None):
"""
Set the color map of the image.
If name is not specified, then set color map for all images.
Args:
cmap(str): The color map of the image.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_color_map", args=[cmap], kwargs={}, image_id=name)
def set_autorange(self, enable: bool = False, name: str = None):
"""
Set the autoscale of the image.
Args:
enable(bool): Whether to autoscale the color bar.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_autorange", args=[enable], kwargs={}, image_id=name)
def set_monitor(self, monitor: str, name: str = None):
"""
Set the monitor of the image.
If name is not specified, then set monitor for all images.
Args:
monitor(str): The name of the monitor.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_monitor", args=[monitor], kwargs={}, image_id=name)
def set_processing(self, name: str = None, **kwargs):
"""
Set the post processing of the image.
If name is not specified, then set post processing for all images.
Args:
name(str): The name of the image. If None, apply to all images.
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- fft: bool
- log: bool
- rot: int
- transpose: bool
"""
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
def set_image_properties(self, name: str = None, **kwargs):
"""
Set the properties of the image.
Args:
name(str): The name of the image. If None, apply to all images.
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- downsample: bool
- color_map: str
- monitor: str
- opacity: float
- vrange: tuple[int,int]
- fft: bool
- log: bool
- rot: int
- transpose: bool
"""
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
def set_fft(self, enable: bool = False, name: str = None):
"""
Set the FFT of the image.
If name is not specified, then set FFT for all images.
Args:
enable(bool): Whether to perform FFT on the monitor data.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_fft", args=[enable], kwargs={}, image_id=name)
def set_log(self, enable: bool = False, name: str = None):
"""
Set the log of the image.
If name is not specified, then set log for all images.
Args:
enable(bool): Whether to perform log on the monitor data.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_log", args=[enable], kwargs={}, image_id=name)
def set_rotation(self, deg_90: int = 0, name: str = None):
"""
Set the rotation of the image.
If name is not specified, then set rotation for all images.
Args:
deg_90(int): The rotation angle of the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_rotation", args=[deg_90], kwargs={}, image_id=name)
def set_transpose(self, enable: bool = False, name: str = None):
"""
Set the transpose of the image.
If name is not specified, then set transpose for all images.
Args:
enable(bool): Whether to transpose the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_transpose", args=[enable], kwargs={}, image_id=name)
def toggle_threading(self, use_threading: bool):
"""
Toggle threading for the widgets postprocessing and updating.
Args:
use_threading(bool): Whether to use threading.
"""
self.use_threading = use_threading
if self.use_threading is False and self.thread.isRunning():
self.cleanup()
@pyqtSlot(dict)
def on_image_update(self, msg: dict):
"""
Update the image of the device monitor from bec.
Args:
msg(dict): The message from bec.
"""
data = msg["data"]
device = msg["device"]
image_to_update = self._images["device_monitor"][device]
processing_config = image_to_update.config.processing
self.processor.set_config(processing_config)
if self.use_threading:
print("using threaded version")
self._create_thread_worker(device, data)
else:
print("using NON-threaded version")
data = self.processor.process_image(data)
self.update_image(device, data)
@pyqtSlot(str, np.ndarray)
def update_image(self, device: str, data: np.ndarray):
"""
Update the image of the device monitor.
Args:
device(str): The name of the device.
data(np.ndarray): The data to be updated.
"""
image_to_update = self._images["device_monitor"][device]
image_to_update.updateImage(data, autoLevels=image_to_update.config.autorange)
def _connect_device_monitor(self, monitor: str):
"""
Connect to the device monitor.
Args:
monitor(str): The name of the monitor.
"""
image_item = self.find_image_by_monitor(monitor)
try:
previous_monitor = image_item.config.monitor
except AttributeError:
previous_monitor = None
if previous_monitor != monitor:
if previous_monitor:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor(previous_monitor)
)
if monitor:
self.bec_dispatcher.connect_slot(
self.on_image_update, MessageEndpoints.device_monitor(monitor)
)
image_item.set_monitor(monitor)
def _add_image_object(
self, source: str, name: str, config: ImageItemConfig, data=None
) -> BECImageItem: # TODO fix types
config.parent_id = self.gui_id
image = BECImageItem(config=config, parent_image=self)
self.plot_item.addItem(image)
self._images[source][name] = image
self.config.images[name] = config
if data is not None:
image.setImage(data)
return image
def _check_image_id(self, val: Any, dict_to_check: dict) -> bool:
"""
Check if val is in the values of the dict_to_check or in the values of the nested dictionaries.
Args:
val(Any): Value to check.
dict_to_check(dict): Dictionary to check.
Returns:
bool: True if val is in the values of the dict_to_check or in the values of the nested dictionaries, False otherwise.
"""
if val in dict_to_check.keys():
return True
for key in dict_to_check:
if isinstance(dict_to_check[key], dict):
if self._check_image_id(val, dict_to_check[key]):
return True
return False
def cleanup(self):
"""
Clean up the widget.
"""
print(f"Cleaning up {self.gui_id}")
# for monitor in self._images["device_monitor"]:
# self.bec_dispatcher.disconnect_slot(
# self.on_image_update, MessageEndpoints.device_monitor(monitor)
# )
# if self.thread is not None and self.thread.isRunning():
# self.thread.quit()
# self.thread.wait()
class ImageProcessor:
"""
Class for processing the image data.
"""
def __init__(self, config: ProcessingConfig = None):
if config is None:
config = ProcessingConfig()
self.config = config
def set_config(self, config: ProcessingConfig):
"""
Set the configuration of the processor.
Args:
config(ProcessingConfig): The configuration of the processor.
"""
self.config = config
def FFT(self, data: np.ndarray) -> np.ndarray:
"""
Perform FFT on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.abs(np.fft.fftshift(np.fft.fft2(data)))
def rotation(self, data: np.ndarray, rotate_90: int) -> np.ndarray:
"""
Rotate the data by 90 degrees n times.
Args:
data(np.ndarray): The data to be processed.
rotate_90(int): The number of 90 degree rotations.
Returns:
np.ndarray: The processed data.
"""
return np.rot90(data, k=rotate_90, axes=(0, 1))
def transpose(self, data: np.ndarray) -> np.ndarray:
"""
Transpose the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.transpose(data)
def log(self, data: np.ndarray) -> np.ndarray:
"""
Perform log on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
# TODO this is not final solution -> data should stay as int16
data = data.astype(np.float32)
offset = 1e-6
data_offset = data + offset
return np.log10(data_offset)
# def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality
# return np.unravel_index(np.argmax(data), data.shape)
def process_image(self, data: np.ndarray) -> np.ndarray:
"""
Process the data according to the configuration.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
if self.config.fft:
data = self.FFT(data)
if self.config.rotation is not None:
data = self.rotation(data, self.config.rotation)
if self.config.transpose:
data = self.transpose(data)
if self.config.log:
data = self.log(data)
return data
class ProcessorWorker(QObject):
"""
Worker for processing the image data.
"""
processed = pyqtSignal(str, np.ndarray)
stopRequested = pyqtSignal()
finished = pyqtSignal()
def __init__(self, processor):
super().__init__()
self.processor = processor
self._isRunning = False
self.stopRequested.connect(self.stop)
@pyqtSlot(str, np.ndarray)
def process_image(self, device: str, image: np.ndarray):
"""
Process the image data.
Args:
device(str): The name of the device.
image(np.ndarray): The image data.
"""
self._isRunning = True
processed_image = self.processor.process_image(image)
self._isRunning = False
if not self._isRunning:
self.processed.emit(device, processed_image)
self.finished.emit()
def stop(self):
self._isRunning = False

View File

@@ -0,0 +1,250 @@
from __future__ import annotations
from typing import Literal, Optional
import numpy as np
import pyqtgraph as pg
from pydantic import BaseModel, Field
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig
class AxisConfig(BaseModel):
title: Optional[str] = Field(None, description="The title of the axes.")
x_label: Optional[str] = Field(None, description="The label for the x-axis.")
y_label: Optional[str] = Field(None, description="The label for the y-axis.")
x_scale: Literal["linear", "log"] = Field("linear", description="The scale of the x-axis.")
y_scale: Literal["linear", "log"] = Field("linear", description="The scale of the y-axis.")
x_lim: Optional[tuple] = Field(None, description="The limits of the x-axis.")
y_lim: Optional[tuple] = Field(None, description="The limits of the y-axis.")
x_grid: bool = Field(False, description="Show grid on the x-axis.")
y_grid: bool = Field(False, description="Show grid on the y-axis.")
class WidgetConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent figure of the plot.")
# Coordinates in the figure
row: int = Field(0, description="The row coordinate in the figure.")
col: int = Field(0, description="The column coordinate in the figure.")
# Appearance settings
axis: AxisConfig = Field(
default_factory=AxisConfig, description="The axis configuration of the plot."
)
class BECPlotBase(BECConnector, pg.GraphicsLayout):
USER_ACCESS = [
"get_config",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"lock_aspect_ratio",
"plot",
"remove",
]
def __init__(
self,
parent: Optional[QWidget] = None, # TODO decide if needed for this class
parent_figure=None,
config: Optional[WidgetConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = WidgetConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, config=config, gui_id=gui_id)
pg.GraphicsLayout.__init__(self, parent)
self.figure = parent_figure
self.plot_item = self.addPlot()
self.add_legend()
def set(self, **kwargs) -> None:
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
"""
# Mapping of keywords to setter methods
method_map = {
"title": self.set_title,
"x_label": self.set_x_label,
"y_label": self.set_y_label,
"x_scale": self.set_x_scale,
"y_scale": self.set_y_scale,
"x_lim": self.set_x_lim,
"y_lim": self.set_y_lim,
}
for key, value in kwargs.items():
if key in method_map:
method_map[key](value)
else:
print(f"Warning: '{key}' is not a recognized property.")
def apply_axis_config(self):
"""Apply the axis configuration to the plot widget."""
config_mappings = {
"title": self.config.axis.title,
"x_label": self.config.axis.x_label,
"y_label": self.config.axis.y_label,
"x_scale": self.config.axis.x_scale,
"y_scale": self.config.axis.y_scale,
"x_lim": self.config.axis.x_lim,
"y_lim": self.config.axis.y_lim,
}
self.set(**{k: v for k, v in config_mappings.items() if v is not None})
def set_title(self, title: str):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
"""
self.plot_item.setTitle(title)
self.config.axis.title = title
def set_x_label(self, label: str):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
"""
self.plot_item.setLabel("bottom", label)
self.config.axis.x_label = label
def set_y_label(self, label: str):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
"""
self.plot_item.setLabel("left", label)
self.config.axis.y_label = label
def set_x_scale(self, scale: Literal["linear", "log"] = "linear"):
"""
Set the scale of the x-axis.
Args:
scale(Literal["linear", "log"]): Scale of the x-axis.
"""
self.plot_item.setLogMode(x=(scale == "log"))
self.config.axis.x_scale = scale
def set_y_scale(self, scale: Literal["linear", "log"] = "linear"):
"""
Set the scale of the y-axis.
Args:
scale(Literal["linear", "log"]): Scale of the y-axis.
"""
self.plot_item.setLogMode(y=(scale == "log"))
self.config.axis.y_scale = scale
def set_x_lim(self, *args) -> None:
"""
Set the limits of the x-axis. This method can accept either two separate arguments
for the minimum and maximum x-axis values, or a single tuple containing both limits.
Usage:
set_x_lim(x_min, x_max)
set_x_lim((x_min, x_max))
Args:
*args: A variable number of arguments. Can be two integers (x_min and x_max)
or a single tuple with two integers.
"""
if len(args) == 1 and isinstance(args[0], tuple):
x_min, x_max = args[0]
elif len(args) == 2:
x_min, x_max = args
else:
raise ValueError("set_x_lim expects either two separate arguments or a single tuple")
self.plot_item.setXRange(x_min, x_max)
self.config.axis.x_lim = (x_min, x_max)
def set_y_lim(self, *args) -> None:
"""
Set the limits of the y-axis. This method can accept either two separate arguments
for the minimum and maximum y-axis values, or a single tuple containing both limits.
Usage:
set_y_lim(y_min, y_max)
set_y_lim((y_min, y_max))
Args:
*args: A variable number of arguments. Can be two integers (y_min and y_max)
or a single tuple with two integers.
"""
if len(args) == 1 and isinstance(args[0], tuple):
y_min, y_max = args[0]
elif len(args) == 2:
y_min, y_max = args
else:
raise ValueError("set_y_lim expects either two separate arguments or a single tuple")
self.plot_item.setYRange(y_min, y_max)
self.config.axis.y_lim = (y_min, y_max)
def set_grid(self, x: bool = False, y: bool = False):
"""
Set the grid of the plot widget.
Args:
x(bool): Show grid on the x-axis.
y(bool): Show grid on the y-axis.
"""
self.plot_item.showGrid(x, y)
self.config.axis.x_grid = x
self.config.axis.y_grid = y
def add_legend(self):
"""Add legend to the plot"""
self.plot_item.addLegend()
def lock_aspect_ratio(self, lock):
"""
Lock aspect ratio.
Args:
lock(bool): True to lock, False to unlock.
"""
self.plot_item.setAspectLocked(lock)
def plot(self, data_x: list | np.ndarray, data_y: list | np.ndarray, **kwargs):
"""
Plot custom data on the plot widget. These data are not saved in config.
Args:
data_x(list|np.ndarray): x-axis data
data_y(list|np.ndarray): y-axis data
**kwargs: Keyword arguments for the plot.
"""
# TODO very basic so far, add more options
# TODO decide name of the method
self.plot_item.plot(data_x, data_y, **kwargs)
def remove(self):
"""Remove the plot widget from the figure."""
if self.figure is not None:
self.figure.remove(widget_id=self.gui_id)
def cleanup(self):
"""Cleanup the plot widget."""

View File

@@ -0,0 +1,731 @@
from __future__ import annotations
from collections import defaultdict
from typing import Literal, Optional, Any
import numpy as np
import pyqtgraph as pg
from pydantic import Field, BaseModel, ValidationError
from pyqtgraph import mkBrush
from qtpy import QtCore
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QWidget
from bec_lib import MessageEndpoints
from bec_lib.scan_data import ScanData
from bec_widgets.utils import Colors, ConnectionConfig, BECConnector, EntryValidator
from bec_widgets.widgets.plots import BECPlotBase, WidgetConfig
class SignalData(BaseModel):
"""The data configuration of a signal in the 1D waveform widget for x and y axis."""
name: str
entry: str
unit: Optional[str] = None # todo implement later
modifier: Optional[str] = None # todo implement later
class Signal(BaseModel):
"""The configuration of a signal in the 1D waveform widget."""
source: str
x: SignalData
y: SignalData
class CurveConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the curve.")
label: Optional[str] = Field(None, description="The label of the curve.")
color: Optional[Any] = Field(None, description="The color of the curve.")
symbol: Optional[str] = Field("o", description="The symbol of the curve.")
symbol_color: Optional[str] = Field(None, description="The color of the symbol of the curve.")
symbol_size: Optional[int] = Field(5, description="The size of the symbol of the curve.")
pen_width: Optional[int] = Field(2, description="The width of the pen of the curve.")
pen_style: Optional[Literal["solid", "dash", "dot", "dashdot"]] = Field(
"solid", description="The style of the pen of the curve."
)
source: Optional[str] = Field(None, description="The source of the curve.")
signals: Optional[Signal] = Field(None, description="The signal of the curve.")
class Waveform1DConfig(WidgetConfig):
color_palette: Literal["plasma", "viridis", "inferno", "magma"] = Field(
"plasma", description="The color palette of the figure widget."
)
curves: dict[str, CurveConfig] = Field(
{}, description="The list of curves to be added to the 1D waveform widget."
)
class BECCurve(BECConnector, pg.PlotDataItem):
USER_ACCESS = [
"set",
"set_data",
"set_color",
"set_symbol",
"set_symbol_color",
"set_symbol_size",
"set_pen_width",
"set_pen_style",
"get_data",
]
def __init__(
self,
name: Optional[str] = None,
config: Optional[CurveConfig] = None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = CurveConfig(label=name, widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
# config.widget_class = self.__class__.__name__
super().__init__(config=config, gui_id=gui_id)
pg.PlotDataItem.__init__(self, name=name)
self.apply_config()
if kwargs:
self.set(**kwargs)
def apply_config(self):
pen_style_map = {
"solid": QtCore.Qt.SolidLine,
"dash": QtCore.Qt.DashLine,
"dot": QtCore.Qt.DotLine,
"dashdot": QtCore.Qt.DashDotLine,
}
pen_style = pen_style_map.get(self.config.pen_style, QtCore.Qt.SolidLine)
pen = pg.mkPen(color=self.config.color, width=self.config.pen_width, style=pen_style)
self.setPen(pen)
if self.config.symbol:
symbol_color = self.config.symbol_color or self.config.color
brush = mkBrush(color=symbol_color)
self.setSymbolBrush(brush)
self.setSymbolSize(self.config.symbol_size)
self.setSymbol(self.config.symbol)
def set_data(self, x, y):
if self.config.source == "custom":
self.setData(x, y)
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
def set(self, **kwargs):
"""
Set the properties of the curve.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- color: str
- symbol: str
- symbol_color: str
- symbol_size: int
- pen_width: int
- pen_style: Literal["solid", "dash", "dot", "dashdot"]
"""
# Mapping of keywords to setter methods
method_map = {
"color": self.set_color,
"symbol": self.set_symbol,
"symbol_color": self.set_symbol_color,
"symbol_size": self.set_symbol_size,
"pen_width": self.set_pen_width,
"pen_style": self.set_pen_style,
}
for key, value in kwargs.items():
if key in method_map:
method_map[key](value)
else:
print(f"Warning: '{key}' is not a recognized property.")
def set_color(self, color: str, symbol_color: Optional[str] = None):
"""
Change the color of the curve.
Args:
color(str): Color of the curve.
symbol_color(str, optional): Color of the symbol. Defaults to None.
"""
self.config.color = color
self.config.symbol_color = symbol_color or color
self.apply_config()
def set_symbol(self, symbol: str):
"""
Change the symbol of the curve.
Args:
symbol(str): Symbol of the curve.
"""
self.config.symbol = symbol
self.apply_config()
def set_symbol_color(self, symbol_color: str):
"""
Change the symbol color of the curve.
Args:
symbol_color(str): Color of the symbol.
"""
self.config.symbol_color = symbol_color
self.apply_config()
def set_symbol_size(self, symbol_size: int):
"""
Change the symbol size of the curve.
Args:
symbol_size(int): Size of the symbol.
"""
self.config.symbol_size = symbol_size
self.apply_config()
def set_pen_width(self, pen_width: int):
"""
Change the pen width of the curve.
Args:
pen_width(int): Width of the pen.
"""
self.config.pen_width = pen_width
self.apply_config()
def set_pen_style(self, pen_style: Literal["solid", "dash", "dot", "dashdot"]):
"""
Change the pen style of the curve.
Args:
pen_style(Literal["solid", "dash", "dot", "dashdot"]): Style of the pen.
"""
self.config.pen_style = pen_style
self.apply_config()
def get_data(self) -> tuple[np.ndarray, np.ndarray]:
"""
Get the data of the curve.
Returns:
tuple[np.ndarray,np.ndarray]: X and Y data of the curve.
"""
x_data, y_data = self.getData()
return x_data, y_data
class BECWaveform1D(BECPlotBase):
USER_ACCESS = [
"add_curve_scan",
"add_curve_custom",
"remove_curve",
"scan_history",
"curves",
"get_curve",
"get_curve_config",
"apply_config",
"get_all_data",
"get_config",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"lock_aspect_ratio",
"plot",
"remove",
]
scan_signal_update = pyqtSignal()
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[Waveform1DConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = Waveform1DConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
self._curves_data = defaultdict(dict)
self.scanID = None
# Scan segment update proxy
self.proxy_update_plot = pg.SignalProxy(
self.scan_signal_update, rateLimit=25, slot=self._update_scan_segment_plot
)
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.get_bec_shortcuts()
# Connect dispatcher signals
self.bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
self.entry_validator = EntryValidator(self.dev)
self.add_legend()
self.apply_config(self.config)
def find_widget_by_id(self, item_id: str) -> BECCurve:
"""
Find the curve by its ID.
Args:
item_id(str): ID of the curve.
Returns:
BECCurve: The curve object.
"""
for curve in self.plot_item.curves:
if curve.gui_id == item_id:
return curve
def apply_config(self, config: dict | WidgetConfig, replot_last_scan: bool = False):
"""
Apply the configuration to the 1D waveform widget.
Args:
config(dict|WidgetConfig): Configuration settings.
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
"""
if isinstance(config, dict):
try:
config = Waveform1DConfig(**config)
except ValidationError as e:
print(f"Validation error when applying config to BECWaveform1D: {e}")
return
self.config = config
self.plot_item.clear() # TODO not sure if on the plot or layout level
self.apply_axis_config()
# Reset curves
self._curves_data = defaultdict(dict)
self._curves = self.plot_item.curves
for curve_id, curve_config in self.config.curves.items():
self.add_curve_by_config(curve_config)
if replot_last_scan:
self.scan_history(scan_index=-1)
def change_gui_id(self, new_gui_id: str):
"""
Change the GUI ID of the waveform widget and update the parent_id in all associated curves.
Args:
new_gui_id (str): The new GUI ID to be set for the waveform widget.
"""
# Update the gui_id in the waveform widget itself
self.gui_id = new_gui_id
self.config.gui_id = new_gui_id
for curve in self.curves:
curve.config.parent_id = new_gui_id
def add_curve_by_config(self, curve_config: CurveConfig | dict) -> BECCurve:
"""
Add a curve to the plot widget by its configuration.
Args:
curve_config(CurveConfig|dict): Configuration of the curve to be added.
Returns:
BECCurve: The curve object.
"""
if isinstance(curve_config, dict):
curve_config = CurveConfig(**curve_config)
curve = self._add_curve_object(
name=curve_config.label, source=curve_config.source, config=curve_config
)
return curve
def get_curve_config(self, curve_id: str, dict_output: bool = True) -> CurveConfig | dict:
"""
Get the configuration of a curve by its ID.
Args:
curve_id(str): ID of the curve.
Returns:
CurveConfig|dict: Configuration of the curve.
"""
for source, curves in self._curves_data.items():
if curve_id in curves:
if dict_output:
return curves[curve_id].config.model_dump()
else:
return curves[curve_id].config
@property
def curves(self) -> list[BECCurve]:
"""
Get the curves of the plot widget as a list
Returns:
list: List of curves.
"""
return self._curves
@curves.setter
def curves(self, value: list[BECCurve]):
self._curves = value
def get_curve(self, identifier) -> BECCurve:
"""
Get the curve by its index or ID.
Args:
identifier(int|str): Identifier of the curve. Can be either an integer (index) or a string (curve_id).
Returns:
BECCurve: The curve object.
"""
if isinstance(identifier, int):
return self.plot_item.curves[identifier]
elif isinstance(identifier, str):
for source_type, curves in self._curves_data.items():
if identifier in curves:
return curves[identifier]
raise ValueError(f"Curve with ID '{identifier}' not found.")
else:
raise ValueError("Identifier must be either an integer (index) or a string (curve_id).")
def add_curve_custom(
self,
x: list | np.ndarray,
y: list | np.ndarray,
label: str = None,
color: str = None,
**kwargs,
) -> BECCurve:
"""
Add a custom data curve to the plot widget.
Args:
x(list|np.ndarray): X data of the curve.
y(list|np.ndarray): Y data of the curve.
label(str, optional): Label of the curve. Defaults to None.
color(str, optional): Color of the curve. Defaults to None.
**kwargs: Additional keyword arguments for the curve configuration.
Returns:
BECCurve: The curve object.
"""
curve_source = "custom"
curve_id = label or f"Curve {len(self.plot_item.curves) + 1}"
curve_exits = self._check_curve_id(curve_id, self._curves_data)
if curve_exits:
raise ValueError(
f"Curve with ID '{curve_id}' already exists in widget '{self.gui_id}'."
)
color = (
color
or Colors.golden_angle_color(
colormap=self.config.color_palette, num=len(self.plot_item.curves) + 1, format="HEX"
)[-1]
)
# Create curve by config
curve_config = CurveConfig(
widget_class="BECCurve",
parent_id=self.gui_id,
label=curve_id,
color=color,
source=curve_source,
**kwargs,
)
curve = self._add_curve_object(
name=curve_id, source=curve_source, config=curve_config, data=(x, y)
)
return curve
def _add_curve_object(
self,
name: str,
source: str,
config: CurveConfig,
data: tuple[list | np.ndarray, list | np.ndarray] = None,
) -> BECCurve:
"""
Add a curve object to the plot widget.
Args:
name(str): ID of the curve.
source(str): Source of the curve.
config(CurveConfig): Configuration of the curve.
data(tuple[list|np.ndarray,list|np.ndarray], optional): Data (x,y) to be plotted. Defaults to None.
Returns:
BECCurve: The curve object.
"""
curve = BECCurve(config=config, name=name)
self._curves_data[source][name] = curve
self.plot_item.addItem(curve)
self.config.curves[name] = curve.config
if data is not None:
curve.setData(data[0], data[1])
return curve
def add_curve_scan(
self,
x_name: str,
y_name: str,
x_entry: Optional[str] = None,
y_entry: Optional[str] = None,
color: Optional[str] = None,
label: Optional[str] = None,
validate_bec: bool = True,
**kwargs,
) -> BECCurve:
"""
Add a curve to the plot widget from the scan segment.
Args:
x_name(str): Name of the x signal.
x_entry(str): Entry of the x signal.
y_name(str): Name of the y signal.
y_entry(str): Entry of the y signal.
color(str, optional): Color of the curve. Defaults to None.
label(str, optional): Label of the curve. Defaults to None.
**kwargs: Additional keyword arguments for the curve configuration.
Returns:
BECCurve: The curve object.
"""
# Check if curve already exists
curve_source = "scan_segment"
# Get entry if not provided and validate
x_entry, y_entry = self._validate_signal_entries(
x_name, y_name, x_entry, y_entry, validate_bec
)
label = label or f"{y_name}-{y_entry}"
curve_exits = self._check_curve_id(label, self._curves_data)
if curve_exits:
raise ValueError(f"Curve with ID '{label}' already exists in widget '{self.gui_id}'.")
color = (
color
or Colors.golden_angle_color(
colormap=self.config.color_palette, num=len(self.plot_item.curves) + 1, format="HEX"
)[-1]
)
# Create curve by config
curve_config = CurveConfig(
widget_class="BECCurve",
parent_id=self.gui_id,
label=label,
color=color,
source=curve_source,
signals=Signal(
source=curve_source,
x=SignalData(name=x_name, entry=x_entry),
y=SignalData(name=y_name, entry=y_entry),
),
**kwargs,
)
curve = self._add_curve_object(name=label, source=curve_source, config=curve_config)
return curve
def _validate_signal_entries(
self,
x_name: str,
y_name: str,
x_entry: str | None,
y_entry: str | None,
validate_bec: bool = True,
) -> tuple[str, str]:
"""
Validate the signal name and entry.
Args:
x_name(str): Name of the x signal.
y_name(str): Name of the y signal.
x_entry(str|None): Entry of the x signal.
y_entry(str|None): Entry of the y signal.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
Returns:
tuple[str,str]: Validated x and y entries.
"""
if validate_bec:
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
else:
x_entry = x_name if x_entry is None else x_entry
y_entry = y_name if y_entry is None else y_entry
return x_entry, y_entry
def _check_curve_id(self, val: Any, dict_to_check: dict) -> bool:
"""
Check if val is in the values of the dict_to_check or in the values of the nested dictionaries.
Args:
val(Any): Value to check.
dict_to_check(dict): Dictionary to check.
Returns:
bool: True if val is in the values of the dict_to_check or in the values of the nested dictionaries, False otherwise.
"""
if val in dict_to_check.keys():
return True
for key in dict_to_check:
if isinstance(dict_to_check[key], dict):
if self._check_curve_id(val, dict_to_check[key]):
return True
return False
def remove_curve(self, *identifiers):
"""
Remove a curve from the plot widget.
Args:
*identifiers: Identifier of the curve to be removed. Can be either an integer (index) or a string (curve_id).
"""
for identifier in identifiers:
if isinstance(identifier, int):
self._remove_curve_by_order(identifier)
elif isinstance(identifier, str):
self._remove_curve_by_id(identifier)
else:
raise ValueError(
"Each identifier must be either an integer (index) or a string (curve_id)."
)
def _remove_curve_by_id(self, curve_id):
"""
Remove a curve by its ID from the plot widget.
Args:
curve_id(str): ID of the curve to be removed.
"""
for source, curves in self._curves_data.items():
if curve_id in curves:
curve = curves.pop(curve_id)
self.plot_item.removeItem(curve)
del self.config.curves[curve_id]
if curve in self.plot_item.curves:
self.plot_item.curves.remove(curve)
return
raise KeyError(f"Curve with ID '{curve_id}' not found.")
def _remove_curve_by_order(self, N):
"""
Remove a curve by its order from the plot widget.
Args:
N(int): Order of the curve to be removed.
"""
if N < len(self.plot_item.curves):
curve = self.plot_item.curves[N]
curve_id = curve.name() # Assuming curve's name is used as its ID
self.plot_item.removeItem(curve)
del self.config.curves[curve_id]
# Remove from self.curve_data
for source, curves in self._curves_data.items():
if curve_id in curves:
del curves[curve_id]
break
else:
raise IndexError(f"Curve order {N} out of range.")
@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:
self.scanID = current_scanID
self.scan_segment_data = self.queue.scan_storage.find_scan_by_ID(
self.scanID
) # TODO do scan access through BECFigure
self.scan_signal_update.emit()
def _update_scan_segment_plot(self):
"""Update the plot with the data from the scan segment."""
data = self.scan_segment_data.data
self._update_scan_curves(data)
def _update_scan_curves(self, data: ScanData):
"""
Update the scan curves with the data from the scan segment.
Args:
data(ScanData): Data from the scan segment.
"""
for curve_id, curve in self._curves_data["scan_segment"].items():
x_name = curve.config.signals.x.name
x_entry = curve.config.signals.x.entry
y_name = curve.config.signals.y.name
y_entry = curve.config.signals.y.entry
try:
data_x = data[x_name][x_entry].val
data_y = data[y_name][y_entry].val
except TypeError:
continue
curve.setData(data_x, data_y)
def scan_history(self, scan_index: int = None, scanID: str = None):
"""
Update the scan curves with the data from the scan storage.
Provide only one of scanID or scan_index.
Args:
scanID(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
"""
if scan_index is not None and scanID is not None:
raise ValueError("Only one of scanID or scan_index can be provided.")
if scan_index is not None:
self.scanID = self.queue.scan_storage.storage[scan_index].scanID
data = self.queue.scan_storage.find_scan_by_ID(self.scanID).data
elif scanID is not None:
self.scanID = scanID
data = self.queue.scan_storage.find_scan_by_ID(self.scanID).data
self._update_scan_curves(data)
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict | pd.DataFrame:
"""
Extract all curve data into a dictionary or a pandas DataFrame.
Args:
output (Literal["dict", "pandas"]): Format of the output data.
Returns:
dict | pd.DataFrame: Data of all curves in the specified format.
"""
data = {}
try:
import pandas as pd
except ImportError:
pd = None
if output == "pandas":
print(
"Pandas is not installed. "
"Please install pandas using 'pip install pandas'."
"Output will be dictionary instead."
)
output = "dict"
for curve in self.plot_item.curves:
x_data, y_data = curve.get_data()
if x_data is not None or y_data is not None:
if output == "dict":
data[curve.name()] = {"x": x_data.tolist(), "y": y_data.tolist()}
elif output == "pandas" and pd is not None:
data[curve.name()] = pd.DataFrame({"x": x_data, "y": y_data})
if output == "pandas" and pd is not None:
combined_data = pd.concat(
[data[curve.name()] for curve in self.plot_item.curves],
axis=1,
keys=[curve.name() for curve in self.plot_item.curves],
)
return combined_data
return data
def cleanup(self):
"""Cleanup the widget connection from BECDispatcher."""
self.bec_dispatcher.disconnect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())

View File

@@ -21,7 +21,8 @@ from qtpy.QtWidgets import (
)
from bec_lib import MessageEndpoints
from bec_widgets.qt_utils.widget_io import WidgetIO
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.utils.bec_dispatcher import BECDispatcher
class ScanArgType:
@@ -45,7 +46,7 @@ class ScanControl(QWidget):
super().__init__(parent)
# Client from BEC + shortcuts to device manager and scans
self.client = bec_dispatcher.client if client is None else client
self.client = BECDispatcher().client if client is None else client
self.dev = self.client.device_manager.devices
self.scans = self.client.scans
@@ -118,8 +119,7 @@ class ScanControl(QWidget):
def populate_scans(self):
"""Populates the scan selection combo box with available scans"""
msg = self.client.producer.get(MessageEndpoints.available_scans())
self.available_scans = msgpack.loads(msg)
self.available_scans = self.client.producer.get(MessageEndpoints.available_scans()).resource
if self.allowed_scans is None:
allowed_scans = self.available_scans.keys()
else:
@@ -425,10 +425,8 @@ class ScanControl(QWidget):
# Application example
if __name__ == "__main__": # pragma: no cover
from bec_widgets.bec_dispatcher import bec_dispatcher
# BECclient global variables
client = bec_dispatcher.client
client = BECDispatcher().client
client.start()
app = QApplication([])

View File

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

View File

@@ -9,29 +9,31 @@
:link: introduction
:link-type: ref
General information about BEC Widgets.
General information.
```
```{grid-item-card} User
:link: user
:link-type: ref
Information for users of BEC Widgets.
Information for users.
```
```{grid-item-card} Developer
:link: developer
:link-type: ref
Information for developers of BEC Widgets.
Information for developers.
```
````
```{toctree}
:maxdepth: 2
---
numbered: true
maxdepth: 1
---
introduction/introduction
user/user
developer/developer
```

View File

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

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

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

View File

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

View File

@@ -0,0 +1,34 @@
(user.apps.motor_app)=
# Motor Alignment
The Motor Alignment Application is a key component of the BEC Widgets suite, designed to facilitate precise alignment of motors.
Users can easily launch this app using the script located at `/bec_widgets/example/motor_movement/motor_example.py` script.
The application's primary function is to enable users to align motors to specific positions and to visually track the motor's trajectory.
## Controlling Motors
In the top middle panel of the application, users will find combobox dropdown menus for selecting the motors they wish to track on the x and y axes of the motor map.
These motors are automatically loaded from the current active BEC instance, ensuring seamless integration and ease of use.
There are two primary methods to control motor movements:
1. **Manual Control with Arrow Keys:** Users can manually drive the motors using arrow keys. Before doing so, they need to select the step size for each motor, allowing for precise and incremental movements.
2. **Direct Position Entry:** Alternatively, users can input a desired position in the text input box and then click the Go button. This action will move the motor directly to the specified coordinates.
As the motors are moved, their trajectory is plotted in real-time, providing users with a visual representation of the motor's path. This feature is particularly useful for understanding the movement patterns and making necessary adjustments.
## Saving and Exporting Data
Users have the ability to save the current motor position in a table widget. This functionality is beneficial for recalling and returning to specific positions. By clicking the Go button in the table widget, the motors will automatically move back to the saved position.
Additionally, users can annotate each saved position with notes and comments directly in the table widget. This feature is invaluable for keeping track of specific alignment settings or observations. The contents of the table, including the notes, can be exported to a .csv file. This exported data can be used for initiating scans or for record-keeping purposes.
The table widget also supports saving and loading functionalities, allowing users to preserve their motor positions and notes across sessions. The saved files are in a user-friendly format for ease of access and use.
## Example of Use
![Motor app example](motor_app_10fps.gif)

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

View File

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

View File

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

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

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

View File

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

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

@@ -0,0 +1,41 @@
(user.widgets)=
# Widgets
## Visualization Widgets
BEC Widgets includes a variety of visualization widgets designed to cater to diverse data representation needs in beamline experiments. These widgets enhance the user experience by providing intuitive and interactive data visualizations.
### 1D Waveform Widget
**Purpose:** This widget provides a straightforward visualization of 1D data. It is particularly useful for plotting positioner movements against detector readings, enabling users to observe correlations and patterns in a simple, linear format.
**Key Features:**
- Real-time plotting of positioner versus detector values.
- Interactive controls for zooming and panning through the data.
- Customizable visual elements such as line color and style.
**Example of Use:**
![Waveform 1D](./widgets/w1D.gif)
### 2D Scatter Plot
**Purpose:** The 2D scatter plot widget is designed for more complex data visualization. It employs a false color map to represent a third dimension (z-axis), making it an ideal tool for visualizing multidimensional data sets.
**Key Features:**
- 2D scatter plot with color-coded data points based on a third variable (two positioners for x/y vs. one detector for colormap).
- Interactive false color map for enhanced data interpretation.
- Tools for selecting and inspecting specific data points.
**Example of Use:**
![Waveform 1D](./widgets/scatter_2D.gif)
### Motor Position Map
**Purpose:** A specialized component derived from the Motor Alignment Tool. It's focused on tracking and visualizing the position of motors, crucial for precise alignment and movement tracking during scans.
**Key Features:**
- Real-time tracking of motor positions.
- Visual representation of motor trajectories, aiding in alignment tasks.
- Ability to record and recall specific motor positions for repetitive tasks.
**Example of Use:**
![Waveform 1D](./widgets/motor.gif)

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

View File

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

View File

@@ -1,7 +1,7 @@
# pylint: disable= missing-module-docstring
from setuptools import setup
from setuptools import setup, find_packages
__version__ = "0.32.2"
__version__ = "0.44.4"
# Default to PyQt6 if no other Qt binding is installed
QT_DEPENDENCY = "PyQt6>=6.0"
@@ -30,11 +30,22 @@ if __name__ == "__main__":
"zmq",
"h5py",
"pyqtdarktheme",
"black",
],
extras_require={
"dev": ["pytest", "pytest-random-order", "coverage", "pytest-qt", "black"],
"dev": [
"pytest",
"pytest-random-order",
"pytest-timeout",
"coverage",
"pytest-qt",
"black",
],
"pyqt5": ["PyQt5>=5.9"],
"pyqt6": ["PyQt6>=6.0"],
},
version=__version__,
packages=find_packages(),
include_package_data=True,
package_data={"": ["*.ui", "*.yaml"]},
)

77
tests/client_mocks.py Normal file
View File

@@ -0,0 +1,77 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import pytest
from unittest.mock import MagicMock
class FakeDevice:
"""Fake minimal positioner class for testing."""
def __init__(self, name, enabled=True):
self.name = name
self.enabled = enabled
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name}}
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.signals[self.name]["value"] = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
def get_mocked_device(device_name: str):
"""
Helper function to mock the devices
Args:
device_name(str): Name of the device to mock
"""
return FakeDevice(name=device_name, enabled=True)
@pytest.fixture(scope="function")
def mocked_client():
# Create a dictionary of mocked devices
device_names = [
"samx",
"samy",
"gauss_bpm",
"gauss_adc1",
"gauss_adc2",
"gauss_adc3",
"bpm4i",
"bpm3a",
"bpm3i",
]
mocked_devices = {name: get_mocked_device(name) for name in device_names}
# Create a MagicMock object
client = MagicMock()
# Mock the device_manager.devices attribute
client.device_manager.devices = MagicMock()
client.device_manager.devices.__getitem__.side_effect = lambda x: mocked_devices.get(x)
client.device_manager.devices.__contains__.side_effect = lambda x: x in mocked_devices
# Set each device as an attribute of the mock
for name, device in mocked_devices.items():
setattr(client.device_manager.devices, name, device)
return client

35
tests/conftest.py Normal file
View File

@@ -0,0 +1,35 @@
import pytest
import threading
from bec_lib.bec_service import BECService
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
@pytest.fixture()
def threads_check():
current_threads = set(
th
for th in threading.enumerate()
if "loguru" not in th.name and th is not threading.main_thread()
)
yield
threads_after = set(
th
for th in threading.enumerate()
if "loguru" not in th.name and th is not threading.main_thread()
)
additional_threads = threads_after - current_threads
assert (
len(additional_threads) == 0
), f"Test creates {len(additional_threads)} threads that are not cleaned: {additional_threads}"
@pytest.fixture(autouse=True)
def bec_dispatcher(threads_check):
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
yield bec_dispatcher
bec_dispatcher.disconnect_all()
# clean BEC client
bec_dispatcher.client.shutdown()
# reinitialize singleton for next test
bec_dispatcher_module._bec_dispatcher = None

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