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

Compare commits

..

70 Commits

Author SHA1 Message Date
d996ff9284 fix(test): add a temporary test 2024-02-27 14:09:54 +01:00
8b43eba282 fix(bec dispatcher): make a new BECClient in _BECDispatcher constructor and process events while waiting 2024-02-27 14:09:54 +01:00
9c822ec480 fix: producer->connector, remove unused '_rpc_update_handler' static method 2024-02-27 14:09:54 +01:00
semantic-release
44b451e66b 0.41.1
Automatically generated by python-semantic-release
2024-02-26 20:04:48 +00:00
a2ed2ebe00 fix(bec_dispatcher): handle redis connection errors more gracefully 2024-02-26 20:58:46 +01:00
8127fc2960 fix(bec_dispatcher): adapt code to redis connector refactoring 2024-02-26 19:26:15 +01:00
semantic-release
6171790f66 0.41.0
Automatically generated by python-semantic-release
2024-02-26 14:40:20 +00:00
wyzula-jan
ebb36f62dd fix(cli/client_utils): "__rpc__" pop from msg_results 2024-02-26 15:30:43 +01:00
wyzula-jan
644f1031f6 fix(tests): BECDispatcher fixture putted back 2024-02-26 14:27:22 +01:00
wyzula-jan
fd711b475f fix(cli/rpc): rpc client can return any type of object + config dict of the widgets 2024-02-26 14:06:36 +01:00
wyzula-jan
57132a4721 fix(cli/rpc): server access children widget.find_widget_by_id(gui_id) 2024-02-26 13:26:55 +01:00
f71dc5c5ab fix(cli): fixed property access, rebased 2024-02-26 10:29:15 +01:00
4630d78fc2 fix(rpc_server): fixed gui_id lookup 2024-02-26 10:25:02 +01:00
da640e888d fix(cli): fixed rpc construction of nested widgets 2024-02-26 10:25:02 +01:00
wyzula-jan
35cd4fd6f1 fix(plots/waveform1d): pandas import clean up, export curves with none skipped 2024-02-25 18:06:33 +01:00
wyzula-jan
f06e652b82 test(plots/waveform1d): tests added 2024-02-25 17:52:11 +01:00
wyzula-jan
5fc8047c8f feat(widgets/waveform1d): data can be exported from rendered curve 2024-02-25 12:52:36 +01:00
wyzula-jan
0363fd5194 feat(widgets/figure): clear_all method for BECFigure 2024-02-23 15:27:09 +01:00
wyzula-jan
826a5e9874 test(test_plot_base): BECPlotBase tests added 2024-02-23 13:37:25 +01:00
wyzula-jan
f668eb8b9b test(test_bec_figure): tests for BECFigure added 2024-02-23 13:06:18 +01:00
wyzula-jan
5964778a64 refactor(widgets/BECCurve): set kwargs for curve style while adding curve 2024-02-23 11:05:01 +01:00
wyzula-jan
8135f68230 test(tests/test_bec_connector): test_bec_connector.py added 2024-02-23 10:53:10 +01:00
wyzula-jan
24c77376b2 fix(widgets/plots): added placeholder for cleanup method to BECPlotBase 2024-02-23 10:53:10 +01:00
wyzula-jan
f364afcb42 refactor(widgets/figure: fixed wrong references to debug jupyter console 2024-02-23 10:53:10 +01:00
wyzula-jan
4051902f09 test(tests/client_mocks): added general mock_client with container for fake devices for testing 2024-02-23 10:53:10 +01:00
wyzula-jan
a28b9c8981 fix(widget/figure): add cleanup method to disconnect all slots before removing Waveform1D from layout 2024-02-23 10:53:10 +01:00
wyzula-jan
9a5c86ea35 feat(widgets/Waveform1D): Waveform1D can be fully constructed by config 2024-02-23 10:53:10 +01:00
wyzula-jan
08534a4739 feat(widgets/figure.py): dark/light theme changer 2024-02-23 10:53:10 +01:00
wyzula-jan
1db77b969b feat(utils/entry_validator): possibility to validate add_scan_curve with current BEC session 2024-02-23 10:53:10 +01:00
wyzula-jan
99dce077c4 refactor(plot/Waveform1D,plot/BECCurve): BECCurve inherits from BECConnector and can refer to parent_id (Waveform1D) and has its own gui_id 2024-02-23 10:53:10 +01:00
wyzula-jan
402adc44e8 refactor(rpc/client): changed path to client.py to relative one 2024-02-23 10:53:10 +01:00
wyzula-jan
c6bdf0b6a5 fix(rpc): added annotations to pass py3.9 tests 2024-02-23 10:53:10 +01:00
wyzula-jan
1c2fb8b972 fix(rpc): connection to on_rpc_update done through bec_dispatcher 2024-02-23 10:53:10 +01:00
a61bf36df5 feat(cli): added cli interface, rebased 2024-02-23 10:53:10 +01:00
wyzula-jan
d678a85957 fix: after removing plot from BECFigure, the coordinates are correctly resigned 2024-02-23 10:53:10 +01:00
wyzula-jan
684592ae37 feat: curve can be modified after adding to the plot 2024-02-23 10:53:10 +01:00
wyzula-jan
f0ed243c91 feat: waveform1d.py curves can be removed by identifier by order(int) or by curve_id(str) 2024-02-23 10:53:10 +01:00
wyzula-jan
cba3863e5a feat: waveform1d.py curves can be stylised; access scan history by index or scanID 2024-02-23 10:53:10 +01:00
wyzula-jan
1d26b23221 feat: start method for BECFigure, jupyter console .ui added to git 2024-02-23 10:53:10 +01:00
wyzula-jan
b827e9eaa7 feat: added @user_access from bec_lib.utils 2024-02-23 10:53:10 +01:00
wyzula-jan
60d150a411 feat: plot can be removed from BECFigure 2024-02-23 10:53:10 +01:00
wyzula-jan
c781b1b4e4 feat: figure.py create widget factory 2024-02-23 10:53:10 +01:00
wyzula-jan
565e475ace feat: waveform1d.py draft 2024-02-23 10:53:10 +01:00
wyzula-jan
7c15d75011 fix: removed DI references, fixed set when adding plot by fig 2024-02-23 10:53:10 +01:00
wyzula-jan
b676877242 feat: rpc decorator to add methods to USER_ACCESS 2024-02-23 10:53:10 +01:00
wyzula-jan
7768e594b5 refactor: BECFigure, BECPlotBase changed back to pyqtgraph classes inheritance 2024-02-23 10:53:10 +01:00
wyzula-jan
9ef331c272 feat: BECFigure and BECPlotBase created 2024-02-23 10:53:10 +01:00
wyzula-jan
4a1792c209 refactor: BECConnector changed config structure 2024-02-23 10:53:10 +01:00
wyzula-jan
91447a2d62 feat: BECConnector -> mixin class for all BEC Widget to hook them to BEC client 2024-02-23 10:53:10 +01:00
semantic-release
ed5bdd99e6 0.40.1
Automatically generated by python-semantic-release
2024-02-23 09:51:25 +00:00
wyzula-jan
feca7a3dcd fix(utils/bec_dispatcher): _do_disconnect_slot will shutdown consumer of slots/signals which were already disconnected 2024-02-22 13:35:57 +01:00
semantic-release
2d9020358d 0.40.0
Automatically generated by python-semantic-release
2024-02-16 20:51:02 +00:00
wyzula-jan
51259097fa feat(utils.colors): golden_angle_color utility can return colors as a list of QColor, RGB or HEC 2024-02-16 20:16:19 +01:00
semantic-release
8a4aeb8dfe 0.39.0
Automatically generated by python-semantic-release
2024-02-12 13:01:47 +00:00
wyzula-jan
4b0542a513 refactor: pylint ignore for tests 2024-02-12 13:53:52 +01:00
wyzula-jan
bf04a4e04a test: motor_control_compilations.py and motor_control.py tests added 2024-02-12 13:53:52 +01:00
wyzula-jan
fa4ca935bb feat: added full app with all motor movement related widgets into motor_control_compilations.py 2024-02-12 13:53:52 +01:00
wyzula-jan
b52e22d81f refactor: motor_control.py clean up 2024-02-12 13:53:52 +01:00
wyzula-jan
2f96e10b9d feat: MotorCoordinateTable mode_switch added for "Individual" and "Start/Stop" modes 2024-02-12 13:53:52 +01:00
wyzula-jan
031cb094e7 feat: motor_control.py MotorCoordinateTable added basic version to store coordinates and show them in motor_map.py 2024-02-12 13:53:52 +01:00
wyzula-jan
8afc5f0c0c refactor: motor_control_compilations.py moved to example part of repository 2024-02-12 13:53:52 +01:00
wyzula-jan
17f14581d7 feat: active motors from motor_map.py can be changed by slot without changing the whole config 2024-02-12 13:53:52 +01:00
wyzula-jan
8361736679 feat: control panels compilations 2024-02-12 13:53:52 +01:00
wyzula-jan
0b9927fcf5 feat: comboboxes of motor selection are changed to orange if the motors are not connected yet 2024-02-12 13:53:52 +01:00
wyzula-jan
8139e271de refactor: base class for motor_control.py widgets 2024-02-12 13:53:52 +01:00
wyzula-jan
6fe08e6b82 feat: motor_control.py MotorControl widgets - Absolute + Relative movement, MotorSelection, ErrorMessage popups 2024-02-12 13:53:52 +01:00
wyzula-jan
968da6f558 build: added all .ui and .yaml files to pypi install; removed gauss_bpm from default config from monitor.py 2024-02-08 10:59:48 +01:00
semantic-release
11ae0b1054 0.38.2
Automatically generated by python-semantic-release
2024-02-07 16:26:49 +00:00
5ebfd2a3c2 test: fixed import in test_validator_errors.py 2024-02-07 17:23:03 +01:00
b36131eed5 fix: adapt code to BEC 1.0 2024-02-07 17:16:43 +01:00
60 changed files with 7327 additions and 125 deletions

View File

@@ -2,6 +2,82 @@
<!--next-version-placeholder-->
## 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

View File

386
bec_widgets/cli/client.py Normal file
View File

@@ -0,0 +1,386 @@
# This file was automatically generated by generate_cli.py
from bec_widgets.cli.client_utils import rpc_call, RPCBase, BECFigureClientMixin
from typing import Literal, Optional, overload
class BECPlotBase(RPCBase):
@rpc_call
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
"""
@rpc_call
def set_title(self, title: "str"):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
"""
@rpc_call
def set_x_label(self, label: "str"):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
"""
@rpc_call
def set_y_label(self, label: "str"):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
"""
@rpc_call
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.
"""
@rpc_call
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.
"""
@rpc_call
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.
"""
@rpc_call
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.
"""
@rpc_call
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.
"""
@rpc_call
def plot_data(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.
"""
@rpc_call
def remove(self):
"""
Remove the plot widget from the figure.
"""
class BECWaveform1D(RPCBase):
@rpc_call
def add_curve_scan(
self,
x_name: "str",
y_name: "str",
x_entry: "Optional[str]" = None,
y_entry: "Optional[str]" = None,
color: "Optional[str]" = None,
label: "Optional[str]" = None,
validate_bec: "bool" = True,
**kwargs
) -> "BECCurve":
"""
Add a curve to the plot widget from the scan segment.
Args:
x_name(str): Name of the x signal.
x_entry(str): Entry of the x signal.
y_name(str): Name of the y signal.
y_entry(str): Entry of the y signal.
color(str, optional): Color of the curve. Defaults to None.
label(str, optional): Label of the curve. Defaults to None.
**kwargs: Additional keyword arguments for the curve configuration.
Returns:
BECCurve: The curve object.
"""
@rpc_call
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.
"""
@rpc_call
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).
"""
@rpc_call
def scan_history(self, scan_index: "int" = None, scanID: "str" = None):
"""
Update the scan curves with the data from the scan storage.
Provide only one of scanID or scan_index.
Args:
scanID(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
"""
@property
@rpc_call
def curves(self) -> "list[BECCurve]":
"""
Get the curves of the plot widget as a list
Returns:
list: List of curves.
"""
@property
@rpc_call
def curves_data(self) -> "dict":
"""
Get the curves data of the plot widget as a dictionary
Returns:
dict: Dictionary of curves data.
"""
@rpc_call
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.
"""
@rpc_call
def get_curve_config(self, curve_id: "str", dict_output: "bool" = True) -> "CurveConfig | dict":
"""
Get the configuration of a curve by its ID.
Args:
curve_id(str): ID of the curve.
Returns:
CurveConfig|dict: Configuration of the curve.
"""
@rpc_call
def apply_config(self, config: "dict | 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.
"""
@rpc_call
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.
"""
class BECFigure(RPCBase, BECFigureClientMixin):
@rpc_call
def add_plot(
self,
widget_id: "str" = None,
row: "int" = None,
col: "int" = None,
config=None,
**axis_kwargs
) -> "BECWaveform1D":
"""
Add a Waveform1D plot to the figure at the specified position.
Args:
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
"""
@rpc_call
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.
"""
@rpc_call
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.
"""
@rpc_call
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.
"""
@rpc_call
def clear_all(self):
"""
Clear all widgets from the figure and reset to default state
"""
class BECCurve(RPCBase):
@rpc_call
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"]
"""
@rpc_call
def set_data(self, x, y):
"""
None
"""
@rpc_call
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.
"""
@rpc_call
def set_symbol(self, symbol: "str"):
"""
Change the symbol of the curve.
Args:
symbol(str): Symbol of the curve.
"""
@rpc_call
def set_symbol_color(self, symbol_color: "str"):
"""
Change the symbol color of the curve.
Args:
symbol_color(str): Color of the symbol.
"""
@rpc_call
def set_symbol_size(self, symbol_size: "int"):
"""
Change the symbol size of the curve.
Args:
symbol_size(int): Size of the symbol.
"""
@rpc_call
def set_pen_width(self, pen_width: "int"):
"""
Change the pen width of the curve.
Args:
pen_width(int): Width of the pen.
"""
@rpc_call
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.
"""
@rpc_call
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.
"""

View File

@@ -0,0 +1,175 @@
import importlib
import select
import subprocess
import uuid
from functools import wraps
from qtpy.QtCore import QCoreApplication
import bec_widgets.cli.client as client
from bec_lib import MessageEndpoints, messages
from bec_widgets.utils.bec_dispatcher import BECDispatcher
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):
return self._run_rpc(func.__name__, *args, **kwargs)
return wrapper
class BECFigureClientMixin:
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._process = None
def show(self) -> None:
"""
Show the figure.
"""
if self._process is None or self._process.poll() is not None:
self._start_plot_process()
def close(self) -> None:
"""
Close the figure.
"""
if self._process is None:
return
self._run_rpc("close", (), wait_for_rpc_response=False)
self._process.kill()
self._process = None
def _start_plot_process(self) -> None:
"""
Start the plot in a new process.
"""
# pylint: disable=subprocess-run-check
monitor_module = importlib.import_module("bec_widgets.cli.server")
monitor_path = monitor_module.__file__
command = f"python {monitor_path} --id {self._gui_id}"
self._process = subprocess.Popen(
command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
def print_log(self) -> None:
"""
Print the log of the plot process.
"""
if self._process is None:
return
print(self._get_stderr_output())
def _get_stderr_output(self) -> str:
stderr_output = []
while self._process.poll() is not None:
readylist, _, _ = select.select([self._process.stderr], [], [], 0.1)
if not readylist:
break
line = self._process.stderr.readline()
if not line:
break
stderr_output.append(line.decode("utf-8"))
return "".join(stderr_output)
def __del__(self) -> None:
self.close()
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}")
@property
def _root(self):
"""
Get the root widget. This is the BECFigure widget that holds
the anchor gui_id.
"""
parent = self
# pylint: disable=protected-access
while parent._parent is not None:
parent = parent._parent
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, **kwargs):
"""
Run the RPC call.
Args:
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
kwargs: The keyword arguments to pass to the method.
Returns:
The result of the RPC call.
"""
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
metadata={"request_id": request_id},
)
print(f"RPCBase: {rpc_msg}")
# pylint: disable=protected-access
receiver = self._root._gui_id
self._client.connector.send(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 msg_result
cls = msg_result.pop("widget_class", None)
msg_result.pop("__rpc__", None)
if not cls:
return msg_result
cls = getattr(client, cls)
print(msg_result)
return cls(parent=self, **msg_result)
return msg_result
def _wait_for_response(self, request_id):
"""
Wait for the response from the server.
"""
response = None
while response is None:
response = self._client.connector.get(
MessageEndpoints.gui_instruction_response(request_id)
)
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
return response

View File

@@ -0,0 +1,96 @@
import inspect
import typing
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 = typing.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.
Args:
file_name(str): The name of the file to write to.
"""
with open(file_name, "w", encoding="utf-8") as file:
file.write(self.header)
file.write(self.content)
if __name__ == "__main__":
import os
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.plots import BECPlotBase, BECWaveform1D # ,BECCurve
from bec_widgets.widgets.plots.waveform1d import BECCurve
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
clss = [BECPlotBase, BECWaveform1D, BECFigure, BECCurve]
generator = ClientGenerator()
generator.generate_client(clss)
generator.write(client_path)

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

@@ -0,0 +1,110 @@
import inspect
from bec_lib import MessageEndpoints, messages
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.plots import BECCurve, BECWaveform1D
class BECWidgetsCLIServer:
WIDGETS = [BECWaveform1D, BECFigure, BECCurve]
def __init__(self, gui_id: str = None, dispatcher: BECDispatcher = None) -> None:
self.dispatcher = BECDispatcher() if dispatcher is None else dispatcher
self.client = self.dispatcher.client
self.client.start()
self.gui_id = gui_id
self.fig = BECFigure(gui_id=self.gui_id)
print(f"Server started with gui_id {self.gui_id}")
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
def start(self):
"""Start the figure window."""
self.fig.start()
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
try:
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
obj = self.get_object_from_config(msg["parameter"])
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
print(e)
self.send_response(request_id, False, {"error": str(e)})
else:
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set(
MessageEndpoints.gui_instruction_response(request_id),
messages.RequestResponseMessage(accepted=accepted, message=msg),
expire=60,
)
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
# check if the object is the figure
if gui_id == self.fig.gui_id:
return self.fig
# check if the object is a widget
if gui_id in self.fig.widgets:
obj = self.fig.widgets[config["gui_id"]]
return obj
if self.fig.widgets:
for widget in self.fig.widgets.values():
item = widget.find_widget_by_id(gui_id)
if item:
return item
raise NotImplementedError(
f"gui_id lookup for widget of type {widget.__class__.__name__} not implemented"
)
raise ValueError(f"Object with gui_id {gui_id} not found")
def run_rpc(self, obj, method, args, kwargs):
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
res = method_obj
else:
sig = inspect.signature(method_obj)
if sig.parameters:
res = method_obj(*args, **kwargs)
else:
res = method_obj()
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
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
if __name__ == "__main__":
import argparse
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")
server.start()

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ from bec_lib import messages, MessageEndpoints, RedisConnector
import time
connector = RedisConnector("localhost:6379")
producer = connector.producer()
metadata = {}
scanID = "ScanID1"
@@ -20,13 +19,11 @@ for ii in range(20):
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},
msg={"data": msg}, # TODO should be msg_dict
expire=1800,
)

View File

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

View File

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

View File

@@ -1297,7 +1297,7 @@ class MotorControl(QThread):
@staticmethod
def _device_status_callback_motors(msg, *, parent, **_kwargs) -> None:
deviceMSG = messages.DeviceMessage.loads(msg.value)
deviceMSG = msg.value
if parent.motor_x.name in deviceMSG.content["signals"]:
parent.current_x = deviceMSG.content["signals"][parent.motor_x.name]["value"]
elif parent.motor_y.name in deviceMSG.content["signals"]:

View File

@@ -40,7 +40,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
@@ -214,7 +214,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):
@@ -270,8 +270,8 @@ class StreamPlot(QtWidgets.QWidget):
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"):
@@ -295,7 +295,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"]

View File

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

View File

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

View File

@@ -1,7 +1,3 @@
# TODO last backup
# todo super last refactor
from __future__ import annotations
import argparse
@@ -10,9 +6,10 @@ import os
from collections.abc import Callable
from typing import Union
from bec_lib import BECClient, messages, ServiceConfig
from bec_lib.redis_connector import RedisConsumerThreaded
from qtpy.QtCore import QObject, Signal as pyqtSignal
import redis
from bec_lib import BECClient, ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
# Adding a new pyqt signal requires a class factory, as they must be part of the class definition
# and cannot be dynamically added as class attributes after the class has been defined.
@@ -22,10 +19,11 @@ _signal_class_factory = (
class _Connection:
"""Utility class to keep track of slots connected to a particular redis consumer"""
"""Utility class to keep track of slots connected to a particular redis connector"""
def __init__(self, callback) -> None:
self.callback = callback
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)()
@@ -33,18 +31,21 @@ class _Connection:
class _BECDispatcher(QObject):
"""Utility class to keep track of slots connected to a particular redis consumer"""
"""Utility class to keep track of slots connected to a particular redis connector"""
def __init__(self, bec_config=None):
super().__init__()
self.client = BECClient()
self.client = BECClient(forced=True) # make a new instance
# TODO: this is a workaround for now to provide service config within qtdesigner, but is
# it possible to provide config via a cli arg?
if bec_config is None and os.path.isfile("bec_config.yaml"):
bec_config = "bec_config.yaml"
self.client.initialize(config=ServiceConfig(config_path=bec_config))
try:
self.client.initialize(config=ServiceConfig(config_path=bec_config))
except redis.exceptions.ConnectionError as e:
print(f"Failed to initialize BECClient: {e}")
self._connections = {}
def connect_slot(
@@ -76,28 +77,28 @@ class _BECDispatcher(QObject):
"""Creates a new connection for given topics."""
def cb(msg):
msg = messages.MessageReader.loads(msg.value)
if not isinstance(msg, list):
msg = [msg]
for msg_i in msg:
for connection_key, connection in self._connections.items():
if set(topics).intersection(
connection_key if isinstance(connection_key, tuple) else [connection_key]
):
connection.signal.emit(msg_i.content, msg_i.metadata)
msg = msg.value
for connection_key, connection in self._connections.items():
if set(topics).intersection(connection_key):
connection.signal.emit(msg.content, msg.metadata)
consumer = self.client.connector.consumer(topics=topics, cb=cb)
consumer.start()
return _Connection(consumer)
try:
self.client.connector.register(topics=topics, cb=cb)
except redis.exceptions.ConnectionError:
print("Could not connect to Redis, skipping registration of topics.")
return _Connection(cb)
def _do_disconnect_slot(self, topic, slot):
print(f"Disconnecting {slot} from {topic}")
connection = self._connections[topic]
connection.signal.disconnect(slot)
try:
connection.signal.disconnect(slot)
except TypeError:
print(f"Could not disconnect slot:'{slot}' from topic:'{topic}'")
print("Continue to remove slot:'{slot}' from 'connection.slots'.")
connection.slots.remove(slot)
if not connection.slots:
print(f"{connection.consumer} is shutting down")
connection.consumer.shutdown()
connection.consumer.join()
del self._connections[topic]
def _disconnect_slot_from_topic(self, slot: Callable, topic: str) -> None:

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

View File

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

View File

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

View File

@@ -4,3 +4,12 @@ from .scan_control import ScanControl
from .toolbar import ModularToolBar
from .editor import BECEditor
from .monitor_scatter_2D import BECMonitor2DScatter
from .motor_control import (
MotorControlRelative,
MotorControlAbsolute,
MotorControlSelection,
MotorThread,
MotorCoordinateTable,
)
from .figure import FigureConfig, BECFigure
from .plots import BECWaveform1D, BECCurve, BECPlotBase

View File

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

View File

@@ -0,0 +1,524 @@
# 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
import numpy as np
import pyqtgraph as pg
import qdarktheme
from pydantic import Field
from pyqtgraph.Qt import uic
from qtpy.QtWidgets import QApplication, QWidget
from qtpy.QtWidgets import QVBoxLayout, QMainWindow
from bec_widgets.utils import BECConnector, BECDispatcher, ConnectionConfig
from bec_widgets.widgets.plots import BECPlotBase, BECWaveform1D, Waveform1DConfig, WidgetConfig
class FigureConfig(ConnectionConfig):
"""Configuration for BECFigure. Inheriting from ConnectionConfig widget_class and gui_id"""
theme: Literal["dark", "light"] = Field("dark", description="The theme of the figure widget.")
num_cols: int = Field(1, description="The number of columns in the figure widget.")
num_rows: int = Field(1, description="The number of rows in the figure widget.")
widgets: dict[str, WidgetConfig] = Field(
{}, description="The list of widgets to be added to the figure widget."
)
class WidgetHandler:
"""Factory for creating and configuring BEC widgets for BECFigure."""
def __init__(self):
self.widget_factory = {
"PlotBase": (BECPlotBase, WidgetConfig),
"Waveform1D": (BECWaveform1D, Waveform1DConfig),
}
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
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 = ["add_plot", "remove", "change_layout", "change_theme", "clear_all"]
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 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 add_plot(
self, widget_id: str = None, row: int = None, col: int = None, config=None, **axis_kwargs
) -> BECWaveform1D:
"""
Add a Waveform1D plot to the figure at the specified position.
Args:
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
"""
return self.add_widget(
widget_type="Waveform1D",
widget_id=widget_id,
row=row,
col=col,
config=config,
**axis_kwargs,
)
def add_widget(
self,
widget_type: Literal["PlotBase", "Waveform1D"] = "PlotBase",
widget_id: str = None,
row: int = None,
col: int = None,
config=None,
**axis_kwargs,
) -> BECPlotBase:
"""
Add a widget to the figure at the specified position.
Args:
widget_type(Literal["PlotBase","Waveform1D"]): The type of the widget to add.
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
"""
if not widget_id:
widget_id = self._generate_unique_widget_id()
if widget_id in self.widgets:
raise ValueError(f"Widget with ID '{widget_id}' already exists.")
widget = self.widget_handler.create_widget(
widget_type=widget_type,
widget_id=widget_id,
parent_figure=self,
parent_id=self.gui_id,
config=config,
**axis_kwargs,
)
# Check if position is occupied
if row is not None and col is not None:
if self.getItem(row, col):
raise ValueError(f"Position at row {row} and column {col} is already occupied.")
else:
widget.config.row = row
widget.config.col = col
# Add widget to the figure
self.addItem(widget, row=row, col=col)
else:
row, col = self._find_next_empty_position()
widget.config.row = row
widget.config.col = col
# Add widget to the figure
self.addItem(widget, row=row, col=col)
# Update num_cols and num_rows based on the added widget
self.config.num_rows = max(self.config.num_rows, row + 1)
self.config.num_cols = max(self.config.num_cols, col + 1)
# Saving config for future referencing
self.config.widgets[widget_id] = widget.config
self.widgets[widget_id] = widget
# Reflect the grid coordinates
self._change_grid(widget_id, row, col)
return widget
def remove(
self,
row: int = None,
col: int = None,
widget_id: str = None,
coordinates: tuple[int, int] = None,
) -> None:
"""
Remove a widget from the figure. Can be removed by its unique identifier or by its coordinates.
Args:
row(int): The row coordinate of the widget to remove.
col(int): The column coordinate of the widget to remove.
widget_id(str): The unique identifier of the widget to remove.
coordinates(tuple[int, int], optional): The coordinates of the widget to remove.
"""
if widget_id:
self._remove_by_id(widget_id)
elif row is not None and col is not None:
self._remove_by_coordinates(row, col)
elif coordinates:
self._remove_by_coordinates(*coordinates)
else:
raise ValueError("Must provide either widget_id or coordinates for removal.")
def _remove_by_coordinates(self, row: int, col: int) -> None:
"""
Remove a widget from the figure by its coordinates.
Args:
row(int): The row coordinate of the widget to remove.
col(int): The column coordinate of the widget to remove.
"""
widget = self._get_widget_by_coordinates(row, col)
if widget:
widget_id = widget.config.gui_id
if widget_id in self.widgets:
self._remove_by_id(widget_id)
def _remove_by_id(self, widget_id: str) -> None:
"""
Remove a widget from the figure by its unique identifier.
Args:
widget_id(str): The unique identifier of the widget to remove.
"""
if widget_id in self.widgets:
widget = self.widgets.pop(widget_id)
widget.cleanup()
self.removeItem(widget)
self.grid[widget.config.row][widget.config.col] = None
self._reindex_grid()
if widget_id in self.config.widgets:
self.config.widgets.pop(widget_id)
print(f"Removed widget {widget_id}.")
else:
raise ValueError(f"Widget with ID '{widget_id}' does not exist.")
def __getitem__(self, key: tuple | str):
if isinstance(key, tuple) and len(key) == 2:
return self._get_widget_by_coordinates(*key)
elif isinstance(key, str):
widget = self.widgets.get(key)
if widget is None:
raise KeyError(f"No widget with ID {key}")
return self.widgets.get(key)
else:
raise TypeError(
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
)
def _get_widget_by_coordinates(self, row: int, col: int) -> BECPlotBase:
"""
Get widget by its coordinates in the figure.
Args:
row(int): the row coordinate
col(int): the column coordinate
Returns:
BECPlotBase: the widget at the given coordinates
"""
widget = self.getItem(row, col)
if widget is None:
raise ValueError(f"No widget at coordinates ({row}, {col})")
return widget
def _find_next_empty_position(self):
"""Find the next empty position (new row) in the figure."""
row, col = 0, 0
while self.getItem(row, col):
row += 1
return row, col
def _generate_unique_widget_id(self):
"""Generate a unique widget ID."""
existing_ids = set(self.widgets.keys())
for i in itertools.count(1):
widget_id = f"widget_{i}"
if widget_id not in existing_ids:
return widget_id
def _change_grid(self, widget_id: str, row: int, col: int):
"""
Change the grid to reflect the new position of the widget.
Args:
widget_id(str): The unique identifier of the widget.
row(int): The new row coordinate of the widget in the figure.
col(int): The new column coordinate of the widget in the figure.
"""
while len(self.grid) <= row:
self.grid.append([])
row = self.grid[row]
while len(row) <= col:
row.append(None)
row[col] = widget_id
def _reindex_grid(self):
"""Reindex the grid to remove empty rows and columns."""
print(f"old grid: {self.grid}")
new_grid = []
for row in self.grid:
new_row = [widget for widget in row if widget is not None]
if new_row:
new_grid.append(new_row)
#
# Update the config of each object to reflect its new position
for row_idx, row in enumerate(new_grid):
for col_idx, widget in enumerate(row):
self.widgets[widget].config.row, self.widgets[widget].config.col = row_idx, col_idx
self.grid = new_grid
self._replot_layout()
def _replot_layout(self):
"""Replot the layout based on the current grid configuration."""
self.clear()
for row_idx, row in enumerate(self.grid):
for col_idx, widget in enumerate(row):
self.addItem(self.widgets[widget], row=row_idx, col=col_idx)
def change_layout(self, max_columns=None, max_rows=None):
"""
Reshuffle the layout of the figure to adjust to a new number of max_columns or max_rows.
If both max_columns and max_rows are provided, max_rows is ignored.
Args:
max_columns (Optional[int]): The new maximum number of columns in the figure.
max_rows (Optional[int]): The new maximum number of rows in the figure.
"""
# Calculate total number of widgets
total_widgets = len(self.widgets)
if max_columns:
# Calculate the required number of rows based on max_columns
required_rows = (total_widgets + max_columns - 1) // max_columns
new_grid = [[None for _ in range(max_columns)] for _ in range(required_rows)]
elif max_rows:
# Calculate the required number of columns based on max_rows
required_columns = (total_widgets + max_rows - 1) // max_rows
new_grid = [[None for _ in range(required_columns)] for _ in range(max_rows)]
else:
# If neither max_columns nor max_rows is specified, just return without changing the layout
return
# Populate the new grid with widgets' IDs
current_idx = 0
for widget_id, widget in self.widgets.items():
row = current_idx // len(new_grid[0])
col = current_idx % len(new_grid[0])
new_grid[row][col] = widget_id
current_idx += 1
self.config.num_rows = row
self.config.num_cols = col
# Update widgets' positions and replot them according to the new grid
self.grid = new_grid
self._reindex_grid() # This method should be updated to handle reshuffling correctly
self._replot_layout() # Assumes this method re-adds widgets to the layout based on self.grid
def clear_all(self):
"""Clear all widgets from the figure and reset to default state"""
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
)
def start(self):
import sys
app = QApplication(sys.argv)
win = QMainWindow()
win.setCentralWidget(self)
win.show()
sys.exit(app.exec_())
##################################################
##################################################
# Debug window
##################################################
##################################################
from qtconsole.inprocess import QtInProcessKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
def __init__(self):
super().__init__()
self.kernel_manager = QtInProcessKernelManager()
self.kernel_manager.start_kernel(show_banner=False)
self.kernel_client = self.kernel_manager.client()
self.kernel_client.start_channels()
self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
def shutdown_kernel(self):
self.kernel_client.stop_channels()
self.kernel_manager.shutdown_kernel()
class DebugWindow(QWidget): # pragma: no cover:
"""Debug window for BEC widgets"""
def __init__(self, parent=None):
super().__init__(parent)
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "figure_debug_minimal.ui"), self)
self._init_ui()
self.splitter.setSizes([200, 100])
# console push
self.console.kernel_manager.kernel.shell.push(
{"fig": self.figure, "w1": self.w1, "w2": self.w2}
)
def _init_ui(self):
# Plotting window
self.glw_1_layout = QVBoxLayout(self.glw) # Create a new QVBoxLayout
self.figure = BECFigure(parent=self) # Create a new BECDeviceMonitor
self.glw_1_layout.addWidget(self.figure) # Add BECDeviceMonitor to the layout
# add stuff to figure
self._init_figure()
self.console_layout = QVBoxLayout(self.widget_console)
self.console = JupyterConsoleWidget()
self.console_layout.addWidget(self.console)
self.console.set_default_style("linux")
def _init_figure(self):
self.figure.add_widget(widget_type="Waveform1D", row=0, col=0, title="Widget 1")
self.figure.add_widget(widget_type="Waveform1D", row=1, col=0, title="Widget 2")
self.figure.add_widget(widget_type="Waveform1D", row=0, col=1, title="Widget 3")
self.figure.add_widget(widget_type="Waveform1D", row=1, col=1, title="Widget 4")
self.w1 = self.figure[0, 0]
self.w2 = self.figure[1, 0]
self.w3 = self.figure[0, 1]
self.w4 = self.figure[1, 1]
# curves for w1
self.w1.add_curve_scan("samx", "bpm4i", pen_style="dash")
self.w1.add_curve_custom(
x=[1, 2, 3, 4, 5],
y=[1, 2, 3, 4, 5],
label="curve-custom",
color="blue",
pen_style="dashdot",
)
self.c1 = self.w1.get_config()
# curves for w2
self.w2.add_curve_scan("samx", "bpm3a", pen_style="solid")
self.w2.add_curve_scan("samx", "bpm4d", pen_style="dot")
self.w2.add_curve_custom(
x=[1, 2, 3, 4, 5], y=[5, 4, 3, 2, 1], color="red", pen_style="dashdot"
)
# curves for w3
self.w3.add_curve_scan("samx", "bpm4i", pen_style="dash")
self.w3.add_curve_custom(
x=[1, 2, 3, 4, 5],
y=[1, 2, 3, 4, 5],
label="curve-custom",
color="blue",
pen_style="dashdot",
)
# curves for w4
self.w4.add_curve_scan("samx", "bpm4i", pen_style="dash")
self.w4.add_curve_custom(
x=[1, 2, 3, 4, 5],
y=[1, 2, 3, 4, 5],
label="curve-custom",
color="blue",
pen_style="dashdot",
)
if __name__ == "__main__": # pragma: no cover
import sys
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication(sys.argv)
win = DebugWindow()
win.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,35 @@
<?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>901</width>
<height>1000</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QSplitter" name="splitter_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QWidget" name="glw" native="true"/>
</widget>
<widget class="QWidget" name="widget_console" native="true"/>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -33,7 +33,7 @@ CONFIG_SCAN_MODE = {
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}],
"y": [{"name": "bpm4i"}],
},
}
],
@@ -47,7 +47,7 @@ CONFIG_SCAN_MODE = {
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_adc1"}],
"y": [{"name": "bpm4i"}],
},
}
],
@@ -61,7 +61,7 @@ CONFIG_SCAN_MODE = {
"type": "scan_segment",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "gauss_adc2"}],
"y": [{"name": "bpm4i"}],
},
}
],
@@ -75,7 +75,7 @@ CONFIG_SCAN_MODE = {
"type": "scan_segment",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "gauss_adc3"}],
"y": [{"name": "bpm4i"}],
},
}
],
@@ -105,7 +105,7 @@ CONFIG_SCAN_MODE = {
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
"y": [{"name": "bpm4i"}, {"name": "bpm4i"}],
},
}
],
@@ -228,7 +228,7 @@ CONFIG_SIMPLE = {
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
"y": [{"name": "bpm4i"}, {"name": "bpm4i"}],
},
}
],

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,6 +122,28 @@ class MotorMap(pg.GraphicsLayoutWidget):
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."""
@@ -146,6 +168,9 @@ class MotorMap(pg.GraphicsLayoutWidget):
# 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", {})
@@ -488,6 +513,32 @@ class MotorMap(pg.GraphicsLayoutWidget):
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):
"""

View File

@@ -0,0 +1,2 @@
from .plot_base import AxisConfig, WidgetConfig, BECPlotBase
from .waveform1d import Waveform1DConfig, BECWaveform1D, BECCurve

View File

@@ -0,0 +1,238 @@
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.PlotItem):
USER_ACCESS = [
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"plot_data",
"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.PlotItem.__init__(self, parent)
self.figure = parent_figure
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.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.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.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.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.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.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.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.showGrid(x, y)
self.config.axis.x_grid = x
self.config.axis.y_grid = y
def add_legend(self):
self.addLegend()
def plot_data(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(data_x, data_y, **kwargs)
def remove(self):
"""Remove the plot widget from the figure."""
if self.figure is not None:
self.figure.remove(widget_id=self.gui_id)
def cleanup(self):
"""Cleanup the plot widget."""

View File

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

View File

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

View File

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

0
tests/__init__.py Normal file
View File

77
tests/client_mocks.py Normal file
View File

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

View File

@@ -0,0 +1,56 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import pytest
from .client_mocks import mocked_client
from bec_widgets.utils import BECConnector, ConnectionConfig
@pytest.fixture
def bec_connector(mocked_client):
connector = BECConnector(client=mocked_client)
return connector
def test_bec_connector_init(bec_connector):
assert bec_connector is not None
assert bec_connector.client is not None
assert isinstance(bec_connector, BECConnector)
assert bec_connector.config.widget_class == "BECConnector"
def test_bec_connector_init_with_gui_id(mocked_client):
bc = BECConnector(client=mocked_client, gui_id="test_gui_id")
assert bc.config.gui_id == "test_gui_id"
assert bc.gui_id == "test_gui_id"
def test_bec_connector_set_gui_id(bec_connector):
bec_connector.set_gui_id("test_gui_id")
assert bec_connector.config.gui_id == "test_gui_id"
def test_bec_connector_change_config(bec_connector):
bec_connector.on_config_update({"gui_id": "test_gui_id"})
assert bec_connector.config.gui_id == "test_gui_id"
def test_bec_connector_get_obj_by_id(bec_connector):
bec_connector.set_gui_id("test_gui_id")
assert bec_connector.get_obj_by_id("test_gui_id") == bec_connector
assert bec_connector.get_obj_by_id("test_gui_id_2") is None
def test_bec_connector_update_client(bec_connector, mocked_client):
client_new = mocked_client
bec_connector.update_client(client_new)
assert bec_connector.client == client_new
assert bec_connector.dev is not None
assert bec_connector.scans is not None
assert bec_connector.queue is not None
assert bec_connector.scan_storage is not None
assert bec_connector.dap is not None
def test_bec_connector_get_config(bec_connector):
assert bec_connector.get_config(dict_output=False) == bec_connector.config
assert bec_connector.get_config() == bec_connector.config.model_dump()

View File

@@ -1,4 +1,4 @@
# pylint: disable=missing-function-docstring
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from unittest.mock import Mock
import pytest
@@ -6,25 +6,24 @@ from bec_lib.messages import ScanMessage
from bec_lib.connector import MessageObject
msg = MessageObject(topic="", value=ScanMessage(point_id=0, scanID=0, data={}).dumps())
msg = MessageObject(topic="", value=ScanMessage(point_id=0, scanID=0, data={}))
@pytest.fixture(name="consumer")
def _consumer(bec_dispatcher):
bec_dispatcher.client.connector.consumer = Mock()
consumer = bec_dispatcher.client.connector.consumer
yield consumer
bec_dispatcher.client.connector = Mock()
yield bec_dispatcher.client.connector
@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, topics="topic0")
consumer.assert_called_once()
consumer.register.assert_called_once()
# trigger consumer callback as if a message was published
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
slot1.assert_called_once()
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
@@ -32,23 +31,23 @@ def test_connect_identical(bec_dispatcher, consumer):
slot1 = Mock()
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
consumer.assert_called_once()
consumer.register.assert_called_once()
consumer.call_args.kwargs["cb"](msg)
consumer.register.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, topics="topic0")
consumer.assert_called_once()
consumer.register.assert_called_once()
bec_dispatcher.connect_slot(slot=slot2, topics="topic0")
consumer.assert_called_once()
consumer.register.assert_called_once()
# trigger consumer callback as if a message was published
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
slot1.assert_called_once()
slot2.assert_called_once()
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
assert slot2.call_count == 2
@@ -56,13 +55,13 @@ def test_connect_many_slots_one_topic(bec_dispatcher, consumer):
def test_connect_one_slot_many_topics(bec_dispatcher, consumer):
slot1 = Mock()
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
assert consumer.call_count == 1
assert consumer.register.call_count == 1
bec_dispatcher.connect_slot(slot=slot1, topics="topic1")
assert consumer.call_count == 2
assert consumer.register.call_count == 2
# trigger consumer callback as if a message was published
consumer.call_args_list[0].kwargs["cb"](msg)
consumer.register.call_args_list[0].kwargs["cb"](msg)
slot1.assert_called_once()
consumer.call_args_list[1].kwargs["cb"](msg)
consumer.register.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 2
@@ -72,19 +71,19 @@ def test_disconnect_one_slot_one_topic(bec_dispatcher, consumer):
# disconnect using a different topic
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic1")
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
assert slot1.call_count == 1
# disconnect using a different slot
bec_dispatcher.disconnect_slot(slot=slot2, topics="topic0")
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
# disconnect using the right slot and topics
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic0")
# reset count to 0 for slot
# reset count to for slot
slot1.reset_mock()
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
assert slot1.call_count == 0
@@ -95,14 +94,14 @@ def test_disconnect_identical(bec_dispatcher, consumer):
bec_dispatcher.connect_slot(slot=slot1, topics="topic0")
# Test to call the slot once (slot should be not connected twice)
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
assert slot1.call_count == 1
# Disconnect the slot
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic0")
# Test to call the slot once (slot should be not connected anymore), count remains 1
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
assert slot1.call_count == 1
@@ -113,19 +112,19 @@ def test_disconnect_many_slots_one_topic(bec_dispatcher, consumer):
# disconnect using a different slot
bec_dispatcher.disconnect_slot(slot3, topics="topic0")
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
assert slot1.call_count == 1
assert slot2.call_count == 1
# disconnect using a different topics
bec_dispatcher.disconnect_slot(slot1, topics="topic1")
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
assert slot2.call_count == 2
# disconnect using the right slot and topics
bec_dispatcher.disconnect_slot(slot1, topics="topic0")
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
assert slot1.call_count == 2
assert slot2.call_count == 3
@@ -137,31 +136,31 @@ def test_disconnect_one_slot_many_topics(bec_dispatcher, consumer):
# disconnect using a different slot
bec_dispatcher.disconnect_slot(slot=slot2, topics="topic0")
consumer.call_args_list[0].kwargs["cb"](msg)
consumer.register.call_args_list[0].kwargs["cb"](msg)
assert slot1.call_count == 1
consumer.call_args_list[1].kwargs["cb"](msg)
consumer.register.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 2
# disconnect using a different topics
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic3")
consumer.call_args_list[0].kwargs["cb"](msg)
consumer.register.call_args_list[0].kwargs["cb"](msg)
assert slot1.call_count == 3
consumer.call_args_list[1].kwargs["cb"](msg)
consumer.register.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 4
# disconnect using the right slot and topics
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic0")
# Calling disconnected topic0 should not call slot1
consumer.call_args_list[0].kwargs["cb"](msg)
consumer.register.call_args_list[0].kwargs["cb"](msg)
assert slot1.call_count == 4
# Calling topic1 should still call slot1
consumer.call_args_list[1].kwargs["cb"](msg)
consumer.register.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 5
# disconnect remaining topic1 from slot1, calling any topic should not increase count
bec_dispatcher.disconnect_slot(slot=slot1, topics="topic1")
consumer.call_args_list[0].kwargs["cb"](msg)
consumer.call_args_list[1].kwargs["cb"](msg)
consumer.register.call_args_list[0].kwargs["cb"](msg)
consumer.register.call_args_list[1].kwargs["cb"](msg)
assert slot1.call_count == 5
@@ -178,9 +177,9 @@ def test_disconnect_all(bec_dispatcher, consumer):
bec_dispatcher.disconnect_all()
# Simulate messages and verify that none of the slots are called
consumer.call_args_list[0].kwargs["cb"](msg)
consumer.call_args_list[1].kwargs["cb"](msg)
consumer.call_args_list[2].kwargs["cb"](msg)
consumer.register.call_args_list[0].kwargs["cb"](msg)
consumer.register.call_args_list[1].kwargs["cb"](msg)
consumer.register.call_args_list[2].kwargs["cb"](msg)
# Ensure that the slots have not been called
assert slot1.call_count == 0
@@ -209,13 +208,13 @@ def test_connect_one_slot_multiple_topics_single_callback(bec_dispatcher, consum
msg_with_topic = MessageObject(
topic=topic, value=ScanMessage(point_id=0, scanID=0, data={}).dumps()
)
consumer.call_args.kwargs["cb"](msg_with_topic)
consumer.register.call_args.kwargs["cb"](msg_with_topic)
# Verify that the slot is called once for each topic
assert slot1.call_count == len(topics)
# Verify that a single consumer is created for all topics
consumer.assert_called_once()
consumer.register.assert_called_once()
def test_disconnect_all_with_single_callback_for_multiple_topics(bec_dispatcher, consumer):
@@ -237,5 +236,5 @@ def test_disconnect_all_with_single_callback_for_multiple_topics(bec_dispatcher,
assert slot1.call_count == 0 # Slot has not been called
# Simulate messages and verify that the slot is not called
consumer.call_args.kwargs["cb"](msg)
consumer.register.call_args.kwargs["cb"](msg)
assert slot1.call_count == 0 # Slot has not been called

226
tests/test_bec_figure.py Normal file
View File

@@ -0,0 +1,226 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import os
import numpy as np
import pytest
from unittest.mock import MagicMock
from .client_mocks import mocked_client
from bec_widgets.widgets import BECFigure
@pytest.fixture
def bec_figure(qtbot, mocked_client):
widget = BECFigure(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def test_bec_figure_init(bec_figure):
assert bec_figure is not None
assert bec_figure.client is not None
assert isinstance(bec_figure, BECFigure)
assert bec_figure.config.widget_class == "BECFigure"
def test_bec_figure_init_with_config(mocked_client):
config = {
"widget_class": "BECFigure",
"gui_id": "test_gui_id",
"theme": "dark",
}
widget = BECFigure(client=mocked_client, config=config)
assert widget.config.gui_id == "test_gui_id"
assert widget.config.theme == "dark"
def test_bec_figure_add_remove_plot(bec_figure):
initial_count = len(bec_figure.widgets)
# Adding 3 widgets - 2 WaveformBase and 1 PlotBase
w0 = bec_figure.add_plot()
w1 = bec_figure.add_plot(widget_id="test_waveform")
w2 = bec_figure.add_widget(widget_id="test_plot", widget_type="PlotBase")
# Check if the widgets were added
assert len(bec_figure.widgets) == initial_count + 3
assert "widget_1" in bec_figure.widgets
assert "test_plot" in bec_figure.widgets
assert "test_waveform" in bec_figure.widgets
assert bec_figure.widgets["widget_1"].config.widget_class == "BECWaveform1D"
assert bec_figure.widgets["test_plot"].config.widget_class == "BECPlotBase"
assert bec_figure.widgets["test_waveform"].config.widget_class == "BECWaveform1D"
# Check accessing positions by the grid in figure
assert bec_figure[0, 0] == w0
assert bec_figure[1, 0] == w1
assert bec_figure[2, 0] == w2
# Removing 1 widget - PlotBase
bec_figure.remove(widget_id="test_plot")
assert len(bec_figure.widgets) == initial_count + 2
assert "test_plot" not in bec_figure.widgets
assert "test_waveform" in bec_figure.widgets
assert bec_figure.widgets["test_waveform"].config.widget_class == "BECWaveform1D"
def test_access_widgets_access_errors(bec_figure):
bec_figure.add_plot(widget_id="test_waveform_1", row=0, col=0)
# access widget by non-existent coordinates
with pytest.raises(ValueError) as excinfo:
bec_figure[0, 2]
assert "No widget at coordinates (0, 2)" in str(excinfo.value)
# access widget by non-existent widget_id
with pytest.raises(KeyError) as excinfo:
bec_figure["non_existent_widget"]
assert "Widget with id 'non_existent_widget' not found" in str(excinfo.value)
# access widget by wrong type
with pytest.raises(TypeError) as excinfo:
bec_figure[1.2]
assert (
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
in str(excinfo.value)
)
def test_add_plot_to_occupied_position(bec_figure):
bec_figure.add_plot(widget_id="test_waveform_1", row=0, col=0)
with pytest.raises(ValueError) as excinfo:
bec_figure.add_plot(widget_id="test_waveform_2", row=0, col=0)
assert "Position at row 0 and column 0 is already occupied." in str(excinfo.value)
def test_add_plot_to_occupied_id(bec_figure):
bec_figure.add_plot(widget_id="test_waveform", row=0, col=0)
with pytest.raises(ValueError) as excinfo:
bec_figure.add_plot(widget_id="test_waveform", row=0, col=1)
assert "Widget with ID 'test_waveform' already exists" in str(excinfo.value)
def test_remove_plots(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform_1", row=0, col=0)
w2 = bec_figure.add_plot(widget_id="test_waveform_2", row=0, col=1)
w3 = bec_figure.add_plot(widget_id="test_waveform_3", row=1, col=0)
w4 = bec_figure.add_plot(widget_id="test_waveform_4", row=1, col=1)
assert bec_figure[0, 0] == w1
assert bec_figure[0, 1] == w2
assert bec_figure[1, 0] == w3
assert bec_figure[1, 1] == w4
# remove by coordinates
bec_figure[0, 0].remove()
assert "test_waveform_1" not in bec_figure.widgets
# remove by widget_id
bec_figure.remove(widget_id="test_waveform_2")
assert "test_waveform_2" not in bec_figure.widgets
# remove by widget object
w3.remove()
assert "test_waveform_3" not in bec_figure.widgets
# check the remaining widget 4
assert bec_figure[0, 0] == w4
assert bec_figure["test_waveform_4"] == w4
assert "test_waveform_4" in bec_figure.widgets
assert len(bec_figure.widgets) == 1
def test_remove_plots_by_coordinates_ints(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform_1", row=0, col=0)
w2 = bec_figure.add_plot(widget_id="test_waveform_2", row=0, col=1)
bec_figure.remove(0, 0)
assert "test_waveform_1" not in bec_figure.widgets
assert "test_waveform_2" in bec_figure.widgets
assert bec_figure[0, 0] == w2
assert len(bec_figure.widgets) == 1
def test_remove_plots_by_coordinates_tuple(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform_1", row=0, col=0)
w2 = bec_figure.add_plot(widget_id="test_waveform_2", row=0, col=1)
bec_figure.remove(coordinates=(0, 0))
assert "test_waveform_1" not in bec_figure.widgets
assert "test_waveform_2" in bec_figure.widgets
assert bec_figure[0, 0] == w2
assert len(bec_figure.widgets) == 1
def test_remove_plot_by_id_error(bec_figure):
bec_figure.add_plot(widget_id="test_waveform_1", row=0, col=0)
with pytest.raises(ValueError) as excinfo:
bec_figure.remove(widget_id="non_existent_widget")
assert "Widget with ID 'non_existent_widget' does not exist." in str(excinfo.value)
def test_remove_plot_by_coordinates_error(bec_figure):
bec_figure.add_plot(widget_id="test_waveform_1", row=0, col=0)
with pytest.raises(ValueError) as excinfo:
bec_figure.remove(0, 1)
assert "No widget at coordinates (0, 1)" in str(excinfo.value)
def test_remove_plot_by_providing_nothing(bec_figure):
bec_figure.add_plot(widget_id="test_waveform_1", row=0, col=0)
with pytest.raises(ValueError) as excinfo:
bec_figure.remove()
assert "Must provide either widget_id or coordinates for removal." in str(excinfo.value)
def test_change_theme(bec_figure):
bec_figure.change_theme("dark")
assert bec_figure.config.theme == "dark"
assert bec_figure.backgroundBrush().color().name() == "#000000"
bec_figure.change_theme("light")
assert bec_figure.config.theme == "light"
assert bec_figure.backgroundBrush().color().name() == "#ffffff"
bec_figure.change_theme("dark")
assert bec_figure.config.theme == "dark"
assert bec_figure.backgroundBrush().color().name() == "#000000"
def test_change_layout(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform_1", row=0, col=0)
w2 = bec_figure.add_plot(widget_id="test_waveform_2", row=0, col=1)
w3 = bec_figure.add_plot(widget_id="test_waveform_3", row=1, col=0)
w4 = bec_figure.add_plot(widget_id="test_waveform_4", row=1, col=1)
bec_figure.change_layout(max_columns=1)
assert np.shape(bec_figure.grid) == (4, 1)
assert bec_figure[0, 0] == w1
assert bec_figure[1, 0] == w2
assert bec_figure[2, 0] == w3
assert bec_figure[3, 0] == w4
bec_figure.change_layout(max_rows=1)
assert np.shape(bec_figure.grid) == (1, 4)
assert bec_figure[0, 0] == w1
assert bec_figure[0, 1] == w2
assert bec_figure[0, 2] == w3
assert bec_figure[0, 3] == w4
def test_clear_all(bec_figure):
bec_figure.add_plot(widget_id="test_waveform_1", row=0, col=0)
bec_figure.add_plot(widget_id="test_waveform_2", row=0, col=1)
bec_figure.add_plot(widget_id="test_waveform_3", row=1, col=0)
bec_figure.add_plot(widget_id="test_waveform_4", row=1, col=1)
bec_figure.clear_all()
assert len(bec_figure.widgets) == 0
assert np.shape(bec_figure.grid) == (0,)

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import os
import yaml
@@ -79,7 +80,7 @@ def mocked_client():
@pytest.fixture(scope="function")
def monitor(qtbot, mocked_client):
def monitor(bec_dispatcher, qtbot, mocked_client):
# client = MagicMock()
widget = BECMonitor(client=mocked_client)
qtbot.addWidget(widget)

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import os
from unittest.mock import MagicMock

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import QPointF

View File

@@ -1,4 +1,4 @@
# pylint: disable=no-name-in-module
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import os
import tempfile

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import json
from unittest.mock import MagicMock, patch
import numpy as np

649
tests/test_motor_control.py Normal file
View File

@@ -0,0 +1,649 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from unittest.mock import patch
from bec_lib.device import Positioner
import pytest
from unittest.mock import MagicMock
from bec_widgets.widgets import (
MotorControlSelection,
MotorControlAbsolute,
MotorControlRelative,
MotorThread,
MotorCoordinateTable,
)
from bec_widgets.examples import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
)
from bec_widgets.widgets.motor_control.motor_control import MotorActions
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"}],
},
},
],
}
#######################################################
# Client and devices fixture
#######################################################
class FakeDevice:
"""Fake minimal positioner class for testing."""
def __init__(self, name, enabled=True, limits=None, read_value=1.0):
super().__init__()
self.name = name
self.enabled = enabled
self.read_value = read_value
self.limits = limits or (-100, 100) # Default limits if not provided
def read(self):
"""Simulates reading the current position of the device."""
return {self.name: {"value": self.read_value}}
def move(self, value, relative=False):
"""Simulates moving the device to a new position."""
if relative:
self.read_value += value
else:
self.read_value = value
# Respect the limits
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
@property
def readback(self):
return MagicMock(get=MagicMock(return_value=self.read_value))
def describe(self):
"""Describes the device."""
return {self.name: {"source": self.name, "dtype": "number", "shape": []}}
@pytest.fixture
def mocked_client():
client = MagicMock()
# Setup the fake devices
motors = {
"samx": FakeDevice("samx", limits=[-10, 10], read_value=2.0),
"samy": FakeDevice("samy", limits=[-5, 5], read_value=3.0),
"aptrx": FakeDevice("aptrx", read_value=4.0),
"aptry": FakeDevice("aptry", read_value=5.0),
}
client.device_manager.devices = MagicMock()
client.device_manager.devices.__getitem__.side_effect = lambda x: motors.get(x, FakeDevice(x))
client.device_manager.devices.enabled_devices = list(motors.values())
# Mock the scans.mv method
def mock_mv(*args, relative=False):
# Extracting motor and value pairs
for i in range(0, len(args), 2):
motor = args[i]
value = args[i + 1]
motor.move(value, relative=relative)
return MagicMock(wait=MagicMock()) # Simulate wait method of the move status object
client.scans = MagicMock(mv=mock_mv)
# Ensure isinstance check for Positioner passes
original_isinstance = isinstance
def isinstance_mock(obj, class_info):
if class_info == Positioner:
return True
return original_isinstance(obj, class_info)
with patch("builtins.isinstance", new=isinstance_mock):
yield client
#######################################################
# Motor Thread
#######################################################
@pytest.fixture
def motor_thread(mocked_client):
"""Fixture for MotorThread with a mocked client."""
return MotorThread(client=mocked_client)
def test_motor_thread_initialization(mocked_client):
motor_thread = MotorThread(client=mocked_client)
assert motor_thread.client == mocked_client
assert isinstance(motor_thread.dev, MagicMock)
def test_get_all_motors_names(mocked_client):
motor_thread = MotorThread(client=mocked_client)
motor_names = motor_thread.get_all_motors_names()
expected_names = ["samx", "samy", "aptrx", "aptry"]
assert sorted(motor_names) == sorted(expected_names)
assert all(name in motor_names for name in expected_names)
assert len(motor_names) == len(expected_names) # Ensure only these motors are returned
def test_get_coordinates(mocked_client):
motor_thread = MotorThread(client=mocked_client)
motor_x, motor_y = "samx", "samy"
x, y = motor_thread.get_coordinates(motor_x, motor_y)
assert x == mocked_client.device_manager.devices[motor_x].readback.get()
assert y == mocked_client.device_manager.devices[motor_y].readback.get()
def test_move_motor_absolute_by_run(mocked_client):
motor_thread = MotorThread(client=mocked_client)
motor_thread.motor_x = "samx"
motor_thread.motor_y = "samy"
motor_thread.target_coordinates = (5.0, -3.0)
motor_thread.action = MotorActions.MOVE_ABSOLUTE
motor_thread.run()
assert mocked_client.device_manager.devices["samx"].read_value == 5.0
assert mocked_client.device_manager.devices["samy"].read_value == -3.0
def test_move_motor_relative_by_run(mocked_client):
motor_thread = MotorThread(client=mocked_client)
motor_thread.motor = "samx"
motor_thread.value = 2.0
motor_thread.action = MotorActions.MOVE_RELATIVE
motor_thread.run()
assert mocked_client.device_manager.devices["samx"].read_value == 4.0
def test_motor_thread_move_absolute(motor_thread):
motor_x = "samx"
motor_y = "samy"
target_x = 5.0
target_y = -3.0
motor_thread.move_absolute(motor_x, motor_y, (target_x, target_y))
motor_thread.wait()
assert motor_thread.dev[motor_x].read()["samx"]["value"] == target_x
assert motor_thread.dev[motor_y].read()["samy"]["value"] == target_y
def test_motor_thread_move_relative(motor_thread):
motor_name = "samx"
move_value = 2.0
initial_value = motor_thread.dev[motor_name].read()["samx"]["value"]
motor_thread.move_relative(motor_name, move_value)
motor_thread.wait()
expected_value = initial_value + move_value
assert motor_thread.dev[motor_name].read()["samx"]["value"] == expected_value
#######################################################
# Motor Control Widgets - MotorControlSelection
#######################################################
@pytest.fixture(scope="function")
def motor_selection_widget(qtbot, mocked_client, motor_thread):
"""Fixture for creating a MotorControlSelection widget with a mocked client."""
widget = MotorControlSelection(
client=mocked_client, config=CONFIG_DEFAULT, motor_thread=motor_thread
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def test_initialization_and_population(motor_selection_widget):
assert motor_selection_widget.comboBox_motor_x.count() == 4
assert motor_selection_widget.comboBox_motor_x.itemText(0) == "samx"
assert motor_selection_widget.comboBox_motor_y.itemText(1) == "samy"
assert motor_selection_widget.comboBox_motor_x.itemText(2) == "aptrx"
assert motor_selection_widget.comboBox_motor_y.itemText(3) == "aptry"
def test_selection_and_signal_emission(motor_selection_widget):
# Connect signal to a custom slot to capture the emitted values
emitted_values = []
def capture_emitted_values(motor_x, motor_y):
emitted_values.append((motor_x, motor_y))
motor_selection_widget.selected_motors_signal.connect(capture_emitted_values)
# Select motors
motor_selection_widget.comboBox_motor_x.setCurrentIndex(0) # Select 'samx'
motor_selection_widget.comboBox_motor_y.setCurrentIndex(1) # Select 'samy'
motor_selection_widget.pushButton_connecMotors.click() # Emit the signal
# Verify the emitted signal
assert emitted_values == [
("samx", "samy")
], "The emitted signal did not match the expected values"
def test_configuration_update(motor_selection_widget):
new_config = {"motor_control": {"motor_x": "samy", "motor_y": "samx"}}
motor_selection_widget.on_config_update(new_config)
assert motor_selection_widget.comboBox_motor_x.currentText() == "samy"
assert motor_selection_widget.comboBox_motor_y.currentText() == "samx"
def test_enable_motor_controls(motor_selection_widget):
motor_selection_widget.enable_motor_controls(False)
assert not motor_selection_widget.comboBox_motor_x.isEnabled()
assert not motor_selection_widget.comboBox_motor_y.isEnabled()
motor_selection_widget.enable_motor_controls(True)
assert motor_selection_widget.comboBox_motor_x.isEnabled()
assert motor_selection_widget.comboBox_motor_y.isEnabled()
#######################################################
# Motor Control Widgets - MotorControlAbsolute
#######################################################
@pytest.fixture(scope="function")
def motor_absolute_widget(qtbot, mocked_client, motor_thread):
widget = MotorControlAbsolute(
client=mocked_client, config=CONFIG_DEFAULT, motor_thread=motor_thread
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def test_absolute_initialization(motor_absolute_widget):
motor_absolute_widget.change_motors("samx", "samy")
motor_absolute_widget.on_config_update(CONFIG_DEFAULT)
assert motor_absolute_widget.motor_x == "samx", "Motor X not initialized correctly"
assert motor_absolute_widget.motor_y == "samy", "Motor Y not initialized correctly"
assert motor_absolute_widget.precision == CONFIG_DEFAULT["motor_control"]["precision"]
def test_absolute_save_current_coordinates(motor_absolute_widget):
motor_absolute_widget.client.device_manager["samx"].set_value(2.0)
motor_absolute_widget.client.device_manager["samy"].set_value(3.0)
motor_absolute_widget.change_motors("samx", "samy")
emitted_coordinates = []
def capture_emit(x_y):
emitted_coordinates.append(x_y)
motor_absolute_widget.coordinates_signal.connect(capture_emit)
# Trigger saving current coordinates
motor_absolute_widget.pushButton_save.click()
# Default position of samx and samy are 2.0 and 3.0 respectively
assert emitted_coordinates == [(2.0, 3.0)]
def test_absolute_set_absolute_coordinates(motor_absolute_widget):
motor_absolute_widget.spinBox_absolute_x.setValue(5)
motor_absolute_widget.spinBox_absolute_y.setValue(10)
# Connect to the coordinates_signal to capture emitted values
emitted_values = []
def capture_coordinates(x_y):
emitted_values.append(x_y)
motor_absolute_widget.coordinates_signal.connect(capture_coordinates)
# Simulate button click for absolute movement
motor_absolute_widget.pushButton_set.click()
assert emitted_values == [(5, 10)]
def test_absolute_go_absolute_coordinates(motor_absolute_widget):
motor_absolute_widget.change_motors("samx", "samy")
motor_absolute_widget.spinBox_absolute_x.setValue(5)
motor_absolute_widget.spinBox_absolute_y.setValue(10)
with patch(
"bec_widgets.widgets.motor_control.motor_control.MotorThread.move_absolute",
new_callable=MagicMock,
) as mock_move_absolute:
motor_absolute_widget.pushButton_go_absolute.click()
mock_move_absolute.assert_called_once_with("samx", "samy", (5, 10))
def test_change_motor_absolute(motor_absolute_widget):
motor_absolute_widget.change_motors("aptrx", "aptry")
assert motor_absolute_widget.motor_x == "aptrx"
assert motor_absolute_widget.motor_y == "aptry"
motor_absolute_widget.change_motors("samx", "samy")
assert motor_absolute_widget.motor_x == "samx"
assert motor_absolute_widget.motor_y == "samy"
def test_set_precision(motor_absolute_widget):
motor_absolute_widget.on_config_update(CONFIG_DEFAULT)
motor_absolute_widget.set_precision(2)
assert motor_absolute_widget.spinBox_absolute_x.decimals() == 2
assert motor_absolute_widget.spinBox_absolute_y.decimals() == 2
#######################################################
# Motor Control Widgets - MotorControlRelative
#######################################################
@pytest.fixture(scope="function")
def motor_relative_widget(qtbot, mocked_client, motor_thread):
widget = MotorControlRelative(
client=mocked_client, config=CONFIG_DEFAULT, motor_thread=motor_thread
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def test_initialization_and_config_update(motor_relative_widget):
motor_relative_widget.on_config_update(CONFIG_DEFAULT)
assert motor_relative_widget.motor_x == CONFIG_DEFAULT["motor_control"]["motor_x"]
assert motor_relative_widget.motor_y == CONFIG_DEFAULT["motor_control"]["motor_y"]
assert motor_relative_widget.precision == CONFIG_DEFAULT["motor_control"]["precision"]
# Simulate a configuration update
new_config = {
"motor_control": {
"motor_x": "new_motor_x",
"motor_y": "new_motor_y",
"precision": 2,
"step_size_x": 5,
"step_size_y": 5,
"step_x_y_same": True,
"move_with_arrows": True,
}
}
motor_relative_widget.on_config_update(new_config)
assert motor_relative_widget.motor_x == "new_motor_x"
assert motor_relative_widget.motor_y == "new_motor_y"
assert motor_relative_widget.precision == 2
def test_move_motor_relative(motor_relative_widget):
motor_relative_widget.on_config_update(CONFIG_DEFAULT)
# Set step sizes
motor_relative_widget.spinBox_step_x.setValue(1)
motor_relative_widget.spinBox_step_y.setValue(1)
# Mock the move_relative method
motor_relative_widget.motor_thread.move_relative = MagicMock()
# Simulate button clicks
motor_relative_widget.toolButton_right.click()
motor_relative_widget.motor_thread.move_relative.assert_called_with(
motor_relative_widget.motor_x, 1
)
motor_relative_widget.toolButton_left.click()
motor_relative_widget.motor_thread.move_relative.assert_called_with(
motor_relative_widget.motor_x, -1
)
motor_relative_widget.toolButton_up.click()
motor_relative_widget.motor_thread.move_relative.assert_called_with(
motor_relative_widget.motor_y, 1
)
motor_relative_widget.toolButton_down.click()
motor_relative_widget.motor_thread.move_relative.assert_called_with(
motor_relative_widget.motor_y, -1
)
def test_precision_update(motor_relative_widget):
# Capture emitted precision values
emitted_values = []
def capture_precision(precision):
emitted_values.append(precision)
motor_relative_widget.precision_signal.connect(capture_precision)
# Update precision
motor_relative_widget.spinBox_precision.setValue(1)
assert emitted_values == [1]
assert motor_relative_widget.spinBox_step_x.decimals() == 1
assert motor_relative_widget.spinBox_step_y.decimals() == 1
def test_sync_step_sizes(motor_relative_widget):
motor_relative_widget.on_config_update(CONFIG_DEFAULT)
motor_relative_widget.checkBox_same_xy.setChecked(True)
# Change step size for X
motor_relative_widget.spinBox_step_x.setValue(2)
assert motor_relative_widget.spinBox_step_y.value() == 2
def test_change_motor_relative(motor_relative_widget):
motor_relative_widget.on_config_update(CONFIG_DEFAULT)
motor_relative_widget.change_motors("aptrx", "aptry")
assert motor_relative_widget.motor_x == "aptrx"
assert motor_relative_widget.motor_y == "aptry"
#######################################################
# Motor Control Widgets - MotorCoordinateTable
#######################################################
@pytest.fixture(scope="function")
def motor_coordinate_table(qtbot, mocked_client, motor_thread):
widget = MotorCoordinateTable(
client=mocked_client, config=CONFIG_DEFAULT, motor_thread=motor_thread
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def test_delete_selected_row(motor_coordinate_table):
# Add a coordinate
motor_coordinate_table.add_coordinate((1.0, 2.0))
motor_coordinate_table.add_coordinate((3.0, 4.0))
# Select the row
motor_coordinate_table.table.selectRow(0)
# Delete the selected row
motor_coordinate_table.delete_selected_row()
assert motor_coordinate_table.table.rowCount() == 1
def test_add_coordinate_and_table_update(motor_coordinate_table):
# Disable Warning message popups for test
motor_coordinate_table.warning_message = False
# Add coordinate in Individual mode
motor_coordinate_table.add_coordinate((1.0, 2.0))
assert motor_coordinate_table.table.rowCount() == 1
# Check if the coordinates match
x_item_individual = motor_coordinate_table.table.cellWidget(0, 3) # Assuming X is in column 3
y_item_individual = motor_coordinate_table.table.cellWidget(0, 4) # Assuming Y is in column 4
assert float(x_item_individual.text()) == 1.0
assert float(y_item_individual.text()) == 2.0
# Switch to Start/Stop and add coordinates
motor_coordinate_table.comboBox_mode.setCurrentIndex(1) # Switch mode
motor_coordinate_table.add_coordinate((3.0, 4.0))
motor_coordinate_table.add_coordinate((5.0, 6.0))
assert motor_coordinate_table.table.rowCount() == 1
def test_plot_coordinates_signal(motor_coordinate_table):
# Connect to the signal
def signal_emitted(coordinates, reference_tag, color):
nonlocal received
received = True
assert len(coordinates) == 1 # Assuming one coordinate was added
assert reference_tag in ["Individual", "Start", "Stop"]
assert color in ["green", "blue", "red"]
received = False
motor_coordinate_table.plot_coordinates_signal.connect(signal_emitted)
# Add a coordinate and check signal
motor_coordinate_table.add_coordinate((1.0, 2.0))
assert received
def test_move_motor_action(motor_coordinate_table):
# Add a coordinate
motor_coordinate_table.add_coordinate((1.0, 2.0))
# Mock the motor thread move_absolute function
motor_coordinate_table.motor_thread.move_absolute = MagicMock()
# Trigger the move action
move_button = motor_coordinate_table.table.cellWidget(0, 1)
move_button.click()
motor_coordinate_table.motor_thread.move_absolute.assert_called_with(
motor_coordinate_table.motor_x, motor_coordinate_table.motor_y, (1.0, 2.0)
)
def test_plot_coordinates_signal_individual(motor_coordinate_table, qtbot):
motor_coordinate_table.warning_message = False
motor_coordinate_table.set_precision(3)
motor_coordinate_table.comboBox_mode.setCurrentIndex(0)
# This list will store the signals emitted during the test
emitted_signals = []
def signal_emitted(coordinates, reference_tag, color):
emitted_signals.append((coordinates, reference_tag, color))
motor_coordinate_table.plot_coordinates_signal.connect(signal_emitted)
# Add new coordinates
motor_coordinate_table.add_coordinate((1.0, 2.0))
qtbot.wait(100)
# Verify the signals
assert len(emitted_signals) > 0, "No signals were emitted."
for coordinates, reference_tag, color in emitted_signals:
assert len(coordinates) > 0, "Coordinates list is empty."
assert reference_tag == "Individual"
assert color == "green"
assert motor_coordinate_table.table.cellWidget(0, 3).text() == "1.000"
assert motor_coordinate_table.table.cellWidget(0, 4).text() == "2.000"
#######################################################
# MotorControl examples compilations
#######################################################
@pytest.fixture(scope="function")
def motor_app(qtbot, mocked_client):
widget = MotorControlApp(config=CONFIG_DEFAULT, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_motor_app_initialization(motor_app):
assert isinstance(motor_app, MotorControlApp)
assert motor_app.client is not None
assert motor_app.config == CONFIG_DEFAULT
@pytest.fixture(scope="function")
def motor_control_map(qtbot, mocked_client):
widget = MotorControlMap(config=CONFIG_DEFAULT, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_motor_control_map_initialization(motor_control_map):
assert isinstance(motor_control_map, MotorControlMap)
assert motor_control_map.client is not None
assert motor_control_map.config == CONFIG_DEFAULT
@pytest.fixture(scope="function")
def motor_control_panel(qtbot, mocked_client):
widget = MotorControlPanel(config=CONFIG_DEFAULT, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_motor_control_panel_initialization(motor_control_panel):
assert isinstance(motor_control_panel, MotorControlPanel)
assert motor_control_panel.client is not None
assert motor_control_panel.config == CONFIG_DEFAULT
@pytest.fixture(scope="function")
def motor_control_panel_absolute(qtbot, mocked_client):
widget = MotorControlPanelAbsolute(config=CONFIG_DEFAULT, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_motor_control_panel_absolute_initialization(motor_control_panel_absolute):
assert isinstance(motor_control_panel_absolute, MotorControlPanelAbsolute)
assert motor_control_panel_absolute.client is not None
assert motor_control_panel_absolute.config == CONFIG_DEFAULT
@pytest.fixture(scope="function")
def motor_control_panel_relative(qtbot, mocked_client):
widget = MotorControlPanelRelative(config=CONFIG_DEFAULT, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_motor_control_panel_relative_initialization(motor_control_panel_relative):
assert isinstance(motor_control_panel_relative, MotorControlPanelRelative)
assert motor_control_panel_relative.client is not None
assert motor_control_panel_relative.config == CONFIG_DEFAULT

View File

View File

@@ -0,0 +1,989 @@
from bec_lib.messages import AvailableResourceMessage
available_scans_message = AvailableResourceMessage(
resource={
"acquire": {
"class": "Acquire",
"base_class": "ScanBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "table",
"doc": "\n A simple acquisition at the current position.\n\n Args:\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.acquire(exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"interactive_scan_trigger": {
"class": "AddInteractiveScanPoint",
"base_class": "ScanComponent",
"arg_input": {"device": "device"},
"required_kwargs": ["required"],
"arg_bundle_size": {"bundle": 1, "min": 1, "max": None},
"scan_report_hint": "",
"doc": "\n An interactive scan for one or more motors.\n\n Args:\n *args: devices\n exp_time: exposure time in s\n steps: number of steps (please note: 5 steps == 6 positions)\n relative: Start from an absolute or relative position\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.interactive_scan_trigger()\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"close_interactive_scan": {
"class": "CloseInteractiveScan",
"base_class": "ScanComponent",
"arg_input": {},
"required_kwargs": ["required"],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "",
"doc": "\n An interactive scan for one or more motors.\n\n Args:\n *args: devices\n exp_time: exposure time in s\n steps: number of steps (please note: 5 steps == 6 positions)\n relative: Start from an absolute or relative position\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.close_interactive_scan(dev.motor1, dev.motor2, exp_time=0.1)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"close_scan_def": {
"class": "CloseScanDef",
"base_class": "RequestBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": 0, "max": 0},
"scan_report_hint": "table",
"doc": None,
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "device_manager",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceManagerBase",
},
{
"name": "monitored",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "list",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{"name": "metadata", "kind": "KEYWORD_ONLY", "default": None, "annotation": "dict"},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"close_scan_group": {
"class": "CloseScanGroup",
"base_class": "RequestBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": 0, "max": 0},
"scan_report_hint": None,
"doc": None,
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "device_manager",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceManagerBase",
},
{
"name": "monitored",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "list",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{"name": "metadata", "kind": "KEYWORD_ONLY", "default": None, "annotation": "dict"},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"cont_line_scan": {
"class": "ContLineScan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float"},
"required_kwargs": ["steps", "relative"],
"arg_bundle_size": {"bundle": 3, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n A line scan for one or more motors.\n\n Args:\n *args (Device, float, float): pairs of device / start position / end position\n exp_time (float): exposure time in seconds. Default is 0.\n steps (int): number of steps. Default is 10.\n relative (bool): if True, the motors will be moved relative to their current position. Default is False.\n burst_at_each_point (int): number of exposures at each point. Default is 1.\n offset (float): offset in motor units. Default is 100.\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.cont_line_scan(dev.motor1, -5, 5, steps=10, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{"name": "steps", "kind": "KEYWORD_ONLY", "default": 10, "annotation": "int"},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{"name": "offset", "kind": "KEYWORD_ONLY", "default": 100, "annotation": "float"},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"device_rpc": {
"class": "DeviceRPC",
"base_class": "RequestBase",
"arg_input": ["device", "str", "list", "dict"],
"required_kwargs": [],
"arg_bundle_size": {"bundle": 4, "min": 1, "max": 1},
"scan_report_hint": None,
"doc": None,
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "device_manager",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceManagerBase",
},
{
"name": "monitored",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "list",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{"name": "metadata", "kind": "KEYWORD_ONLY", "default": None, "annotation": "dict"},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"fermat_scan": {
"class": "FermatSpiralScan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float"},
"required_kwargs": ["step", "relative"],
"arg_bundle_size": {"bundle": 3, "min": 2, "max": 2},
"scan_report_hint": "table",
"doc": '\n A scan following Fermat\'s spiral.\n\n Args:\n *args: pairs of device / start position / end position arguments\n step (float): step size in motor units. Default is 0.1.\n exp_time (float): exposure time in seconds. Default is 0.\n settling_time (float): settling time in seconds. Default is 0.\n relative (bool): if True, the motors will be moved relative to their current position. Default is False.\n burst_at_each_point (int): number of exposures at each point. Default is 1.\n spiral_type (float): type of spiral to use. Default is 0.\n optim_trajectory (str): trajectory optimization method. Default is None. Options are "corridor" and "none".\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.fermat_scan(dev.motor1, -5, 5, dev.motor2, -5, 5, step=0.5, exp_time=0.1, relative=True, optim_trajectory="corridor")\n\n ',
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "step", "kind": "KEYWORD_ONLY", "default": 0.1, "annotation": "float"},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{
"name": "settling_time",
"kind": "KEYWORD_ONLY",
"default": 0,
"annotation": "float",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "spiral_type",
"kind": "KEYWORD_ONLY",
"default": 0,
"annotation": "float",
},
{
"name": "optim_trajectory",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": {"Literal": ["corridor", None]},
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"line_scan": {
"class": "LineScan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float"},
"required_kwargs": ["steps", "relative"],
"arg_bundle_size": {"bundle": 3, "min": 1, "max": None},
"scan_report_hint": "table",
"doc": "\n A line scan for one or more motors.\n\n Args:\n *args (Device, float, float): pairs of device / start position / end position\n exp_time (float): exposure time in s. Default: 0\n steps (int): number of steps. Default: 10\n relative (bool): if True, the start and end positions are relative to the current position. Default: False\n burst_at_each_point (int): number of acquisition per point. Default: 1\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.line_scan(dev.motor1, -5, 5, dev.motor2, -5, 5, steps=10, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{"name": "steps", "kind": "KEYWORD_ONLY", "default": None, "annotation": "int"},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"list_scan": {
"class": "ListScan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "positions": "list"},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 2, "min": 1, "max": None},
"scan_report_hint": "table",
"doc": "\n A scan following the positions specified in a list.\n Please note that all lists must be of equal length.\n\n Args:\n *args: pairs of motors and position lists\n relative: Start from an absolute or relative position\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.list_scan(dev.motor1, [0,1,2,3,4], dev.motor2, [4,3,2,1,0], exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"monitor_scan": {
"class": "MonitorScan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float"},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 3, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n Readout all primary devices at each update of the monitored device.\n\n Args:\n device (Device): monitored device\n start (float): start position of the monitored device\n stop (float): stop position of the monitored device\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.monitor_scan(dev.motor1, -5, 5, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "device",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "start",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "stop",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"mv": {
"class": "Move",
"base_class": "RequestBase",
"arg_input": {"device": "device", "target": "float"},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 2, "min": 1, "max": None},
"scan_report_hint": None,
"doc": "\n Move device(s) to an absolute position\n Args:\n *args (Device, float): pairs of device / position arguments\n relative (bool): if True, move relative to current position\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.mv(dev.samx, 1, dev.samy,2)\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"open_interactive_scan": {
"class": "OpenInteractiveScan",
"base_class": "ScanComponent",
"arg_input": {"device": "device"},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 1, "min": 1, "max": None},
"scan_report_hint": "",
"doc": "\n An interactive scan for one or more motors.\n\n Args:\n *args: devices\n exp_time: exposure time in s\n steps: number of steps (please note: 5 steps == 6 positions)\n relative: Start from an absolute or relative position\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.open_interactive_scan(dev.motor1, dev.motor2, exp_time=0.1)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"open_scan_def": {
"class": "OpenScanDef",
"base_class": "RequestBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": 0, "max": 0},
"scan_report_hint": None,
"doc": None,
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "device_manager",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceManagerBase",
},
{
"name": "monitored",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "list",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{"name": "metadata", "kind": "KEYWORD_ONLY", "default": None, "annotation": "dict"},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"round_roi_scan": {
"class": "RoundROIScan",
"base_class": "ScanBase",
"arg_input": {
"motor_1": "device",
"motor_2": "device",
"width_1": "float",
"width_2": "float",
},
"required_kwargs": ["dr", "nth", "relative"],
"arg_bundle_size": {"bundle": 4, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n A scan following a round-roi-like pattern.\n\n Args:\n *args: motor1, width for motor1, motor2, width for motor2,\n dr (float): shell width. Default is 1.\n nth (int): number of points in the first shell. Default is 5.\n exp_time (float): exposure time in seconds. Default is 0.\n relative (bool): Start from an absolute or relative position. Default is False.\n burst_at_each_point (int): number of acquisition per point. Default is 1.\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.round_roi_scan(dev.motor1, 20, dev.motor2, 20, dr=2, nth=3, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "dr", "kind": "KEYWORD_ONLY", "default": 1, "annotation": "float"},
{"name": "nth", "kind": "KEYWORD_ONLY", "default": 5, "annotation": "int"},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"round_scan": {
"class": "RoundScan",
"base_class": "ScanBase",
"arg_input": {
"motor_1": "device",
"motor_2": "device",
"inner_ring": "float",
"outer_ring": "float",
"number_of_rings": "int",
"number_of_positions_in_first_ring": "int",
},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 6, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n A scan following a round shell-like pattern.\n\n Args:\n *args: motor1, motor2, inner ring, outer ring, number of rings, number of positions in the first ring\n relative (bool): if True, the motors will be moved relative to their current position. Default is False.\n burst_at_each_point (int): number of exposures at each point. Default is 1.\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.round_scan(dev.motor1, dev.motor2, 0, 25, 5, 3, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"round_scan_fly": {
"class": "RoundScanFlySim",
"base_class": "ScanBase",
"arg_input": {
"flyer": "device",
"inner_ring": "float",
"outer_ring": "float",
"number_of_rings": "int",
"number_of_positions_in_first_ring": "int",
},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 5, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n A fly scan following a round shell-like pattern.\n\n Args:\n *args: motor1, motor2, inner ring, outer ring, number of rings, number of positions in the first ring\n relative: Start from an absolute or relative position\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.round_scan_fly(dev.flyer_sim, 0, 50, 5, 3, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"grid_scan": {
"class": "Scan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float", "steps": "int"},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 4, "min": 2, "max": None},
"scan_report_hint": "table",
"doc": "\n Scan two motors in a grid.\n\n Args:\n *args (Device, float, float, int): pairs of device / start / stop / steps arguments\n exp_time (float): exposure time in seconds. Default is 0.\n settling_time (float): settling time in seconds. Default is 0.\n relative (bool): if True, the motors will be moved relative to their current position. Default is False.\n burst_at_each_point (int): number of exposures at each point. Default is 1.\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.grid_scan(dev.motor1, -5, 5, 10, dev.motor2, -5, 5, 10, exp_time=0.1, relative=True)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0, "annotation": "float"},
{
"name": "settling_time",
"kind": "KEYWORD_ONLY",
"default": 0,
"annotation": "float",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "bool",
},
{
"name": "burst_at_each_point",
"kind": "KEYWORD_ONLY",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"time_scan": {
"class": "TimeScan",
"base_class": "ScanBase",
"arg_input": {},
"required_kwargs": ["points", "interval"],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "table",
"doc": '\n Trigger and readout devices at a fixed interval.\n Note that the interval time cannot be less than the exposure time.\n The effective "sleep" time between points is\n sleep_time = interval - exp_time\n\n Args:\n points: number of points\n interval: time interval between points\n exp_time: exposure time in s\n burst: number of acquisition per point\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.time_scan(points=10, interval=1.5, exp_time=0.1, relative=True)\n\n ',
"signature": [
{
"name": "points",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "int",
},
{
"name": "interval",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "exp_time",
"kind": "POSITIONAL_OR_KEYWORD",
"default": 0,
"annotation": "float",
},
{
"name": "burst_at_each_point",
"kind": "POSITIONAL_OR_KEYWORD",
"default": 1,
"annotation": "int",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"umv": {
"class": "UpdatedMove",
"base_class": "RequestBase",
"arg_input": {"device": "device", "target": "float"},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 2, "min": 1, "max": None},
"scan_report_hint": "readback",
"doc": "\n Move device(s) to an absolute position and show live updates. This is a blocking call. For non-blocking use Move.\n Args:\n *args (Device, float): pairs of device / position arguments\n relative (bool): if True, move relative to current position\n\n Returns:\n ScanReport\n\n Examples:\n >>> scans.umv(dev.samx, 1, dev.samy,2)\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "relative",
"kind": "KEYWORD_ONLY",
"default": False,
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"lamni_fermat_scan": {
"class": "LamNIFermatScan",
"base_class": "ScanBase",
"arg_input": {},
"required_kwargs": ["fov_size", "exp_time", "step", "angle"],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "table",
"doc": "\n A LamNI scan following Fermat's spiral.\n\n Kwargs:\n fov_size [um]: Fov in the piezo plane (i.e. piezo range). Max 80 um\n step [um]: stepsize\n shift_x/y [mm]: extra shift in x/y. The shift is directly applied to the scan. It will not be auto rotated. (default 0).\n center_x/center_y [mm]: center position in x/y at 0 deg. This shift is rotated\n using the geometry of LamNI\n It is determined by the first 'click' in the x-ray eye alignemnt procedure\n angle [deg]: rotation angle (will rotate first)\n scan_type: fly (i.e. HW triggered step in case of LamNI) or step\n stitch_x/y: shift scan to adjacent stitch region\n fov_circular [um]: generate a circular field of view in the sample plane. This is an additional cropping to fov_size.\n stitch_overlap [um]: overlap of the stitched regions\n Returns:\n\n Examples:\n >>> scans.lamni_fermat_scan(fov_size=[20], step=0.5, exp_time=0.1)\n >>> scans.lamni_fermat_scan(fov_size=[20, 25], center_x=0.02, center_y=0, shift_x=0, shift_y=0, angle=0, step=0.5, fov_circular=0, exp_time=0.1)\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"lamni_move_to_scan_center": {
"class": "LamNIMoveToScanCenter",
"base_class": "RequestBase",
"arg_input": {"shift_x": "float", "shift_y": "float", "angle": "float"},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 3, "min": 1, "max": 1},
"scan_report_hint": None,
"doc": "\n Move LamNI to a new scan center.\n\n Args:\n *args: shift x, shift y, tomo angle in deg\n\n Examples:\n >>> scans.lamni_move_to_scan_center(1.2, 2.8, 12.5)\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "_empty",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"owis_grid": {
"class": "OwisGrid",
"base_class": "FlyScanBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "scan_progress",
"doc": "Owis-based grid scan.",
"signature": [
{
"name": "start_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "end_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "interval_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "int",
},
{
"name": "start_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "end_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "interval_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "int",
},
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0.1, "annotation": "float"},
{
"name": "readout_time",
"kind": "KEYWORD_ONLY",
"default": 0.003,
"annotation": "float",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"sgalil_grid": {
"class": "SgalilGrid",
"base_class": "FlyScanBase",
"arg_input": {},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "scan_progress",
"doc": "\n SGalil-based grid scan.\n\n Args:\n start_y (float): start position of y axis (fast axis)\n end_y (float): end position of y axis (fast axis)\n interval_y (int): number of points in y axis\n start_x (float): start position of x axis (slow axis)\n end_x (float): end position of x axis (slow axis)\n interval_x (int): number of points in x axis\n exp_time (float): exposure time in seconds. Default is 0.1s\n readout_time (float): readout time in seconds, minimum of 3e-3s (3ms)\n\n Exp:\n scans.sgalil_grid(start_y = val1, end_y= val1, interval_y = val1, start_x = val1, end_x = val1, interval_x = val1, exp_time = 0.02, readout_time = 3e-3)\n\n\n ",
"signature": [
{
"name": "start_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "end_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "interval_y",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "int",
},
{
"name": "start_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "end_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{
"name": "interval_x",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "int",
},
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{"name": "exp_time", "kind": "KEYWORD_ONLY", "default": 0.1, "annotation": "float"},
{
"name": "readout_time",
"kind": "KEYWORD_ONLY",
"default": 0.1,
"annotation": "float",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"hyst_scan": {
"class": "HystScan",
"base_class": "ScanBase",
"arg_input": {
"field_motor": "device",
"start_field": "float",
"end_field": "float",
"mono": "device",
"energy1": "float",
"energy2": "float",
},
"required_kwargs": [],
"arg_bundle_size": {"bundle": 3, "min": 1, "max": 1},
"scan_report_hint": "table",
"doc": "\n A hysteresis scan.\n\n scans.hyst_scan(field_motor, start_field, end_field, mono, energy1, energy2)\n\n Examples:\n >>> scans.hyst_scan(dev.field_x, 0, 0.5, dev.mono, 600, 640, ramp_rate=2)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
"otf_scan": {
"class": "OTFScan",
"base_class": "FlyScanBase",
"arg_input": {},
"required_kwargs": ["e1", "e2", "time"],
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"scan_report_hint": "table",
"doc": "Scans the energy from e1 to e2 in <time> minutes.\n\n Examples:\n >>> scans.otf_scan(e1=700, e2=740, time=4)\n\n ",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "parameter",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
},
}
)

Binary file not shown.

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import unittest
from unittest.mock import MagicMock, patch

62
tests/test_plot_base.py Normal file
View File

@@ -0,0 +1,62 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import pytest
from .client_mocks import mocked_client
from .test_bec_figure import bec_figure
def test_init_plot_base(bec_figure):
plot_base = bec_figure.add_widget(widget_type="PlotBase", widget_id="test_plot")
assert plot_base is not None
assert plot_base.config.widget_class == "BECPlotBase"
assert plot_base.config.gui_id == "test_plot"
def test_plot_base_axes_by_separate_methods(bec_figure):
plot_base = bec_figure.add_widget(widget_type="PlotBase", widget_id="test_plot")
plot_base.set_title("Test Title")
plot_base.set_x_label("Test x Label")
plot_base.set_y_label("Test y Label")
plot_base.set_x_lim(1, 100)
plot_base.set_y_lim(5, 500)
plot_base.set_grid(True, True)
plot_base.set_x_scale("log")
plot_base.set_y_scale("log")
assert plot_base.titleLabel.text == "Test Title"
assert plot_base.config.axis.title == "Test Title"
assert plot_base.getAxis("bottom").labelText == "Test x Label"
assert plot_base.config.axis.x_label == "Test x Label"
assert plot_base.getAxis("left").labelText == "Test y Label"
assert plot_base.config.axis.y_label == "Test y Label"
assert plot_base.config.axis.x_lim == (1, 100)
assert plot_base.config.axis.y_lim == (5, 500)
assert plot_base.ctrl.xGridCheck.isChecked() == True
assert plot_base.ctrl.yGridCheck.isChecked() == True
assert plot_base.ctrl.logXCheck.isChecked() == True
assert plot_base.ctrl.logYCheck.isChecked() == True
def test_plot_base_axes_added_by_kwargs(bec_figure):
plot_base = bec_figure.add_widget(widget_type="PlotBase", widget_id="test_plot")
plot_base.set(
title="Test Title",
x_label="Test x Label",
y_label="Test y Label",
x_lim=(1, 100),
y_lim=(5, 500),
x_scale="log",
y_scale="log",
)
assert plot_base.titleLabel.text == "Test Title"
assert plot_base.config.axis.title == "Test Title"
assert plot_base.getAxis("bottom").labelText == "Test x Label"
assert plot_base.config.axis.x_label == "Test x Label"
assert plot_base.getAxis("left").labelText == "Test y Label"
assert plot_base.config.axis.y_label == "Test y Label"
assert plot_base.config.axis.x_lim == (1, 100)
assert plot_base.config.axis.y_lim == (5, 500)
assert plot_base.ctrl.logXCheck.isChecked() == True
assert plot_base.ctrl.logYCheck.isChecked() == True

26
tests/test_problem.py Normal file
View File

@@ -0,0 +1,26 @@
import pytest
from bec_widgets.cli.client import BECFigure
from bec_widgets.cli.server import BECWidgetsCLIServer
from bec_widgets.utils.bec_dispatcher import _BECDispatcher
@pytest.fixture
def rpc_server(qtbot):
# make a new dispatcher (not using the singleton), since the server is supposed to run in another process
dispatcher = _BECDispatcher()
server = BECWidgetsCLIServer(gui_id="id_test", dispatcher=dispatcher)
qtbot.addWidget(server.fig)
qtbot.waitExposed(server.fig)
qtbot.wait(200)
yield server
server.client.shutdown()
def test_rpc_waveform1d(rpc_server, qtbot):
fig = BECFigure(rpc_server.gui_id)
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")

View File

@@ -1,25 +1,15 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import os
import pickle
from unittest.mock import MagicMock
import msgpack
import pytest
from qtpy.QtWidgets import QLineEdit
from bec_widgets.widgets import ScanControl
from bec_widgets.utils.widget_io import WidgetIO
# TODO there has to be a better way to mock messages than this, in this case I just took the msg from bec
def load_test_msg(msg_name):
"""Helper function to load msg from pickle file."""
msg_path = os.path.join(os.path.dirname(__file__), "test_msgs", f"{msg_name}.pkl")
with open(msg_path, "rb") as f:
msg = pickle.load(f)
return msg
packed_message = load_test_msg("msg_dict")["available_scans"]
from .test_msgs.available_scans_message import available_scans_message
class FakePositioner:
@@ -45,7 +35,7 @@ def mocked_client():
client = MagicMock()
# Mock the producer.get method to return the packed message
client.producer.get.return_value = packed_message
client.producer.get.return_value = available_scans_message
# # Mock the device_manager.devices attribute to return a mock object for samx
client.device_manager.devices = MagicMock()
@@ -66,7 +56,7 @@ def scan_control(qtbot, mocked_client): # , mock_dev):
def test_populate_scans(scan_control, mocked_client):
# The comboBox should be populated with all scan from the message right after initialization
expected_scans = msgpack.loads(packed_message).keys()
expected_scans = available_scans_message.resource.keys()
assert scan_control.comboBox_scan_selection.count() == len(expected_scans)
for scan in expected_scans: # Each scan should be in the comboBox
assert scan_control.comboBox_scan_selection.findText(scan) != -1
@@ -77,7 +67,7 @@ def test_populate_scans(scan_control, mocked_client):
) # TODO now only for line_scan and grid_scan, later for all loaded scans
def test_on_scan_selected(scan_control, scan_name):
# Expected scan info from the message signature
expected_scan_info = msgpack.loads(packed_message)[scan_name]
expected_scan_info = available_scans_message.resource[scan_name]
# Select a scan from the comboBox
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
@@ -110,7 +100,7 @@ def test_on_scan_selected(scan_control, scan_name):
@pytest.mark.parametrize("scan_name", ["line_scan", "grid_scan"])
def test_add_remove_bundle(scan_control, scan_name):
# Expected scan info from the message signature
expected_scan_info = msgpack.loads(packed_message)[scan_name]
expected_scan_info = available_scans_message.resource[scan_name]
# Select a scan from the comboBox
scan_control.comboBox_scan_selection.setCurrentText(scan_name)

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from bec_widgets.widgets.scan_plot import scan_plot

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from unittest import mock
import numpy as np

View File

@@ -1,4 +1,4 @@
# pylint: disable=missing-function-docstring
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import pytest
from pydantic import ValidationError
from bec_widgets.validation.monitor_config_validator import (
@@ -8,7 +8,7 @@ from bec_widgets.validation.monitor_config_validator import (
PlotConfig,
)
from test_bec_monitor import mocked_client
from .test_bec_monitor import mocked_client
@pytest.fixture(scope="function")

411
tests/test_waveform1d.py Normal file
View File

@@ -0,0 +1,411 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest.mock import MagicMock
import numpy as np
import pytest
from bec_widgets.widgets.plots.waveform1d import SignalData, Signal, CurveConfig
from .client_mocks import mocked_client
from .test_bec_figure import bec_figure
def test_adding_curve_to_waveform(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform")
# adding curve which is in bec - only names
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
assert c1.config.label == "bpm4i-bpm4i"
# adding curve which is in bec - names and entry
c2 = w1.add_curve_scan(x_name="samx", x_entry="samx", y_name="bpm3a", y_entry="bpm3a")
assert c2.config.label == "bpm3a-bpm3a"
# adding curve which is not in bec
with pytest.raises(ValueError) as excinfo:
w1.add_curve_scan(x_name="non_existent_device", y_name="non_existent_device")
assert "Device 'non_existent_device' not found in current BEC session" in str(excinfo.value)
# adding wrong entry for samx
with pytest.raises(ValueError) as excinfo:
w1.add_curve_scan(
x_name="samx", x_entry="non_existent_entry", y_name="bpm3a", y_entry="bpm3a"
)
assert "Entry 'non_existent_entry' not found in device 'samx' signals" in str(excinfo.value)
# adding wrong device with validation switched off
c3 = w1.add_curve_scan(x_name="samx", y_name="non_existent_device", validate_bec=False)
assert c3.config.label == "non_existent_device-non_existent_device"
def test_adding_curve_with_same_id(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform")
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i", gui_id="test_curve")
with pytest.raises(ValueError) as excinfo:
w1.add_curve_scan(x_name="samx", y_name="bpm4i", gui_id="test_curve")
assert "Curve with ID 'test_curve' already exists." in str(excinfo.value)
def test_create_waveform1D_by_config(bec_figure):
w1_config_input = {
"widget_class": "BECWaveform1D",
"gui_id": "widget_1",
"parent_id": "BECFigure_1708689320.788527",
"row": 0,
"col": 0,
"axis": {
"title": "Widget 1",
"x_label": None,
"y_label": None,
"x_scale": "linear",
"y_scale": "linear",
"x_lim": (1, 10),
"y_lim": None,
"x_grid": False,
"y_grid": False,
},
"color_palette": "plasma",
"curves": {
"bpm4i-bpm4i": {
"widget_class": "BECCurve",
"gui_id": "BECCurve_1708689321.226847",
"parent_id": "widget_1",
"label": "bpm4i-bpm4i",
"color": "#cc4778",
"symbol": "o",
"symbol_color": None,
"symbol_size": 5,
"pen_width": 2,
"pen_style": "dash",
"source": "scan_segment",
"signals": {
"source": "scan_segment",
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None},
"y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None},
},
},
"curve-custom": {
"widget_class": "BECCurve",
"gui_id": "BECCurve_1708689321.22867",
"parent_id": "widget_1",
"label": "curve-custom",
"color": "blue",
"symbol": "o",
"symbol_color": None,
"symbol_size": 5,
"pen_width": 2,
"pen_style": "dashdot",
"source": "custom",
"signals": None,
},
},
}
w1 = bec_figure.add_plot(widget_id="test_waveform", config=w1_config_input)
w1_config_output = w1.get_config()
assert w1_config_input == w1_config_output
assert w1.titleLabel.text == "Widget 1"
assert w1.config.axis.title == "Widget 1"
def test_change_gui_id(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform")
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
w1.change_gui_id("new_id")
assert w1.config.gui_id == "new_id"
assert c1.config.parent_id == "new_id"
def test_getting_curve(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform")
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i", gui_id="test_curve")
c1_expected_config = CurveConfig(
widget_class="BECCurve",
gui_id="test_curve",
parent_id="test_waveform",
label="bpm4i-bpm4i",
color="#cc4778",
symbol="o",
symbol_color=None,
symbol_size=5,
pen_width=2,
pen_style="solid",
source="scan_segment",
signals=Signal(
source="scan_segment",
x=SignalData(name="samx", entry="samx", unit=None, modifier=None),
y=SignalData(name="bpm4i", entry="bpm4i", unit=None, modifier=None),
),
)
assert w1.curves[0].config == c1_expected_config
assert w1.curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config
assert w1.get_curve(0).config == c1_expected_config
assert w1.get_curve("bpm4i-bpm4i").config == c1_expected_config
assert c1.get_config(False) == c1_expected_config
assert c1.get_config() == c1_expected_config.model_dump()
def test_getting_curve_errors(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform")
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i", gui_id="test_curve")
with pytest.raises(ValueError) as excinfo:
w1.get_curve("non_existent_curve")
assert "Curve with ID 'non_existent_curve' not found." in str(excinfo.value)
with pytest.raises(IndexError) as excinfo:
w1.get_curve(1)
assert "list index out of range" in str(excinfo.value)
with pytest.raises(ValueError) as excinfo:
w1.get_curve(1.2)
assert "Identifier must be either an integer (index) or a string (curve_id)." in str(
excinfo.value
)
def test_add_curve(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform")
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
assert len(w1.curves) == 1
assert w1.curves_data["scan_segment"] == {"bpm4i-bpm4i": c1}
assert c1.config.label == "bpm4i-bpm4i"
assert c1.config.source == "scan_segment"
def test_remove_curve(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform")
w1.add_curve_scan(x_name="samx", y_name="bpm4i")
w1.add_curve_scan(x_name="samx", y_name="bpm3a")
w1.remove_curve(0)
w1.remove_curve("bpm3a-bpm3a")
assert len(w1.curves) == 0
assert w1.curves_data["scan_segment"] == {}
with pytest.raises(ValueError) as excinfo:
w1.remove_curve(1.2)
assert "Each identifier must be either an integer (index) or a string (curve_id)." in str(
excinfo.value
)
def test_change_curve_appearance_methods(bec_figure, qtbot):
w1 = bec_figure.add_plot(widget_id="test_waveform")
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
c1.set_color("#0000ff")
c1.set_symbol("x")
c1.set_symbol_color("#ff0000")
c1.set_symbol_size(10)
c1.set_pen_width(3)
c1.set_pen_style("dashdot")
qtbot.wait(500)
assert c1.config.color == "#0000ff"
assert c1.config.symbol == "x"
assert c1.config.symbol_color == "#ff0000"
assert c1.config.symbol_size == 10
assert c1.config.pen_width == 3
assert c1.config.pen_style == "dashdot"
assert c1.config.source == "scan_segment"
assert c1.config.signals.model_dump() == {
"source": "scan_segment",
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None},
"y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None},
}
def test_change_curve_appearance_args(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform")
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
c1.set(
color="#0000ff",
symbol="x",
symbol_color="#ff0000",
symbol_size=10,
pen_width=3,
pen_style="dashdot",
)
assert c1.config.color == "#0000ff"
assert c1.config.symbol == "x"
assert c1.config.symbol_color == "#ff0000"
assert c1.config.symbol_size == 10
assert c1.config.pen_width == 3
assert c1.config.pen_style == "dashdot"
assert c1.config.source == "scan_segment"
assert c1.config.signals.model_dump() == {
"source": "scan_segment",
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None},
"y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None},
}
def test_set_custom_curve_data(bec_figure, qtbot):
w1 = bec_figure.add_plot(widget_id="test_waveform")
c1 = w1.add_curve_custom(
x=[1, 2, 3],
y=[4, 5, 6],
label="custom_curve",
color="#0000ff",
symbol="x",
symbol_color="#ff0000",
symbol_size=10,
pen_width=3,
pen_style="dashdot",
)
x_init, y_init = c1.get_data()
assert np.array_equal(x_init, [1, 2, 3])
assert np.array_equal(y_init, [4, 5, 6])
assert c1.config.label == "custom_curve"
assert c1.config.color == "#0000ff"
assert c1.config.symbol == "x"
assert c1.config.symbol_color == "#ff0000"
assert c1.config.symbol_size == 10
assert c1.config.pen_width == 3
assert c1.config.pen_style == "dashdot"
assert c1.config.source == "custom"
assert c1.config.signals == None
c1.set_data(x=[4, 5, 6], y=[7, 8, 9])
x_new, y_new = c1.get_data()
assert np.array_equal(x_new, [4, 5, 6])
assert np.array_equal(y_new, [7, 8, 9])
def test_get_all_data(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform")
c1 = w1.add_curve_custom(
x=[1, 2, 3],
y=[4, 5, 6],
label="custom_curve-1",
color="#0000ff",
symbol="x",
symbol_color="#ff0000",
symbol_size=10,
pen_width=3,
pen_style="dashdot",
)
c2 = w1.add_curve_custom(
x=[4, 5, 6],
y=[7, 8, 9],
label="custom_curve-2",
color="#00ff00",
symbol="o",
symbol_color="#00ff00",
symbol_size=20,
pen_width=4,
pen_style="dash",
)
all_data = w1.get_all_data()
assert all_data == {
"custom_curve-1": {"x": [1, 2, 3], "y": [4, 5, 6]},
"custom_curve-2": {"x": [4, 5, 6], "y": [7, 8, 9]},
}
def test_curve_add_by_config(bec_figure):
w1 = bec_figure.add_plot(widget_id="test_waveform")
c1_config_input = {
"widget_class": "BECCurve",
"gui_id": "BECCurve_1708689321.226847",
"parent_id": "widget_1",
"label": "bpm4i-bpm4i",
"color": "#cc4778",
"symbol": "o",
"symbol_color": None,
"symbol_size": 5,
"pen_width": 2,
"pen_style": "dash",
"source": "scan_segment",
"signals": {
"source": "scan_segment",
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None},
"y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None},
},
}
c1 = w1.add_curve_by_config(c1_config_input)
c1_config_dict = c1.get_config()
assert c1_config_dict == c1_config_input
assert c1.config == CurveConfig(**c1_config_input)
assert c1.get_config(False) == CurveConfig(**c1_config_input)
def test_scan_update(bec_figure, qtbot):
w1 = bec_figure.add_plot(widget_id="test_waveform")
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
msg_waveform = {
"data": {
"samx": {"samx": {"value": 10}},
"bpm4i": {"bpm4i": {"value": 5}},
"gauss_bpm": {"gauss_bpm": {"value": 6}},
"gauss_adc1": {"gauss_adc1": {"value": 8}},
"gauss_adc2": {"gauss_adc2": {"value": 9}},
},
"scanID": 1,
}
# Mock scan_storage.find_scan_by_ID
mock_scan_data_waveform = MagicMock()
mock_scan_data_waveform.data = {
device_name: {
entry: MagicMock(val=[msg_waveform["data"][device_name][entry]["value"]])
for entry in msg_waveform["data"][device_name]
}
for device_name in msg_waveform["data"]
}
metadata_waveform = {"scan_name": "line_scan"}
w1.queue.scan_storage.find_scan_by_ID.return_value = mock_scan_data_waveform
w1.on_scan_segment(msg_waveform, metadata_waveform)
qtbot.wait(500)
assert c1.get_data() == ([10], [5])
def test_scan_history_with_val_access(bec_figure, qtbot):
w1 = bec_figure.add_plot(widget_id="test_waveform_history_val")
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
mock_scan_data = {
"samx": {"samx": MagicMock(val=np.array([1, 2, 3]))}, # Use MagicMock for .val
"bpm4i": {"bpm4i": MagicMock(val=np.array([4, 5, 6]))}, # Use MagicMock for .val
}
mock_scan_storage = MagicMock()
mock_scan_storage.find_scan_by_ID.return_value = MagicMock(data=mock_scan_data)
w1.queue.scan_storage = mock_scan_storage
fake_scanID = "fake_scanID"
w1.scan_history(scanID=fake_scanID)
qtbot.wait(500)
x_data, y_data = c1.get_data()
assert np.array_equal(x_data, [1, 2, 3])
assert np.array_equal(y_data, [4, 5, 6])

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import pytest
from qtpy.QtWidgets import (
QWidget,

View File

@@ -1,3 +1,4 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import os
import tempfile
from unittest.mock import patch