1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-22 08:14:35 +02:00

Compare commits

..

244 Commits

Author SHA1 Message Date
semantic-release 1d84bca753 0.50.0
Automatically generated by python-semantic-release
2024-04-29 08:26:54 +00:00
wyzula_j 4f261be4c7 test(cli/rpc_register): e2e RPCRegister 2024-04-28 18:54:20 +02:00
wyzula_j 40eb75f85a test(cli/rpc_register): rpc_register tests added 2024-04-28 12:47:17 +02:00
wyzula_j 13c018a797 fix(widgets/figure): access pattern changed for getting widgets by coordinates for rpc 2024-04-28 12:42:58 +02:00
wyzula_j 8f20a0b3b1 fix(plots): cleanup policy reviewed for children items 2024-04-28 12:42:58 +02:00
wyzula_j 6b6a6b2249 fix(rpc/client_utils): getoutput more transparent + error handling 2024-04-28 12:42:58 +02:00
wyzula_j 2ca32675ec fix(rpc_register): thread lock for listign all connections 2024-04-28 12:42:58 +02:00
wyzula_j 381d713837 feat(plots): universal cleanup and remove also for children items 2024-04-28 12:42:58 +02:00
wyzula_j a898e7e4f1 feat(rpc/rpc_register): singleton rpc register for all rpc connections for session 2024-04-28 12:42:58 +02:00
semantic-release 6d13a3283b 0.49.1
Automatically generated by python-semantic-release
2024-04-26 16:17:32 +00:00
wyzula_j ab8537483d fix(widgets/editor): qscintilla editor removed 2024-04-26 17:57:54 +02:00
wyzula_j a22229849c build(pyqt6): fixing PyQt6-Qt6 package to 6.6.3 2024-04-25 17:07:29 +02:00
semantic-release 1ba266080c 0.49.0
Automatically generated by python-semantic-release
2024-04-24 15:57:14 +00:00
wyzula_j 6500a00682 feat(rpc/client_utils): timeout for rpc response 2024-04-24 17:49:23 +02:00
wyzula_j 9602085f82 fix(rpc/client_utils): close clean up policy for BECFigure 2024-04-24 10:54:24 +02:00
semantic-release a1c369de9b 0.48.0
Automatically generated by python-semantic-release
2024-04-24 05:29:44 +00:00
wakonig_k 6238693ffb feat(cli): added auto updates plugin support 2024-04-23 15:22:45 +02:00
semantic-release f3a387e77f 0.47.0
Automatically generated by python-semantic-release
2024-04-23 13:12:39 +00:00
wyzula_j 71cb80d544 feat(utils/thread_checker): util class to check the thread leakage for closeEvent in qt 2024-04-23 14:53:13 +02:00
wyzula_j 77ff7962cc refactor(utils/container_utils): part of the logic regarding locating widgets moved from BECFigure to utility class 2024-04-22 12:07:37 +02:00
semantic-release a516b1b247 0.46.7
Automatically generated by python-semantic-release
2024-04-21 16:24:50 +00:00
wyzula_j 67a99a1a19 fix(plot/image): monitors are now validated with current bec session 2024-04-20 01:35:17 +02:00
semantic-release e55daee756 0.46.6
Automatically generated by python-semantic-release
2024-04-19 16:51:51 +00:00
wakonig_k 1111610f32 fix(cli): fixed support for devices as cli input 2024-04-19 18:18:25 +02:00
wakonig_k 81484e8160 ci: changed ophyd default branch to main 2024-04-19 13:34:44 +02:00
semantic-release 2e349bd705 0.46.5
Automatically generated by python-semantic-release
2024-04-19 08:17:58 +00:00
wyzula_j a156803389 test(rpc/bec_figure): test_rpc_plotting_shortcuts_init_configs extended by testing scatter z gradient for BECWaveform through RPC 2024-04-19 01:09:39 +02:00
wyzula_j 2955b5ec02 refactor(rpc/client_utils): update script for grid_scan adds z axis device 2024-04-19 00:17:00 +02:00
wyzula_j ff52100e23 fix(widgets/figure): individual cleanup disabled, making stuck rpc 2024-04-19 00:16:22 +02:00
wyzula_j 026c0792be fix(plots/waveform): colormap is correctly passed from BECFigure 2024-04-19 00:15:04 +02:00
wyzula_j b632ed1095 refactor(examples/jupyter_console_window): jupyter console debugging window moved to examples 2024-04-16 19:47:01 +02:00
semantic-release 98beea37e6 0.46.4
Automatically generated by python-semantic-release
2024-04-16 15:22:38 +00:00
wakonig_k 4bcae0f921 ci: set branch name for semver 2024-04-16 17:10:48 +02:00
wakonig_k 22fb5a5656 ci: fixed multi-project pipeline 2024-04-16 17:00:27 +02:00
guijar_m 4da625e439 fix: renaming of bec_client to bec_ipython_client 2024-04-16 17:00:27 +02:00
wyzula_j 05e268d466 ci: "master" renamed to "main" in semver and pages section 2024-04-16 15:23:22 +02:00
wakonig_k 42a9a0ca15 ci: added workflow .gitlab-ci.yml 2024-04-16 10:06:48 +02:00
wyzula_j b6feb9adb3 ci: CI_MERGE_REQUEST_TARGET_BRANCH_NAME changed to main 2024-04-16 09:53:16 +02:00
wyzula_j 1bc18a201c test(e2e/rpc): rpc e2e tests extended 2024-04-16 09:51:39 +02:00
wyzula_j c12f2cee80 fix(plots/motor_map): user can get data as dict from BECMotorMap 2024-04-16 09:51:39 +02:00
wyzula_j c2c583fce6 fix(plots/image): user can get data as np.ndarray from BECImageItem 2024-04-16 09:51:39 +02:00
wyzula_j 5600624c57 refactor(isort): isort applied 2024-04-16 09:51:39 +02:00
wyzula_j 66c0649d7e ci(tests): unit tests ci path corrected 2024-04-16 09:51:39 +02:00
wyzula_j 2446c401d9 test: unit tests moved to separate folder; scope of autouse bec_dispatcher fixture reduced only for unit tests; ci adjusted 2024-04-16 09:51:39 +02:00
wyzula_j 4d0df364d3 test(end-2-end): rpc end-2-end tests 2024-04-16 09:51:39 +02:00
wyzula_j ecdf0f122b fix(rpc/server): server can accept client or dispatcher 2024-04-16 09:51:39 +02:00
usov_i df5234aa52 ci: pull images via gitlab dependency proxy 2024-04-16 09:31:01 +02:00
usov_i 62080e6b40 Revert "ci: merge AdditionalTests with test stage"
This reverts commit 2e3f46ea36
2024-04-15 16:45:41 +02:00
usov_i 2e3f46ea36 ci: merge AdditionalTests with test stage 2024-04-15 15:01:50 +02:00
wyzula_j be9847e9d2 refactor(plots/image): all rpc widgets can access config_dict as property 2024-04-15 11:45:06 +02:00
wyzula_j 2f7317b328 refactor(plots/image): images are accessed as property .images -> returns list[BECImage] 2024-04-15 11:40:30 +02:00
wakonig_k bd3b1ba043 ci: changed default BEC branch to main 2024-04-12 17:04:42 +02:00
semantic-release 59e82dfd00 0.46.3
Automatically generated by python-semantic-release
2024-04-11 16:27:58 +00:00
wyzula_j 0b86a0009d fix(test_fake_redis): TestMessage fixed to pydantic BaseModel 2024-04-11 15:28:06 +02:00
wyzula_j 49327a8dbd fix(plots/motor_map): removed single callback flag for connecting device_readback motors 2024-04-11 11:53:28 +02:00
guijar_m 301bb916da test(utils/bec_dispatcher): tests fixed 2024-04-11 11:53:28 +02:00
guijar_m 285bf0164b fix(cli/client_utils): print_log is buffered; add output processing thread 2024-04-11 11:53:28 +02:00
guijar_m 90907e0a9c refactor(bec_dispatcher): new BEC dispatcher - rebased 2024-04-11 10:54:46 +02:00
guijar_m 9def3734af fix: producer->connector 2024-04-11 10:50:46 +02:00
semantic-release 3a241e897b 0.46.2
Automatically generated by python-semantic-release
2024-04-10 20:19:43 +00:00
wyzula_j ee617b73a2 fix(widget/plots): added "get_config" to all children of BECConnector to USER_ACCESS 2024-04-10 18:06:37 +02:00
wyzula_j 92cea90971 refactor(utils/bec_dispatcher): new singleton definition 2024-04-10 16:41:28 +02:00
semantic-release 452ba20216 0.46.1
Automatically generated by python-semantic-release
2024-04-10 14:40:35 +00:00
wyzula_j cf29035e28 fix(rpc/client): correct name for RPC class BECWaveform (instead of BECWaveform1D) 2024-04-10 16:34:41 +02:00
semantic-release 3fc09dd2aa 0.46.0
Automatically generated by python-semantic-release
2024-04-09 20:26:07 +00:00
wyzula_j fe101f9328 refactor(widget/monitor_scatter_2D): deleted 2024-04-09 13:26:22 +02:00
wyzula_j 754d81edf3 test(plot/figure): test extended to check shortcuts for creating subplots 2024-04-09 13:26:22 +02:00
wyzula_j 3d399ba1f5 feat(plot/waveform1d): BECWaveform1D can show z data of scatter coded to different detector like BECMonitor2DScatter; BECWaveform1D name changed to BECWaveform 2024-04-09 13:26:22 +02:00
wakonig_k 6dc1000de5 test: fixed default value for scan id 2024-04-06 08:50:19 +02:00
semantic-release 89b4deb5cd 0.45.0
Automatically generated by python-semantic-release
2024-03-26 22:10:53 +00:00
wyzula_j 6e0e69b9f7 test(plot/motor_map): tests extended 2024-03-26 22:53:46 +01:00
wyzula_j b8519e8770 feat(plots/bec_figure): Motor Map integrated to BECFigure 2024-03-26 22:53:46 +01:00
wyzula_j 0f69c346cd feat(plots/bec_motor_map): BECMotorMap build on BECPlotBase 2024-03-26 22:53:46 +01:00
wakonig_k 88014d24c1 docs: added api reference; closes #123 2024-03-26 21:48:46 +01:00
wyzula_j ea4d743a25 test: mock_client unified for all tests 2024-03-26 15:39:42 +01:00
semantic-release 5935d4865c 0.44.5
Automatically generated by python-semantic-release
2024-03-25 12:49:00 +00:00
wyzula_j c5826f8887 fix: circular imports 2024-03-25 13:39:21 +01:00
wyzula_j 62f0b15193 refactor: isort import formatting 2024-03-25 13:21:38 +01:00
wakonig_k d846266332 refactor: renamed scanID to scan_id 2024-03-22 16:15:44 +01:00
semantic-release 06d0331dee 0.44.4
Automatically generated by python-semantic-release
2024-03-22 15:04:58 +00:00
wyzula_j e6b065767c fix(cli/server): thread heartbeat replaced with QTimer 2024-03-22 15:59:53 +01:00
wyzula_j 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
wakonig_k a92aead769 fix(cli): don't call user script if gui is not alive 2024-03-20 22:11:09 +01:00
wakonig_k 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
wyzula_j 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
wakonig_k 203ae09606 fix(cli): removed hard-coded signal 2024-03-18 07:43:32 +01:00
wakonig_k 2d39c5e4d1 fix(cli): fixed cleanup procedure 2024-03-18 07:43:32 +01:00
wakonig_k 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
wyzula_j 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
wyzula_j c319dacb24 fix(utils/bec_dispatcher): BECDispatcher can accept new EndpointInfo dataclass. 2024-03-07 13:46:51 +01:00
usov_i 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
wakonig_k a2ed2ebe00 fix(bec_dispatcher): handle redis connection errors more gracefully 2024-02-26 20:58:46 +01:00
guijar_m 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
wakonig_k f71dc5c5ab fix(cli): fixed property access, rebased 2024-02-26 10:29:15 +01:00
wakonig_k 4630d78fc2 fix(rpc_server): fixed gui_id lookup 2024-02-26 10:25:02 +01:00
wakonig_k 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
wakonig_k 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
wakonig_k 5ebfd2a3c2 test: fixed import in test_validator_errors.py 2024-02-07 17:23:03 +01:00
guijar_m 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
guijar_m 4664568672 fix(tests): ensure BEC service is shutdown after bec dispatcher test 2024-01-20 23:04:51 +01:00
guijar_m 3fb6644543 fix(tests): ensure threads started during plot tests are properly stopped 2024-01-20 23:01:41 +01:00
guijar_m 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
wyzula_j 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
129 changed files with 13798 additions and 4407 deletions
+3 -2
View File
@@ -1,2 +1,3 @@
black --line-length=100 $(git diff --cached --name-only --diff-filter=ACM -- '***.py')
git add $(git diff --cached --name-only --diff-filter=ACM -- '***.py')
black --line-length=100 $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
isort --line-length=100 --profile=black --multi-line=3 --trailing-comma $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
git add $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
+78 -28
View File
@@ -1,10 +1,22 @@
# 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_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
#commands to run in the Docker container before starting each job.
variables:
DOCKER_TLS_CERTDIR: ""
BEC_CORE_BRANCH: "main"
OPHYD_DEVICES_BRANCH: "main"
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_PIPELINE_SOURCE == "pipeline"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
include:
- template: Security/Secret-Detection.gitlab-ci.yml
@@ -15,13 +27,15 @@ stages:
- Formatter
- test
- AdditionalTests
- End2End
- Deploy
formatter:
stage: Formatter
needs: []
script:
- pip install black
- pip install black isort
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color --line-length=100 ./
pylint:
stage: Formatter
@@ -77,47 +91,82 @@ 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]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests
- 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/unit_tests
- coverage report
- coverage xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
reports:
junit: report.xml
cobertura: coverage.xml
#tests-3.9-pyqt5: #todo enable when we decide what qt distributions we want to support
# extends: "tests"
# stage: AdditionalTests
# image: $CI_DOCKER_REGISTRY/python:3.9
# script:
# - apt-get update
# - apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
# - pip install .[dev,pyqt5]
# - pytest -v --random-order ./tests
tests-3.10:
extends: "tests"
stage: AdditionalTests
image: $CI_DOCKER_REGISTRY/python:3.10
allow_failure: true
coverage_report:
coverage_format: cobertura
path: coverage.xml
tests-3.11:
extends: "tests"
stage: AdditionalTests
image: $CI_DOCKER_REGISTRY/python:3.11
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
allow_failure: true
tests-3.12:
extends: "tests"
stage: AdditionalTests
image: $CI_DOCKER_REGISTRY/python:3.12
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
allow_failure: true
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- conda config --prepend channels conda-forge
- conda config --set channel_priority strict
- conda config --set always_yes yes --set changeps1 no
- conda create -q -n test-environment python=3.10
- conda init bash
- source ~/.bashrc
- conda activate test-environment
- 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
- cd ./bec
- source ./bin/install_bec_dev.sh -t
- pip install -e ./bec_lib[dev]
- pip install -e ./bec_ipython_client[dev]
- cd ../
- pip install -e .[dev]
- cd ./tests/end-2-end
- pytest --start-servers --flush-redis --random-order
artifacts:
when: on_failure
paths:
- ./logs/*.log
expire_in: 1 week
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
semver:
stage: Deploy
@@ -139,13 +188,14 @@ 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
-D branch=main
allow_failure: false
rules:
- if: '$CI_COMMIT_REF_NAME == "master"'
- if: '$CI_COMMIT_REF_NAME == "main"'
pages:
stage: Deploy
@@ -156,6 +206,6 @@ pages:
- if: '$CI_COMMIT_TAG != null'
variables:
TARGET_BRANCH: $CI_COMMIT_TAG
- if: '$CI_COMMIT_REF_NAME == "master"'
- if: '$CI_COMMIT_REF_NAME == "main"'
script:
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/
+1 -1
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.
+1 -1
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:
+359
View File
@@ -2,6 +2,365 @@
<!--next-version-placeholder-->
## v0.50.0 (2024-04-29)
### Feature
* **plots:** Universal cleanup and remove also for children items ([`381d713`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/381d713837bb9217c58ba1d8b89691aa35c9f5ec))
* **rpc/rpc_register:** Singleton rpc register for all rpc connections for session ([`a898e7e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a898e7e4f14e9ae854703dddbd1eb8c50cb640ff))
### Fix
* **widgets/figure:** Access pattern changed for getting widgets by coordinates for rpc ([`13c018a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/13c018a79704a7497c140df57179d294e43ecffa))
* **plots:** Cleanup policy reviewed for children items ([`8f20a0b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8f20a0b3b1b5dd117b36b45645717190b9ee9cbf))
* **rpc/client_utils:** Getoutput more transparent + error handling ([`6b6a6b2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6b6a6b2249f24d3d02bd5fcd7ef1c63ed794c304))
* **rpc_register:** Thread lock for listign all connections ([`2ca3267`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2ca32675ec3f00137e2140259db51f6e5aa7bb71))
## v0.49.1 (2024-04-26)
### Fix
* **widgets/editor:** Qscintilla editor removed ([`ab85374`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ab8537483da6c87cb9a0b0f01706208c964f292d))
## v0.49.0 (2024-04-24)
### Feature
* **rpc/client_utils:** Timeout for rpc response ([`6500a00`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6500a00682a2a7ca535a138bd9496ed8470856a8))
### Fix
* **rpc/client_utils:** Close clean up policy for BECFigure ([`9602085`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9602085f82cbc983f89b5bfe48bf35f08438fa87))
## v0.48.0 (2024-04-24)
### Feature
* **cli:** Added auto updates plugin support ([`6238693`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6238693ffb44b47a56b969bc4129f2af7a2c04fe))
## v0.47.0 (2024-04-23)
### Feature
* **utils/thread_checker:** Util class to check the thread leakage for closeEvent in qt ([`71cb80d`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/71cb80d544c5f4ef499379a431ce0c17907c7ce8))
## v0.46.7 (2024-04-21)
### Fix
* **plot/image:** Monitors are now validated with current bec session ([`67a99a1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/67a99a1a19c261f9a1f09635f274cd9fbfe53639))
## v0.46.6 (2024-04-19)
### Fix
* **cli:** Fixed support for devices as cli input ([`1111610`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1111610f3206c5c46db6b4bd1e8827f1a4cd9e3f))
## v0.46.5 (2024-04-19)
### Fix
* **widgets/figure:** Individual cleanup disabled, making stuck rpc ([`ff52100`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ff52100e234debdfb5ccc0869352cfafde52ac93))
* **plots/waveform:** Colormap is correctly passed from BECFigure ([`026c079`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/026c0792bee25723013fffe57ccff10d9b652913))
## v0.46.4 (2024-04-16)
### Fix
* Renaming of bec_client to bec_ipython_client ([`4da625e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4da625e4398bdd937c2b788592f15f7530148292))
* **plots/motor_map:** User can get data as dict from BECMotorMap ([`c12f2ce`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c12f2cee80b13137a2b70e2d121a079e20d124e2))
* **plots/image:** User can get data as np.ndarray from BECImageItem ([`c2c583f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c2c583fce6f28981990c504dd065705124e40e44))
* **rpc/server:** Server can accept client or dispatcher ([`ecdf0f1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ecdf0f122b628ee378b80793d498cedafe50fbf8))
## v0.46.3 (2024-04-11)
### Fix
* **test_fake_redis:** TestMessage fixed to pydantic BaseModel ([`0b86a00`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0b86a0009d9366b710294a3ab55cb9f4894472c0))
* **plots/motor_map:** Removed single callback flag for connecting device_readback motors ([`49327a8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/49327a8dbde270c67bc0ce7c757fd4a3eae118b4))
* **cli/client_utils:** Print_log is buffered; add output processing thread ([`285bf01`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/285bf0164b6deb91678f03ab2a190680b6d83a02))
* Producer->connector ([`9def373`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9def3734afb361ac2d5cc933661766cdc440e09d))
## v0.46.2 (2024-04-10)
### Fix
* **widget/plots:** Added "get_config" to all children of `BECConnector` to USER_ACCESS ([`ee617b7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ee617b73a2fcad8194394182fcecb0dd4f583a8e))
## v0.46.1 (2024-04-10)
### Fix
* **rpc/client:** Correct name for RPC class BECWaveform (instead of BECWaveform1D) ([`cf29035`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cf29035e283e55efa547cbac88e8b622190dfc4d))
## v0.46.0 (2024-04-09)
### Feature
* **plot/waveform1d:** BECWaveform1D can show z data of scatter coded to different detector like BECMonitor2DScatter; BECWaveform1D name changed to BECWaveform ([`3d399ba`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3d399ba1f5d85bc67964febcf8921355f9f1c285))
## v0.45.0 (2024-03-26)
### Feature
* **plots/bec_figure:** Motor Map integrated to BECFigure ([`b8519e8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b8519e8770f8ffc46a1255c18119fc7978ff1d39))
* **plots/bec_motor_map:** BECMotorMap build on BECPlotBase ([`0f69c34`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0f69c346cd24b7afcd23f444525a170e062b0368))
### Documentation
* Added api reference; closes #123 ([`88014d2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/88014d24c1c272a6deea7436a6fa058bdb06fb57))
## v0.44.5 (2024-03-25)
### Fix
* Circular imports ([`c5826f8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c5826f8887ed44d15d05c8ed0e337080b3146c5a))
## 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
+2
View File
@@ -0,0 +1,2 @@
from .auto_updates import AutoUpdates, ScanInfo
from .client import BECFigure
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel
if TYPE_CHECKING:
from .client import BECFigure
class ScanInfo(BaseModel):
scan_id: str
scan_number: int
scan_name: str
scan_report_devices: list
monitored_devices: list
status: str
class AutoUpdates:
def __init__(self, figure: BECFigure, enabled: bool = True):
self.enabled = enabled
self.figure = figure
@staticmethod
def get_scan_info(msg) -> ScanInfo:
"""
Update the script with the given data.
"""
info = msg.info
status = msg.status
scan_id = msg.scan_id
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]
return ScanInfo(
scan_id=scan_id,
scan_number=scan_number,
scan_name=scan_name,
scan_report_devices=scan_report_devices,
monitored_devices=monitored_devices,
status=status,
)
def run(self, msg):
"""
Run the update function if enabled.
"""
if not self.enabled:
return
if msg.status != "open":
return
info = self.get_scan_info(msg)
self.handler(info)
@staticmethod
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 handler(self, info: ScanInfo) -> None:
"""
Default update function.
"""
if info.scan_name == "line_scan" and info.scan_report_devices:
self.simple_line_scan(info)
return
if info.scan_name == "grid_scan" and info.scan_report_devices:
self.simple_grid_scan(info)
return
if info.scan_report_devices:
self.best_effort(info)
return
def simple_line_scan(self, info: ScanInfo) -> None:
"""
Simple line scan.
"""
dev_x = info.scan_report_devices[0]
dev_y = self.get_selected_device(info.monitored_devices, self.figure.selected_device)
if not dev_y:
return
self.figure.clear_all()
plt = self.figure.plot(dev_x, dev_y)
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def simple_grid_scan(self, info: ScanInfo) -> None:
"""
Simple grid scan.
"""
dev_x = info.scan_report_devices[0]
dev_y = info.scan_report_devices[1]
dev_z = self.get_selected_device(info.monitored_devices, self.figure.selected_device)
self.figure.clear_all()
plt = self.figure.plot(dev_x, dev_y, dev_z, label=f"Scan {info.scan_number}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def best_effort(self, info: ScanInfo) -> None:
"""
Best effort scan.
"""
dev_x = info.scan_report_devices[0]
dev_y = self.get_selected_device(info.monitored_devices, self.figure.selected_device)
if not dev_y:
return
self.figure.clear_all()
plt = self.figure.plot(dev_x, dev_y, label=f"Scan {info.scan_number}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because it is too large Load Diff
+298
View File
@@ -0,0 +1,298 @@
from __future__ import annotations
import importlib
import importlib.metadata as imd
import os
import select
import subprocess
import sys
import threading
import time
import uuid
from functools import wraps
from typing import TYPE_CHECKING
from bec_lib import MessageEndpoints, messages
from bec_lib.connector import MessageObject
from bec_lib.device import DeviceBase
from qtpy.QtCore import QCoreApplication
import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates
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):
# we could rely on a strict type check here, but this is more flexible
# moreover, it would anyway crash for objects...
out = []
for arg in args:
if hasattr(arg, "name"):
arg = arg.name
out.append(arg)
args = tuple(out)
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self.gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
return wrapper
class BECFigureClientMixin:
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._process = None
self.update_script = self._get_update_script()
self._target_endpoint = MessageEndpoints.scan_status()
self._selected_device = None
self.stderr_output = []
def _get_update_script(self) -> AutoUpdates:
eps = imd.entry_points(group="bec.widgets.auto_updates")
for ep in eps:
if ep.name == "plugin_widgets_update":
return ep.load()(figure=self)
return 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
self.update_script.run(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
if self.gui_is_alive():
self._run_rpc("close", (), wait_for_rpc_response=True)
else:
self._run_rpc("close", (), wait_for_rpc_response=False)
self._process.terminate()
self._process_output_processing_thread.join()
self._process = None
self._client.shutdown()
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 = [sys.executable, "-u", monitor_path, "--id", self._gui_id]
self._process = subprocess.Popen(
command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
self._process_output_processing_thread = threading.Thread(target=self._get_output)
self._process_output_processing_thread.start()
def print_log(self) -> None:
"""
Print the log of the plot process.
"""
if self._process is None:
return
print("".join(self.stderr_output))
# Flush list
self.stderr_output.clear()
def _get_output(self) -> str:
try:
os.set_blocking(self._process.stdout.fileno(), False)
os.set_blocking(self._process.stderr.fileno(), False)
while self._process.poll() is None:
readylist, _, _ = select.select(
[self._process.stdout, self._process.stderr], [], [], 1
)
if self._process.stdout in readylist:
output = self._process.stdout.read(1024)
if output:
print(output, end="")
if self._process.stderr in readylist:
error_output = self._process.stderr.read(1024)
if error_output:
print(error_output, end="", file=sys.stderr)
self.stderr_output.append(error_output)
except Exception as e:
print(f"Error reading process output: {str(e)}")
class RPCResponseTimeoutError(Exception):
"""Exception raised when an RPC response is not received within the expected time."""
def __init__(self, request_id, timeout):
super().__init__(
f"RPC response not received within {timeout} seconds for request ID {request_id}"
)
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},
)
# 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: str, timeout: int = 5):
"""
Wait for the response from the server.
Args:
request_id(str): The request ID.
timeout(int): The timeout in seconds.
Returns:
The response from the server.
"""
start_time = time.time()
response = None
while response is None and self.gui_is_alive() and (time.time() - start_time) < timeout:
response = self._client.connector.get(
MessageEndpoints.gui_instruction_response(request_id)
)
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
time.sleep(0.1)
if response is None and (time.time() - start_time) >= timeout:
raise RPCResponseTimeoutError(request_id, timeout)
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
+130
View File
@@ -0,0 +1,130 @@
# pylint: disable=missing-module-docstring
from __future__ import annotations
import inspect
import sys
import black
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.utils import BECConnector
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.plots import BECImageShow, BECMotorMap, BECPlotBase, BECWaveform
from bec_widgets.widgets.plots.image import BECImageItem
from bec_widgets.widgets.plots.waveform import BECCurve
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
clss = [
BECPlotBase,
BECWaveform,
BECFigure,
BECCurve,
BECImageShow,
BECConnector,
BECImageItem,
BECMotorMap,
]
generator = ClientGenerator()
generator.generate_client(clss)
generator.write(client_path)
+76
View File
@@ -0,0 +1,76 @@
from threading import Lock
from weakref import WeakValueDictionary
from qtpy.QtCore import QObject
class RPCRegister:
"""
A singleton class that keeps track of all the RPC objects registered in the system for CLI usage.
"""
_instance = None
_initialized = False
_lock = Lock()
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(RPCRegister, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._rpc_register = WeakValueDictionary()
self._initialized = True
def add_rpc(self, rpc: QObject):
"""
Add an RPC object to the register.
Args:
rpc(QObject): The RPC object to be added to the register.
"""
if not hasattr(rpc, "gui_id"):
raise ValueError("RPC object must have a 'gui_id' attribute.")
self._rpc_register[rpc.gui_id] = rpc
def remove_rpc(self, rpc: str):
"""
Remove an RPC object from the register.
Args:
rpc(str): The RPC object to be removed from the register.
"""
if not hasattr(rpc, "gui_id"):
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
self._rpc_register.pop(rpc.gui_id, None)
def get_rpc_by_id(self, gui_id: str) -> QObject:
"""
Get an RPC object by its ID.
Args:
gui_id(str): The ID of the RPC object to be retrieved.
Returns:
QObject: The RPC object with the given ID.
"""
rpc_object = self._rpc_register.get(gui_id, None)
return rpc_object
def list_all_connections(self) -> dict:
"""
List all the registered RPC objects.
Returns:
dict: A dictionary containing all the registered RPC objects.
"""
with self._lock:
connections = dict(self._rpc_register)
return connections
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance.
"""
cls._instance = None
cls._initialized = False
+139
View File
@@ -0,0 +1,139 @@
import inspect
import threading
import time
from bec_lib import MessageEndpoints, messages
from qtpy.QtCore import QTimer
from bec_widgets.cli.rpc_register import RPCRegister
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, BECWaveform
class BECWidgetsCLIServer:
WIDGETS = [BECWaveform, BECFigure, BECCurve, BECImageShow]
def __init__(self, gui_id: str = None, dispatcher: BECDispatcher = None, client=None) -> None:
self.dispatcher = BECDispatcher() if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
self.fig = BECFigure(gui_id=self.gui_id)
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self.fig)
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:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
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")
obj = self.rpc_register.get_rpc_by_id(gui_id)
if obj is None:
raise ValueError(f"Object with gui_id {gui_id} not found")
return obj
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,
)
def shutdown(self):
self._shutdown_event = True
self._heartbeat_timer.stop()
self.client.shutdown()
if __name__ == "__main__": # pragma: no cover
import argparse
import sys
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QMainWindow
app = QApplication(sys.argv)
app.setApplicationName("BEC Figure")
icon = QIcon()
icon.addFile("bec_widgets_icon.png", size=QSize(48, 48))
app.setWindowIcon(icon)
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
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())
+9
View File
@@ -0,0 +1,9 @@
from .motor_movement import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
MotorCoordinateTable,
MotorThread,
)
@@ -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.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()
+34 -25
View File
@@ -6,20 +6,11 @@ import h5py
import numpy as np
import pyqtgraph as pg
import zmq
from pyqtgraph.Qt import uic
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import (
QWidget,
QFileDialog,
QShortcut,
QDialog,
QVBoxLayout,
QLabel,
QFrame,
)
from pyqtgraph.Qt import uic
from qtpy.QtWidgets import QDialog, QFileDialog, QFrame, QLabel, QShortcut, QVBoxLayout, QWidget
# from scipy.stats import multivariate_normal
@@ -47,7 +38,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 +179,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
@@ -290,6 +298,7 @@ class EigerPlot(QWidget):
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
@@ -0,0 +1,107 @@
import os
import numpy as np
import pyqtgraph as pg
from pyqtgraph.Qt import uic
from qtconsole.inprocess import QtInProcessKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.widgets import BECFigure
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 JupyterConsoleWindow(QWidget): # pragma: no cover:
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API)."""
def __init__(self, parent=None):
super().__init__(parent)
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "jupyter_console_window.ui"), self)
self._init_ui()
self.splitter.setSizes([200, 100])
self.safe_close = False
# self.figure.clean_signal.connect(self.confirm_close)
self.register = RPCRegister()
self.register.add_rpc(self.figure)
print("Registered objects:", dict(self.register.list_all_connections()))
# console push
self.console.kernel_manager.kernel.shell.push(
{
"fig": self.figure,
"register": self.register,
"w1": self.w1,
"w2": self.w2,
"w3": self.w3,
"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.plot("samx", "bpm4d")
self.figure.motor_map("samx", "samy")
self.figure.image("eiger", color_map="viridis", vrange=(0, 100))
self.figure.change_layout(2, 2)
self.w1 = self.figure[0, 0]
self.w2 = self.figure[0, 1]
self.w3 = self.figure[1, 0]
# curves for w1
self.w1.add_curve_scan("samx", "samy", "bpm4i", pen_style="dash")
self.w1.add_curve_scan("samx", "samy", "bpm3a", pen_style="dash")
self.c1 = self.w1.get_config()
if __name__ == "__main__": # pragma: no cover
import sys
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console")
win = JupyterConsoleWindow()
win.show()
sys.exit(app.exec_())
@@ -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>
+23 -26
View File
@@ -1,19 +1,15 @@
# import simulation_progress as SP
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import Signal as pyqtSignal, Slot as pyqtSlot
from qtpy.QtWidgets import (
QApplication,
QVBoxLayout,
QWidget,
)
from bec_lib import MessageEndpoints, messages
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
class StreamApp(QWidget):
update_signal = pyqtSignal()
new_scanID = pyqtSignal(str)
new_scan_id = pyqtSignal(str)
def __init__(self, device, sub_device):
super().__init__()
@@ -23,7 +19,7 @@ class StreamApp(QWidget):
self.setWindowTitle("MCA readout")
self.data = None
self.scanID = None
self.scan_id = None
self.stream_consumer = None
self.device = device
@@ -33,7 +29,7 @@ class StreamApp(QWidget):
# self.start_device_consumer(self.device) # for simulation
self.new_scanID.connect(self.create_new_stream_consumer)
self.new_scan_id.connect(self.create_new_stream_consumer)
self.update_signal.connect(self.plot_new)
def init_ui(self):
@@ -64,17 +60,17 @@ class StreamApp(QWidget):
# self.glw.addItem(self.hist)
@pyqtSlot(str)
def create_new_stream_consumer(self, scanID: str):
print(f"Creating new stream consumer for scanID: {scanID}")
def create_new_stream_consumer(self, scan_id: str):
print(f"Creating new stream consumer for scan_id: {scan_id}")
self.connect_stream_consumer(scanID, self.device)
self.connect_stream_consumer(scan_id, self.device)
def connect_stream_consumer(self, scanID, device):
def connect_stream_consumer(self, scan_id, device):
if self.stream_consumer is not None:
self.stream_consumer.shutdown()
self.stream_consumer = connector.stream_consumer(
topics=MessageEndpoints.device_async_readback(scanID=scanID, device=device),
topics=MessageEndpoints.device_async_readback(scan_id=scan_id, device=device),
cb=self._streamer_cb,
parent=self,
)
@@ -102,7 +98,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,26 +119,27 @@ 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"]
current_scan_id = msgDEV.content["scan_id"]
if parent.scanID is None:
parent.scanID = current_scanID
parent.new_scanID.emit(current_scanID)
print(f"New scanID: {current_scanID}")
if parent.scan_id is None:
parent.scan_id = current_scan_id
parent.new_scan_id.emit(current_scan_id)
print(f"New scan_id: {current_scan_id}")
if current_scanID != parent.scanID:
parent.scanID = current_scanID
if current_scan_id != parent.scan_id:
parent.scan_id = current_scan_id
# parent.data = None
# parent.imageItem.clear()
parent.new_scanID.emit(current_scanID)
parent.new_scan_id.emit(current_scan_id)
print(f"New scanID: {current_scanID}")
print(f"New scan_id: {current_scan_id}")
if __name__ == "__main__":
import argparse
from bec_lib import RedisConnector
parser = argparse.ArgumentParser(description="Stream App.")
+9 -17
View File
@@ -1,32 +1,24 @@
from bec_lib import messages, MessageEndpoints, RedisConnector
import time
from bec_lib import MessageEndpoints, RedisConnector, messages
connector = RedisConnector("localhost:6379")
producer = connector.producer()
metadata = {}
scanID = "ScanID1"
scan_id = "ScanID1"
metadata.update(
{
"scanID": scanID, # this will be different for each scan
"async_update": "append",
}
{"scan_id": scan_id, "async_update": "append"} # this will be different for each scan
)
for ii in range(20):
data = {"mca1": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "mca2": [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]}
msg = messages.DeviceMessage(
signals=data,
metadata=metadata,
).dumps()
msg = messages.DeviceMessage(signals=data, metadata=metadata).dumps()
# producer.send(topic=MessageEndpoints.device_status(device="mca"), msg=msg)
producer.xadd(
connector.xadd(
topic=MessageEndpoints.device_async_readback(
scanID=scanID, device="mca"
), # scanID will be different for each scan
msg={"data": msg},
scan_id=scan_id, device="mca"
), # scan_id will be different for each scan
msg={"data": msg}, # TODO should be msg_dict
expire=1800,
)
+1 -1
View File
@@ -74,7 +74,7 @@
<x>0</x>
<y>0</y>
<width>1433</width>
<height>24</height>
<height>37</height>
</rect>
</property>
</widget>
+115 -124
View File
@@ -1,43 +1,13 @@
import os
from qtpy import uic
from qtpy.QtWidgets import QMainWindow, QApplication
from qtpy.QtWidgets import QApplication, QMainWindow
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,
@@ -47,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,
@@ -92,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"}],
},
}
],
},
],
},
@@ -174,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__)
@@ -185,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
@@ -195,10 +188,8 @@ class ModularApp(QMainWindow):
if __name__ == "__main__":
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
# BECclient global variables
client = bec_dispatcher.client
client = BECDispatcher().client
client.start()
app = QApplication([])
@@ -0,0 +1,9 @@
from .motor_control_compilations import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
MotorCoordinateTable,
MotorThread,
)
@@ -0,0 +1,255 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import qdarktheme
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QSplitter, QVBoxLayout, QWidget
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets import (
MotorControlAbsolute,
MotorControlRelative,
MotorControlSelection,
MotorCoordinateTable,
MotorMap,
MotorThread,
)
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())
@@ -883,7 +883,7 @@
</column>
<column>
<property name="text">
<string>scanID</string>
<string>scan_id</string>
</property>
</column>
<column>
@@ -5,29 +5,28 @@ from functools import partial
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints, messages
from pyqtgraph.Qt import QtCore, QtWidgets, uic
from qtpy import QtGui
from qtpy.QtCore import QThread, Slot as pyqtSlot
from qtpy.QtCore import Signal as pyqtSignal, Qt
from qtpy.QtGui import QDoubleValidator
from qtpy.QtGui import QKeySequence
from qtpy.QtCore import Qt, QThread
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtGui import QDoubleValidator, QKeySequence
from qtpy.QtWidgets import (
QApplication,
QWidget,
QFileDialog,
QDialog,
QVBoxLayout,
QLabel,
QPushButton,
QFileDialog,
QFrame,
QLabel,
QMessageBox,
QPushButton,
QShortcut,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QMessageBox
from qtpy.QtWidgets import QShortcut
from pyqtgraph.Qt import QtWidgets, uic, QtCore
from bec_lib import MessageEndpoints, messages
from bec_widgets.utils import DoubleValidationDelegate
# TODO - General features
# - put motor status (moving, stopped, etc)
# - add mouse interactions with the plot -> click to select coordinates, double click to move?
@@ -1129,10 +1128,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 +1180,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 +1296,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"]:
@@ -1304,9 +1305,9 @@ class MotorControl(QThread):
if __name__ == "__main__":
import yaml
import argparse
import yaml
from bec_lib import BECClient, ServiceConfig
parser = argparse.ArgumentParser(description="Motor App")
@@ -1,3 +0,0 @@
x_value: "samx"
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
dap_worker: "gaussian_fit_worker_3"
@@ -1,3 +0,0 @@
x_value: "samx"
y_values: ["gauss_bpm", "gauss_adc1", "gauss_adc2"]
dap_worker: None
-271
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.utils import Crosshair, ctrl_c
# TODO implement:
# - implement scanID database for visualizing previous scans
# - multiple signals for different monitors
# - change how dap is handled in bec_dispatcher to handle more workers
class PlotApp(QWidget):
"""
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.utils.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()
-75
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>
@@ -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"
@@ -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"
-730
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.utils import Crosshair, Colors
# TODO implement:
# - implement scanID database for visualizing previous scans
class PlotApp(QWidget):
"""
Main class for PlotApp, designed to plot multiple signals in a grid layout
based on a flexible YAML configuration.
Attributes:
update_signal (pyqtSignal): Signal to trigger plot updates.
plot_data (list of dict): List of dictionaries containing plot configurations.
Each dictionary specifies x and y signals, including their
name and entry, for a particular plot.
Args:
config (dict): Configuration dictionary containing all settings for the plotting app.
It should include the following keys:
- 'plot_settings': Dictionary containing global plot settings.
- 'plot_data': List of dictionaries specifying the signals to plot.
parent (QWidget, optional): Parent widget.
Example:
General Plot Configuration:
{
'plot_settings': {'background_color': 'black', 'num_columns': 2, 'colormap': 'plasma', 'scan_types': False},
'plot_data': [
{
'plot_name': 'Plot A',
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x', 'entry': 'entry_x'}]},
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y', 'entry': 'entry_y'}]}
}
]
}
Different Scans Mode Configuration:
{
'plot_settings': {'background_color': 'black', 'num_columns': 2, 'colormap': 'plasma', 'scan_types': True},
'plot_data': {
'scan_type_1': [
{
'plot_name': 'Plot 1',
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x1', 'entry': 'entry_x1'}]},
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y1', 'entry': 'entry_y1'}]}
}
],
'scan_type_2': [
{
'plot_name': 'Plot 2',
'x': {'label': 'X-axis', 'signals': [{'name': 'device_x2', 'entry': 'entry_x2'}]},
'y': {'label': 'Y-axis', 'signals': [{'name': 'device_y2', 'entry': 'entry_y2'}]}
}
]
}
}
"""
update_signal = pyqtSignal()
update_dap_signal = pyqtSignal()
def __init__(self, config: dict, client=None, parent=None):
super(PlotApp, self).__init__(parent)
# Error handler
self.error_handler = ErrorHandler(parent=self)
# Client and device manager from BEC
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
# Loading UI
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "plot_app.ui"), self)
self.data = {}
self.crosshairs = None
self.plots = None
self.curves_data = None
self.grid_coordinates = None
self.scanID = None
self.user_colors = {} # key: (plot_name, y_name, y_entry), value: color
# Default config
self.config = config
# Validate the configuration before proceeding
self.load_config(self.config)
# Default splitter size
self.splitter.setSizes([400, 100])
# Buttons
self.pushButton_save.clicked.connect(self.save_settings_to_yaml)
self.pushButton_load.clicked.connect(self.load_settings_from_yaml)
# Connect the update signal to the update plot method
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self.update_plot
)
# Change layout of plots when the number of columns is changed in GUI
self.spinBox_N_columns.valueChanged.connect(lambda x: self.init_ui(x))
def load_config(self, config: dict) -> None:
"""
Load and validate the configuration, retrying until a valid configuration is provided or the user cancels.
Args:
config (dict): Configuration dictionary form .yaml file.
Returns:
None
"""
valid_config = False
self.error_handler.set_retry_action(self.load_settings_from_yaml)
while not valid_config:
if config is None:
self.config = (
self.load_settings_from_yaml()
) # Load config if it hasn't been loaded yet
try: # Validate loaded config file
self.error_handler.validate_config_file(config)
valid_config = True
except ValueError as e:
self.config = None # Reset config_to_test to force reloading configuration
self.config = self.error_handler.handle_error(str(e))
if valid_config is True: # Initialize config if validation succeeds
self.init_config(self.config)
def init_config(self, config: dict) -> None:
"""
Initializes or update the configuration settings for the PlotApp.
Args:
config (dict): Dictionary containing plot settings and data configurations.
"""
# YAML config
self.plot_settings = config.get("plot_settings", {})
self.plot_data_config = config.get("plot_data", {})
self.scan_types = self.plot_settings.get("scan_types", False)
if self.scan_types is False: # Device tracking mode
self.plot_data = self.plot_data_config # TODO logic has to be improved
else: # setup first line scan as default, then changed with different scan type
self.plot_data = self.plot_data_config[list(self.plot_data_config.keys())[0]]
# Setting global plot settings
self.init_plot_background(self.plot_settings["background_color"])
# Initialize the UI
self.init_ui(self.plot_settings["num_columns"])
self.spinBox_N_columns.setValue(
self.plot_settings["num_columns"]
) # TODO has to be checked if it will not setup more columns than plots
self.spinBox_N_columns.setMaximum(len(self.plot_data))
def init_plot_background(self, background_color: str) -> None:
"""
Initialize plot settings based on the background color.
Args:
background_color (str): The background color ('white' or 'black').
This method sets the background and foreground colors for pyqtgraph.
If the background is dark ('black'), the foreground will be set to 'white',
and vice versa.
"""
if background_color.lower() == "black":
pg.setConfigOption("background", "k")
pg.setConfigOption("foreground", "w")
elif background_color.lower() == "white":
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
else:
raise ValueError(
f"Invalid background color {background_color}. Allowed values are 'white' or 'black'."
)
# TODO simplify -> find way how to setup also foreground color
# if background_color.lower() not in ["black", "white"]:
# raise ValueError(
# f"Invalid background color {background_color}. Allowed values are 'white' or 'black'."
# )
# self.glw.setBackground(background_color.lower())
def init_ui(self, num_columns: int = 3) -> None:
"""
Initialize the UI components, create plots and store their grid positions.
Args:
num_columns (int): Number of columns to wrap the layout.
This method initializes a dictionary `self.plots` to store the plot objects
along with their corresponding x and y signal names. It dynamically arranges
the plots in a grid layout based on the given number of columns and dynamically
stretches the last plots to fit the remaining space.
"""
self.glw.clear()
self.plots = {}
self.grid_coordinates = []
num_plots = len(self.plot_data)
# Check if num_columns exceeds the number of plots
if num_columns >= num_plots:
num_columns = num_plots
self.plot_settings["num_columns"] = num_columns # Update the settings
print(
f"Warning: num_columns in the YAML file was greater than the number of plots. Resetting num_columns to number of plots:{num_columns}."
)
else:
self.plot_settings["num_columns"] = num_columns # Update the settings
num_rows = num_plots // num_columns
last_row_cols = num_plots % num_columns
remaining_space = num_columns - last_row_cols
for i, plot_config in enumerate(self.plot_data):
row, col = i // num_columns, i % num_columns
colspan = 1
if row == num_rows and remaining_space > 0:
if last_row_cols == 1:
colspan = num_columns
else:
colspan = remaining_space // last_row_cols + 1
remaining_space -= colspan - 1
last_row_cols -= 1
plot_name = plot_config.get("plot_name", "")
x_label = plot_config["x"].get("label", "")
y_label = plot_config["y"].get("label", "")
plot = self.glw.addPlot(row=row, col=col, colspan=colspan, title=plot_name)
plot.setLabel("bottom", x_label)
plot.setLabel("left", y_label)
plot.addLegend()
self.plots[plot_name] = plot
self.grid_coordinates.append((row, col))
self.init_curves()
def init_curves(self) -> None:
"""
Initialize curve data and properties, and update table row labels.
This method initializes a nested dictionary `self.curves_data` to store
the curve objects for each x and y signal pair. It also updates the row labels
in `self.tableWidget_crosshair` to include the grid position for each y-value.
"""
self.curves_data = {}
row_labels = []
for idx, plot_config in enumerate(self.plot_data):
plot_name = plot_config.get("plot_name", "")
plot = self.plots[plot_name]
plot.clear()
y_configs = plot_config["y"]["signals"]
colors_ys = Colors.golden_angle_color(
colormap=self.plot_settings["colormap"], num=len(y_configs)
)
curve_list = []
for i, (y_config, color) in enumerate(zip(y_configs, colors_ys)):
# print(y_config)
y_name = y_config["name"]
y_entries = y_config.get("entry", [y_name])
if not isinstance(y_entries, list):
y_entries = [y_entries]
for y_entry in y_entries:
user_color = self.user_colors.get((plot_name, y_name, y_entry), None)
color_to_use = user_color if user_color else color
pen_curve = mkPen(color=color_to_use, width=2, style=QtCore.Qt.DashLine)
brush_curve = mkBrush(color=color_to_use)
curve_data = pg.PlotDataItem(
symbolSize=5,
symbolBrush=brush_curve,
pen=pen_curve,
skipFiniteCheck=True,
name=f"{y_name} ({y_entry})",
)
curve_list.append((y_name, y_entry, curve_data))
plot.addItem(curve_data)
row_labels.append(f"{y_name} ({y_entry}) - {plot_name}")
# Create a ColorButton and set its color
color_btn = ColorButton()
color_btn.setColor(color_to_use)
color_btn.sigColorChanged.connect(
lambda btn=color_btn, plot=plot_name, yname=y_name, yentry=y_entry, curve=curve_data: self.change_curve_color(
btn, plot, yname, yentry, curve
)
)
# Add the ColorButton as a QWidget to the table
color_widget = QtWidgets.QWidget()
layout = QtWidgets.QHBoxLayout()
layout.addWidget(color_btn)
layout.setContentsMargins(0, 0, 0, 0)
color_widget.setLayout(layout)
row = len(row_labels) - 1 # The row index in the table
self.tableWidget_crosshair.setCellWidget(row, 2, color_widget)
self.curves_data[plot_name] = curve_list
self.tableWidget_crosshair.setRowCount(len(row_labels))
self.tableWidget_crosshair.setVerticalHeaderLabels(row_labels)
self.hook_crosshair()
def change_curve_color(
self,
btn: pyqtgraph.ColorButton,
plot_name: str,
y_name: str,
y_entry: str,
curve: pyqtgraph.PlotDataItem,
) -> None:
"""
Change the color of a curve and update the corresponding ColorButton.
Args:
btn (ColorButton): The ColorButton that was clicked.
plot_name (str): The name of the plot where the curve belongs.
y_name (str): The name of the y signal.
y_entry (str): The entry of the y signal.
curve (PlotDataItem): The curve to be changed.
"""
color = btn.color()
pen_curve = mkPen(color=color, width=2, style=QtCore.Qt.DashLine)
brush_curve = mkBrush(color=color)
curve.setPen(pen_curve)
curve.setSymbolBrush(brush_curve)
self.user_colors[(plot_name, y_name, y_entry)] = color
def hook_crosshair(self) -> None:
"""Attach crosshairs to each plot and connect them to the update_table method."""
self.crosshairs = {}
for plot_name, plot in self.plots.items():
crosshair = Crosshair(plot, precision=3)
crosshair.coordinatesChanged1D.connect(
lambda x, y, plot=plot: self.update_table(
self.tableWidget_crosshair, x, y, column=0, plot=plot
)
)
crosshair.coordinatesClicked1D.connect(
lambda x, y, plot=plot: self.update_table(
self.tableWidget_crosshair, x, y, column=1, plot=plot
)
)
self.crosshairs[plot_name] = crosshair
def update_table(
self, table_widget: QTableWidget, x: float, y_values: list, column: int, plot: pg.PlotItem
) -> None:
"""
Update the table with coordinates based on cursor movements and clicks.
Args:
table_widget (QTableWidget): The table to be updated.
x (float): The x-coordinate from the plot.
y_values (list): The y-coordinates from the plot.
column (int): The column in the table to be updated.
plot (PlotItem): The plot from which the coordinates are taken.
This method calculates the correct row in the table for each y-value
and updates the cell at (row, column) with the new x and y coordinates.
"""
plot_name = [name for name, value in self.plots.items() if value == plot][0]
starting_row = 0
for plot_config in self.plot_data:
if plot_config.get("plot_name", "") == plot_name:
break
for y_config in plot_config.get("y", {}).get("signals", []):
y_entries = y_config.get("entry", [y_config.get("name", "")])
if not isinstance(y_entries, list):
y_entries = [y_entries]
starting_row += len(y_entries)
for i, y in enumerate(y_values):
row = starting_row + i
table_widget.setItem(row, column, QTableWidgetItem(f"({x}, {y})"))
table_widget.resizeColumnsToContents()
def update_plot(self) -> None:
"""Update the plot data based on the stored data dictionary."""
for plot_name, curve_list in self.curves_data.items():
for y_name, y_entry, curve in curve_list:
x_config = next(
(pc["x"] for pc in self.plot_data if pc.get("plot_name") == plot_name), {}
)
x_signal_config = x_config["signals"][0]
x_name = x_signal_config.get("name", "")
x_entry = x_signal_config.get("entry", x_name)
key = (x_name, x_entry, y_name, y_entry)
data_x = self.data.get(key, {}).get("x", [])
data_y = self.data.get(key, {}).get("y", [])
curve.setData(data_x, data_y)
@pyqtSlot(dict, dict)
def on_scan_segment(
self, msg, metadata
) -> None: # TODO the logic should be separated from GUI operation
"""
Handle new scan segments and saves data to a dictionary. Linked through bec_dispatcher.
Args:
msg (dict): Message received with scan data.
metadata (dict): Metadata of the scan.
"""
current_scanID = msg.get("scanID", None)
if current_scanID is None:
return
if current_scanID != self.scanID:
if self.scan_types is False:
self.plot_data = self.plot_data_config
elif self.scan_types is True:
currentName = metadata.get("scan_name")
if currentName is None:
raise ValueError(
f"Scan name not found in metadata. Please check the scan_name in the YAML config or in bec "
f"configuration."
)
self.plot_data = self.plot_data_config.get(currentName, [])
if self.plot_data == []:
raise ValueError(
f"Scan name {currentName} not found in the YAML config. Please check the scan_name in the "
f"YAML config or in bec configuration."
)
# Init UI
self.init_ui(self.plot_settings["num_columns"])
self.spinBox_N_columns.setValue(
self.plot_settings["num_columns"]
) # TODO has to be checked if it will not setup more columns than plots
self.spinBox_N_columns.setMaximum(len(self.plot_data))
self.scanID = current_scanID
self.data = {}
self.init_curves()
for plot_config in self.plot_data:
plot_name = plot_config.get("plot_name", "Unnamed Plot")
x_config = plot_config["x"]
x_signal_config = x_config["signals"][0] # Assuming there's at least one signal for x
x_name = x_signal_config.get("name", "")
if not x_name:
raise ValueError(f"Name for x signal must be specified in plot: {plot_name}.")
x_entry_list = x_signal_config.get("entry", [])
if not x_entry_list:
x_entry_list = (
self.dev[x_name]._hints if hasattr(self.dev[x_name], "_hints") else [x_name]
)
if not isinstance(x_entry_list, list):
x_entry_list = [x_entry_list]
y_configs = plot_config["y"]["signals"]
for x_entry in x_entry_list:
for y_config in y_configs:
y_name = y_config.get("name", "")
if not y_name:
raise ValueError(
f"Name for y signal must be specified in plot: {plot_name}."
)
y_entry_list = y_config.get("entry", [])
if not y_entry_list:
y_entry_list = (
self.dev[y_name]._hints
if hasattr(self.dev[y_name], "_hints")
else [y_name]
)
if not isinstance(y_entry_list, list):
y_entry_list = [y_entry_list]
for y_entry in y_entry_list:
key = (x_name, x_entry, y_name, y_entry)
data_x = msg["data"].get(x_name, {}).get(x_entry, {}).get("value", None)
data_y = msg["data"].get(y_name, {}).get(y_entry, {}).get("value", None)
if data_x is None:
raise ValueError(
f"Incorrect entry '{x_entry}' specified for x in plot: {plot_name}, x name: {x_name}"
)
if data_y is None:
if hasattr(self.dev[y_name], "_hints"):
raise ValueError(
f"Incorrect entry '{y_entry}' specified for y in plot: {plot_name}, y name: {y_name}"
)
else:
raise ValueError(
f"No hints available for y in plot: {plot_name}, and name '{y_name}' did not work as entry"
)
if data_x is not None:
self.data.setdefault(key, {}).setdefault("x", []).append(data_x)
if data_y is not None:
self.data.setdefault(key, {}).setdefault("y", []).append(data_y)
self.update_signal.emit()
def save_settings_to_yaml(self):
"""Save the current settings to a .yaml file using a file dialog."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getSaveFileName(
self, "Save Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options
)
if file_path:
try:
if not file_path.endswith(".yaml"):
file_path += ".yaml"
with open(file_path, "w") as file:
yaml.dump(
{"plot_settings": self.plot_settings, "plot_data": self.plot_data_config},
file,
)
print(f"Settings saved to {file_path}")
except Exception as e:
print(f"An error occurred while saving the settings to {file_path}: {e}")
def load_settings_from_yaml(self) -> dict: # TODO can be replace by the utils function
"""Load settings from a .yaml file using a file dialog and update the current settings."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getOpenFileName(
self, "Load Settings", "", "YAML Files (*.yaml);;All Files (*)", options=options
)
if file_path:
try:
with open(file_path, "r") as file:
self.config = yaml.safe_load(file)
self.load_config(self.config) # validate new config
return config
except FileNotFoundError:
print(f"The file {file_path} was not found.")
except Exception as e:
print(f"An error occurred while loading the settings from {file_path}: {e}")
return None # Return None on exception to indicate failure
class ErrorHandler:
def __init__(self, parent=None):
self.parent = parent
self.errors = []
self.retry_action = None
logging.basicConfig(level=logging.ERROR) # Configure logging
def set_retry_action(self, action):
self.retry_action = action # Store a reference to the retry action
def handle_error(self, error_message: str):
# logging.error(f"{error_message}\n{traceback.format_exc()}") #TODO decide if useful
choice = QMessageBox.critical(
self.parent,
"Error",
f"{error_message}\n\nWould you like to retry?",
QMessageBox.Retry | QMessageBox.Cancel,
)
if choice == QMessageBox.Retry and self.retry_action is not None:
return self.retry_action()
else:
exit(1) # Exit the program if the user selects Cancel or if no retry_action is provided
def validate_config_file(self, config: dict) -> None:
"""
Validate the configuration dictionary.
Args:
config (dict): Configuration dictionary form .yaml file.
Returns:
None
"""
self.errors = []
# Validate common keys
required_top_level_keys = ["plot_settings", "plot_data"]
for key in required_top_level_keys:
if key not in config:
self.errors.append(f"Missing required key: {key}")
# Only continue if no errors so far
if not self.errors:
# Determine the configuration mode (device or scan)
plot_settings = config.get("plot_settings", {})
scan_types = plot_settings.get("scan_types", False)
plot_data = config.get("plot_data", [])
if scan_types:
# Validate scan mode configuration
for scan_type, plots in plot_data.items():
for i, plot_config in enumerate(plots):
self.validate_plot_config(plot_config, i)
else:
# Validate device mode configuration
for i, plot_config in enumerate(plot_data):
self.validate_plot_config(plot_config, i)
if self.errors != []:
self.handle_error("\n".join(self.errors))
def validate_plot_config(self, plot_config: dict, i: int):
"""
Validate individual plot configuration.
Args:
plot_config (dict): Individual plot configuration.
i (int): Index of the plot configuration.
Returns:
None
"""
for axis in ["x", "y"]:
axis_config = plot_config.get(axis)
plot_name = plot_config.get("plot_name", "")
if axis_config is None:
error_msg = f"Missing '{axis}' configuration in plot {i} - {plot_name}"
logging.error(error_msg) # Log the error
self.errors.append(error_msg)
signals_config = axis_config.get("signals")
if signals_config is None:
error_msg = (
f"Missing 'signals' configuration for {axis} axis in plot {i} - '{plot_name}'"
)
logging.error(error_msg) # Log the error
self.errors.append(error_msg)
elif not isinstance(signals_config, list) or len(signals_config) == 0:
error_msg = (
f"'signals' configuration for {axis} axis in plot {i} must be a non-empty list"
)
logging.error(error_msg) # Log the error
self.errors.append(error_msg)
# TODO add condition for name and entry
if __name__ == "__main__":
import yaml
import argparse
# from bec_widgets import ctrl_c
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
parser = argparse.ArgumentParser(description="Plotting App")
parser.add_argument(
"--config",
"-c",
help="Path to the .yaml configuration file",
default="config_example.yaml",
)
args = parser.parse_args()
try:
with open(args.config, "r") as file:
config = yaml.safe_load(file)
except FileNotFoundError:
print(f"The file {args.config} was not found.")
exit(1)
except Exception as e:
print(f"An error occurred while loading the config file: {e}")
exit(1)
# BECclient global variables
client = bec_dispatcher.client
client.start()
app = QApplication([])
plotApp = PlotApp(config=config, client=client)
# Connecting signals from bec_dispatcher
bec_dispatcher.connect_slot(plotApp.on_scan_segment, MessageEndpoints.scan_segment())
# ctrl_c.setup(app)
window = plotApp
window.show()
app.exec()
-115
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>
+24 -17
View File
@@ -5,17 +5,16 @@ import time
import numpy as np
import pyqtgraph
import pyqtgraph as pg
from bec_lib import messages, MessageEndpoints
from bec_lib import MessageEndpoints, messages
from bec_lib.redis_connector import RedisConnector
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QTableWidgetItem
from pyqtgraph import mkBrush, mkPen
from pyqtgraph.Qt import QtCore, QtWidgets, uic
from pyqtgraph.Qt.QtCore import pyqtSignal
from bec_widgets.utils import Crosshair, Colors
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QTableWidgetItem
# client = bec_dispatcher.client
from bec_widgets.utils import Colors, Crosshair
from bec_widgets.utils.bec_dispatcher import BECDispatcher
class StreamPlot(QtWidgets.QWidget):
@@ -32,7 +31,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
@@ -42,7 +41,7 @@ class StreamPlot(QtWidgets.QWidget):
uic.loadUi(os.path.join(current_path, "line_plot.ui"), self)
self._idle_time = 100
self.producer = RedisConnector(["localhost:6379"]).producer()
self.connector = RedisConnector(["localhost:6379"])
self.y_value_list = y_value_list
self.previous_y_value_list = None
@@ -56,7 +55,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()
##########################
@@ -66,6 +68,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"""
##########################
@@ -169,8 +176,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)
@@ -209,7 +215,7 @@ class StreamPlot(QtWidgets.QWidget):
]
}
msg = messages.DeviceMessage(signals=return_dict).dumps()
self.producer.set_and_publish("px_stream/gui_event", msg=msg)
self.connector.set_and_publish("px_stream/gui_event", msg=msg)
self.roi_signal.emit(region)
def init_table(self):
@@ -259,14 +265,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]
msgs = self.client.connector.lrange(topic=endpoint, start=-1, end=-1)
data = msgs
if not data:
continue
with np.errstate(divide="ignore", invalid="ignore"):
@@ -290,7 +296,7 @@ class StreamPlot(QtWidgets.QWidget):
def new_proj(self, content: dict, _metadata: dict):
proj_nr = content["signals"]["proj_nr"]
endpoint = f"px_stream/projection_{proj_nr}/metadata"
msg_raw = self.client.producer.get(topic=endpoint)
msg_raw = self.client.connector.get(topic=endpoint)
msg = messages.DeviceMessage.loads(msg_raw)
self._current_q = msg.content["signals"]["q"]
self._current_norm = msg.content["signals"]["norm_sum"]
@@ -314,6 +320,7 @@ if __name__ == "__main__":
print(f"Plotting signals for: {', '.join(value.signals)}")
# Client from dispatcher
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
app = QtWidgets.QApplication([])
@@ -1,56 +0,0 @@
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
from qtpy.QtGui import QIcon
from bec_widgets.widgets.scan_plot.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"
@@ -1,56 +0,0 @@
from qtpy.QtDesigner import QPyDesignerCustomWidgetPlugin
from qtpy.QtGui import QIcon
from bec_widgets.widgets.scan_plot.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"
+8 -3
View File
@@ -1,4 +1,9 @@
from .crosshair import Crosshair
from .colors import Colors
from .validator_delegate import DoubleValidationDelegate
from .bec_connector import BECConnector, ConnectionConfig
from .bec_dispatcher import BECDispatcher
from .bec_table import BECTable
from .colors import Colors
from .container_utils import WidgetContainerUtils
from .crosshair import Crosshair
from .entry_validator import EntryValidator
from .rpc_decorator import register_rpc_methods, rpc_public
from .validator_delegate import DoubleValidationDelegate
+153
View File
@@ -0,0 +1,153 @@
# pylint: disable = no-name-in-module,missing-module-docstring
from __future__ import annotations
import time
from typing import Optional, Type
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import Slot as pyqtSlot
from bec_widgets.cli.rpc_register import RPCRegister
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 = ["config_dict", "get_all_rpc"]
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
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
# register widget to rpc register
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
def get_all_rpc(self) -> dict:
"""Get all registered RPC objects."""
all_connections = self.rpc_register.list_all_connections()
return dict(all_connections)
@property
def rpc_id(self) -> str:
"""Get the RPC ID of the widget."""
return self.gui_id
@rpc_id.setter
def rpc_id(self, rpc_id: str) -> None:
"""Set the RPC ID of the widget."""
self.gui_id = rpc_id
@property
def config_dict(self) -> dict:
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
return self.config.model_dump()
@config_dict.setter
def config_dict(self, config: BaseModel) -> None:
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
self.config = config
@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
def closeEvent(self, event):
self.client.shutdown()
super().closeEvent(event)
+122 -80
View File
@@ -1,99 +1,141 @@
from __future__ import annotations
import argparse
import itertools
import os
import collections
from collections.abc import Callable
from typing import TYPE_CHECKING, Union
from bec_lib import BECClient, messages, ServiceConfig
from bec_lib.redis_connector import RedisConsumerThreaded
from qtpy.QtCore import QObject, Signal as pyqtSignal
import redis
from bec_lib import BECClient
from bec_lib.redis_connector import MessageObject, RedisConnector
from qtpy.QtCore import QObject
from qtpy.QtCore import 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()
)
if TYPE_CHECKING:
from bec_lib.endpoints import EndpointInfo
class _Connection:
"""Utility class to keep track of slots connected to a particular redis consumer"""
class QtThreadSafeCallback(QObject):
cb_signal = pyqtSignal(dict, dict)
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):
def __init__(self, cb):
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.cb = cb
self.cb_signal.connect(self.cb)
self.client.initialize(config=ServiceConfig(config_path=bec_config))
self._connections = {}
def __hash__(self):
# make 2 differents QtThreadSafeCallback to look
# identical when used as dictionary keys, if the
# callback is the same
return id(self.cb)
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
def __call__(self, msg_content, metadata):
self.cb_signal.emit(msg_content, metadata)
class QtRedisConnector(RedisConnector):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _execute_callback(self, cb, msg, kwargs):
if not isinstance(cb, QtThreadSafeCallback):
return super()._execute_callback(cb, msg, kwargs)
# if msg.msg_type == "bundle_message":
# # big warning: how to handle bundle messages?
# # message with messages inside ; which slot to call?
# # bundle_msg = msg
# # for msg in bundle_msg:
# # ...
# # for now, only consider the 1st message
# msg = msg[0]
# raise RuntimeError(f"
if isinstance(msg, MessageObject):
if isinstance(msg.value, list):
msg = msg.value[0]
else:
msg = msg.value
# we can notice kwargs are lost when passed to Qt slot
metadata = msg.metadata
cb(msg.content, metadata)
else:
# from stream
msg = msg["data"]
cb(msg.content, msg.metadata)
class BECDispatcher:
"""Utility class to keep track of slots connected to a particular redis connector"""
_instance = None
_initialized = False
def __new__(cls, client=None, *args, **kwargs):
if cls._instance is None:
cls._instance = super(BECDispatcher, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self, client=None):
if self._initialized:
return
self._slots = collections.defaultdict(set)
self.client = client
if self.client is None:
self.client = BECClient(connector_cls=QtRedisConnector, forced=True)
else:
if self.client.started:
# have to reinitialize client to use proper connector
self.client.shutdown()
self.client._BECClient__init_params["connector_cls"] = QtRedisConnector
try:
self.client.start()
except redis.exceptions.ConnectionError:
print("Could not connect to Redis, skipping start of BECClient.")
self._initialized = True
@classmethod
def reset_singleton(cls):
cls._instance = None
cls._initialized = False
def connect_slot(
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, 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
topics (EndpointInfo | str | list): A topic or list of topics 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:
slot = QtThreadSafeCallback(slot)
self.client.connector.register(topics, cb=slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].update(set(topics_str))
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)
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
self.client.connector.unregister(topics, cb=slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].difference_update(set(topics_str))
if not self._slots[slot]:
del self._slots[slot]
consumer = self.client.connector.consumer(topics=topic, cb=cb)
consumer.start()
def disconnect_topics(self, topics: Union[str, list]):
self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
for slot in list(self._slots.keys()):
slot_topics = self._slots[slot]
slot_topics.difference_update(set(topics_str))
if not slot_topics:
del self._slots[slot]
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)
def disconnect_all(self, *args, **kwargs):
self.disconnect_topics(self.client.connector._topics_cb)
+1 -1
View File
@@ -1,5 +1,5 @@
from qtpy.QtWidgets import QTableWidget
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QTableWidget
class BECTable(QTableWidget):
+26 -11
View File
@@ -1,6 +1,8 @@
from typing import Literal
import numpy as np
import pyqtgraph as pg
from pyqtgraph import mkColor
from qtpy.QtGui import QColor
class Colors:
@@ -10,6 +12,9 @@ class Colors:
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 = []
@@ -21,30 +26,40 @@ class Colors:
return angles
@staticmethod
def golden_angle_color(colormap: str, num: int) -> list:
def golden_angle_color(
colormap: str, num: int, format: Literal["QColor", "HEX", "RGB"] = "QColor"
) -> list:
"""
Extract num colors for from the specified colormap following golden angle distribution.
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
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 with length <num>
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.color
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 = [
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
]
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
+45
View File
@@ -0,0 +1,45 @@
import itertools
from typing import Type
from qtpy.QtWidgets import QWidget
class WidgetContainerUtils:
@staticmethod
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
"""
Generate a unique widget ID.
Args:
container(dict): The container of widgets.
prefix(str): The prefix of the widget ID.
Returns:
widget_id(str): The unique widget ID.
"""
existing_ids = set(container.keys())
for i in itertools.count(1):
widget_id = f"{prefix}_{i}"
if widget_id not in existing_ids:
return widget_id
@staticmethod
def find_first_widget_by_class(
container: dict, widget_class: Type[QWidget], can_fail: bool = True
) -> QWidget | None:
"""
Find the first widget of a given class in the figure.
Args:
container(dict): The container of widgets.
widget_class(Type): 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:
widget: The widget of the given class.
"""
for widget_id, widget in container.items():
if isinstance(widget, widget_class):
return widget
if can_fail:
return None
else:
raise ValueError(f"No widget of class {widget_class} found.")
+2 -1
View File
@@ -2,7 +2,8 @@ import numpy as np
import pyqtgraph as pg
# from qtpy.QtCore import QObject, pyqtSignal
from qtpy.QtCore import QObject, Signal as pyqtSignal
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
class Crosshair(QObject):
+40
View File
@@ -0,0 +1,40 @@
class EntryValidator:
def __init__(self, devices):
self.devices = devices
def validate_signal(self, name: str, entry: str = None) -> str:
"""
Validate a signal entry for a given device. If the entry is not provided, the first signal entry will be used from the device hints.
Args:
name(str): Device name
entry(str): Signal entry
Returns:
str: Signal entry
"""
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
def validate_monitor(self, monitor: str) -> str:
"""
Validate a monitor entry for a given device.
Args:
monitor(str): Monitor entry
Returns:
str: Monitor entry
"""
if monitor not in self.devices:
raise ValueError(f"Device '{monitor}' not found in current BEC session")
return monitor
+15
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
+37
View File
@@ -0,0 +1,37 @@
import threading
class ThreadTracker:
def __init__(self, exclude_names=None):
self.exclude_names = exclude_names if exclude_names else []
self.initial_threads = self._capture_threads()
def _capture_threads(self):
return set(
th
for th in threading.enumerate()
if not any(ex_name in th.name for ex_name in self.exclude_names)
and th is not threading.main_thread()
)
def _thread_info(self, threads):
return ", \n".join(f"{th.name}(ID: {th.ident})" for th in threads)
def check_unfinished_threads(self):
current_threads = self._capture_threads()
additional_threads = current_threads - self.initial_threads
closed_threads = self.initial_threads - current_threads
if additional_threads:
raise Exception(
f"###### Initial threads ######:\n {self._thread_info(self.initial_threads)}\n"
f"###### Current threads ######:\n {self._thread_info(current_threads)}\n"
f"###### Closed threads ######:\n {self._thread_info(closed_threads)}\n"
f"###### Unfinished threads ######:\n {self._thread_info(additional_threads)}"
)
else:
print(
"All threads properly closed.\n"
f"###### Initial threads ######:\n {self._thread_info(self.initial_threads)}\n"
f"###### Current threads ######:\n {self._thread_info(current_threads)}\n"
f"###### Closed threads ######:\n {self._thread_info(closed_threads)}"
)
+1 -1
View File
@@ -2,7 +2,7 @@
# from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit
from qtpy.QtWidgets import QLineEdit, QStyledItemDelegate
class DoubleValidationDelegate(QStyledItemDelegate):
+6 -6
View File
@@ -3,16 +3,16 @@ from abc import ABC, abstractmethod
from qtpy.QtWidgets import (
QApplication,
QWidget,
QLineEdit,
QCheckBox,
QComboBox,
QTableWidget,
QSpinBox,
QDoubleSpinBox,
QLabel,
QLineEdit,
QSpinBox,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QCheckBox,
QLabel,
QWidget,
)
+1
View File
@@ -1,6 +1,7 @@
# pylint: disable=no-name-in-module
from typing import Union
import yaml
from qtpy.QtWidgets import QFileDialog
@@ -1,6 +1,6 @@
from typing import Optional, Union
from typing import Literal, Optional, Union
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
from pydantic_core import PydanticCustomError
@@ -43,13 +43,8 @@ class Signal(BaseModel):
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}" does not have "signals" defined. Check device configuration.',
{"wrong_value": name},
)
# Get device description
description = device.describe()
# Validate 'entry'
entry = values.get("entry")
@@ -57,7 +52,7 @@ class Signal(BaseModel):
# Set entry based on hints if not provided
if entry is None:
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in device.signals:
if entry not in description:
raise PydanticCustomError(
"no_entry_for_device",
'Entry "{wrong_value}" not found in device "{device_name}" signals',
@@ -68,37 +63,22 @@ class Signal(BaseModel):
return values
class PlotAxis(BaseModel):
class AxisSignal(BaseModel):
"""
Represents an axis (X or Y) in a plot configuration.
Configuration signal axis for a single plot.
Attributes:
label (Optional[str]): The label for the axis.
signals (list[Signal]): A list of signals to be plotted on this axis.
x (list): Signal for the X axis.
y (list): Signals for the Y axis.
"""
label: Optional[str]
signals: list[Signal] = Field(default_factory=list)
class PlotConfig(BaseModel):
"""
Configuration for a single plot.
Attributes:
plot_name (Optional[str]): Name of the plot.
x (PlotAxis): Configuration for the X axis.
y (PlotAxis): Configuration for the Y axis.
"""
plot_name: Optional[str]
x: PlotAxis = Field(...)
y: PlotAxis = Field(...)
x: list[Signal] = Field(default_factory=list)
y: list[Signal] = Field(default_factory=list)
@field_validator("x")
@classmethod
def validate_x_signals(cls, v):
if len(v.signals) != 1:
"""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}"',
@@ -108,25 +88,113 @@ class PlotConfig(BaseModel):
return v
class PlotSettings(BaseModel):
class SourceHistoryValidator(BaseModel):
"""History source validator
Attributes:
type (str): type of source - history
scan_id (str): Scan ID for history source.
signals (list): Signal for the source.
"""
Global settings for plotting.
type: Literal["history"]
scan_id: str # TODO can be validated if it is a valid scan_id
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)
scan_id (Optional[str]): Scan ID for history source.
signals (Optional[AxisSignal]): Signal for the source.
"""
type: Literal["scan_segment", "history", "redis"]
scan_id: Optional[str] = None
signals: Optional[dict] = None
class PlotConfig(BaseModel):
"""
Configuration for a single plot.
Attributes:
background_color (str): Color of the plot background.
axis_width (Optional[int]): Width of the plot axes.
axis_color (Optional[str]): Color of the plot axes.
num_columns (int): Number of columns in the plot layout.
colormap (str): Colormap to be used.
scan_types (bool): Indicates if the configuration is for different scan types.
plot_name (Optional[str]): Name of the plot.
x_label (Optional[str]): The label for the x-axis.
y_label (Optional[str]): The label for the y-axis.
sources (list): A list of sources to be plotted on this axis.
"""
background_color: str
plot_name: Optional[str] = None
x_label: Optional[str] = None
y_label: Optional[str] = None
sources: list = Field(default_factory=list)
@field_validator("sources")
@classmethod
def validate_sources(cls, values):
"""Validate the sources of the plot configuration, based on the type of source."""
validated_sources = []
for source in values:
# Check if source type is supported
Source(**source)
source_type = source.get("type", None)
# Validate source based on type
if source_type == "scan_segment":
validated_sources.append(SourceSegmentValidator(**source))
elif source_type == "history":
validated_sources.append(SourceHistoryValidator(**source))
elif source_type == "redis":
validated_sources.append(SourceRedisValidator(**source))
return validated_sources
class PlotSettings(BaseModel):
"""
Global settings for plotting affecting mostly visuals.
Attributes:
background_color (str): Color of the plot background. Default is black.
axis_width (Optional[int]): Width of the plot axes. Default is 2.
axis_color (Optional[str]): Color of the plot axes. Default is None.
num_columns (int): Number of columns in the plot layout. Default is 1.
colormap (str): Colormap to be used. Default is magma.
scan_types (bool): Indicates if the configuration is for different scan types. Default is False.
"""
background_color: Literal["black", "white"] = "black"
axis_width: Optional[int] = 2
axis_color: Optional[str] = None
num_columns: int
colormap: str
scan_types: bool
num_columns: Optional[int] = 1
colormap: Optional[str] = "magma"
scan_types: Optional[bool] = False
class DeviceMonitorConfig(BaseModel):
@@ -180,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)
+11 -3
View File
@@ -1,4 +1,12 @@
from .monitor import BECMonitor, ConfigDialog
from .figure import BECFigure, FigureConfig
from .monitor import BECMonitor
from .motor_control import (
MotorControlAbsolute,
MotorControlRelative,
MotorControlSelection,
MotorCoordinateTable,
MotorThread,
)
from .motor_map import MotorMap
from .plots import BECCurve, BECMotorMap, BECWaveform
from .scan_control import ScanControl
from .toolbar import ModularToolBar
from .editor import BECEditor
-1
View File
@@ -1 +0,0 @@
from .editor import BECEditor
-415
View File
@@ -1,415 +0,0 @@
import subprocess
import qdarktheme
from jedi import Script
from jedi.api import Completion
# pylint: disable=no-name-in-module
from qtpy.Qsci import QsciScintilla, QsciLexerPython, QsciAPIs
from qtpy.QtCore import Qt
from qtpy.QtCore import Signal, QThread
from qtpy.QtGui import QColor, QFont
from qtpy.QtWidgets import (
QApplication,
QFileDialog,
QTextEdit,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QSplitter
from qtconsole.manager import QtKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from bec_widgets.widgets import ModularToolBar
class AutoCompleter(QThread):
"""Initializes the AutoCompleter thread for handling autocompletion and signature help.
Args:
file_path (str): The path to the file for which autocompletion is required.
api (QsciAPIs): The QScintilla API instance used for managing autocompletions.
enable_docstring (bool, optional): Flag to determine if docstrings should be included in the signatures.
"""
def __init__(self, file_path: str, api: QsciAPIs, enable_docstring: bool = False):
super().__init__(None)
self.file_path = file_path
self.script: Script = None
self.api: QsciAPIs = api
self.completions: list[Completion] = None
self.line = 0
self.index = 0
self.text = ""
# TODO so far disabled, quite buggy, docstring extraction has to be generalised
self.enable_docstring = enable_docstring
def update_script(self, text: str):
"""Updates the script for Jedi completion based on the current editor text.
Args:
text (str): The current text of the editor.
"""
if self.script is None or self.script.path != text:
self.script = Script(text, path=self.file_path)
def run(self):
"""Runs the thread for generating autocompletions. Overrides QThread.run."""
self.update_script(self.text)
try:
self.completions = self.script.complete(self.line, self.index)
self.load_autocomplete(self.completions)
except Exception as err:
print(err)
self.finished.emit()
def get_function_signature(self, line: int, index: int, text: str) -> str:
"""Fetches the function signature for a given position in the text.
Args:
line (int): The line number in the editor.
index (int): The index (column number) in the line.
text (str): The current text of the editor.
Returns:
str: A string containing the function signature or an empty string if not available.
"""
self.update_script(text)
try:
signatures = self.script.get_signatures(line, index)
if signatures and self.enable_docstring is True:
full_docstring = signatures[0].docstring(raw=True)
compact_docstring = self.get_compact_docstring(full_docstring)
return compact_docstring
if signatures and self.enable_docstring is False:
return signatures[0].to_string()
except Exception as err:
print(f"Signature Error:{err}")
return ""
def load_autocomplete(self, completions: list):
"""Loads the autocomplete suggestions into the QScintilla API.
Args:
completions (list[Completion]): A list of Completion objects to be added to the API.
"""
self.api.clear()
for i in completions:
self.api.add(i.name)
self.api.prepare()
def get_completions(self, line: int, index: int, text: str):
"""Starts the autocompletion process for a given position in the text.
Args:
line (int): The line number in the editor.
index (int): The index (column number) in the line.
text (str): The current text of the editor.
"""
self.line = line
self.index = index
self.text = text
self.start()
def get_compact_docstring(self, full_docstring):
"""Generates a compact version of a function's docstring.
Args:
full_docstring (str): The full docstring of a function.
Returns:
str: A compact version of the docstring.
"""
lines = full_docstring.split("\n")
# TODO make it also for different docstring styles, now it is only for numpy style
cutoff_indices = [
i
for i, line in enumerate(lines)
if line.strip().lower() in ["parameters", "returns", "examples", "see also", "warnings"]
]
if cutoff_indices:
lines = lines[: cutoff_indices[0]]
compact_docstring = "\n".join(lines).strip()
return compact_docstring
class ScriptRunnerThread(QThread):
"""Initializes the thread for running a Python script.
Args:
script (str): The script to be executed.
"""
outputSignal = Signal(str)
def __init__(self, script):
super().__init__()
self.script = script
def run(self):
"""Executes the script in a subprocess and emits output through a signal. Overrides QThread.run."""
process = subprocess.Popen(
["python", "-u", "-c", self.script],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
universal_newlines=True,
text=True,
)
while True:
output = process.stdout.readline()
if output == "" and process.poll() is not None:
break
if output:
self.outputSignal.emit(output)
error = process.communicate()[1]
if error:
self.outputSignal.emit(error)
class BECEditor(QWidget):
"""Initializes the BEC Editor widget.
Args:
toolbar_enabled (bool, optional): Determines if the toolbar should be enabled. Defaults to True.
"""
def __init__(
self, toolbar_enabled=True, jupyter_terminal_enabled=False, docstring_tooltip=False
):
super().__init__()
self.script_runner_thread = None
self.file_path = None
self.docstring_tooltip = docstring_tooltip
self.jupyter_terminal_enabled = jupyter_terminal_enabled
# TODO just temporary solution, could be extended to other languages
self.is_python_file = True
# Initialize the editor and terminal
self.editor = QsciScintilla()
if self.jupyter_terminal_enabled:
self.terminal = self.make_jupyter_widget_with_kernel()
else:
self.terminal = QTextEdit()
self.terminal.setReadOnly(True)
# Layout
self.layout = QVBoxLayout()
# Initialize and add the toolbar if enabled
if toolbar_enabled:
self.toolbar = ModularToolBar(self)
self.layout.addWidget(self.toolbar)
# Initialize the splitter
self.splitter = QSplitter(Qt.Orientation.Vertical, self)
self.splitter.addWidget(self.editor)
self.splitter.addWidget(self.terminal)
self.splitter.setSizes([400, 200])
# Add Splitter to layout
self.layout.addWidget(self.splitter)
self.setLayout(self.layout)
self.setup_editor()
def setup_editor(self):
"""Sets up the editor with necessary configurations like lexer, auto indentation, and line numbers."""
# Set the lexer for Python
self.lexer = QsciLexerPython()
self.editor.setLexer(self.lexer)
# Enable auto indentation and competition within the editor
self.editor.setAutoIndent(True)
self.editor.setIndentationsUseTabs(False)
self.editor.setIndentationWidth(4)
self.editor.setAutoCompletionSource(QsciScintilla.AutoCompletionSource.AcsAll)
self.editor.setAutoCompletionThreshold(1)
# Autocomplete for python file
# Connect cursor position change signal for autocompletion
self.editor.cursorPositionChanged.connect(self.on_cursor_position_changed)
# if self.is_python_file: #TODO can be changed depending on supported languages
self.__api = QsciAPIs(self.lexer)
self.auto_completer = AutoCompleter(
self.editor.text(), self.__api, enable_docstring=self.docstring_tooltip
)
self.auto_completer.finished.connect(self.loaded_autocomplete)
# Enable line numbers in the margin
self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin)
self.editor.setMarginWidth(0, "0000") # Adjust the width as needed
# Additional UI elements like menu for load/save can be added here
self.set_editor_style()
@staticmethod
def make_jupyter_widget_with_kernel() -> object:
"""Start a kernel, connect to it, and create a RichJupyterWidget to use it"""
kernel_manager = QtKernelManager(kernel_name="python3")
kernel_manager.start_kernel()
kernel_client = kernel_manager.client()
kernel_client.start_channels()
jupyter_widget = RichJupyterWidget()
jupyter_widget.set_default_style("linux")
jupyter_widget.kernel_manager = kernel_manager
jupyter_widget.kernel_client = kernel_client
return jupyter_widget
def show_call_tip(self, position):
"""Shows a call tip at the given position in the editor.
Args:
position (int): The position in the editor where the call tip should be shown.
"""
line, index = self.editor.lineIndexFromPosition(position)
signature = self.auto_completer.get_function_signature(line + 1, index, self.editor.text())
if signature:
self.editor.showUserList(1, [signature])
def on_cursor_position_changed(self, line, index):
"""Handles the event of cursor position change in the editor.
Args:
line (int): The current line number where the cursor is.
index (int): The current column index where the cursor is.
"""
# if self.is_python_file: #TODO can be changed depending on supported languages
# Get completions
self.auto_completer.get_completions(line + 1, index, self.editor.text())
self.editor.autoCompleteFromAPIs()
# Show call tip - signature
position = self.editor.positionFromLineIndex(line, index)
self.show_call_tip(position)
def loaded_autocomplete(self):
"""Placeholder method for actions after autocompletion data is loaded."""
def set_editor_style(self):
"""Sets the style and color scheme for the editor."""
# Dracula Theme Colors
background_color = QColor("#282a36")
text_color = QColor("#f8f8f2")
keyword_color = QColor("#8be9fd")
string_color = QColor("#f1fa8c")
comment_color = QColor("#6272a4")
class_function_color = QColor("#50fa7b")
# Set Font
font = QFont()
font.setFamily("Consolas")
font.setPointSize(10)
self.editor.setFont(font)
self.editor.setMarginsFont(font)
# Set Editor Colors
self.editor.setMarginsBackgroundColor(background_color)
self.editor.setMarginsForegroundColor(text_color)
self.editor.setCaretForegroundColor(text_color)
self.editor.setCaretLineBackgroundColor(QColor("#44475a"))
self.editor.setPaper(background_color) # Set the background color for the entire paper
self.editor.setColor(text_color)
# Set editor
# Syntax Highlighting Colors
lexer = self.editor.lexer()
if lexer:
lexer.setDefaultPaper(background_color) # Set the background color for the text area
lexer.setDefaultColor(text_color)
lexer.setColor(keyword_color, QsciLexerPython.Keyword)
lexer.setColor(string_color, QsciLexerPython.DoubleQuotedString)
lexer.setColor(string_color, QsciLexerPython.SingleQuotedString)
lexer.setColor(comment_color, QsciLexerPython.Comment)
lexer.setColor(class_function_color, QsciLexerPython.ClassName)
lexer.setColor(class_function_color, QsciLexerPython.FunctionMethodName)
# Set the style for all text to have a transparent background
# TODO find better way how to do it!
for style in range(
128
): # QsciScintilla supports 128 styles by default, this set all to transparent background
self.lexer.setPaper(background_color, style)
def run_script(self):
"""Runs the current script in the editor."""
if self.jupyter_terminal_enabled:
script = self.editor.text()
self.terminal.execute(script)
else:
script = self.editor.text()
self.script_runner_thread = ScriptRunnerThread(script)
self.script_runner_thread.outputSignal.connect(self.update_terminal)
self.script_runner_thread.start()
def update_terminal(self, text):
"""Updates the terminal with new text.
Args:
text (str): The text to be appended to the terminal.
"""
self.terminal.append(text)
def enable_docstring_tooltip(self):
"""Enables the docstring tooltip."""
self.docstring_tooltip = True
self.auto_completer.enable_docstring = True
def open_file(self):
"""Opens a file dialog for selecting and opening a Python file in the editor."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getOpenFileName(
self, "Open file", "", "Python files (*.py);;All Files (*)", options=options
)
if not file_path:
return
try:
with open(file_path, "r") as file:
text = file.read()
self.editor.setText(text)
except FileNotFoundError:
print(f"The file {file_path} was not found.")
except Exception as e:
print(f"An error occurred while opening the file {file_path}: {e}")
def save_file(self):
"""Opens a save file dialog for saving the current script in the editor."""
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
file_path, _ = QFileDialog.getSaveFileName(
self, "Save file", "", "Python files (*.py);;All Files (*)", options=options
)
if not file_path:
return
try:
if not file_path.endswith(".py"):
file_path += ".py"
with open(file_path, "w") as file:
text = self.editor.text()
file.write(text)
print(f"File saved to {file_path}")
except Exception as e:
print(f"An error occurred while saving the file to {file_path}: {e}")
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
qdarktheme.setup_theme("auto")
mainWin = BECEditor(jupyter_terminal_enabled=True)
mainWin.show()
app.exec()
+1
View File
@@ -0,0 +1 @@
from .figure import BECFigure, FigureConfig
+778
View File
@@ -0,0 +1,778 @@
# 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.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
from bec_widgets.utils import BECConnector, BECDispatcher, ConnectionConfig, WidgetContainerUtils
from bec_widgets.widgets.plots import (
BECImageShow,
BECMotorMap,
BECPlotBase,
BECWaveform,
Waveform1DConfig,
WidgetConfig,
)
from bec_widgets.widgets.plots.image import ImageConfig
from bec_widgets.widgets.plots.motor_map import MotorMapConfig
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": (BECWaveform, Waveform1DConfig),
"ImShow": (BECImageShow, ImageConfig),
"MotorMap": (BECMotorMap, MotorMapConfig),
}
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 = [
"rpc_id",
"config_dict",
"axes",
"widgets",
"add_plot",
"add_image",
"add_motor_map",
"plot",
"image",
"motor_map",
"remove",
"change_layout",
"change_theme",
"clear_all",
"get_all_rpc",
"widget_list",
]
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 = []
def __getitem__(self, key: tuple | str):
if isinstance(key, tuple) and len(key) == 2:
return self.axes(*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)"
)
@property
def widget_list(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
@widget_list.setter
def widget_list(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,
z_name: str = None,
x_entry: str = None,
y_entry: str = None,
z_entry: str = None,
x: list | np.ndarray = None,
y: list | np.ndarray = None,
color: Optional[str] = None,
color_map_z: Optional[str] = "plasma",
label: Optional[str] = None,
validate: bool = True,
row: int = None,
col: int = None,
config=None,
**axis_kwargs,
) -> BECWaveform:
"""
Add a Waveform1D plot to the figure at the specified position.
Args:
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 = WidgetContainerUtils.generate_unique_widget_id(self._widgets)
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 -> 1D Waveform
if x_name is not None and y_name is not None and z_name is None and x is None and y is None:
waveform.add_curve_scan(
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 scan curve -> 2D Waveform Scatter
if (
x_name is not None
and y_name is not None
and z_name is not None
and x is None
and y is None
):
waveform.add_curve_scan(
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
color=color,
color_map_z=color_map_z,
label=label,
validate=validate,
)
# 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,
z_name: str = None,
x_entry: str = None,
y_entry: str = None,
z_entry: str = None,
x: list | np.ndarray = None,
y: list | np.ndarray = None,
color: Optional[str] = None,
color_map_z: Optional[str] = "plasma",
label: Optional[str] = None,
validate: bool = True,
**axis_kwargs,
) -> BECWaveform:
"""
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.
z_name(str): The name of the device for the z-axis.
x_entry(str): The name of the entry for the x-axis.
y_entry(str): The name of the entry for the y-axis.
z_entry(str): The name of the entry for the z-axis.
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.
color_map_z(str): The color map to use for the z-axis.
label(str): The label of the curve.
validate(bool): If True, validate the device names and entries.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECWaveform: The waveform plot widget.
"""
waveform = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, BECWaveform, can_fail=True
)
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 -> 1D Waveform
if x_name is not None and y_name is not None and z_name is None and x is None and y is None:
waveform.add_curve_scan(
x_name=x_name,
y_name=y_name,
x_entry=x_entry,
y_entry=y_entry,
color=color,
color_map_z="plasma",
label=label,
validate=validate,
)
# User wants to add scan curve -> 2D Waveform Scatter
elif (
x_name is not None
and y_name is not None
and z_name is not None
and x is None
and y is None
):
waveform.add_curve_scan(
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
color=color,
color_map_z=color_map_z,
label=label,
validate=validate,
)
# 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 and z_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 = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, BECImageShow, can_fail=True
)
if image is not None:
if axis_kwargs:
image.set(**axis_kwargs)
else:
image = self.add_image(color_bar=color_bar, **axis_kwargs)
# 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: Additional axis properties to set on the widget after creation.
Returns:
BECImageShow: The image widget.
"""
widget_id = WidgetContainerUtils.generate_unique_widget_id(self._widgets)
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 motor_map(self, motor_x: str = None, motor_y: str = None, **axis_kwargs) -> BECMotorMap:
"""
Add a motor map to the figure. Always access the first motor map widget in the figure.
Args:
motor_x(str): The name of the motor for the X axis.
motor_y(str): The name of the motor for the Y axis.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECMotorMap: The motor map widget.
"""
motor_map = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, BECMotorMap, can_fail=True
)
if motor_map is not None:
if axis_kwargs:
motor_map.set(**axis_kwargs)
else:
motor_map = self.add_motor_map(**axis_kwargs)
if motor_x is not None and motor_y is not None:
motor_map.change_motors(motor_x, motor_y)
return motor_map
def add_motor_map(
self,
motor_x: str = None,
motor_y: str = None,
row: int = None,
col: int = None,
config=None,
**axis_kwargs,
) -> BECMotorMap:
"""
Args:
motor_x(str): The name of the motor for the X axis.
motor_y(str): The name of the motor for the Y axis.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs:
Returns:
BECMotorMap: The motor map widget.
"""
widget_id = WidgetContainerUtils.generate_unique_widget_id(self._widgets)
if config is None:
config = MotorMapConfig(
widget_class="BECMotorMap",
gui_id=widget_id,
parent_id=self.gui_id,
)
motor_map = self.add_widget(
widget_type="MotorMap",
widget_id=widget_id,
row=row,
col=col,
config=config,
**axis_kwargs,
)
if motor_x is not None and motor_y is not None:
motor_map.change_motors(motor_x, motor_y)
return motor_map
def add_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 = WidgetContainerUtils.generate_unique_widget_id(self._widgets)
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 _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.axes(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)
else:
raise ValueError(f"Widget with ID '{widget_id}' does not exist.")
def axes(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 _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."""
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
)
-1
View File
@@ -1,2 +1 @@
from .monitor import BECMonitor
from .config_dialog import ConfigDialog
+186 -106
View File
@@ -1,18 +1,22 @@
import os
from pydantic import ValidationError
from qtpy import uic
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import (
QApplication,
QWidget,
QVBoxLayout,
QTableWidget,
QTabWidget,
QTableWidgetItem,
QLineEdit,
QMessageBox,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.yaml_dialog import load_yaml, save_yaml
from bec_widgets.validation import MonitorConfigValidator
current_path = os.path.dirname(__file__)
Ui_Form, BaseClass = uic.loadUiType(os.path.join(current_path, "config_dialog.ui"))
@@ -21,7 +25,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 +35,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 +80,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 +172,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 +366,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 +384,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 +489,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 +541,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()
+448 -218
View File
@@ -1,4 +1,4 @@
# pylint: disable = no-name-in-module,
# pylint: disable = no-name-in-module,missing-module-docstring
import time
import pyqtgraph as pg
@@ -10,10 +10,10 @@ 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
from bec_widgets.utils.yaml_dialog import load_yaml
from bec_widgets.utils import Colors, Crosshair, yaml_dialog
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.validation import MonitorConfigValidator
from bec_widgets.utils.bec_dispatcher import bec_dispatcher
from bec_widgets.widgets.monitor.config_dialog import ConfigDialog
# just for demonstration purposes if script run directly
CONFIG_SCAN_MODE = {
@@ -27,65 +27,156 @@ 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"},
],
},
"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_WRONG = {
"plot_settings": {
"background_color": "black",
"num_columns": 2,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor Y",
"y_label": "bpm4i",
"sources": [
{
"type": "non_existing_source",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
{
"type": "history",
"scan_id": "<scan_id>",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "non_sense_entry"}],
"y": [
{"name": "non_existing_name"},
{"name": "samy", "entry": "non_existing_entry"},
],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "samx"}, {"name": "samy", "entry": "samx"}],
}
}
],
},
],
}
CONFIG_SIMPLE = {
"plot_settings": {
"background_color": "black",
@@ -96,76 +187,51 @@ 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 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",
# "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": "samx"}],
"y": [{"name": "bpm4i"}, {"name": "bpm4i"}],
},
}
],
},
],
}
CONFIG_NO_ENTRY = {
"plot_settings": {
"background_color": "white",
"num_columns": 5,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {"label": "Motor Y", "signals": [{"name": "samx"}]},
"y": {"label": "bpm4i", "signals": [{"name": "bpm4i"}]},
},
{
"plot_name": "Gauss plots vs samx",
"x": {"label": "Motor X", "signals": [{"name": "samx"}]},
"y": {"label": "Gauss", "signals": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}]},
},
],
}
CONFIG_WRONG = {
"plot_settings": {
"background_color": "white",
"num_columns": 5,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {"label": "Motor Y", "signals": [{"name": "samx"}]},
"y": {"label": "bpm4i", "signals": [{"name": "bpm4i"}]},
},
{
"plot_name": "Gauss plots vs samx",
"x": {"label": "Motor X", "signals": [{"name": "samx"}]},
"y": {
"label": "Gauss",
"signals": [
{"name": "gauss_bpm"},
{"name": "non_exist1"},
{"name": "non_exist2"},
{"name": "gauss_bpm", "entry": "samx"},
],
},
},
],
}
test_config = {
CONFIG_REDIS = {
"plot_settings": {
"background_color": "white",
"axis_width": 2,
@@ -176,8 +242,20 @@ test_config = {
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {"label": "Motor Y", "signals": [{"name": "samx"}]},
"y": {"label": "bpm4i", "signals": [{"name": "bpm4i"}]},
"x_label": "Motor Y",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {"x": [{"name": "samx"}], "y": [{"name": "gauss_bpm"}]},
},
{
"type": "redis",
"endpoint": "public/gui/data/6cd5ea3f-a9a9-4736-b4ed-74ab9edfb996",
"update": "append",
"signals": {"x": [{"name": "x_default_tag"}], "y": [{"name": "y_default_tag"}]},
},
],
}
],
}
@@ -193,11 +271,13 @@ class BECMonitor(pg.GraphicsLayoutWidget):
config: dict = None,
enable_crosshair: bool = True,
gui_id=None,
skip_validation: bool = False,
):
super(BECMonitor, self).__init__(parent=parent)
super().__init__(parent=parent)
# Client and device manager from BEC
self.plot_data = None
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.dev = self.client.device_manager.devices
self.queue = self.client.queue
@@ -206,7 +286,7 @@ class BECMonitor(pg.GraphicsLayoutWidget):
self.gui_id = gui_id
if self.gui_id is None:
self.gui_id = self.__class__.__name__ + str(time.time()) # TODO still in discussion
self.gui_id = self.__class__.__name__ + str(time.time())
# Connect slots dispatcher
bec_dispatcher.connect_slot(self.on_scan_segment, MessageEndpoints.scan_segment())
@@ -214,28 +294,30 @@ class BECMonitor(pg.GraphicsLayoutWidget):
bec_dispatcher.connect_slot(
self.on_instruction, MessageEndpoints.gui_instructions(self.gui_id)
)
bec_dispatcher.connect_slot(self.on_data_from_redis, MessageEndpoints.gui_data(self.gui_id))
# Current configuration
self.config = config
self.skip_validation = skip_validation
# Enable crosshair
self.enable_crosshair = enable_crosshair
# Displayed Data
self.data = {}
self.database = None
self.crosshairs = None
self.plots = None
self.curves_data = None
self.grid_coordinates = None
self.scanID = None
self.scan_id = None
# TODO make colors accessible to users
self.user_colors = {} # key: (plot_name, y_name, y_entry), value: color
# Connect the update signal to the update plot method #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
@@ -259,9 +341,46 @@ class BECMonitor(pg.GraphicsLayoutWidget):
else: # without incoming data setup the first configuration to the first scan type sorted alphabetically by name
self.plot_data = self.plot_data_config[min(list(self.plot_data_config.keys()))]
# Initialize the database
self.database = self._init_database(self.plot_data)
# Initialize the UI
self._init_ui(self.plot_settings["num_columns"])
if self.scan_id 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.
@@ -308,8 +427,9 @@ class BECMonitor(pg.GraphicsLayoutWidget):
last_row_cols -= 1
plot_name = plot_config.get("plot_name", "")
x_label = plot_config["x"].get("label", "")
y_label = plot_config["y"].get("label", "")
x_label = plot_config.get("x_label", "")
y_label = plot_config.get("y_label", "")
plot = self.addPlot(row=row, col=col, colspan=colspan, title=plot_name)
plot.setLabel("bottom", x_label)
@@ -320,6 +440,7 @@ class BECMonitor(pg.GraphicsLayoutWidget):
self.plots[plot_name] = plot
self.grid_coordinates.append((row, col))
# Initialize curves
self.init_curves()
def _set_plot_colors(self, plot: pg.PlotItem, plot_settings: dict) -> None:
@@ -346,7 +467,6 @@ class BECMonitor(pg.GraphicsLayoutWidget):
f"Invalid background color {plot_settings['background_color']}. Allowed values"
" are 'white' or 'black'."
)
print(plot_settings)
pen = pg.mkPen(color=color, width=pen_width)
x_axis = plot.getAxis("bottom") # 'bottom' corresponds to the x-axis
x_axis.setPen(pen)
@@ -360,54 +480,64 @@ class BECMonitor(pg.GraphicsLayoutWidget):
def init_curves(self) -> None:
"""
Initialize curve data and properties, and update table row labels.
This method initializes a nested dictionary `self.curves_data` to store
the curve objects for each x and y signal pair. It also updates the row labels
in `self.tableWidget_crosshair` to include the grid position for each y-value.
Initialize curve data and properties for each plot and data source.
"""
self.curves_data = {}
row_labels = []
for idx, plot_config in enumerate(self.plot_data):
plot_name = plot_config.get("plot_name", "")
plot = self.plots[plot_name]
plot.clear()
y_configs = plot_config["y"]["signals"]
colors_ys = Colors.golden_angle_color(
colormap=self.plot_settings["colormap"], num=len(y_configs)
)
curve_list = []
for i, (y_config, color) in enumerate(zip(y_configs, colors_ys)):
y_name = y_config["name"]
y_entry = y_config["entry"]
user_color = self.user_colors.get((plot_name, y_name, y_entry), None)
color_to_use = user_color if user_color else color
pen_curve = mkPen(color=color_to_use, width=2, style=QtCore.Qt.DashLine)
brush_curve = mkBrush(color=color_to_use)
curve_data = pg.PlotDataItem(
symbolSize=5,
symbolBrush=brush_curve,
pen=pen_curve,
skipFiniteCheck=True,
name=f"{y_name} ({y_entry})",
for source in plot_config["sources"]:
source_type = source["type"]
y_signals = source["signals"].get("y", [])
colors_ys = Colors.golden_angle_color(
colormap=self.plot_settings["colormap"], num=len(y_signals)
)
curve_list.append((y_name, y_entry, curve_data))
plot.addItem(curve_data)
row_labels.append(f"{y_name} ({y_entry}) - {plot_name}")
if source_type not in self.curves_data:
self.curves_data[source_type] = {}
if plot_name not in self.curves_data[source_type]:
self.curves_data[source_type][plot_name] = []
self.curves_data[plot_name] = curve_list
for i, (y_signal, color) in enumerate(zip(y_signals, colors_ys)):
y_name = y_signal["name"]
y_entry = y_signal.get("entry", y_name)
curve_name = f"{y_name} ({y_entry})-{source_type[0].upper()}"
curve_data = self.create_curve(curve_name, color)
plot.addItem(curve_data)
self.curves_data[source_type][plot_name].append((y_name, y_entry, curve_data))
# Hook Crosshair
if self.enable_crosshair == True:
# Render static plot elements
self.update_plot()
# # Hook Crosshair #TODO enable later, currently not working
if self.enable_crosshair is True:
self.hook_crosshair()
def create_curve(self, curve_name: str, color: str) -> pg.PlotDataItem:
"""
Create
Args:
curve_name: Name of the curve
color(str): Color of the curve
Returns:
pg.PlotDataItem: Assigned curve object
"""
user_color = self.user_colors.get(curve_name, None)
color_to_use = user_color if user_color else color
pen_curve = mkPen(color=color_to_use, width=2, style=QtCore.Qt.DashLine)
brush_curve = mkBrush(color=color_to_use)
return pg.PlotDataItem(
symbolSize=5,
symbolBrush=brush_curve,
pen=pen_curve,
skipFiniteCheck=True,
name=curve_name,
)
def hook_crosshair(self) -> None:
"""Hook the crosshair to all plots."""
# TODO can be extended to hook crosshair signal for mouse move/clicked
@@ -416,22 +546,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."""
@@ -439,9 +597,10 @@ class BECMonitor(pg.GraphicsLayoutWidget):
def show_config_dialog(self):
"""Show the configuration dialog."""
from .config_dialog 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()
@@ -453,6 +612,11 @@ class BECMonitor(pg.GraphicsLayoutWidget):
self.client = client
self.dev = self.client.device_manager.devices
def _close_all_plots(self):
"""Close all plots."""
for plot in self.plots.values():
plot.clear()
@pyqtSlot(dict)
def on_instruction(self, msg_content: dict) -> None:
"""
@@ -460,6 +624,7 @@ class BECMonitor(pg.GraphicsLayoutWidget):
Possible actions are:
- clear: Clear the plots
- close: Close the GUI
- config_dialog: Open the configuration dialog
Args:
msg_content (dict): Message content with the instruction and parameters.
@@ -469,8 +634,11 @@ class BECMonitor(pg.GraphicsLayoutWidget):
if action == "clear":
self.flush()
self._close_all_plots()
elif action == "close":
self.close()
elif action == "config_dialog":
self.show_config_dialog()
else:
print(f"Unknown instruction received: {msg_content}")
@@ -481,17 +649,25 @@ 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_str = str(e)
formatted_error_message = BECMonitor.format_validation_error(error_str)
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)
# 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:
@@ -517,13 +693,29 @@ class BECMonitor(pg.GraphicsLayoutWidget):
return formatted_error_message
def flush(self) -> None:
"""Flush the data dictionary."""
self.data = {}
self.init_curves()
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.
@@ -531,67 +723,99 @@ 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:
current_scan_id = msg.get("scan_id", None)
if current_scan_id is None:
return
if current_scanID != self.scanID:
if current_scan_id != self.scan_id:
if self.scan_types is False:
self.plot_data = self.plot_data_config
elif self.scan_types is True:
current_name = metadata.get("scan_name")
if current_name is None:
raise ValueError(
f"Scan name not found in metadata. Please check the scan_name in the YAML"
f" config or in bec configuration."
"Scan name not found in metadata. Please check the scan_name in the YAML"
" config or in bec configuration."
)
self.plot_data = self.plot_data_config.get(current_name, [])
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 {current_name} not found in the YAML config. Please check the scan_name in the "
f"YAML config or in bec configuration."
"YAML config or in bec configuration."
)
# Init UI
self._init_ui(self.plot_settings["num_columns"])
self.scanID = current_scanID
self.flush()
self.scan_id = current_scan_id
self.scan_data = self.queue.scan_storage.find_scan_by_ID(self.scan_id)
if not self.scan_data:
print(f"No data found for scan_id: {self.scan_id}") # 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 scan_id.
"""
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.utils.bec_dispatcher import bec_dispatcher
parser = argparse.ArgumentParser()
parser.add_argument("--config_file", help="Path to the config file.")
parser.add_argument("--config", help="Path to the config file.")
@@ -603,13 +827,19 @@ if __name__ == "__main__": # pragma: no cover
config = json.loads(args.config)
elif args.config_file is not None:
# Load config from file
config = load_yaml(args.config_file)
config = yaml_dialog.load_yaml(args.config_file)
else:
config = test_config
config = CONFIG_SIMPLE
client = bec_dispatcher.client
client = BECDispatcher().client
client.start()
app = QApplication(sys.argv)
monitor = BECMonitor(config=config, gui_id=args.id)
monitor = BECMonitor(config=config, gui_id=args.id, skip_validation=False)
monitor.show()
# just to test redis data
# redis_data = {
# "x": {"data": [1, 2, 3], "tag": "x_default_tag"},
# "y": {"y_default_tag": {"data": [1, 2, 3]}},
# }
# monitor.on_data_from_redis({"data": redis_data})
sys.exit(app.exec())
@@ -0,0 +1,7 @@
from .motor_control import (
MotorControlAbsolute,
MotorControlRelative,
MotorControlSelection,
MotorCoordinateTable,
MotorThread,
)
File diff suppressed because it is too large Load Diff
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -0,0 +1 @@
from .motor_map import MotorMap
+605
View File
@@ -0,0 +1,605 @@
# 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, QtGui
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.yaml_dialog import load_yaml
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,
)
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())
+4
View File
@@ -0,0 +1,4 @@
from .image import BECImageItem, BECImageShow, ImageItemConfig
from .motor_map import BECMotorMap, MotorMapConfig
from .plot_base import AxisConfig, BECPlotBase, WidgetConfig
from .waveform import BECCurve, BECWaveform, Waveform1DConfig
+933
View File
@@ -0,0 +1,933 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints
from pydantic import BaseModel, Field, ValidationError
from qtpy.QtCore import QObject, QThread
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig, EntryValidator
from .plot_base 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 = [
"rpc_id",
"config_dict",
"set",
"set_fft",
"set_log",
"set_rotation",
"set_transpose",
"set_opacity",
"set_autorange",
"set_color_map",
"set_auto_downsample",
"set_monitor",
"set_vrange",
"get_data",
]
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 get_data(self) -> np.ndarray:
"""
Get the data of the image.
Returns:
np.ndarray: The data of the image.
"""
return self.image
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'")
def cleanup(self):
"""Clean up widget."""
self.rpc_register.remove_rpc(self)
class BECImageShow(BECPlotBase):
USER_ACCESS = [
"rpc_id",
"config_dict",
"add_image_by_config",
"get_image_config",
"get_image_dict",
"add_monitor_image",
"add_custom_image",
"set_vrange",
"set_color_map",
"set_autorange",
"set_monitor",
"set_processing",
"set_image_properties",
"set_fft",
"set_log",
"set_rotation",
"set_transpose",
"toggle_threading",
"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",
"images",
]
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
)
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.get_bec_shortcuts()
self.entry_validator = EntryValidator(self.dev)
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_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
@property
def images(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
@images.setter
def images(self, value: dict[str, dict[str, BECImageItem]]):
"""
Set the images from a dictionary.
Args:
value (dict[str, dict[str, BECImageItem]]): The images to set, organized by source and id.
"""
self._images = value
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}'."
)
monitor = self.entry_validator.validate_monitor(monitor)
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:
self._create_thread_worker(device, data)
else:
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 _validate_monitor(self, monitor: str, validate_bec: bool = True):
"""
Validate the monitor name.
Args:
monitor(str): The name of the monitor.
validate_bec(bool): Whether to validate the monitor name with BEC.
Returns:
bool: True if the monitor name is valid, False otherwise.
"""
if not monitor or monitor == "":
return False
if validate_bec:
return monitor in self.dev
return True
def cleanup(self):
"""
Clean up the widget.
"""
for monitor in self._images["device_monitor"]:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor(monitor)
)
for image in self.images:
image.cleanup()
self.rpc_register.remove_rpc(self)
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
+428
View File
@@ -0,0 +1,428 @@
from __future__ import annotations
from collections import defaultdict
from typing import Optional, Union
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints
from pydantic import Field
from qtpy import QtCore, QtGui
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import EntryValidator
from bec_widgets.widgets.plots.plot_base import BECPlotBase, WidgetConfig
from bec_widgets.widgets.plots.waveform import Signal, SignalData
class MotorMapConfig(WidgetConfig):
signals: Optional[Signal] = Field(None, description="Signals of the motor map")
color_map: Optional[str] = Field(
"Greys", description="Color scheme of the motor position gradient."
) # TODO decide if useful for anything, or just keep GREYS always
scatter_size: Optional[int] = Field(5, description="Size of the scatter points.")
max_points: Optional[int] = Field(1000, description="Maximum number of points to display.")
num_dim_points: Optional[int] = Field(
100,
description="Number of points to dim before the color remains same for older recorded position.",
)
precision: Optional[int] = Field(2, description="Decimal precision of the motor position.")
background_value: Optional[int] = Field(
25, description="Background value of the motor map."
) # TODO can be percentage from 255 calculated
class BECMotorMap(BECPlotBase):
USER_ACCESS = [
"config_dict",
"change_motors",
"set_max_points",
"set_precision",
"set_num_dim_points",
"set_background_value",
"set_scatter_size",
"get_data",
]
# QT Signals
update_signal = pyqtSignal()
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[MotorMapConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = MotorMapConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.get_bec_shortcuts()
self.entry_validator = EntryValidator(self.dev)
self.motor_x = None
self.motor_y = None
self.database_buffer = {"x": [], "y": []}
self.plot_components = defaultdict(dict) # container for plot components
# connect update signal to update plot
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self._update_plot
)
# TODO decide if needed to implement, maybe there will be no children widgets for motormap for now...
# def find_widget_by_id(self, item_id: str) -> BECCurve:
# """
# Find the curve by its ID.
# Args:
# item_id(str): ID of the curve.
#
# Returns:
# BECCurve: The curve object.
# """
# for curve in self.plot_item.curves:
# if curve.gui_id == item_id:
# return curve
@pyqtSlot(str, str, str, str, bool)
def change_motors(
self,
motor_x: str,
motor_y: str,
motor_x_entry: str = None,
motor_y_entry: str = None,
validate_bec: bool = True,
) -> None:
"""
Change the active motors for the plot.
Args:
motor_x(str): Motor name for the X axis.
motor_y(str): Motor name for the Y axis.
motor_x_entry(str): Motor entry for the X axis.
motor_y_entry(str): Motor entry for the Y axis.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
"""
motor_x_entry, motor_y_entry = self._validate_signal_entries(
motor_x, motor_y, motor_x_entry, motor_y_entry, validate_bec
)
motor_x_limit = self._get_motor_limit(motor_x)
motor_y_limit = self._get_motor_limit(motor_y)
signal = Signal(
source="device_readback",
x=SignalData(name=motor_x, entry=motor_x_entry, limits=motor_x_limit),
y=SignalData(name=motor_y, entry=motor_y_entry, limits=motor_y_limit),
)
self.config.signals = signal
# reconnect the signals
self._connect_motor_to_slots()
# Redraw the motor map
self._make_motor_map()
def get_data(self) -> dict:
"""
Get the data of the motor map.
Returns:
dict: Data of the motor map.
"""
data = {
"x": self.database_buffer["x"],
"y": self.database_buffer["y"],
}
return data
# TODO setup all visual properties
def set_max_points(self, max_points: int) -> None:
"""
Set the maximum number of points to display.
Args:
max_points(int): Maximum number of points to display.
"""
self.config.max_points = max_points
def set_precision(self, precision: int) -> None:
"""
Set the decimal precision of the motor position.
Args:
precision(int): Decimal precision of the motor position.
"""
self.config.precision = precision
def set_num_dim_points(self, num_dim_points: int) -> None:
"""
Set the number of dim points for the motor map.
Args:
num_dim_points(int): Number of dim points.
"""
self.config.num_dim_points = num_dim_points
def set_background_value(self, background_value: int) -> None:
"""
Set the background value of the motor map.
Args:
background_value(int): Background value of the motor map.
"""
self.config.background_value = background_value
def set_scatter_size(self, scatter_size: int) -> None:
"""
Set the scatter size of the motor map plot.
Args:
scatter_size(int): Size of the scatter points.
"""
self.config.scatter_size = scatter_size
def _disconnect_current_motors(self):
"""Disconnect the current motors from the slots."""
if self.motor_x is not None and self.motor_y is not None:
endpoints = [
MessageEndpoints.device_readback(self.motor_x),
MessageEndpoints.device_readback(self.motor_y),
]
self.bec_dispatcher.disconnect_slot(self.on_device_readback, endpoints)
def _connect_motor_to_slots(self):
"""Connect motors to slots."""
self._disconnect_current_motors()
self.motor_x = self.config.signals.x.name
self.motor_y = self.config.signals.y.name
endpoints = [
MessageEndpoints.device_readback(self.motor_x),
MessageEndpoints.device_readback(self.motor_y),
]
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints)
def _make_motor_map(self):
"""
Create the motor map plot.
"""
# Create limit map
motor_x_limit = self.config.signals.x.limits
motor_y_limit = self.config.signals.y.limits
self.plot_components["limit_map"] = self._make_limit_map(motor_x_limit, motor_y_limit)
self.plot_item.addItem(self.plot_components["limit_map"])
self.plot_components["limit_map"].setZValue(-1)
# Create scatter plot
scatter_size = self.config.scatter_size
self.plot_components["scatter"] = pg.ScatterPlotItem(
size=scatter_size, brush=pg.mkBrush(255, 255, 255, 255)
)
self.plot_item.addItem(self.plot_components["scatter"])
self.plot_components["scatter"].setZValue(0)
# Enable Grid
self.set_grid(True, True)
# Add the crosshair for initial motor coordinates
initial_position_x = self._get_motor_init_position(
self.motor_x, self.config.signals.x.entry, self.config.precision
)
initial_position_y = self._get_motor_init_position(
self.motor_y, self.config.signals.y.entry, self.config.precision
)
self.database_buffer["x"] = [initial_position_x]
self.database_buffer["y"] = [initial_position_y]
self.plot_components["scatter"].setData([initial_position_x], [initial_position_y])
self._add_coordinantes_crosshair(initial_position_x, initial_position_y)
# Set default labels for the plot
self.set(x_label=f"Motor X ({self.motor_x})", y_label=f"Motor Y ({self.motor_y})")
def _add_coordinantes_crosshair(self, x: float, y: float) -> None:
"""
Add crosshair to the plot to highlight the current position.
Args:
x(float): X coordinate.
y(float): Y coordinate.
"""
# 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.plot_components["highlight_H"] = highlight_H
self.plot_components["highlight_V"] = highlight_V
# Add crosshair to the plot
self.plot_item.addItem(highlight_H)
self.plot_item.addItem(highlight_V)
highlight_V.setPos(x)
highlight_H.setPos(y)
def _make_limit_map(self, limits_x: list, limits_y: list) -> pg.ImageItem:
"""
Create a limit map for the motor map plot.
Args:
limits_x(list): Motor limits for the x axis.
limits_y(list): Motor limits for the y axis.
Returns:
pg.ImageItem: Limit map.
"""
limit_x_min, limit_x_max = limits_x
limit_y_min, limit_y_max = limits_y
map_width = int(limit_x_max - limit_x_min + 1)
map_height = int(limit_y_max - limit_y_min + 1)
# Create limits map
background_value = self.config.background_value
limit_map_data = np.full((map_width, map_height), background_value, dtype=np.float32)
limit_map = pg.ImageItem()
limit_map.setImage(limit_map_data)
# 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)
return limit_map
def _get_motor_init_position(self, name: str, entry: str, precision: int) -> float:
"""
Get the motor initial position from the config.
Args:
name(str): Motor name.
entry(str): Motor entry.
precision(int): Decimal precision of the motor position.
Returns:
float: Motor initial position.
"""
init_position = round(self.dev[name].read()[entry]["value"], precision)
return init_position
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 _get_motor_limit(self, motor: str) -> Union[list | None]: # TODO check if works correctly
"""
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 _update_plot(self):
"""Update the motor map plot."""
x = self.database_buffer["x"]
y = self.database_buffer["y"]
# Setup gradient brush for history
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(x)
# Calculate the decrement step based on self.num_dim_points
num_dim_points = self.config.num_dim_points
decrement_step = (255 - 50) / num_dim_points
for i in range(1, min(num_dim_points + 1, len(x) + 1)):
brightness = max(60, 255 - decrement_step * (i - 1))
brushes[-i] = pg.mkBrush(brightness, brightness, brightness, 255)
brushes[-1] = pg.mkBrush(255, 255, 255, 255) # Newest point is always full brightness
scatter_size = self.config.scatter_size
# Update the scatter plot
self.plot_components["scatter"].setData(
x=x,
y=y,
brush=brushes,
pen=None,
size=scatter_size,
)
# Get last know position for crosshair
current_x = x[-1]
current_y = y[-1]
# Update the crosshair
self.plot_components["highlight_V"].setPos(current_x)
self.plot_components["highlight_H"].setPos(current_y)
# TODO not update title but some label
# Update plot title
precision = self.config.precision
self.set_title(
f"Motor position: ({round(current_x,precision)}, {round(current_y,precision)})"
)
@pyqtSlot(dict)
def on_device_readback(self, msg: dict) -> None:
"""
Update the motor map plot with the new motor position.
Args:
msg(dict): Message from the device readback.
"""
if self.motor_x is None or self.motor_y is None:
return
if self.motor_x in msg["signals"]:
x = msg["signals"][self.motor_x]["value"]
self.database_buffer["x"].append(x)
self.database_buffer["y"].append(self.database_buffer["y"][-1])
elif self.motor_y in msg["signals"]:
y = msg["signals"][self.motor_y]["value"]
self.database_buffer["y"].append(y)
self.database_buffer["x"].append(self.database_buffer["x"][-1])
self.update_signal.emit()
def cleanup(self):
"""Cleanup the widget."""
self._disconnect_current_motors()
self.rpc_register.remove_rpc(self)
+250
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 = [
"config_dict",
"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(row=0, col=0)
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.cleanup()
self.figure.remove(widget_id=self.gui_id)
def cleanup(self):
"""Cleanup the plot widget."""
+802
View File
@@ -0,0 +1,802 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints
from bec_lib.scan_data import ScanData
from pydantic import BaseModel, Field, 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_widgets.utils import BECConnector, Colors, ConnectionConfig, EntryValidator
from bec_widgets.widgets.plots.plot_base 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
limits: Optional[list[float]] = None # todo implement later
class Signal(BaseModel):
"""The configuration of a signal in the 1D waveform widget."""
source: str
x: SignalData # TODO maybe add metadata for config gui later
y: SignalData
z: Optional[SignalData] = None
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.")
colormap: Optional[str] = Field("plasma", description="The colormap of the curves z gradient.")
class Waveform1DConfig(WidgetConfig):
color_palette: Literal["plasma", "viridis", "inferno", "magma"] = Field(
"plasma", description="The color palette of the figure widget."
) # TODO can be extended to all colormaps from current pyqtgraph session
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 = [
"remove",
"rpc_id",
"config_dict",
"set",
"set_data",
"set_color",
"set_colormap",
"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,
parent_item: Optional[pg.PlotItem] = 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.parent_item = parent_item
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,
"colormap": self.set_colormap,
"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 set_colormap(self, colormap: str):
"""
Set the colormap for the scatter plot z gradient.
Args:
colormap(str): Colormap for the scatter plot.
"""
self.config.colormap = colormap
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
def cleanup(self):
"""Cleanup the curve."""
self.rpc_register.remove_rpc(self)
def remove(self):
"""Remove the curve from the plot."""
self.cleanup()
self.parent_item.removeItem(self)
class BECWaveform(BECPlotBase):
USER_ACCESS = [
"rpc_id",
"config_dict",
"add_curve_scan",
"add_curve_custom",
"remove_curve",
"scan_history",
"curves",
"get_curve",
"get_curve_config",
"apply_config",
"get_all_data",
"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.scan_id = 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 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, parent_item=self.plot_item)
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,
z_name: Optional[str] = None,
x_entry: Optional[str] = None,
y_entry: Optional[str] = None,
z_entry: Optional[str] = None,
color: Optional[str] = None,
color_map_z: Optional[str] = "plasma",
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.
z_name(str): Name of the z signal.
z_entry(str): Entry of the z signal.
color(str, optional): Color of the curve. Defaults to None.
color_map_z(str): The color map to use for the z-axis.
label(str, optional): Label of the curve. Defaults to None.
**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, z_entry = self._validate_signal_entries(
x_name, y_name, z_name, x_entry, y_entry, z_entry, validate_bec
)
if z_name is not None and z_entry is not None:
label = label or f"{z_name}-{z_entry}"
else:
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,
color_map=color_map_z,
source=curve_source,
signals=Signal(
source=curve_source,
x=SignalData(name=x_name, entry=x_entry),
y=SignalData(name=y_name, entry=y_entry),
z=SignalData(name=z_name, entry=z_entry) if z_name else None,
),
**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,
z_name: str | None,
x_entry: str | None,
y_entry: str | None,
z_entry: str | None,
validate_bec: bool = True,
) -> tuple[str, str, str | None]:
"""
Validate the signal name and entry.
Args:
x_name(str): Name of the x signal.
y_name(str): Name of the y signal.
z_name(str): Name of the z signal.
x_entry(str|None): Entry of the x signal.
y_entry(str|None): Entry of the y signal.
z_entry(str|None): Entry of the z signal.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
Returns:
tuple[str,str,str|None]: Validated x, y, z 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)
if z_name:
z_entry = self.entry_validator.validate_signal(z_name, z_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
z_entry = z_name if z_entry is None else z_entry
return x_entry, y_entry, z_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_scan_id = msg.get("scan_id", None)
if current_scan_id is None:
return
if current_scan_id != self.scan_id:
self.scan_id = current_scan_id
self.scan_segment_data = self.queue.scan_storage.find_scan_by_ID(
self.scan_id
) # TODO do scan access through BECFigure
self.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.
"""
data_x = None
data_y = None
data_z = None
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
if curve.config.signals.z:
z_name = curve.config.signals.z.name
z_entry = curve.config.signals.z.entry
try:
data_x = data[x_name][x_entry].val
data_y = data[y_name][y_entry].val
if curve.config.signals.z:
data_z = data[z_name][z_entry].val
color_z = self._make_z_gradient(
data_z, curve.config.colormap
) # TODO decide how to implement custom gradient
except TypeError:
continue
if data_z is not None and color_z is not None:
curve.setData(x=data_x, y=data_y, symbolBrush=color_z)
else:
curve.setData(data_x, data_y)
def _make_z_gradient(self, data_z: list | np.ndarray, colormap: str) -> list | None:
"""
Make a gradient color for the z values.
Args:
data_z(list|np.ndarray): Z values.
colormap(str): Colormap for the gradient color.
Returns:
list: List of colors for the z values.
"""
# Normalize z_values for color mapping
z_min, z_max = np.min(data_z), np.max(data_z)
if z_max != z_min: # Ensure that there is a range in the z values
z_values_norm = (data_z - z_min) / (z_max - z_min)
colormap = pg.colormap.get(colormap) # using colormap from global settings
colors = [colormap.map(z, mode="qcolor") for z in z_values_norm]
return colors
else:
return None
def scan_history(self, scan_index: int = None, scan_id: str = None):
"""
Update the scan curves with the data from the scan storage.
Provide only one of scan_id or scan_index.
Args:
scan_id(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 scan_id is not None:
raise ValueError("Only one of scan_id or scan_index can be provided.")
if scan_index is not None:
self.scan_id = self.queue.scan_storage.storage[scan_index].scan_id
data = self.queue.scan_storage.find_scan_by_ID(self.scan_id).data
elif scan_id is not None:
self.scan_id = scan_id
data = self.queue.scan_storage.find_scan_by_ID(self.scan_id).data
self._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())
for curve in self.curves:
curve.cleanup()
self.rpc_register.remove_rpc(self)
@@ -1,26 +1,27 @@
import msgpack
from bec_lib import MessageEndpoints
from qtpy.QtWidgets import (
QApplication,
QWidget,
QComboBox,
QPushButton,
QVBoxLayout,
QGroupBox,
QLabel,
QLineEdit,
QDoubleSpinBox,
QSpinBox,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFrame,
QHBoxLayout,
QLayout,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLayout,
QLineEdit,
QPushButton,
QSpinBox,
QTableWidget,
QTableWidgetItem,
QHeaderView,
QVBoxLayout,
QWidget,
)
from bec_lib import MessageEndpoints
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.widget_io import WidgetIO
@@ -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.utils.bec_dispatcher import bec_dispatcher
# BECclient global variables
client = bec_dispatcher.client
client = BECDispatcher().client
client.start()
app = QApplication([])
@@ -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.utils.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())
-151
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.utils.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())
+2 -4
View File
@@ -1,11 +1,9 @@
from abc import ABC, abstractmethod
# pylint: disable=no-name-in-module
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QToolBar, QStyle, QApplication
from qtpy.QtCore import QTimer
from qtpy.QtCore import QSize, QTimer
from qtpy.QtGui import QAction
from qtpy.QtWidgets import QWidget
from qtpy.QtWidgets import QApplication, QStyle, QToolBar, QWidget
class ToolBarAction(ABC):
+11 -1
View File
@@ -1,6 +1,14 @@
(developer)=
# Development
```{toctree}
---
maxdepth: 1
hidden: true
---
reference/
```
To contribute to the development of BEC Widgets, start by setting up the development environment:
1. **Clone the Repository**:
@@ -13,4 +21,6 @@ cd bec-widgets
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]
```
```
+10
View File
@@ -0,0 +1,10 @@
## API Reference
```{eval-rst}
.. autosummary::
:toctree: _autosummary
:template: custom-module-template.rst
:recursive:
bec_widgets
```
+31 -1
View File
@@ -1,4 +1,34 @@
(user.apps.motor_app)=
# Motor Alignment
_to be added..._
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

+2 -2
View File
@@ -6,7 +6,7 @@
Before installing BEC Widgets, please ensure the following requirements are met:
1. **Python Version:** BEC Widgets requires Python version 3.9 or higher. Verify your Python version to ensure compatibility.
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**
@@ -43,4 +43,4 @@ If you encounter issues during installation, particularly with PyQt, try purging
pip cache purge
```
This can resolve conflicts or issues with package installations.
This can resolve conflicts or issues with package installations.
+37 -2
View File
@@ -1,6 +1,41 @@
(user.widgets)=
# Widgets
Guide to use widgets.
## Visualization Widgets
_work in progress_
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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

+1 -1
View File
@@ -15,7 +15,7 @@ classifiers =
package_dir =
= .
packages = find:
python_requires = >=3.9
python_requires = >=3.10
[options.packages.find]
where = .
+24 -8
View File
@@ -1,11 +1,10 @@
# pylint: disable= missing-module-docstring
from setuptools import setup
from setuptools import find_packages, setup
__version__ = "0.34.1"
__version__ = "0.50.0"
# Default to PyQt6 if no other Qt binding is installed
QT_DEPENDENCY = "PyQt6>=6.0"
QSCINTILLA_DEPENDENCY = "PyQt6-QScintilla"
QT_DEPENDENCY = "PyQt6<=6.6.3"
# pylint: disable=unused-import
try:
@@ -14,15 +13,14 @@ except ImportError:
pass
else:
QT_DEPENDENCY = "PyQt5>=5.9"
QSCINTILLA_DEPENDENCY = "QScintilla"
if __name__ == "__main__":
setup(
install_requires=[
"pydantic",
"qtconsole",
"PyQt6-Qt6<=6.6.3",
QT_DEPENDENCY,
QSCINTILLA_DEPENDENCY,
"jedi",
"qtpy",
"pyqtgraph",
@@ -30,11 +28,29 @@ 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",
"isort",
],
"pyqt5": ["PyQt5>=5.9"],
"pyqt6": ["PyQt6>=6.0"],
"pyqt6": ["PyQt6<=6.6.3"],
},
version=__version__,
packages=find_packages(),
include_package_data=True,
package_data={
"": [
"*.ui",
"*.yaml",
"*.png",
]
},
)
+25
View File
@@ -0,0 +1,25 @@
import pytest
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.cli.server import BECWidgetsCLIServer
from bec_widgets.utils import BECDispatcher
@pytest.fixture(autouse=True)
def rpc_register():
yield RPCRegister()
RPCRegister.reset_singleton()
@pytest.fixture
def rpc_server(qtbot, bec_client_lib, threads_check):
dispatcher = BECDispatcher(client=bec_client_lib) # Has to init singleton with fixture client
server = BECWidgetsCLIServer(gui_id="figure")
qtbot.addWidget(server.fig)
qtbot.waitExposed(server.fig)
qtbot.wait(1000) # 1s long to wait until gui is ready
yield server
dispatcher.disconnect_all()
server.client.shutdown()
server.shutdown()
dispatcher.reset_singleton()
+165
View File
@@ -0,0 +1,165 @@
import numpy as np
import pytest
from bec_lib import MessageEndpoints
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
def test_rpc_waveform1d_custom_curve(rpc_server, qtbot):
fig = BECFigure(rpc_server.gui_id)
fig_server = rpc_server.fig
ax = fig.add_plot()
curve = ax.add_curve_custom([1, 2, 3], [1, 2, 3])
curve.set_color("red")
curve = ax.curves[0]
curve.set_color("blue")
assert len(fig_server.widgets) == 1
assert len(fig_server.widgets["widget_1"].curves) == 1
def test_rpc_plotting_shortcuts_init_configs(rpc_server, qtbot):
fig = BECFigure(rpc_server.gui_id)
fig_server = rpc_server.fig
plt = fig.plot("samx", "bpm4i")
im = fig.image("eiger")
motor_map = fig.motor_map("samx", "samy")
plt_z = fig.add_plot("samx", "samy", "bpm4i")
# Checking if classes are correctly initialised
assert len(fig_server.widgets) == 4
assert plt.__class__.__name__ == "BECWaveform"
assert plt.__class__ == BECWaveform
assert im.__class__.__name__ == "BECImageShow"
assert im.__class__ == BECImageShow
assert motor_map.__class__.__name__ == "BECMotorMap"
assert motor_map.__class__ == BECMotorMap
# check if the correct devices are set
# plot
assert plt.config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
"source": "scan_segment",
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
"y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
"z": None,
}
# image
assert im.config_dict["images"]["eiger"]["monitor"] == "eiger"
# motor map
assert motor_map.config_dict["signals"] == {
"source": "device_readback",
"x": {
"name": "samx",
"entry": "samx",
"unit": None,
"modifier": None,
"limits": [-50.0, 50.0],
},
"y": {
"name": "samy",
"entry": "samy",
"unit": None,
"modifier": None,
"limits": [-50.0, 50.0],
},
"z": None,
}
# plot with z scatter
assert plt_z.config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
"source": "scan_segment",
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
"y": {"name": "samy", "entry": "samy", "unit": None, "modifier": None, "limits": None},
"z": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
}
def test_rpc_waveform_scan(rpc_server, qtbot):
fig = BECFigure(rpc_server.gui_id)
# add 3 different curves to track
plt = fig.plot("samx", "bpm4i")
fig.plot("samx", "bpm3a")
fig.plot("samx", "bpm4d")
client = rpc_server.client
dev = client.device_manager.devices
scans = client.scans
queue = client.queue
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
# wait for scan to finish
while not status.status == "COMPLETED":
qtbot.wait(200)
last_scan_data = queue.scan_storage.storage[-1].data
# get data from curves
plt_data = plt.get_all_data()
# check plotted data
assert plt_data["bpm4i-bpm4i"]["x"] == last_scan_data["samx"]["samx"].val
assert plt_data["bpm4i-bpm4i"]["y"] == last_scan_data["bpm4i"]["bpm4i"].val
assert plt_data["bpm3a-bpm3a"]["x"] == last_scan_data["samx"]["samx"].val
assert plt_data["bpm3a-bpm3a"]["y"] == last_scan_data["bpm3a"]["bpm3a"].val
assert plt_data["bpm4d-bpm4d"]["x"] == last_scan_data["samx"]["samx"].val
assert plt_data["bpm4d-bpm4d"]["y"] == last_scan_data["bpm4d"]["bpm4d"].val
def test_rpc_image(rpc_server, qtbot):
fig = BECFigure(rpc_server.gui_id)
im = fig.image("eiger")
client = rpc_server.client
dev = client.device_manager.devices
scans = client.scans
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
# wait for scan to finish
while not status.status == "COMPLETED":
qtbot.wait(200)
last_image_device = client.connector.get_last(MessageEndpoints.device_monitor("eiger"))[
"data"
].data
qtbot.wait(500)
last_image_plot = im.images[0].get_data()
# check plotted data
np.testing.assert_equal(last_image_device, last_image_plot)
def test_rpc_motor_map(rpc_server, qtbot):
fig = BECFigure(rpc_server.gui_id)
fig_server = rpc_server.fig
motor_map = fig.motor_map("samx", "samy")
client = rpc_server.client
dev = client.device_manager.devices
scans = client.scans
initial_pos_x = dev.samx.read()["samx"]["value"]
initial_pos_y = dev.samy.read()["samy"]["value"]
status = scans.mv(dev.samx, 1, dev.samy, 2, relative=True)
# wait for scan to finish
while not status.status == "COMPLETED":
qtbot.wait(200)
final_pos_x = dev.samx.read()["samx"]["value"]
final_pos_y = dev.samy.read()["samy"]["value"]
# check plotted data
motor_map_data = motor_map.get_data()
np.testing.assert_equal(
[motor_map_data["x"][0], motor_map_data["y"][0]], [initial_pos_x, initial_pos_y]
)
np.testing.assert_equal(
[motor_map_data["x"][-1], motor_map_data["y"][-1]], [final_pos_x, final_pos_y]
)
+50
View File
@@ -0,0 +1,50 @@
import pytest
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
def find_deepest_value(d: dict):
"""
Recursively find the deepest value in a dictionary
Args:
d(dict): Dictionary to search
Returns:
The deepest value in the dictionary.
"""
if isinstance(d, dict):
if d:
return find_deepest_value(next(iter(d.values())))
return d
def test_rpc_register_list_connections(rpc_server, rpc_register, qtbot):
fig = BECFigure(rpc_server.gui_id)
fig_server = rpc_server.fig
plt = fig.plot("samx", "bpm4i")
im = fig.image("eiger")
motor_map = fig.motor_map("samx", "samy")
plt_z = fig.add_plot("samx", "samy", "bpm4i")
all_connections = rpc_register.list_all_connections()
# Construct dict of all rpc items manually
all_subwidgets_expected = dict(fig_server.widgets)
curve_1D = find_deepest_value(fig_server.widgets[plt.rpc_id]._curves_data)
curve_2D = find_deepest_value(fig_server.widgets[plt_z.rpc_id]._curves_data)
curves_expected = {curve_1D.rpc_id: curve_1D, curve_2D.rpc_id: curve_2D}
fig_expected = {fig.rpc_id: fig_server}
image_item_expected = {
fig_server.widgets[im.rpc_id].images[0].rpc_id: fig_server.widgets[im.rpc_id].images[0]
}
all_connections_expected = {
**all_subwidgets_expected,
**curves_expected,
**fig_expected,
**image_item_expected,
}
assert len(all_connections) == 8
assert all_connections == all_connections_expected
-160
View File
@@ -1,160 +0,0 @@
from unittest.mock import Mock
import pytest
from bec_lib.messages import ScanMessage
from bec_lib.connector import MessageObject
# TODO: find a better way to mock singletons
from bec_widgets.utils.bec_dispatcher import _BECDispatcher
msg = MessageObject(topic="", value=ScanMessage(point_id=0, scanID=0, data={}).dumps())
@pytest.fixture(name="bec_dispatcher")
def _bec_dispatcher():
bec_dispatcher = _BECDispatcher()
yield bec_dispatcher
@pytest.fixture(name="consumer")
def _consumer(bec_dispatcher):
bec_dispatcher.client.connector.consumer = Mock()
consumer = bec_dispatcher.client.connector.consumer
yield consumer
@pytest.mark.filterwarnings("ignore:Failed to connect to redis.")
def test_connect_one_slot(bec_dispatcher, consumer):
slot1 = Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
consumer.assert_called_once()
# trigger consumer callback as if a message was published
consumer.call_args.kwargs["cb"](msg)
slot1.assert_called_once()
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
def test_connect_identical(bec_dispatcher, consumer):
slot1 = Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
consumer.assert_called_once()
consumer.call_args.kwargs["cb"](msg)
slot1.assert_called_once()
def test_connect_many_slots_one_topic(bec_dispatcher, consumer):
slot1, slot2 = Mock(), Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
consumer.assert_called_once()
bec_dispatcher.connect_slot(slot=slot2, topic="topic0")
consumer.assert_called_once()
# trigger consumer callback as if a message was published
consumer.call_args.kwargs["cb"](msg)
slot1.assert_called_once()
slot2.assert_called_once()
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
assert slot2.call_count == 2
def test_connect_one_slot_many_topics(bec_dispatcher, consumer):
slot1 = Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
assert consumer.call_count == 1
bec_dispatcher.connect_slot(slot=slot1, topic="topic1")
assert consumer.call_count == 2
# trigger consumer callback as if a message was published
consumer.call_args_list[0].kwargs["cb"](msg)
slot1.assert_called_once()
consumer.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 2
def test_disconnect_one_slot_one_topic(bec_dispatcher, consumer):
slot1, slot2 = Mock(), Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
# disconnect using a different slot
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic1")
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 1
# disconnect using a different topic
bec_dispatcher.disconnect_slot(slot=slot2, topic="topic0")
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
# disconnect using the right slot and topic
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic0")
with pytest.raises(KeyError):
consumer.call_args.kwargs["cb"](msg)
def test_disconnect_identical(bec_dispatcher, consumer):
slot1 = Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic0")
with pytest.raises(KeyError):
consumer.call_args.kwargs["cb"](msg)
def test_disconnect_many_slots_one_topic(bec_dispatcher, consumer):
slot1, slot2, slot3 = Mock(), Mock(), Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot2, topic="topic0")
# disconnect using a different slot
bec_dispatcher.disconnect_slot(slot3, topic="topic0")
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 1
assert slot2.call_count == 1
# disconnect using a different topic
bec_dispatcher.disconnect_slot(slot1, topic="topic1")
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
assert slot2.call_count == 2
# disconnect using the right slot and topic
bec_dispatcher.disconnect_slot(slot1, topic="topic0")
consumer.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
assert slot2.call_count == 3
def test_disconnect_one_slot_many_topics(bec_dispatcher, consumer):
slot1, slot2 = Mock(), Mock()
bec_dispatcher.connect_slot(slot=slot1, topic="topic0")
bec_dispatcher.connect_slot(slot=slot1, topic="topic1")
# disconnect using a different slot
bec_dispatcher.disconnect_slot(slot=slot2, topic="topic0")
consumer.call_args_list[0].kwargs["cb"](msg)
assert slot1.call_count == 1
consumer.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 2
# disconnect using a different topic
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic3")
consumer.call_args_list[0].kwargs["cb"](msg)
assert slot1.call_count == 3
consumer.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 4
# disconnect using the right slot and topic
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic0")
with pytest.raises(KeyError):
consumer.call_args_list[0].kwargs["cb"](msg)
consumer.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 5
bec_dispatcher.disconnect_slot(slot=slot1, topic="topic1")
with pytest.raises(KeyError):
consumer.call_args_list[0].kwargs["cb"](msg)
with pytest.raises(KeyError):
consumer.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 5
-30
View File
@@ -1,30 +0,0 @@
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_adc1"
entry: "gauss_adc1"
- name: "gauss_adc2"
entry: "gauss_adc2"
@@ -1,24 +0,0 @@
plot_settings:
background_color: "white"
num_columns: 5
colormap: "plasma"
scan_types: false
plot_data:
- plot_name: "BPM4i plots vs samx"
x:
label: "Motor Y"
signals:
- name: "samx"
y:
label: "bpm4i"
signals:
- name: "bpm4i"
- plot_name: "Gauss plots vs samx"
x:
label: "Motor X"
signals:
- name: "samx"
y:
label: "Gauss"
signals:
- name: "gauss_bpm"
-77
View File
@@ -1,77 +0,0 @@
plot_settings:
background_color: "white"
num_columns: 3
colormap: "plasma"
scan_types: true
plot_data:
grid_scan:
- plot_name: "Grid plot 1"
x:
label: "Motor X"
signals:
- name: "samx"
entry: "samx"
y:
label: "BPM"
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- plot_name: "Grid plot 2"
x:
label: "Motor X"
signals:
- name: "samx"
entry: "samx"
y:
label: "BPM"
signals:
- name: "gauss_adc1"
entry: "gauss_adc1"
- plot_name: "Grid plot 3"
x:
label: "Motor Y"
signals:
- name: "samx"
entry: "samx"
y:
label: "BPM"
signals:
- name: "gauss_adc2"
entry: "gauss_adc2"
- plot_name: "Grid plot 4"
x:
label: "Motor Y"
signals:
- name: "samx"
entry: "samx"
y:
label: "BPM"
signals:
- name: "bpm4i"
entry: "bpm4i"
line_scan:
- plot_name: "Multiple Gauss Plot"
x:
label: "Motor X"
signals:
- name: "samx"
y:
label: "BPM"
signals:
- name: "gauss_bpm"
entry: "gauss_bpm"
- name: "gauss_adc1"
entry: "gauss_adc1"
- name: "gauss_adc2"
entry: "gauss_adc2"
- plot_name: "BPM Plot"
x:
label: "Motor X"
signals:
- name: "samx"
entry: "samx"
y:
label: "Multi"
signals:
- name: "bpm4i"
entry: "bpm4i"
-172
View File
@@ -1,172 +0,0 @@
# pylint: disable=no-name-in-module
import os
import tempfile
from unittest.mock import MagicMock
from unittest.mock import patch, mock_open
import pytest
from qtpy.QtWidgets import QTextEdit
from qtpy.Qsci import QsciScintilla
from bec_widgets.widgets.editor.editor import AutoCompleter, BECEditor
@pytest.fixture(scope="function")
def editor(qtbot, docstring_tooltip=False):
"""Helper function to set up the BECEditor widget."""
widget = BECEditor(toolbar_enabled=True, docstring_tooltip=docstring_tooltip)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def find_action_by_text(toolbar, text):
"""Helper function to find an action in the toolbar by its text."""
for action in toolbar.actions():
if action.text() == text:
return action
return None
def test_bec_editor_initialization(editor):
"""Test if the BECEditor widget is initialized correctly."""
assert isinstance(editor.editor, QsciScintilla)
assert isinstance(editor.terminal, QTextEdit)
assert isinstance(editor.auto_completer, AutoCompleter)
@patch("bec_widgets.widgets.editor.editor.Script") # Mock the Script class from jedi
def test_autocompleter_suggestions(mock_script, editor, qtbot):
"""Test if the autocompleter provides correct suggestions based on input."""
# Set up mock return values for the Script.complete method
mock_completion = MagicMock()
mock_completion.name = "mocked_method"
mock_script.return_value.complete.return_value = [mock_completion]
# Simulate user input in the editor
test_code = "print("
editor.editor.setText(test_code)
line, index = editor.editor.getCursorPosition()
# Trigger autocomplete
editor.auto_completer.get_completions(line, index, test_code)
# Use qtbot to wait for the completion thread
qtbot.waitUntil(lambda: editor.auto_completer.completions is not None, timeout=1000)
# Check if the expected completion is in the autocompleter's suggestions
suggested_methods = [completion.name for completion in editor.auto_completer.completions]
assert "mocked_method" in suggested_methods
@patch("bec_widgets.widgets.editor.editor.Script") # Mock the Script class from jedi
@pytest.mark.parametrize(
"docstring_enabled, expected_signature",
[(True, "Mocked signature with docstring"), (False, "Mocked signature")],
)
def test_autocompleter_signature(mock_script, editor, docstring_enabled, expected_signature):
"""Test if the autocompleter provides correct function signature based on docstring setting."""
# Set docstring mode based on parameter
editor.docstring_tooltip = docstring_enabled
editor.auto_completer.enable_docstring = docstring_enabled
# Set up mock return values for the Script.get_signatures method
mock_signature = MagicMock()
if docstring_enabled:
mock_signature.docstring.return_value = expected_signature
else:
mock_signature.to_string.return_value = expected_signature
mock_script.return_value.get_signatures.return_value = [mock_signature]
# Simulate user input that would trigger a signature request
test_code = "print("
editor.editor.setText(test_code)
line, index = editor.editor.getCursorPosition()
# Trigger signature request
signature = editor.auto_completer.get_function_signature(line, index, test_code)
# Check if the expected signature is returned
assert signature == expected_signature
def test_open_file(editor):
"""Test open_file method of BECEditor."""
# Create a temporary file with some content
with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as temp_file:
temp_file.write(b"test file content")
# Mock user selecting the file in the dialog
with patch("qtpy.QtWidgets.QFileDialog.getOpenFileName", return_value=(temp_file.name, "")):
with patch("builtins.open", new_callable=mock_open, read_data="test file content"):
editor.open_file()
# Verify if the editor's text is set to the file content
assert editor.editor.text() == "test file content"
# Clean up by removing the temporary file
os.remove(temp_file.name)
def test_save_file(editor):
"""Test save_file method of BECEditor."""
# Set some text in the editor
editor.editor.setText("test save content")
# Mock user selecting the file in the dialog
with patch(
"qtpy.QtWidgets.QFileDialog.getSaveFileName", return_value=("/path/to/save/file.py", "")
):
with patch("builtins.open", new_callable=mock_open) as mock_file:
editor.save_file()
# Verify if the file was opened correctly for writing
mock_file.assert_called_with("/path/to/save/file.py", "w")
# Verify if the editor's text was written to the file
mock_file().write.assert_called_with("test save content")
def test_open_file_through_toolbar(editor):
"""Test the open_file method through the ModularToolBar."""
# Create a temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as temp_file:
temp_file.write(b"test file content")
# Find the open file action in the toolbar
open_action = find_action_by_text(editor.toolbar, "Open File")
assert open_action is not None, "Open File action should be found"
# Mock the file dialog and built-in open function
with patch("qtpy.QtWidgets.QFileDialog.getOpenFileName", return_value=(temp_file.name, "")):
with patch("builtins.open", new_callable=mock_open, read_data="test file content"):
open_action.trigger()
# Verify if the editor's text is set to the file content
assert editor.editor.text() == "test file content"
# Clean up
os.remove(temp_file.name)
def test_save_file_through_toolbar(editor):
"""Test the save_file method through the ModularToolBar."""
# Set some text in the editor
editor.editor.setText("test save content")
# Find the save file action in the toolbar
save_action = find_action_by_text(editor.toolbar, "Save File")
assert save_action is not None, "Save File action should be found"
# Mock the file dialog and built-in open function
with patch(
"qtpy.QtWidgets.QFileDialog.getSaveFileName", return_value=("/path/to/save/file.py", "")
):
with patch("builtins.open", new_callable=mock_open) as mock_file:
save_action.trigger()
# Verify if the file was opened correctly for writing
mock_file.assert_called_with("/path/to/save/file.py", "w")
# Verify if the editor's text was written to the file
mock_file().write.assert_called_with("test save content")
Binary file not shown.
-488
View File
@@ -1,488 +0,0 @@
import unittest
from unittest.mock import MagicMock, patch
import pyqtgraph as pg
import pytest
from qtpy.QtWidgets import QMessageBox
from bec_widgets.examples.plot_app.plot_app import PlotApp, ErrorHandler
def setup_plot_app(qtbot, config):
"""Helper function to set up the PlotApp widget."""
client = MagicMock()
widget = PlotApp(config=config, client=client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
@pytest.fixture
def error_handler():
# TODO so far tested separately, but the error message scenarios can be tested directly in the plot app
return ErrorHandler()
config_device_mode_all_filled = {
"plot_settings": {
"background_color": "black",
"num_columns": 2,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {
"label": "Motor Y",
"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_device_mode_no_entry = {
"plot_settings": {
"background_color": "white",
"num_columns": 1,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {
"label": "Motor Y",
"signals": [{"name": "samx"}], # Entry is missing
},
"y": {
"label": "bpm4i",
"signals": [{"name": "bpm4i"}], # Entry is missing
},
},
{
"plot_name": "Gauss plots vs samx",
"x": {
"label": "Motor X",
"signals": [{"name": "samx"}], # Entry is missing
},
"y": {
"label": "Gauss",
"signals": [{"name": "gauss_bpm"}], # Entry is missing
},
},
],
}
config_scan_mode = config = {
"plot_settings": {
"background_color": "white",
"num_columns": 3,
"colormap": "plasma",
"scan_types": True,
},
"plot_data": {
"grid_scan": [
{
"plot_name": "Grid plot 1",
"x": {"label": "Motor X", "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": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [{"name": "gauss_bpm", "entry": "gauss_bpm"}],
},
},
{
"plot_name": "Grid plot 4",
"x": {"label": "Motor Y", "signals": [{"name": "samx", "entry": "samx"}]},
"y": {
"label": "BPM",
"signals": [{"name": "gauss_adc3", "entry": "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": "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": "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"]},
],
},
},
],
},
}
config_all_wrong = {
"plot_settings": {
"background_color": "white",
"num_columns": 1,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x": {
"label": "Motor Y",
# signals are missing
},
"y": {
"label": "bpm4i",
"signals": [{"name": "bpm4i", "entry": "gauss_bpm"}], # wrong entry
},
},
],
}
@pytest.mark.parametrize(
"config, plot_setting_bg, num_plot ,pg_background",
[
(config_device_mode_all_filled, "black", 2, "k"),
(config_device_mode_no_entry, "white", 2, "w"),
(config_scan_mode, "white", 4, "w"),
],
)
def test_init_config(qtbot, config, plot_setting_bg, num_plot, pg_background):
plot_app = setup_plot_app(qtbot, config)
assert plot_app.plot_settings["background_color"] == plot_setting_bg
assert len(plot_app.plot_data) == num_plot
assert pg.getConfigOption("background") == pg_background
@pytest.mark.parametrize(
"config, num_columns_input, expected_num_columns, expected_plot_names, expected_coordinates",
[
(
config_device_mode_all_filled,
2,
2,
["BPM4i plots vs samx", "Gauss plots vs samx"],
[(0, 0), (0, 1)],
),
(
config_device_mode_all_filled,
5,
2,
["BPM4i plots vs samx", "Gauss plots vs samx"],
[(0, 0), (0, 1)],
), # num_columns greater than number of plots
(
config_device_mode_no_entry,
1,
1,
["BPM4i plots vs samx", "Gauss plots vs samx"],
[(0, 0), (1, 0)],
),
(
config_device_mode_no_entry,
2,
2,
["BPM4i plots vs samx", "Gauss plots vs samx"],
[(0, 0), (0, 1)],
),
(
config_device_mode_no_entry,
5,
2,
["BPM4i plots vs samx", "Gauss plots vs samx"],
[(0, 0), (0, 1)],
), # num_columns greater than number of plots,
(
config_scan_mode,
2,
2,
[
"Grid plot 1",
"Grid plot 2",
"Grid plot 3",
"Grid plot 4",
],
[(0, 0), (0, 1), (1, 0), (1, 1)],
),
(
config_scan_mode,
3,
3,
[
"Grid plot 1",
"Grid plot 2",
"Grid plot 3",
"Grid plot 4",
],
[(0, 0), (0, 1), (0, 2), (1, 0)],
),
(
config_scan_mode,
5,
4,
[
"Grid plot 1",
"Grid plot 2",
"Grid plot 3",
"Grid plot 4",
],
[(0, 0), (0, 1), (0, 2), (0, 3)],
), # num_columns greater than number of plots
],
)
def test_init_ui(
qtbot,
config,
num_columns_input,
expected_num_columns,
expected_plot_names,
expected_coordinates,
):
plot_app = setup_plot_app(qtbot, config)
plot_app.init_ui(num_columns_input)
# Validate number of columns
assert plot_app.plot_settings["num_columns"] == expected_num_columns
# Validate the plots are created correctly
for expected_name in expected_plot_names:
assert expected_name in plot_app.plots.keys()
# Validate the grid_coordinates
assert plot_app.grid_coordinates == expected_coordinates
def mock_getitem(dev_name):
"""Helper function to mock the __getitem__ method of the 'dev' object.""" ""
mock_instance = MagicMock()
if dev_name == "samx":
mock_instance._hints = "samx"
elif dev_name == "bpm4i":
mock_instance._hints = "bpm4i"
elif dev_name == "gauss_bpm":
mock_instance._hints = "gauss_bpm"
return mock_instance
@pytest.mark.parametrize(
"config, msg, metadata, expected_data",
[
# Case: msg does not have 'scanID'
(config_device_mode_all_filled, {"data": {}}, {}, {}),
# Case: scan_types is False, msg contains all valid fields, and entry is present in config
(
config_device_mode_all_filled,
{
"data": {
"samx": {"samx": {"value": 10}},
"bpm4i": {"bpm4i": {"value": 5}},
"gauss_bpm": {"gauss_bpm": {"value": 7}},
},
"scanID": 1,
},
{},
{
("samx", "samx", "bpm4i", "bpm4i"): {"x": [10], "y": [5]},
("samx", "samx", "gauss_bpm", "gauss_bpm"): {"x": [10], "y": [7]},
},
),
# Case: scan_types is False, msg contains all valid fields and entry is missing in config, should use hints
(
config_device_mode_no_entry,
{
"data": {
"samx": {"samx": {"value": 10}},
"bpm4i": {"bpm4i": {"value": 5}},
"gauss_bpm": {"gauss_bpm": {"value": 7}},
},
"scanID": 1,
},
{},
{
("samx", "samx", "bpm4i", "bpm4i"): {"x": [10], "y": [5]},
("samx", "samx", "gauss_bpm", "gauss_bpm"): {"x": [10], "y": [7]},
},
),
],
)
def test_on_scan_segment(qtbot, config, msg, metadata, expected_data):
plot_app = setup_plot_app(qtbot, config)
# Initialize and run test
plot_app.data = {}
plot_app.scanID = 0
# Get hints
plot_app.dev.__getitem__.side_effect = mock_getitem
plot_app.on_scan_segment(msg, metadata)
assert plot_app.data == expected_data
@pytest.mark.parametrize(
"config, msg, metadata, expected_exception_message",
[
# Case: scan_types is True, but metadata does not contain 'scan_name'
(
config_scan_mode,
{"data": {}, "scanID": 1},
{}, # No 'scan_name' in metadata
"Scan name not found in metadata. Please check the scan_name in the YAML config or in bec configuration.",
),
# Case: scan_types is True, metadata contains non-existing 'scan_name'
(
config_scan_mode,
{"data": {}, "scanID": 1},
{"scan_name": "non_existing_scan"},
"Scan name non_existing_scan not found in the YAML config. Please check the scan_name in the YAML config "
"or in bec configuration.",
),
],
)
def test_on_scan_message_error_handling(qtbot, config, msg, metadata, expected_exception_message):
plot_app = setup_plot_app(qtbot, config)
# Initialize
plot_app.init_curves = MagicMock()
plot_app.data = {}
plot_app.scanID = 0
plot_app.dev.__getitem__.side_effect = mock_getitem
with pytest.raises(ValueError) as exc_info:
plot_app.on_scan_segment(msg, metadata)
assert str(exc_info.value) == expected_exception_message
####################
# ErrorHandler tests
####################
def test_initialization(error_handler):
assert error_handler.errors == []
assert error_handler.parent is None
assert error_handler.retry_action is None
@patch(
"bec_widgets.examples.plot_app.plot_app.QMessageBox.critical", return_value=QMessageBox.Retry
)
def test_handle_error_retry(mocked_critical, error_handler):
retry_action = MagicMock()
error_handler.set_retry_action(retry_action)
error_handler.handle_error("error message")
retry_action.assert_called_once()
@patch(
"bec_widgets.examples.plot_app.plot_app.QMessageBox.critical", return_value=QMessageBox.Cancel
)
def test_handle_error_cancel(mocked_critical, error_handler):
retry_action = MagicMock()
with pytest.raises(SystemExit) as excinfo:
error_handler.handle_error("error message")
assert excinfo.value.code == 1
retry_action.assert_not_called()
@pytest.mark.parametrize(
"config, expected_errors",
[
(config_device_mode_all_filled, []),
(config_device_mode_no_entry, []),
(config_scan_mode, []),
(
config_all_wrong,
["Missing 'signals' configuration for x axis in plot 0 - 'BPM4i plots vs samx'"],
),
],
)
def test_error_handler(error_handler, config, expected_errors):
# Mock QMessageBox
error_handler.handle_error = MagicMock()
# Mock logging
with unittest.mock.patch("bec_widgets.examples.plot_app.plot_app.logging") as mocked_logging:
error_handler.validate_config_file(config)
# Assert
assert error_handler.errors == expected_errors
# If there are expected errors, check if handle_error was called
if expected_errors:
error_handler.handle_error.assert_called_once()
mocked_logging.error.assert_called()
else:
mocked_logging.error.assert_not_called()
error_handler.handle_error.assert_not_called()
def test_validate_plot_config(error_handler):
plot_config = {
"x": {"label": "Motor X", "signals": []}, # empty signals list should trigger an error
"y": {"label": "Motor Y", "signals": [{"name": "samx", "entry": "samx"}]},
}
error_handler.validate_plot_config(plot_config, 0)
assert error_handler.errors == [
"'signals' configuration for x axis in plot 0 must be a non-empty list"
]

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