mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-13 19:21:50 +02:00
feat(waveform): new Waveform widget based on NextGen PlotBase
This commit is contained in:
@ -46,6 +46,7 @@ class Widgets(str, enum.Enum):
|
||||
StopButton = "StopButton"
|
||||
TextBox = "TextBox"
|
||||
VSCodeEditor = "VSCodeEditor"
|
||||
Waveform = "Waveform"
|
||||
WebsiteWidget = "WebsiteWidget"
|
||||
|
||||
|
||||
@ -2996,6 +2997,161 @@ class BECWaveformWidget(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class Curve(RPCBase):
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the curve from the plot.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def _rpc_id(self) -> "str":
|
||||
"""
|
||||
Get the RPC ID of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
|
||||
@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: "list | np.ndarray", y: "list | np.ndarray"):
|
||||
"""
|
||||
Set the data of the curve.
|
||||
|
||||
Args:
|
||||
x(list|np.ndarray): The x data.
|
||||
y(list|np.ndarray): The y data.
|
||||
|
||||
Raises:
|
||||
ValueError: If the source is not custom.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_color(self, color: "str", symbol_color: "str | None" = 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_color_map_z(self, colormap: "str"):
|
||||
"""
|
||||
Set the colormap for the scatter plot z gradient.
|
||||
|
||||
Args:
|
||||
colormap(str): Colormap for the scatter plot.
|
||||
"""
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def dap_params(self):
|
||||
"""
|
||||
Get the dap parameters.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def dap_summary(self):
|
||||
"""
|
||||
Get the dap summary.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def dap_oversample(self):
|
||||
"""
|
||||
Get the dap oversample.
|
||||
"""
|
||||
|
||||
@dap_oversample.setter
|
||||
@rpc_call
|
||||
def dap_oversample(self):
|
||||
"""
|
||||
Get the dap oversample.
|
||||
"""
|
||||
|
||||
|
||||
class DapComboBox(RPCBase):
|
||||
@rpc_call
|
||||
def select_y_axis(self, y_axis: str):
|
||||
@ -3775,6 +3931,417 @@ class TextBox(RPCBase):
|
||||
class VSCodeEditor(RPCBase): ...
|
||||
|
||||
|
||||
class Waveform(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@enable_toolbar.setter
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_side_panel(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@enable_side_panel.setter
|
||||
@rpc_call
|
||||
def enable_side_panel(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_fps_monitor(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@enable_fps_monitor.setter
|
||||
@rpc_call
|
||||
def enable_fps_monitor(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set(self, **kwargs):
|
||||
"""
|
||||
Set the properties of the plot widget.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
|
||||
Possible properties:
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def title(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@title.setter
|
||||
@rpc_call
|
||||
def title(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_label(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@x_label.setter
|
||||
@rpc_call
|
||||
def x_label(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_label(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@y_label.setter
|
||||
@rpc_call
|
||||
def y_label(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_limits(self) -> "QPointF":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@x_limits.setter
|
||||
@rpc_call
|
||||
def x_limits(self) -> "QPointF":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_limits(self) -> "QPointF":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@y_limits.setter
|
||||
@rpc_call
|
||||
def y_limits(self) -> "QPointF":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_grid(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@x_grid.setter
|
||||
@rpc_call
|
||||
def x_grid(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_grid(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@y_grid.setter
|
||||
@rpc_call
|
||||
def y_grid(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def inner_axes(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@inner_axes.setter
|
||||
@rpc_call
|
||||
def inner_axes(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def outer_axes(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@outer_axes.setter
|
||||
@rpc_call
|
||||
def outer_axes(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@auto_range_x.setter
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_y(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@auto_range_y.setter
|
||||
@rpc_call
|
||||
def auto_range_y(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@x_log.setter
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_log(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@y_log.setter
|
||||
@rpc_call
|
||||
def y_log(self) -> "bool":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def legend_label_size(self) -> "int":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@legend_label_size.setter
|
||||
@rpc_call
|
||||
def legend_label_size(self) -> "int":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def __getitem__(self, key: "int | str"):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def curves(self) -> "list[Curve]":
|
||||
"""
|
||||
Get the curves of the plot widget as a list.
|
||||
|
||||
Returns:
|
||||
list: List of curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_mode(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@x_mode.setter
|
||||
@rpc_call
|
||||
def x_mode(self) -> "str":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def color_palette(self) -> "str":
|
||||
"""
|
||||
The color palette of the figure widget.
|
||||
"""
|
||||
|
||||
@color_palette.setter
|
||||
@rpc_call
|
||||
def color_palette(self) -> "str":
|
||||
"""
|
||||
The color palette of the figure widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def plot(
|
||||
self,
|
||||
arg1: "list | np.ndarray | str | None" = None,
|
||||
y: "list | np.ndarray | None" = None,
|
||||
x: "list | np.ndarray | None" = None,
|
||||
x_name: "str | None" = None,
|
||||
y_name: "str | None" = None,
|
||||
x_entry: "str | None" = None,
|
||||
y_entry: "str | None" = None,
|
||||
color: "str | None" = None,
|
||||
label: "str | None" = None,
|
||||
dap: "str | None" = None,
|
||||
**kwargs,
|
||||
) -> "Curve":
|
||||
"""
|
||||
Plot a curve to the plot widget.
|
||||
|
||||
Args:
|
||||
arg1(list | np.ndarray | str | None): First argument, which can be x data, y data, or y_name.
|
||||
y(list | np.ndarray): Custom y data to plot.
|
||||
x(list | np.ndarray): Custom y data to plot.
|
||||
x_name(str): Name of the x signal.
|
||||
- "auto": Use the best effort signal.
|
||||
- "timestamp": Use the timestamp signal.
|
||||
- "index": Use the index signal.
|
||||
- Custom signal name of a device from BEC.
|
||||
y_name(str): The name of the device for the y-axis.
|
||||
x_entry(str): The name of the entry for the x-axis.
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
color(str): The color of the curve.
|
||||
label(str): The label of the curve.
|
||||
dap(str): The dap model to use for the curve, only available for sync devices.
|
||||
If not specified, none will be added.
|
||||
Use the same string as is the name of the LMFit model.
|
||||
|
||||
Returns:
|
||||
Curve: The curve object.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_dap_curve(
|
||||
self,
|
||||
device_label: "str",
|
||||
dap_name: "str",
|
||||
color: "str | None" = None,
|
||||
dap_oversample: "int" = 1,
|
||||
**kwargs,
|
||||
) -> "Curve":
|
||||
"""
|
||||
Create a new DAP curve referencing the existing device curve `device_label`,
|
||||
with the data processing model `dap_name`.
|
||||
|
||||
Args:
|
||||
device_label(str): The label of the device curve to add DAP to.
|
||||
dap_name(str): The name of the DAP model to use.
|
||||
color(str): The color of the curve.
|
||||
dap_oversample(int): The oversampling factor for the DAP curve.
|
||||
**kwargs
|
||||
|
||||
Returns:
|
||||
Curve: The new DAP curve.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove_curve(self, curve: "int | str"):
|
||||
"""
|
||||
Remove a curve from the plot widget.
|
||||
|
||||
Args:
|
||||
curve(int|str): The curve to remove. Can be the order of the curve or the name of the curve.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def update_with_scan_history(self, scan_index: "int" = None, scan_id: "str" = None):
|
||||
"""
|
||||
Update the scan curves with the data from the scan storage.
|
||||
Provide only one of scan_id or scan_index.
|
||||
|
||||
Args:
|
||||
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
|
||||
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_dap_params(self) -> "dict[str, dict]":
|
||||
"""
|
||||
Get the DAP parameters of all DAP curves.
|
||||
|
||||
Returns:
|
||||
dict[str, dict]: DAP parameters of all DAP curves.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_dap_summary(self) -> "dict[str, dict]":
|
||||
"""
|
||||
Get the DAP summary of all DAP curves.
|
||||
|
||||
Returns:
|
||||
dict[str, dict]: DAP summary of all DAP curves.
|
||||
"""
|
||||
|
||||
|
||||
class WebsiteWidget(RPCBase):
|
||||
@rpc_call
|
||||
def set_url(self, url: str) -> None:
|
||||
|
@ -21,6 +21,7 @@ from bec_widgets.widgets.containers.figure import BECFigure
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
@ -65,6 +66,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"btn6": self.btn6,
|
||||
"pb": self.pb,
|
||||
"pi": self.pi,
|
||||
"wfng": self.wfng,
|
||||
}
|
||||
)
|
||||
|
||||
@ -100,7 +102,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
self.pb = PlotBase()
|
||||
self.pi = self.pb.plot_item
|
||||
fourth_tab_layout.addWidget(self.pb)
|
||||
tab_widget.addTab(fourth_tab, "PltoBase")
|
||||
tab_widget.addTab(fourth_tab, "PlotBase")
|
||||
|
||||
tab_widget.setCurrentIndex(3)
|
||||
|
||||
@ -117,6 +119,15 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
self.btn5 = QPushButton("Button 5")
|
||||
self.btn6 = QPushButton("Button 6")
|
||||
|
||||
fifth_tab = QWidget()
|
||||
fifth_tab_layout = QVBoxLayout(fifth_tab)
|
||||
self.wfng = Waveform()
|
||||
fifth_tab_layout.addWidget(self.wfng)
|
||||
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
tab_widget.setCurrentIndex(4)
|
||||
# add stuff to the new Waveform widget
|
||||
self._init_waveform()
|
||||
|
||||
# add stuff to figure
|
||||
self._init_figure()
|
||||
|
||||
@ -125,6 +136,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
|
||||
self.setWindowTitle("Jupyter Console Window")
|
||||
|
||||
def _init_waveform(self):
|
||||
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve1")
|
||||
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve2")
|
||||
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve3")
|
||||
self.wfng.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
self.wfng.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
|
||||
|
||||
def _init_figure(self):
|
||||
self.w1 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0)
|
||||
self.w1.set(
|
||||
@ -191,9 +209,11 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
self.im.image("waveform", "1d")
|
||||
|
||||
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
|
||||
self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
|
||||
self.wf = self.d2.add_widget("BECWaveformWidget", row=0, col=0)
|
||||
self.wf.plot("bpm4i")
|
||||
self.wf.plot("bpm3a")
|
||||
|
||||
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
|
||||
self.mw = None # self.wf.multi_waveform(monitor="waveform") # , config=config)
|
||||
|
||||
self.dock.save_state()
|
||||
|
||||
@ -219,7 +239,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
apply_theme("dark")
|
||||
icon = material_icon("terminal", color="#434343", filled=True)
|
||||
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
|
@ -26,7 +26,7 @@ from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
@ -89,9 +89,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
label="Add Plot ",
|
||||
actions={
|
||||
"waveform": MaterialIconAction(
|
||||
icon_name=BECWaveformWidget.ICON_NAME,
|
||||
tooltip="Add Waveform",
|
||||
filled=True,
|
||||
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
icon_name=BECMultiWaveformWidget.ICON_NAME,
|
||||
@ -171,7 +169,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
def _hook_toolbar(self):
|
||||
# Menu Plot
|
||||
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform")
|
||||
lambda: self.add_dock(widget="Waveform", prefix="waveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
|
||||
lambda: self.add_dock(widget="BECMultiWaveformWidget", prefix="multi_waveform")
|
||||
@ -472,8 +470,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.deleteLater()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
|
0
bec_widgets/widgets/plots_next_gen/__init__.py
Normal file
0
bec_widgets/widgets/plots_next_gen/__init__.py
Normal file
@ -219,6 +219,10 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.axis_settings_dialog = None
|
||||
self.toolbar.widgets["axis"].action.setChecked(False)
|
||||
|
||||
def reset_legend(self):
|
||||
"""In the case that the legend is not visible, reset it to be visible to top left corner"""
|
||||
self.plot_item.legend.autoAnchor(50)
|
||||
|
||||
################################################################################
|
||||
# Toggle UI Elements
|
||||
################################################################################
|
||||
|
@ -18,9 +18,14 @@ class ROIBundle(ToolbarBundle):
|
||||
crosshair = MaterialIconAction(
|
||||
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
|
||||
)
|
||||
reset_legend = MaterialIconAction(
|
||||
icon_name="restart_alt", tooltip="Reset the position of legend.", checkable=False
|
||||
)
|
||||
|
||||
# Add them to the bundle
|
||||
self.add_action("crosshair", crosshair)
|
||||
self.add_action("reset_legend", reset_legend)
|
||||
|
||||
# Immediately connect signals
|
||||
crosshair.action.toggled.connect(self.target_widget.toggle_crosshair)
|
||||
reset_legend.action.triggered.connect(self.target_widget.reset_legend)
|
||||
|
328
bec_widgets/widgets/plots_next_gen/waveform/curve.py
Normal file
328
bec_widgets/widgets/plots_next_gen/waveform/curve.py
Normal file
@ -0,0 +1,328 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy import QtCore
|
||||
|
||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class DeviceSignal(BaseModel):
|
||||
"""The configuration of a signal in the 1D waveform widget."""
|
||||
|
||||
name: str
|
||||
entry: str
|
||||
dap: str | None = None
|
||||
dap_oversample: int = 1
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class CurveConfig(ConnectionConfig):
|
||||
parent_id: str | None = Field(None, description="The parent plot of the curve.")
|
||||
label: str | None = Field(None, description="The label of the curve.")
|
||||
color: str | tuple | None = Field(None, description="The color of the curve.")
|
||||
symbol: str | None = Field("o", description="The symbol of the curve.")
|
||||
symbol_color: str | tuple | None = Field(
|
||||
None, description="The color of the symbol of the curve."
|
||||
)
|
||||
symbol_size: int | None = Field(7, description="The size of the symbol of the curve.")
|
||||
pen_width: int | None = Field(4, description="The width of the pen of the curve.")
|
||||
pen_style: Literal["solid", "dash", "dot", "dashdot"] | None = Field(
|
||||
"solid", description="The style of the pen of the curve."
|
||||
)
|
||||
source: Literal["device", "dap", "custom"] = Field(
|
||||
"custom", description="The source of the curve."
|
||||
)
|
||||
signal: DeviceSignal | None = Field(None, description="The signal of the curve.")
|
||||
parent_label: str | None = Field(
|
||||
None, description="The label of the parent plot, only relevant for dap curves."
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
_validate_color = field_validator("color")(Colors.validate_color)
|
||||
_validate_symbol_color = field_validator("symbol_color")(Colors.validate_color)
|
||||
|
||||
|
||||
class Curve(BECConnector, pg.PlotDataItem):
|
||||
USER_ACCESS = [
|
||||
"remove",
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"set",
|
||||
"set_data",
|
||||
"set_color",
|
||||
"set_color_map_z",
|
||||
"set_symbol",
|
||||
"set_symbol_color",
|
||||
"set_symbol_size",
|
||||
"set_pen_width",
|
||||
"set_pen_style",
|
||||
"get_data",
|
||||
"dap_params",
|
||||
"dap_summary",
|
||||
"dap_oversample",
|
||||
"dap_oversample.setter",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None = None,
|
||||
config: CurveConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
parent_item: Waveform | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
config = CurveConfig(label=name, widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
else:
|
||||
self.config = config
|
||||
super().__init__(config=config, gui_id=gui_id)
|
||||
pg.PlotDataItem.__init__(self, name=name)
|
||||
|
||||
self.parent_item = parent_item
|
||||
self.apply_config()
|
||||
self.dap_params = None
|
||||
self.dap_summary = None
|
||||
if kwargs:
|
||||
self.set(**kwargs)
|
||||
|
||||
def apply_config(self, config: dict | CurveConfig | None = None, **kwargs) -> None:
|
||||
"""
|
||||
Apply the configuration to the curve.
|
||||
|
||||
Args:
|
||||
config(dict|CurveConfig, optional): The configuration to apply.
|
||||
"""
|
||||
|
||||
if config is not None:
|
||||
if isinstance(config, dict):
|
||||
config = CurveConfig(**config)
|
||||
self.config = config
|
||||
|
||||
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 = pg.mkBrush(color=symbol_color)
|
||||
|
||||
self.setSymbolBrush(brush)
|
||||
self.setSymbolSize(self.config.symbol_size)
|
||||
self.setSymbol(self.config.symbol)
|
||||
|
||||
@property
|
||||
def dap_params(self):
|
||||
"""
|
||||
Get the dap parameters.
|
||||
"""
|
||||
return self._dap_params
|
||||
|
||||
@dap_params.setter
|
||||
def dap_params(self, value):
|
||||
"""
|
||||
Set the dap parameters.
|
||||
|
||||
Args:
|
||||
value(dict): The dap parameters.
|
||||
"""
|
||||
self._dap_params = value
|
||||
|
||||
@property
|
||||
def dap_summary(self):
|
||||
"""
|
||||
Get the dap summary.
|
||||
"""
|
||||
return self._dap_report
|
||||
|
||||
@dap_summary.setter
|
||||
def dap_summary(self, value):
|
||||
"""
|
||||
Set the dap summary.
|
||||
"""
|
||||
self._dap_report = value
|
||||
|
||||
@property
|
||||
def dap_oversample(self):
|
||||
"""
|
||||
Get the dap oversample.
|
||||
"""
|
||||
return self.config.signal.dap_oversample
|
||||
|
||||
@dap_oversample.setter
|
||||
def dap_oversample(self, value):
|
||||
"""
|
||||
Set the dap oversample.
|
||||
|
||||
Args:
|
||||
value(int): The dap oversample.
|
||||
"""
|
||||
self.config.signal.dap_oversample = value
|
||||
self.parent_item.request_dap() # do immediate request for dap update
|
||||
|
||||
def set_data(self, x: list | np.ndarray, y: list | np.ndarray):
|
||||
"""
|
||||
Set the data of the curve.
|
||||
|
||||
Args:
|
||||
x(list|np.ndarray): The x data.
|
||||
y(list|np.ndarray): The y data.
|
||||
|
||||
Raises:
|
||||
ValueError: If the source is not custom.
|
||||
"""
|
||||
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,
|
||||
"color_map_z": self.set_color_map_z,
|
||||
"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:
|
||||
logger.warning(f"Warning: '{key}' is not a recognized property.")
|
||||
|
||||
def set_color(self, color: str, symbol_color: str | None = 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.setSymbol(symbol)
|
||||
self.updateItems()
|
||||
|
||||
def set_symbol_color(self, symbol_color: str):
|
||||
"""
|
||||
Change the symbol color of the curve.
|
||||
|
||||
Args:
|
||||
symbol_color(str): Color of the symbol.
|
||||
"""
|
||||
self.config.symbol_color = symbol_color
|
||||
self.apply_config()
|
||||
|
||||
def set_symbol_size(self, symbol_size: int):
|
||||
"""
|
||||
Change the symbol size of the curve.
|
||||
|
||||
Args:
|
||||
symbol_size(int): Size of the symbol.
|
||||
"""
|
||||
self.config.symbol_size = symbol_size
|
||||
self.apply_config()
|
||||
|
||||
def set_pen_width(self, pen_width: int):
|
||||
"""
|
||||
Change the pen width of the curve.
|
||||
|
||||
Args:
|
||||
pen_width(int): Width of the pen.
|
||||
"""
|
||||
self.config.pen_width = pen_width
|
||||
self.apply_config()
|
||||
|
||||
def set_pen_style(self, pen_style: Literal["solid", "dash", "dot", "dashdot"]):
|
||||
"""
|
||||
Change the pen style of the curve.
|
||||
|
||||
Args:
|
||||
pen_style(Literal["solid", "dash", "dot", "dashdot"]): Style of the pen.
|
||||
"""
|
||||
self.config.pen_style = pen_style
|
||||
self.apply_config()
|
||||
|
||||
def set_color_map_z(self, colormap: str):
|
||||
"""
|
||||
Set the colormap for the scatter plot z gradient.
|
||||
|
||||
Args:
|
||||
colormap(str): Colormap for the scatter plot.
|
||||
"""
|
||||
self.config.color_map_z = colormap
|
||||
self.apply_config()
|
||||
self.parent_item.update_with_scan_history(-1)
|
||||
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
x_data, y_data = self.getData()
|
||||
except TypeError:
|
||||
x_data, y_data = np.array([]), np.array([])
|
||||
return x_data, y_data
|
||||
|
||||
def clear_data(self):
|
||||
"""
|
||||
Clear the data of the curve.
|
||||
"""
|
||||
self.setData([], [])
|
||||
|
||||
def remove(self):
|
||||
"""Remove the curve from the plot."""
|
||||
# self.parent_item.removeItem(self)
|
||||
self.parent_item.remove_curve(self.name())
|
||||
self.rpc_register.remove_rpc(self)
|
@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform_plugin import WaveformPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(WaveformPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.settings.curve_settings.curve_tree import CurveTree
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class CurveSetting(SettingWidget):
|
||||
def __init__(self, parent=None, target_widget: Waveform = None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setObjectName("CurveSetting")
|
||||
self.target_widget = target_widget
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
|
||||
self._init_x_box()
|
||||
self._init_y_box()
|
||||
|
||||
self.setFixedWidth(580) # TODO height is still debate
|
||||
|
||||
def _init_x_box(self):
|
||||
self.x_axis_box = QGroupBox("X Axis")
|
||||
self.x_axis_box.layout = QHBoxLayout(self.x_axis_box)
|
||||
self.x_axis_box.layout.setContentsMargins(10, 10, 10, 10)
|
||||
self.x_axis_box.layout.setSpacing(10)
|
||||
|
||||
self.mode_combo_label = QLabel("Mode")
|
||||
self.mode_combo = QComboBox()
|
||||
self.mode_combo.addItems(["auto", "index", "timestamp", "device"])
|
||||
|
||||
self.spacer = QWidget()
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
self.device_x_label = QLabel("Device")
|
||||
self.device_x = DeviceLineEdit()
|
||||
|
||||
self._get_x_mode_from_waveform()
|
||||
self.switch_x_device_selection()
|
||||
|
||||
self.mode_combo.currentTextChanged.connect(self.switch_x_device_selection)
|
||||
|
||||
self.x_axis_box.layout.addWidget(self.mode_combo_label)
|
||||
self.x_axis_box.layout.addWidget(self.mode_combo)
|
||||
self.x_axis_box.layout.addWidget(self.spacer)
|
||||
self.x_axis_box.layout.addWidget(self.device_x_label)
|
||||
self.x_axis_box.layout.addWidget(self.device_x)
|
||||
|
||||
self.x_axis_box.setFixedHeight(80)
|
||||
self.layout.addWidget(self.x_axis_box)
|
||||
|
||||
def _get_x_mode_from_waveform(self):
|
||||
if self.target_widget.x_mode in ["auto", "index", "timestamp"]:
|
||||
self.mode_combo.setCurrentText(self.target_widget.x_mode)
|
||||
else:
|
||||
self.mode_combo.setCurrentText("device")
|
||||
|
||||
def switch_x_device_selection(self):
|
||||
if self.mode_combo.currentText() == "device":
|
||||
self.device_x.setEnabled(True)
|
||||
self.device_x.setText(self.target_widget.x_axis_mode["name"])
|
||||
else:
|
||||
self.device_x.setEnabled(False)
|
||||
|
||||
def _init_y_box(self):
|
||||
self.y_axis_box = QGroupBox("Y Axis")
|
||||
self.y_axis_box.layout = QVBoxLayout(self.y_axis_box)
|
||||
self.y_axis_box.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.y_axis_box.layout.setSpacing(0)
|
||||
|
||||
self.curve_manager = CurveTree(self, waveform=self.target_widget)
|
||||
self.y_axis_box.layout.addWidget(self.curve_manager)
|
||||
|
||||
self.layout.addWidget(self.y_axis_box)
|
||||
|
||||
@SafeSlot()
|
||||
def accept_changes(self):
|
||||
"""
|
||||
Accepts the changes made in the settings widget and applies them to the target widget.
|
||||
"""
|
||||
if self.mode_combo.currentText() == "device":
|
||||
self.target_widget.x_mode = self.device_x.text()
|
||||
else:
|
||||
self.target_widget.x_mode = self.mode_combo.currentText()
|
||||
self.curve_manager.send_curve_json()
|
||||
|
||||
@SafeSlot()
|
||||
def refresh(self):
|
||||
"""Refresh the curve tree and the x axis combo box in the case Waveform is modified from rpc."""
|
||||
self.curve_manager.refresh_from_waveform()
|
||||
self._get_x_mode_from_waveform()
|
@ -0,0 +1,538 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.utils import ConnectionConfig, EntryValidator
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.curve import CurveConfig, DeviceSignal
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
"""A QPushButton subclass that displays a color.
|
||||
|
||||
The background is set to the given color and the button text is the hex code.
|
||||
The text color is chosen automatically (black if the background is light, white if dark)
|
||||
to guarantee good readability.
|
||||
"""
|
||||
|
||||
def __init__(self, color="#000000", parent=None):
|
||||
"""Initialize the color button.
|
||||
|
||||
Args:
|
||||
color (str): The initial color in hex format (e.g., '#000000').
|
||||
parent: Optional QWidget parent.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.set_color(color)
|
||||
|
||||
def set_color(self, color):
|
||||
"""Set the button's color and update its appearance.
|
||||
|
||||
Args:
|
||||
color (str or QColor): The new color to assign.
|
||||
"""
|
||||
if isinstance(color, QColor):
|
||||
self._color = color.name()
|
||||
else:
|
||||
self._color = color
|
||||
self._update_appearance()
|
||||
|
||||
def color(self):
|
||||
"""Return the current color in hex."""
|
||||
return self._color
|
||||
|
||||
def _update_appearance(self):
|
||||
"""Update the button style based on the background color's brightness."""
|
||||
c = QColor(self._color)
|
||||
brightness = c.lightnessF()
|
||||
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
|
||||
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
|
||||
self.setText(self._color)
|
||||
|
||||
|
||||
class CurveRow(QTreeWidgetItem):
|
||||
DELETE_BUTTON_COLOR = "#CC181E"
|
||||
"""A unified row that can represent either a device or a DAP curve.
|
||||
|
||||
Columns:
|
||||
0: Actions (delete or "Add DAP" if source=device)
|
||||
1..2: DeviceLineEdit and QLineEdit if source=device, or "Model" label and DapComboBox if source=dap
|
||||
3: ColorButton
|
||||
4: Style QComboBox
|
||||
5: Pen width QSpinBox
|
||||
6: Symbol size QSpinBox
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tree: QTreeWidget,
|
||||
parent_item: QTreeWidgetItem | None = None,
|
||||
config: CurveConfig | None = None,
|
||||
device_manager=None,
|
||||
):
|
||||
if parent_item:
|
||||
super().__init__(parent_item)
|
||||
else:
|
||||
# A top-level device row.
|
||||
super().__init__(tree)
|
||||
|
||||
self.tree = tree
|
||||
self.parent_item = parent_item
|
||||
self.curve_tree = tree.parent() # The CurveTree widget
|
||||
self.curve_tree.all_items.append(self) # Track stable ordering
|
||||
|
||||
self.dev = device_manager
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
|
||||
self.config = config or CurveConfig()
|
||||
self.source = self.config.source
|
||||
|
||||
# Create column 0 (Actions)
|
||||
self._init_actions()
|
||||
# Create columns 1..2, depending on source
|
||||
self._init_source_ui()
|
||||
# Create columns 3..6 (color, style, width, symbol)
|
||||
self._init_style_controls()
|
||||
|
||||
def _init_actions(self):
|
||||
"""Create the actions widget in column 0, including a delete button and maybe 'Add DAP'."""
|
||||
self.actions_widget = QWidget()
|
||||
actions_layout = QHBoxLayout(self.actions_widget)
|
||||
actions_layout.setContentsMargins(0, 0, 0, 0)
|
||||
actions_layout.setSpacing(0)
|
||||
|
||||
# Delete button
|
||||
self.delete_button = QToolButton()
|
||||
delete_icon = material_icon(
|
||||
"delete",
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=False,
|
||||
color=self.DELETE_BUTTON_COLOR,
|
||||
)
|
||||
self.delete_button.setIcon(delete_icon)
|
||||
self.delete_button.clicked.connect(lambda: self.remove_self())
|
||||
actions_layout.addWidget(self.delete_button)
|
||||
|
||||
# If device row, add "Add DAP" button
|
||||
if self.source == "device":
|
||||
self.add_dap_button = QPushButton("DAP")
|
||||
self.add_dap_button.clicked.connect(lambda: self.add_dap_row())
|
||||
actions_layout.addWidget(self.add_dap_button)
|
||||
|
||||
self.tree.setItemWidget(self, 0, self.actions_widget)
|
||||
|
||||
def _init_source_ui(self):
|
||||
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
|
||||
if self.source == "device":
|
||||
# Device row: columns 1..2 are device line edits
|
||||
self.device_edit = DeviceLineEdit()
|
||||
self.entry_edit = QLineEdit() # TODO in future will be signal line edit
|
||||
if self.config.signal:
|
||||
self.device_edit.setText(self.config.signal.name or "")
|
||||
self.entry_edit.setText(self.config.signal.entry or "")
|
||||
|
||||
self.tree.setItemWidget(self, 1, self.device_edit)
|
||||
self.tree.setItemWidget(self, 2, self.entry_edit)
|
||||
|
||||
else:
|
||||
# DAP row: column1= "Model" label, column2= DapComboBox
|
||||
self.label_widget = QLabel("Model")
|
||||
self.tree.setItemWidget(self, 1, self.label_widget)
|
||||
self.dap_combo = DapComboBox()
|
||||
self.dap_combo.populate_fit_model_combobox()
|
||||
# If config.signal has a dap
|
||||
if self.config.signal and self.config.signal.dap:
|
||||
dap_value = self.config.signal.dap
|
||||
idx = self.dap_combo.fit_model_combobox.findText(dap_value)
|
||||
if idx >= 0:
|
||||
self.dap_combo.fit_model_combobox.setCurrentIndex(idx)
|
||||
else:
|
||||
self.dap_combo.select_fit_model("GaussianModel") # default
|
||||
|
||||
self.tree.setItemWidget(self, 2, self.dap_combo)
|
||||
|
||||
def _init_style_controls(self):
|
||||
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
|
||||
# Color in col 3
|
||||
self.color_button = ColorButton(self.config.color)
|
||||
self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
|
||||
self.tree.setItemWidget(self, 3, self.color_button)
|
||||
|
||||
# Style in col 4
|
||||
self.style_combo = QComboBox()
|
||||
self.style_combo.addItems(["solid", "dash", "dot", "dashdot"])
|
||||
idx = self.style_combo.findText(self.config.pen_style)
|
||||
if idx >= 0:
|
||||
self.style_combo.setCurrentIndex(idx)
|
||||
self.tree.setItemWidget(self, 4, self.style_combo)
|
||||
|
||||
# Pen width in col 5
|
||||
self.width_spin = QSpinBox()
|
||||
self.width_spin.setRange(1, 20)
|
||||
self.width_spin.setValue(self.config.pen_width)
|
||||
self.tree.setItemWidget(self, 5, self.width_spin)
|
||||
|
||||
# Symbol size in col 6
|
||||
self.symbol_spin = QSpinBox()
|
||||
self.symbol_spin.setRange(1, 20)
|
||||
self.symbol_spin.setValue(self.config.symbol_size)
|
||||
self.tree.setItemWidget(self, 6, self.symbol_spin)
|
||||
|
||||
def _select_color(self, button):
|
||||
"""
|
||||
Selects a new color using a color dialog and applies it to the specified button. Updates
|
||||
related configuration properties based on the chosen color.
|
||||
|
||||
Args:
|
||||
button: The button widget whose color is being modified.
|
||||
"""
|
||||
current_color = QColor(button.color())
|
||||
chosen_color = QColorDialog.getColor(current_color, self.tree, "Select Curve Color")
|
||||
if chosen_color.isValid():
|
||||
button.set_color(chosen_color)
|
||||
self.config.color = chosen_color.name()
|
||||
self.config.symbol_color = chosen_color.name()
|
||||
|
||||
def add_dap_row(self):
|
||||
"""Create a new DAP row as a child. Only valid if source='device'."""
|
||||
if self.source != "device":
|
||||
return
|
||||
curve_tree = self.tree.parent()
|
||||
parent_label = self.config.label
|
||||
|
||||
# Inherit device name/entry
|
||||
dev_name = ""
|
||||
dev_entry = ""
|
||||
if self.config.signal:
|
||||
dev_name = self.config.signal.name
|
||||
dev_entry = self.config.signal.entry
|
||||
|
||||
# Create a new config for the DAP row
|
||||
dap_cfg = CurveConfig(
|
||||
widget_class="Curve",
|
||||
source="dap",
|
||||
parent_label=parent_label,
|
||||
signal=DeviceSignal(name=dev_name, entry=dev_entry),
|
||||
)
|
||||
new_dap = CurveRow(self.tree, parent_item=self, config=dap_cfg, device_manager=self.dev)
|
||||
# Expand device row to show new child
|
||||
self.tree.expandItem(self)
|
||||
|
||||
# Give the new row a color from the buffer:
|
||||
curve_tree._ensure_color_buffer_size()
|
||||
idx = len(curve_tree.all_items) - 1
|
||||
new_col = curve_tree.color_buffer[idx]
|
||||
new_dap.color_button.set_color(new_col)
|
||||
new_dap.config.color = new_col
|
||||
new_dap.config.symbol_color = new_col
|
||||
|
||||
def remove_self(self):
|
||||
"""Remove this row from the tree and from the parent's item list."""
|
||||
# If top-level:
|
||||
index = self.tree.indexOfTopLevelItem(self)
|
||||
if index != -1:
|
||||
self.tree.takeTopLevelItem(index)
|
||||
else:
|
||||
# If child item
|
||||
if self.parent_item:
|
||||
self.parent_item.removeChild(self)
|
||||
# Also remove from all_items
|
||||
curve_tree = self.tree.parent()
|
||||
if self in curve_tree.all_items:
|
||||
curve_tree.all_items.remove(self)
|
||||
|
||||
def export_data(self) -> dict:
|
||||
"""Collect data from the GUI widgets, update config, and return as a dict.
|
||||
|
||||
Returns:
|
||||
dict: The serialized config based on the GUI state.
|
||||
"""
|
||||
if self.source == "device":
|
||||
# Gather device name/entry
|
||||
device_name = ""
|
||||
device_entry = ""
|
||||
if hasattr(self, "device_edit"):
|
||||
device_name = self.device_edit.text()
|
||||
if hasattr(self, "entry_edit"):
|
||||
device_entry = self.entry_validator.validate_signal(
|
||||
name=device_name, entry=self.entry_edit.text()
|
||||
)
|
||||
self.entry_edit.setText(device_entry)
|
||||
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
|
||||
self.config.source = "device"
|
||||
if not self.config.label:
|
||||
self.config.label = f"{device_name}-{device_entry}".strip("-")
|
||||
else:
|
||||
# DAP logic
|
||||
parent_conf_dict = {}
|
||||
if self.parent_item:
|
||||
parent_conf_dict = self.parent_item.export_data()
|
||||
parent_conf = CurveConfig(**parent_conf_dict)
|
||||
dev_name = ""
|
||||
dev_entry = ""
|
||||
if parent_conf.signal:
|
||||
dev_name = parent_conf.signal.name
|
||||
dev_entry = parent_conf.signal.entry
|
||||
# Dap from the DapComboBox
|
||||
new_dap = "GaussianModel"
|
||||
if hasattr(self, "dap_combo"):
|
||||
new_dap = self.dap_combo.fit_model_combobox.currentText()
|
||||
self.config.signal = DeviceSignal(name=dev_name, entry=dev_entry, dap=new_dap)
|
||||
self.config.source = "dap"
|
||||
self.config.parent_label = parent_conf.label
|
||||
self.config.label = f"{parent_conf.label}-{new_dap}".strip("-")
|
||||
|
||||
# Common style fields
|
||||
self.config.color = self.color_button.color()
|
||||
self.config.symbol_color = self.color_button.color()
|
||||
self.config.pen_style = self.style_combo.currentText()
|
||||
self.config.pen_width = self.width_spin.value()
|
||||
self.config.symbol_size = self.symbol_spin.value()
|
||||
|
||||
return self.config.model_dump()
|
||||
|
||||
|
||||
class CurveTree(BECWidget, QWidget):
|
||||
"""A tree widget that manages device and DAP curves."""
|
||||
|
||||
PLUGIN = False
|
||||
RPC = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: ConnectionConfig | None = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
waveform: Waveform | None = None,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(client=client, gui_id=gui_id, config=config)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.waveform = waveform
|
||||
if self.waveform and hasattr(self.waveform, "color_palette"):
|
||||
self.color_palette = self.waveform.color_palette
|
||||
else:
|
||||
self.color_palette = "magma"
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self.color_buffer = []
|
||||
self.all_items = []
|
||||
self.layout = QVBoxLayout(self)
|
||||
self._init_toolbar()
|
||||
self._init_tree()
|
||||
self.refresh_from_waveform()
|
||||
|
||||
def _init_toolbar(self):
|
||||
"""Initialize the toolbar with actions: add, send, refresh, expand, collapse, renormalize."""
|
||||
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
|
||||
add = MaterialIconAction(
|
||||
icon_name="add", tooltip="Add new curve", checkable=False, parent=self
|
||||
)
|
||||
expand = MaterialIconAction(
|
||||
icon_name="unfold_more", tooltip="Expand All DAP", checkable=False, parent=self
|
||||
)
|
||||
collapse = MaterialIconAction(
|
||||
icon_name="unfold_less", tooltip="Collapse All DAP", checkable=False, parent=self
|
||||
)
|
||||
|
||||
self.toolbar.add_action("add", add, self)
|
||||
self.toolbar.add_action("expand_all", expand, self)
|
||||
self.toolbar.add_action("collapse_all", collapse, self)
|
||||
|
||||
# Add colormap widget (not updating waveform's color_palette until Send is pressed)
|
||||
self.spacer = QWidget()
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
|
||||
# Renormalize colors button
|
||||
renorm_action = MaterialIconAction(
|
||||
icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self
|
||||
)
|
||||
self.toolbar.add_action("renormalize_colors", renorm_action, self)
|
||||
renorm_action.action.triggered.connect(lambda checked: self.renormalize_colors())
|
||||
|
||||
self.colormap_widget = BECColorMapWidget(cmap=self.color_palette or "magma")
|
||||
self.toolbar.addWidget(self.colormap_widget)
|
||||
self.colormap_widget.colormap_changed_signal.connect(self.handle_colormap_changed)
|
||||
|
||||
add.action.triggered.connect(lambda checked: self.add_new_curve())
|
||||
expand.action.triggered.connect(lambda checked: self.expand_all_daps())
|
||||
collapse.action.triggered.connect(lambda checked: self.collapse_all_daps())
|
||||
|
||||
self.layout.addWidget(self.toolbar)
|
||||
|
||||
def _init_tree(self):
|
||||
"""Initialize the QTreeWidget with 7 columns and compact widths."""
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setColumnCount(7)
|
||||
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
|
||||
self.tree.setColumnWidth(0, 90)
|
||||
self.tree.setColumnWidth(1, 100)
|
||||
self.tree.setColumnWidth(2, 100)
|
||||
self.tree.setColumnWidth(3, 70)
|
||||
self.tree.setColumnWidth(4, 80)
|
||||
self.tree.setColumnWidth(5, 40)
|
||||
self.tree.setColumnWidth(6, 40)
|
||||
self.layout.addWidget(self.tree)
|
||||
|
||||
def _init_color_buffer(self, size: int):
|
||||
"""
|
||||
Initializes the color buffer with a calculated set of colors based on the golden
|
||||
angle sequence.
|
||||
|
||||
Args:
|
||||
size (int): The number of colors to be generated for the color buffer.
|
||||
"""
|
||||
self.color_buffer = Colors.golden_angle_color(
|
||||
colormap=self.colormap_widget.colormap, num=size, format="HEX"
|
||||
)
|
||||
|
||||
def _ensure_color_buffer_size(self):
|
||||
"""
|
||||
Ensures that the color buffer size meets the required number of items.
|
||||
"""
|
||||
current_count = len(self.color_buffer)
|
||||
color_list = Colors.golden_angle_color(
|
||||
colormap=self.color_palette, num=max(10, current_count + 1), format="HEX"
|
||||
)
|
||||
self.color_buffer = color_list
|
||||
|
||||
def handle_colormap_changed(self, new_cmap: str):
|
||||
"""
|
||||
Handles the updating of the color palette when the colormap is changed.
|
||||
|
||||
Args:
|
||||
new_cmap: The new colormap to be set as the color palette.
|
||||
"""
|
||||
self.color_palette = new_cmap
|
||||
|
||||
def renormalize_colors(self):
|
||||
"""Overwrite all existing rows with new colors from the buffer in their creation order."""
|
||||
total = len(self.all_items)
|
||||
self._ensure_color_buffer_size()
|
||||
for idx, item in enumerate(self.all_items):
|
||||
if hasattr(item, "color_button"):
|
||||
new_col = self.color_buffer[idx]
|
||||
item.color_button.set_color(new_col)
|
||||
if hasattr(item, "config"):
|
||||
item.config.color = new_col
|
||||
item.config.symbol_color = new_col
|
||||
|
||||
def add_new_curve(self, name: str = None, entry: str = None):
|
||||
"""Add a new device-type CurveRow with an assigned colormap color.
|
||||
|
||||
Args:
|
||||
name (str, optional): Device name.
|
||||
entry (str, optional): Device entry.
|
||||
style (str, optional): Pen style. Defaults to "solid".
|
||||
width (int, optional): Pen width. Defaults to 4.
|
||||
symbol_size (int, optional): Symbol size. Defaults to 7.
|
||||
|
||||
Returns:
|
||||
CurveRow: The newly created top-level row.
|
||||
"""
|
||||
cfg = CurveConfig(
|
||||
widget_class="Curve",
|
||||
parent_id=self.waveform.gui_id,
|
||||
source="device",
|
||||
signal=DeviceSignal(name=name or "", entry=entry or ""),
|
||||
)
|
||||
new_row = CurveRow(self.tree, parent_item=None, config=cfg, device_manager=self.dev)
|
||||
|
||||
# Assign color from the buffer ONLY to this new curve.
|
||||
total_items = len(self.all_items)
|
||||
self._ensure_color_buffer_size()
|
||||
color_idx = total_items - 1 # new row is last
|
||||
new_col = self.color_buffer[color_idx]
|
||||
new_row.color_button.set_color(new_col)
|
||||
new_row.config.color = new_col
|
||||
new_row.config.symbol_color = new_col
|
||||
|
||||
return new_row
|
||||
|
||||
def send_curve_json(self):
|
||||
"""Send the current tree's config as JSON to the waveform, updating wavefrom.color_palette as well."""
|
||||
if self.waveform is not None:
|
||||
self.waveform.color_palette = self.color_palette
|
||||
data = self.export_all_curves()
|
||||
json_data = json.dumps(data, indent=2)
|
||||
if self.waveform is not None:
|
||||
self.waveform.curve_json = json_data
|
||||
|
||||
def export_all_curves(self) -> list:
|
||||
"""Recursively export data from each row.
|
||||
|
||||
Returns:
|
||||
list: A list of exported config dicts for every row (device and DAP).
|
||||
"""
|
||||
curves = []
|
||||
for i in range(self.tree.topLevelItemCount()):
|
||||
item = self.tree.topLevelItem(i)
|
||||
if isinstance(item, CurveRow):
|
||||
curves.append(item.export_data())
|
||||
for j in range(item.childCount()):
|
||||
child = item.child(j)
|
||||
if isinstance(child, CurveRow):
|
||||
curves.append(child.export_data())
|
||||
return curves
|
||||
|
||||
def expand_all_daps(self):
|
||||
"""Expand all top-level rows to reveal child DAP rows."""
|
||||
for i in range(self.tree.topLevelItemCount()):
|
||||
item = self.tree.topLevelItem(i)
|
||||
self.tree.expandItem(item)
|
||||
|
||||
def collapse_all_daps(self):
|
||||
"""Collapse all top-level rows, hiding child DAP rows."""
|
||||
for i in range(self.tree.topLevelItemCount()):
|
||||
item = self.tree.topLevelItem(i)
|
||||
self.tree.collapseItem(item)
|
||||
|
||||
def refresh_from_waveform(self):
|
||||
"""Clear the tree and rebuild from the waveform's existing curves if any, else add sample rows."""
|
||||
if self.waveform is None:
|
||||
return
|
||||
self.tree.clear()
|
||||
self.all_items = []
|
||||
|
||||
device_curves = [c for c in self.waveform.curves if c.config.source == "device"]
|
||||
dap_curves = [c for c in self.waveform.curves if c.config.source == "dap"]
|
||||
for dev in device_curves:
|
||||
dr = CurveRow(self.tree, parent_item=None, config=dev.config, device_manager=self.dev)
|
||||
for dap in dap_curves:
|
||||
if dap.config.parent_label == dev.config.label:
|
||||
CurveRow(self.tree, parent_item=dr, config=dap.config, device_manager=self.dev)
|
@ -0,0 +1,84 @@
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Signal, Slot
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.linear_region_selector import LinearRegionWrapper
|
||||
|
||||
|
||||
class WaveformROIManager(QObject):
|
||||
"""
|
||||
A reusable helper class that manages a single linear ROI region on a given plot item.
|
||||
It provides signals to notify about region changes and active state.
|
||||
"""
|
||||
|
||||
roi_changed = Signal(tuple) # Emitted when the ROI (left, right) changes
|
||||
roi_active = Signal(bool) # Emitted when ROI is enabled or disabled
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, parent=None):
|
||||
super().__init__(parent)
|
||||
self._plot_item = plot_item
|
||||
self._roi_wrapper: LinearRegionWrapper | None = None
|
||||
self._roi_region: tuple[float, float] | None = None
|
||||
self._accent_colors = get_accent_colors()
|
||||
|
||||
@property
|
||||
def roi_region(self) -> tuple[float, float] | None:
|
||||
return self._roi_region
|
||||
|
||||
@roi_region.setter
|
||||
def roi_region(self, value: tuple[float, float] | None):
|
||||
self._roi_region = value
|
||||
if self._roi_wrapper is not None and value is not None:
|
||||
self._roi_wrapper.linear_region_selector.setRegion(value)
|
||||
|
||||
@Slot(bool)
|
||||
def toggle_roi(self, enabled: bool) -> None:
|
||||
if enabled:
|
||||
self._enable_roi()
|
||||
else:
|
||||
self._disable_roi()
|
||||
|
||||
@Slot(tuple)
|
||||
def select_roi(self, region: tuple[float, float]):
|
||||
# If ROI not present, enabling it
|
||||
if self._roi_wrapper is None:
|
||||
self.toggle_roi(True)
|
||||
self.roi_region = region
|
||||
|
||||
def _enable_roi(self):
|
||||
if self._roi_wrapper is not None:
|
||||
# Already enabled
|
||||
return
|
||||
color = self._accent_colors.default
|
||||
color.setAlpha(int(0.2 * 255))
|
||||
hover_color = self._accent_colors.default
|
||||
hover_color.setAlpha(int(0.35 * 255))
|
||||
|
||||
self._roi_wrapper = LinearRegionWrapper(
|
||||
self._plot_item, color=color, hover_color=hover_color, parent=self
|
||||
)
|
||||
self._roi_wrapper.add_region_selector()
|
||||
self._roi_wrapper.region_changed.connect(self._on_region_changed)
|
||||
|
||||
# If we already had a region, apply it
|
||||
if self._roi_region is not None:
|
||||
self._roi_wrapper.linear_region_selector.setRegion(self._roi_region)
|
||||
else:
|
||||
self._roi_region = self._roi_wrapper.linear_region_selector.getRegion()
|
||||
|
||||
self.roi_active.emit(True)
|
||||
|
||||
def _disable_roi(self):
|
||||
if self._roi_wrapper is not None:
|
||||
self._roi_wrapper.region_changed.disconnect(self._on_region_changed)
|
||||
self._roi_wrapper.cleanup()
|
||||
self._roi_wrapper.deleteLater()
|
||||
self._roi_wrapper = None
|
||||
|
||||
self._roi_region = None
|
||||
self.roi_active.emit(False)
|
||||
|
||||
@Slot(tuple)
|
||||
def _on_region_changed(self, region: tuple[float, float]):
|
||||
self._roi_region = region
|
||||
self.roi_changed.emit(region)
|
1611
bec_widgets/widgets/plots_next_gen/waveform/waveform.py
Normal file
1611
bec_widgets/widgets/plots_next_gen/waveform/waveform.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1 @@
|
||||
{'files': ['waveform.py']}
|
@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='Waveform' name='waveform'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class WaveformPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = Waveform(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "Plot Widgets Next Gen"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(Waveform.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "waveform"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "Waveform"
|
||||
|
||||
def toolTip(self):
|
||||
return "Waveform"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
@ -1,8 +1,11 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
from math import inf
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import fakeredis
|
||||
import pytest
|
||||
from bec_lib.bec_service import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.redis_connector import RedisConnector
|
||||
|
||||
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
|
||||
@ -50,3 +53,150 @@ def mocked_client(bec_dispatcher):
|
||||
with patch("builtins.isinstance", new=isinstance_mock):
|
||||
yield client
|
||||
connector.shutdown() # TODO change to real BECClient
|
||||
|
||||
|
||||
##################################################
|
||||
# Client Fixture with DAP
|
||||
##################################################
|
||||
@pytest.fixture(scope="function")
|
||||
def dap_plugin_message():
|
||||
msg = messages.AvailableResourceMessage(
|
||||
**{
|
||||
"resource": {
|
||||
"GaussianModel": {
|
||||
"class": "LmfitService1D",
|
||||
"user_friendly_name": "GaussianModel",
|
||||
"class_doc": "A model based on a Gaussian or normal distribution lineshape.\n\n The model has three Parameters: `amplitude`, `center`, and `sigma`.\n In addition, parameters `fwhm` and `height` are included as\n constraints to report full width at half maximum and maximum peak\n height, respectively.\n\n .. math::\n\n f(x; A, \\mu, \\sigma) = \\frac{A}{\\sigma\\sqrt{2\\pi}} e^{[{-{(x-\\mu)^2}/{{2\\sigma}^2}}]}\n\n where the parameter `amplitude` corresponds to :math:`A`, `center` to\n :math:`\\mu`, and `sigma` to :math:`\\sigma`. The full width at half\n maximum is :math:`2\\sigma\\sqrt{2\\ln{2}}`, approximately\n :math:`2.3548\\sigma`.\n\n For more information, see: https://en.wikipedia.org/wiki/Normal_distribution\n\n ",
|
||||
"run_doc": "A model based on a Gaussian or normal distribution lineshape.\n\n The model has three Parameters: `amplitude`, `center`, and `sigma`.\n In addition, parameters `fwhm` and `height` are included as\n constraints to report full width at half maximum and maximum peak\n height, respectively.\n\n .. math::\n\n f(x; A, \\mu, \\sigma) = \\frac{A}{\\sigma\\sqrt{2\\pi}} e^{[{-{(x-\\mu)^2}/{{2\\sigma}^2}}]}\n\n where the parameter `amplitude` corresponds to :math:`A`, `center` to\n :math:`\\mu`, and `sigma` to :math:`\\sigma`. The full width at half\n maximum is :math:`2\\sigma\\sqrt{2\\ln{2}}`, approximately\n :math:`2.3548\\sigma`.\n\n For more information, see: https://en.wikipedia.org/wiki/Normal_distribution\n\n \n Args:\n scan_item (ScanItem): Scan item or scan ID\n device_x (DeviceBase | str): Device name for x\n signal_x (DeviceBase | str): Signal name for x\n device_y (DeviceBase | str): Device name for y\n signal_y (DeviceBase | str): Signal name for y\n parameters (dict): Fit parameters\n ",
|
||||
"run_name": "fit",
|
||||
"signature": [
|
||||
{
|
||||
"name": "args",
|
||||
"kind": "VAR_POSITIONAL",
|
||||
"default": "_empty",
|
||||
"annotation": "_empty",
|
||||
},
|
||||
{
|
||||
"name": "scan_item",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "ScanItem | str",
|
||||
},
|
||||
{
|
||||
"name": "device_x",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "DeviceBase | str",
|
||||
},
|
||||
{
|
||||
"name": "signal_x",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "DeviceBase | str",
|
||||
},
|
||||
{
|
||||
"name": "device_y",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "DeviceBase | str",
|
||||
},
|
||||
{
|
||||
"name": "signal_y",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "DeviceBase | str",
|
||||
},
|
||||
{
|
||||
"name": "parameters",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": "dict",
|
||||
},
|
||||
{
|
||||
"name": "kwargs",
|
||||
"kind": "VAR_KEYWORD",
|
||||
"default": "_empty",
|
||||
"annotation": "_empty",
|
||||
},
|
||||
],
|
||||
"auto_fit_supported": True,
|
||||
"params": {
|
||||
"amplitude": {
|
||||
"name": "amplitude",
|
||||
"value": 1.0,
|
||||
"vary": True,
|
||||
"min": -inf,
|
||||
"max": inf,
|
||||
"expr": None,
|
||||
"brute_step": None,
|
||||
"user_data": None,
|
||||
},
|
||||
"center": {
|
||||
"name": "center",
|
||||
"value": 0.0,
|
||||
"vary": True,
|
||||
"min": -inf,
|
||||
"max": inf,
|
||||
"expr": None,
|
||||
"brute_step": None,
|
||||
"user_data": None,
|
||||
},
|
||||
"sigma": {
|
||||
"name": "sigma",
|
||||
"value": 1.0,
|
||||
"vary": True,
|
||||
"min": 0,
|
||||
"max": inf,
|
||||
"expr": None,
|
||||
"brute_step": None,
|
||||
"user_data": None,
|
||||
},
|
||||
"fwhm": {
|
||||
"name": "fwhm",
|
||||
"value": 2.35482,
|
||||
"vary": False,
|
||||
"min": -inf,
|
||||
"max": inf,
|
||||
"expr": "2.3548200*sigma",
|
||||
"brute_step": None,
|
||||
"user_data": None,
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"value": 0.3989423,
|
||||
"vary": False,
|
||||
"min": -inf,
|
||||
"max": inf,
|
||||
"expr": "0.3989423*amplitude/max(1e-15, sigma)",
|
||||
"brute_step": None,
|
||||
"user_data": None,
|
||||
},
|
||||
},
|
||||
"class_args": [],
|
||||
"class_kwargs": {"model": "GaussianModel"},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
yield msg
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mocked_client_with_dap(mocked_client, dap_plugin_message):
|
||||
dap_services = {
|
||||
"BECClient": messages.StatusMessage(name="BECClient", status=1, info={}),
|
||||
"DAPServer/LmfitService1D": messages.StatusMessage(
|
||||
name="LmfitService1D", status=1, info={}
|
||||
),
|
||||
}
|
||||
client = mocked_client
|
||||
client.service_status = dap_services
|
||||
client.connector.set(
|
||||
topic=MessageEndpoints.dap_available_plugins("dap"), msg=dap_plugin_message
|
||||
)
|
||||
|
||||
# Patch the client's DAP attribute so that the available models include "GaussianModel"
|
||||
patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}}
|
||||
client.dap._available_dap_plugins = patched_models
|
||||
|
||||
yield client
|
||||
|
367
tests/unit_tests/test_curve_settings.py
Normal file
367
tests/unit_tests/test_curve_settings.py
Normal file
@ -0,0 +1,367 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QComboBox, QVBoxLayout
|
||||
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.settings.curve_settings.curve_setting import (
|
||||
CurveSetting,
|
||||
)
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.settings.curve_settings.curve_tree import CurveTree
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
from tests.unit_tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
##################################################
|
||||
# CurveSetting
|
||||
##################################################
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def curve_setting_fixture(qtbot, mocked_client):
|
||||
"""
|
||||
Creates a CurveSetting widget targeting a mock or real Waveform widget.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "auto"
|
||||
curve_setting = create_widget(qtbot, CurveSetting, parent=None, target_widget=wf)
|
||||
return curve_setting, wf
|
||||
|
||||
|
||||
def test_curve_setting_init(curve_setting_fixture):
|
||||
"""
|
||||
Ensure CurveSetting constructs properly, with a CurveTree inside
|
||||
and an x-axis group box for modes.
|
||||
"""
|
||||
curve_setting, wf = curve_setting_fixture
|
||||
|
||||
# Basic checks
|
||||
assert curve_setting.objectName() == "CurveSetting"
|
||||
# The layout should be QVBoxLayout
|
||||
assert isinstance(curve_setting.layout, QVBoxLayout)
|
||||
|
||||
# There's an x_axis_box group and a y_axis_box group
|
||||
assert hasattr(curve_setting, "x_axis_box")
|
||||
assert hasattr(curve_setting, "y_axis_box")
|
||||
|
||||
# The x_axis_box should contain a QComboBox for mode
|
||||
mode_combo = curve_setting.mode_combo
|
||||
assert isinstance(mode_combo, QComboBox)
|
||||
# Should contain these items: ["auto", "index", "timestamp", "device"]
|
||||
expected_modes = ["auto", "index", "timestamp", "device"]
|
||||
for m in expected_modes:
|
||||
assert m in [
|
||||
curve_setting.mode_combo.itemText(i) for i in range(curve_setting.mode_combo.count())
|
||||
]
|
||||
|
||||
# Check that there's a curve_manager inside y_axis_box
|
||||
assert hasattr(curve_setting, "curve_manager")
|
||||
assert curve_setting.y_axis_box.layout.count() > 0
|
||||
|
||||
|
||||
def test_curve_setting_accept_changes(curve_setting_fixture, qtbot):
|
||||
"""
|
||||
Test that calling accept_changes() applies x-axis mode changes
|
||||
and triggers the CurveTree to send its curve JSON to the target waveform.
|
||||
"""
|
||||
curve_setting, wf = curve_setting_fixture
|
||||
|
||||
# Suppose user chooses "index" from the combo
|
||||
curve_setting.mode_combo.setCurrentText("index")
|
||||
# The device_x is disabled if not device mode
|
||||
|
||||
# Spy on 'send_curve_json' from the curve_manager
|
||||
send_spy = MagicMock()
|
||||
curve_setting.curve_manager.send_curve_json = send_spy
|
||||
|
||||
# Call accept_changes()
|
||||
curve_setting.accept_changes()
|
||||
|
||||
# Check that we updated the waveform
|
||||
assert wf.x_mode == "index"
|
||||
# Check that the manager send_curve_json was called
|
||||
send_spy.assert_called_once()
|
||||
|
||||
|
||||
def test_curve_setting_switch_device_mode(curve_setting_fixture, qtbot):
|
||||
"""
|
||||
If user chooses device mode from the combo, the device_x line edit should be enabled
|
||||
and set to the current wavefrom.x_axis_mode["name"].
|
||||
"""
|
||||
curve_setting, wf = curve_setting_fixture
|
||||
|
||||
# Initially we assume "auto"
|
||||
assert curve_setting.mode_combo.currentText() == "auto"
|
||||
# Switch to device
|
||||
curve_setting.mode_combo.setCurrentText("device")
|
||||
assert curve_setting.device_x.isEnabled()
|
||||
|
||||
# This line edit should reflect the waveform.x_axis_mode["name"], or be blank if none
|
||||
assert curve_setting.device_x.text() == wf.x_axis_mode["name"]
|
||||
|
||||
|
||||
def test_curve_setting_refresh(curve_setting_fixture, qtbot):
|
||||
"""
|
||||
Test that calling refresh() refreshes the embedded CurveTree
|
||||
and re-reads the x axis mode from the waveform.
|
||||
"""
|
||||
curve_setting, wf = curve_setting_fixture
|
||||
|
||||
# Suppose the waveform changed x_mode from "auto" to "timestamp" behind the scenes
|
||||
wf.x_mode = "timestamp"
|
||||
# Spy on the curve_manager
|
||||
refresh_spy = MagicMock()
|
||||
curve_setting.curve_manager.refresh_from_waveform = refresh_spy
|
||||
|
||||
# Call refresh
|
||||
curve_setting.refresh()
|
||||
|
||||
refresh_spy.assert_called_once()
|
||||
# The combo should now read "timestamp"
|
||||
assert curve_setting.mode_combo.currentText() == "timestamp"
|
||||
|
||||
|
||||
##################################################
|
||||
# CurveTree
|
||||
##################################################
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def curve_tree_fixture(qtbot, mocked_client_with_dap):
|
||||
"""
|
||||
Creates a CurveTree widget referencing a mocked or real Waveform.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.color_palette = "magma"
|
||||
curve_tree = create_widget(qtbot, CurveTree, parent=None, waveform=wf)
|
||||
return curve_tree, wf
|
||||
|
||||
|
||||
def test_curve_tree_init(curve_tree_fixture):
|
||||
"""
|
||||
Test that the CurveTree initializes properly with references to the waveform,
|
||||
sets up the toolbar, and an empty QTreeWidget.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
assert curve_tree.waveform == wf
|
||||
assert curve_tree.color_palette == "magma"
|
||||
assert curve_tree.tree.columnCount() == 7
|
||||
|
||||
assert "add" in curve_tree.toolbar.widgets
|
||||
assert "expand_all" in curve_tree.toolbar.widgets
|
||||
assert "collapse_all" in curve_tree.toolbar.widgets
|
||||
assert "renormalize_colors" in curve_tree.toolbar.widgets
|
||||
|
||||
|
||||
def test_add_new_curve(curve_tree_fixture):
|
||||
"""
|
||||
Test that add_new_curve() adds a top-level item with a device curve config,
|
||||
assigns it a color from the buffer, and doesn't modify existing rows.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
curve_tree.color_buffer = ["#111111", "#222222", "#333333", "#444444", "#555555"]
|
||||
|
||||
assert curve_tree.tree.topLevelItemCount() == 0
|
||||
|
||||
with patch.object(curve_tree, "_ensure_color_buffer_size") as ensure_spy:
|
||||
new_item = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
ensure_spy.assert_called_once()
|
||||
|
||||
assert curve_tree.tree.topLevelItemCount() == 1
|
||||
last_item = curve_tree.all_items[-1]
|
||||
assert last_item is new_item
|
||||
assert new_item.config.source == "device"
|
||||
assert new_item.config.signal.name == "bpm4i"
|
||||
assert new_item.config.signal.entry == "bpm4i"
|
||||
assert new_item.config.color in curve_tree.color_buffer
|
||||
|
||||
|
||||
def test_renormalize_colors(curve_tree_fixture):
|
||||
"""
|
||||
Test that renormalize_colors overwrites colors for all items in creation order.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
# Add multiple curves
|
||||
c1 = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
c2 = curve_tree.add_new_curve(name="bpm3a", entry="bpm3a")
|
||||
curve_tree.color_buffer = []
|
||||
|
||||
set_color_spy_c1 = patch.object(c1.color_button, "set_color")
|
||||
set_color_spy_c2 = patch.object(c2.color_button, "set_color")
|
||||
|
||||
with set_color_spy_c1 as spy1, set_color_spy_c2 as spy2:
|
||||
curve_tree.renormalize_colors()
|
||||
spy1.assert_called_once()
|
||||
spy2.assert_called_once()
|
||||
|
||||
|
||||
def test_expand_collapse(curve_tree_fixture):
|
||||
"""
|
||||
Test expand_all_daps() and collapse_all_daps() calls expand/collapse on every top-level item.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
c1 = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
curve_tree.tree.expandAll()
|
||||
expand_spy = patch.object(curve_tree.tree, "expandItem")
|
||||
collapse_spy = patch.object(curve_tree.tree, "collapseItem")
|
||||
|
||||
with expand_spy as e_spy:
|
||||
curve_tree.expand_all_daps()
|
||||
e_spy.assert_called_once_with(c1)
|
||||
|
||||
with collapse_spy as c_spy:
|
||||
curve_tree.collapse_all_daps()
|
||||
c_spy.assert_called_once_with(c1)
|
||||
|
||||
|
||||
def test_send_curve_json(curve_tree_fixture, monkeypatch):
|
||||
"""
|
||||
Test that send_curve_json sets the waveform's color_palette and curve_json
|
||||
to the exported config from the tree.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
# Add multiple curves
|
||||
curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
curve_tree.add_new_curve(name="bpm3a", entry="bpm3a")
|
||||
|
||||
curve_tree.color_palette = "viridis"
|
||||
curve_tree.send_curve_json()
|
||||
|
||||
assert wf.color_palette == "viridis"
|
||||
data = json.loads(wf.curve_json)
|
||||
assert len(data) == 2
|
||||
labels = [d["label"] for d in data]
|
||||
assert "bpm4i-bpm4i" in labels
|
||||
assert "bpm3a-bpm3a" in labels
|
||||
|
||||
|
||||
def test_refresh_from_waveform(qtbot, mocked_client_with_dap, monkeypatch):
|
||||
"""
|
||||
Test that refresh_from_waveform() rebuilds the tree from the waveform's curve_json
|
||||
"""
|
||||
patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}}
|
||||
monkeypatch.setattr(mocked_client_with_dap.dap, "_available_dap_plugins", patched_models)
|
||||
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.x_mode = "auto"
|
||||
curve_tree = create_widget(qtbot, CurveTree, parent=None, waveform=wf)
|
||||
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.plot(arg1="bpm3a", dap="GaussianModel")
|
||||
|
||||
# Clear the tree to simulate a fresh rebuild.
|
||||
curve_tree.tree.clear()
|
||||
curve_tree.all_items.clear()
|
||||
assert curve_tree.tree.topLevelItemCount() == 0
|
||||
|
||||
# For DAP rows
|
||||
curve_tree.refresh_from_waveform()
|
||||
assert curve_tree.tree.topLevelItemCount() == 2
|
||||
|
||||
|
||||
def test_add_dap_row(curve_tree_fixture):
|
||||
"""
|
||||
Test that add_dap_row creates a new DAP curve as a child of a device curve,
|
||||
with the correct configuration and parent-child relationship.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
|
||||
# Add a device curve first
|
||||
device_row = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
assert device_row.source == "device"
|
||||
assert curve_tree.tree.topLevelItemCount() == 1
|
||||
assert device_row.childCount() == 0
|
||||
|
||||
# Now add a DAP row to it
|
||||
device_row.add_dap_row()
|
||||
|
||||
# Check that child was added
|
||||
assert device_row.childCount() == 1
|
||||
dap_child = device_row.child(0)
|
||||
|
||||
# Verify the DAP child has the correct configuration
|
||||
assert dap_child.source == "dap"
|
||||
assert dap_child.config.parent_label == device_row.config.label
|
||||
|
||||
# Check that the DAP inherits device name/entry from parent
|
||||
assert dap_child.config.signal.name == "bpm4i"
|
||||
assert dap_child.config.signal.entry == "bpm4i"
|
||||
|
||||
# Check that the item is in the curve_tree's all_items list
|
||||
assert dap_child in curve_tree.all_items
|
||||
|
||||
|
||||
def test_remove_self_top_level(curve_tree_fixture):
|
||||
"""
|
||||
Test that remove_self removes a top-level device row from the tree.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
|
||||
# Add two device curves
|
||||
row1 = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
row2 = curve_tree.add_new_curve(name="bpm3a", entry="bpm3a")
|
||||
assert curve_tree.tree.topLevelItemCount() == 2
|
||||
assert len(curve_tree.all_items) == 2
|
||||
|
||||
# Remove the first row
|
||||
row1.remove_self()
|
||||
|
||||
# Check that only one row remains and it's the correct one
|
||||
assert curve_tree.tree.topLevelItemCount() == 1
|
||||
assert curve_tree.tree.topLevelItem(0) == row2
|
||||
assert len(curve_tree.all_items) == 1
|
||||
assert curve_tree.all_items[0] == row2
|
||||
|
||||
|
||||
def test_remove_self_child(curve_tree_fixture):
|
||||
"""
|
||||
Test that remove_self removes a child DAP row while preserving the parent device row.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
|
||||
# Add a device curve and a DAP child
|
||||
device_row = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
device_row.add_dap_row()
|
||||
dap_child = device_row.child(0)
|
||||
|
||||
assert curve_tree.tree.topLevelItemCount() == 1
|
||||
assert device_row.childCount() == 1
|
||||
assert len(curve_tree.all_items) == 2
|
||||
|
||||
# Remove the DAP child
|
||||
dap_child.remove_self()
|
||||
|
||||
# Check that the parent device row still exists but has no children
|
||||
assert curve_tree.tree.topLevelItemCount() == 1
|
||||
assert device_row.childCount() == 0
|
||||
assert len(curve_tree.all_items) == 1
|
||||
assert curve_tree.all_items[0] == device_row
|
||||
|
||||
|
||||
def test_export_data_dap(curve_tree_fixture):
|
||||
"""
|
||||
Test that export_data from a DAP row correctly includes parent relationship and DAP model.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
|
||||
# Add a device curve with specific parameters
|
||||
device_row = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
device_row.config.label = "bpm4i-main"
|
||||
|
||||
# Add a DAP child
|
||||
device_row.add_dap_row()
|
||||
dap_child = device_row.child(0)
|
||||
|
||||
# Set a specific model in the DAP combobox
|
||||
dap_child.dap_combo.fit_model_combobox.setCurrentText("GaussianModel")
|
||||
|
||||
# Export data from the DAP row
|
||||
exported = dap_child.export_data()
|
||||
|
||||
# Check the exported data
|
||||
assert exported["source"] == "dap"
|
||||
assert exported["parent_label"] == "bpm4i-main"
|
||||
assert exported["signal"]["name"] == "bpm4i"
|
||||
assert exported["signal"]["entry"] == "bpm4i"
|
||||
assert exported["signal"]["dap"] == "GaussianModel"
|
||||
assert exported["label"] == "bpm4i-main-GaussianModel"
|
787
tests/unit_tests/test_waveform_next_gen.py
Normal file
787
tests/unit_tests/test_waveform_next_gen.py
Normal file
@ -0,0 +1,787 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import pytest
|
||||
from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
|
||||
|
||||
from bec_widgets.widgets.plots_next_gen.plot_base import UIMode
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.curve import DeviceSignal
|
||||
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
|
||||
from tests.unit_tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
|
||||
|
||||
from .conftest import create_widget
|
||||
|
||||
##################################################
|
||||
# Waveform widget base functionality tests
|
||||
##################################################
|
||||
|
||||
|
||||
def test_waveform_initialization(qtbot, mocked_client):
|
||||
"""
|
||||
Test that a new Waveform widget initializes with the correct defaults.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
assert wf.objectName() == "Waveform"
|
||||
# Inherited from PlotBase
|
||||
assert wf.title == ""
|
||||
assert wf.x_label == ""
|
||||
assert wf.y_label == ""
|
||||
# No crosshair or FPS monitor by default
|
||||
assert wf.crosshair is None
|
||||
assert wf.fps_monitor is None
|
||||
# No curves initially
|
||||
assert len(wf.plot_item.curves) == 0
|
||||
|
||||
|
||||
def test_waveform_with_side_menu(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client, popups=False)
|
||||
|
||||
assert wf.ui_mode == UIMode.SIDE
|
||||
|
||||
|
||||
def test_plot_custom_curve(qtbot, mocked_client):
|
||||
"""
|
||||
Test that calling plot with explicit x and y data creates a custom curve.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
curve = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
|
||||
assert curve is not None
|
||||
assert curve.config.source == "custom"
|
||||
assert curve.config.label == "custom_curve"
|
||||
x_data, y_data = curve.get_data()
|
||||
np.testing.assert_array_equal(x_data, np.array([1, 2, 3]))
|
||||
np.testing.assert_array_equal(y_data, np.array([4, 5, 6]))
|
||||
|
||||
|
||||
def test_plot_single_arg_input_1d(qtbot, mocked_client):
|
||||
"""
|
||||
Test that when a single 1D numpy array is passed, the curve is created with
|
||||
x-data as a generated index.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
data = np.array([10, 20, 30])
|
||||
curve = wf.plot(data, label="curve_1d")
|
||||
x_data, y_data = curve.get_data()
|
||||
np.testing.assert_array_equal(x_data, np.arange(len(data)))
|
||||
np.testing.assert_array_equal(y_data, data)
|
||||
|
||||
|
||||
def test_plot_single_arg_input_2d(qtbot, mocked_client):
|
||||
"""
|
||||
Test that when a single 2D numpy array (N x 2) is passed,
|
||||
x and y data are extracted from the first and second columns.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
data = np.array([[1, 4], [2, 5], [3, 6]])
|
||||
curve = wf.plot(data, label="curve_2d")
|
||||
x_data, y_data = curve.get_data()
|
||||
np.testing.assert_array_equal(x_data, data[:, 0])
|
||||
np.testing.assert_array_equal(y_data, data[:, 1])
|
||||
|
||||
|
||||
def test_plot_single_arg_input_sync(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
c1 = wf.plot(arg1="bpm4i")
|
||||
c2 = wf.plot(arg1="bpm3a")
|
||||
|
||||
assert c1.config.source == "device"
|
||||
assert c2.config.source == "device"
|
||||
assert c1.config.signal == DeviceSignal(name="bpm4i", entry="bpm4i", dap=None)
|
||||
assert c2.config.signal == DeviceSignal(name="bpm3a", entry="bpm3a", dap=None)
|
||||
|
||||
# Check that the curve is added to the plot
|
||||
assert len(wf.plot_item.curves) == 2
|
||||
|
||||
|
||||
def test_plot_single_arg_input_async(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
c1 = wf.plot(arg1="eiger")
|
||||
c2 = wf.plot(arg1="async_device")
|
||||
|
||||
assert c1.config.source == "device"
|
||||
assert c2.config.source == "device"
|
||||
assert c1.config.signal == DeviceSignal(name="eiger", entry="eiger", dap=None)
|
||||
assert c2.config.signal == DeviceSignal(name="async_device", entry="async_device", dap=None)
|
||||
|
||||
# Check that the curve is added to the plot
|
||||
assert len(wf.plot_item.curves) == 2
|
||||
|
||||
|
||||
def test_curve_access_pattern(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
c1 = wf.plot(arg1="bpm4i")
|
||||
c2 = wf.plot(arg1="bpm3a")
|
||||
|
||||
# Check that the curve is added to the plot
|
||||
assert len(wf.plot_item.curves) == 2
|
||||
|
||||
# Check that the curve is accessible by label
|
||||
assert wf.get_curve("bpm4i-bpm4i") == c1
|
||||
assert wf.get_curve("bpm3a-bpm3a") == c2
|
||||
|
||||
# Check that the curve is accessible by index
|
||||
assert wf.get_curve(0) == c1
|
||||
assert wf.get_curve(1) == c2
|
||||
|
||||
# Check that the curve is accessible by label
|
||||
assert wf["bpm4i-bpm4i"] == c1
|
||||
assert wf["bpm3a-bpm3a"] == c2
|
||||
assert wf[0] == c1
|
||||
assert wf[1] == c2
|
||||
|
||||
assert wf.curves[0] == c1
|
||||
assert wf.curves[1] == c2
|
||||
|
||||
|
||||
def test_find_curve_by_label(qtbot, mocked_client):
|
||||
"""
|
||||
Test the _find_curve_by_label method returns the correct curve or None if not found.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c1 = wf.plot(arg1="bpm4i", label="c1_label")
|
||||
c2 = wf.plot(arg1="bpm3a", label="c2_label")
|
||||
|
||||
found = wf._find_curve_by_label("c1_label")
|
||||
assert found == c1, "Should return the first curve"
|
||||
missing = wf._find_curve_by_label("bogus_label")
|
||||
assert missing is None, "Should return None if not found"
|
||||
|
||||
|
||||
def test_set_x_mode(qtbot, mocked_client):
|
||||
"""
|
||||
Test that setting x_mode updates the internal x-axis mode state and switches
|
||||
the bottom axis of the plot.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "timestamp"
|
||||
assert wf.x_axis_mode["name"] == "timestamp"
|
||||
# When x_mode is 'timestamp', the bottom axis should be a DateAxisItem.
|
||||
assert isinstance(wf.plot_item.axes["bottom"]["item"], DateAxisItem)
|
||||
|
||||
wf.x_mode = "index"
|
||||
# For other modes, the bottom axis becomes the default AxisItem.
|
||||
assert isinstance(wf.plot_item.axes["bottom"]["item"], pg.AxisItem)
|
||||
|
||||
wf.x_mode = "samx"
|
||||
assert wf.x_axis_mode["name"] == "samx"
|
||||
assert isinstance(wf.plot_item.axes["bottom"]["item"], pg.AxisItem)
|
||||
|
||||
|
||||
def test_color_palette_update(qtbot, mocked_client):
|
||||
"""
|
||||
Test that updating the color_palette property changes the color of existing curves.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
curve = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
|
||||
original_color = curve.config.color
|
||||
# Change to a different valid palette
|
||||
wf.color_palette = "plasma"
|
||||
assert wf.config.color_palette == "plasma"
|
||||
# After updating the palette, the curve's color should be re-generated.
|
||||
assert curve.config.color != original_color
|
||||
|
||||
|
||||
def test_curve_json_property(qtbot, mocked_client):
|
||||
"""
|
||||
Test that the curve_json property returns a JSON string representing
|
||||
non-custom curves. Since custom curves are not serialized, if only a custom
|
||||
curve is added, an empty list should be returned.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
|
||||
json_str = wf.curve_json
|
||||
data = json.loads(json_str)
|
||||
assert isinstance(data, list)
|
||||
# Only custom curves exist so none should be serialized.
|
||||
assert len(data) == 0
|
||||
|
||||
|
||||
def test_remove_curve_waveform(qtbot, mocked_client):
|
||||
"""
|
||||
Test that curves can be removed from the waveform using either their label or index.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve1")
|
||||
wf.plot(x=[4, 5, 6], y=[7, 8, 9], label="curve2")
|
||||
num_before = len(wf.plot_item.curves)
|
||||
wf.remove_curve("curve1")
|
||||
num_after = len(wf.plot_item.curves)
|
||||
assert num_after == num_before - 1
|
||||
|
||||
wf.remove_curve(0)
|
||||
assert len(wf.plot_item.curves) == num_after - 1
|
||||
|
||||
|
||||
def test_get_all_data_empty(qtbot, mocked_client):
|
||||
"""
|
||||
Test that get_all_data returns an empty dictionary when no curves have been added.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
all_data = wf.get_all_data(output="dict")
|
||||
assert all_data == {}
|
||||
|
||||
|
||||
def test_get_all_data_dict(qtbot, mocked_client):
|
||||
"""
|
||||
Test that get_all_data returns a dictionary with the expected x and y data for each curve.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve1")
|
||||
wf.plot(x=[7, 8, 9], y=[10, 11, 12], label="curve2")
|
||||
|
||||
all_data = wf.get_all_data(output="dict")
|
||||
|
||||
expected = {
|
||||
"curve1": {"x": [1, 2, 3], "y": [4, 5, 6]},
|
||||
"curve2": {"x": [7, 8, 9], "y": [10, 11, 12]},
|
||||
}
|
||||
assert all_data == expected
|
||||
|
||||
|
||||
def test_curve_json_getter_setter(qtbot, mocked_client):
|
||||
"""
|
||||
Test that the curve_json getter returns a JSON string representing device curves
|
||||
and that setting curve_json re-creates the curves.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
# These curves should be in JSON
|
||||
wf.plot(arg1="bpm4i")
|
||||
wf.plot(arg1="bpm3a")
|
||||
# Custom curves should be ignored
|
||||
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
|
||||
wf.plot([1, 2, 3, 4])
|
||||
|
||||
# Get JSON from the getter.
|
||||
json_str = wf.curve_json
|
||||
curve_configs = json.loads(json_str)
|
||||
# Only device curves are serialized; expect two configurations.
|
||||
assert isinstance(curve_configs, list)
|
||||
assert len(curve_configs) == 2
|
||||
labels = [cfg["label"] for cfg in curve_configs]
|
||||
assert "bpm4i-bpm4i" in labels
|
||||
assert "bpm3a-bpm3a" in labels
|
||||
|
||||
# Clear all curves.
|
||||
wf.clear_all()
|
||||
assert len(wf.plot_item.curves) == 0
|
||||
|
||||
# Use the JSON setter to re-create the curves.
|
||||
wf.curve_json = json_str
|
||||
# After setting, the waveform should have two curves.
|
||||
assert len(wf.plot_item.curves) == 2
|
||||
new_labels = [curve.name() for curve in wf.plot_item.curves]
|
||||
for lab in labels:
|
||||
assert lab in new_labels
|
||||
|
||||
|
||||
def test_curve_json_setter_ignores_custom(qtbot, mocked_client):
|
||||
"""
|
||||
Test that when curve_json setter is given a JSON string containing a
|
||||
curve with source "custom", that curve is not added.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
device_curve_config = {
|
||||
"widget_class": "Curve",
|
||||
"parent_id": wf.gui_id,
|
||||
"label": "device_curve",
|
||||
"color": "#ff0000",
|
||||
"source": "device",
|
||||
"signal": {"name": "bpm4i", "entry": "bpm4i", "dap": None},
|
||||
}
|
||||
custom_curve_config = {
|
||||
"widget_class": "Curve",
|
||||
"parent_id": wf.gui_id,
|
||||
"label": "custom_curve",
|
||||
"color": "#00ff00",
|
||||
"source": "custom",
|
||||
# No signal for custom curves.
|
||||
}
|
||||
json_str = json.dumps([device_curve_config, custom_curve_config], indent=2)
|
||||
wf.curve_json = json_str
|
||||
# Only the device curve should be added.
|
||||
curves = wf.plot_item.curves
|
||||
assert len(curves) == 1
|
||||
assert curves[0].name() == "device_curve"
|
||||
|
||||
|
||||
##################################################
|
||||
# Waveform widget scan logic tests
|
||||
##################################################
|
||||
|
||||
|
||||
class DummyData:
|
||||
def __init__(self, val, timestamps):
|
||||
self.val = val
|
||||
self.timestamps = timestamps
|
||||
|
||||
def get(self, key, default=None):
|
||||
if key == "val":
|
||||
return self.val
|
||||
return default
|
||||
|
||||
|
||||
def create_dummy_scan_item():
|
||||
"""
|
||||
Helper to create a dummy scan item with both live_data and metadata/status_message info.
|
||||
"""
|
||||
dummy_live_data = {
|
||||
"samx": {"samx": DummyData(val=[10, 20, 30], timestamps=[100, 200, 300])},
|
||||
"bpm4i": {"bpm4i": DummyData(val=[5, 6, 7], timestamps=[101, 201, 301])},
|
||||
"async_device": {"async_device": DummyData(val=[1, 2, 3], timestamps=[11, 21, 31])},
|
||||
}
|
||||
dummy_scan = MagicMock()
|
||||
dummy_scan.live_data = dummy_live_data
|
||||
dummy_scan.metadata = {
|
||||
"bec": {
|
||||
"scan_id": "dummy",
|
||||
"scan_report_devices": ["samx"],
|
||||
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
|
||||
}
|
||||
}
|
||||
dummy_scan.status_message = MagicMock()
|
||||
dummy_scan.status_message.info = {
|
||||
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
|
||||
"scan_report_devices": ["samx"],
|
||||
}
|
||||
return dummy_scan
|
||||
|
||||
|
||||
def test_update_sync_curves(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Test that update_sync_curves retrieves live data correctly and calls setData on sync curves.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(arg1="bpm4i")
|
||||
wf._sync_curves = [c]
|
||||
wf.x_mode = "timestamp"
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
|
||||
recorded = {}
|
||||
|
||||
def fake_setData(x, y):
|
||||
recorded["x"] = x
|
||||
recorded["y"] = y
|
||||
|
||||
monkeypatch.setattr(c, "setData", fake_setData)
|
||||
|
||||
wf.update_sync_curves()
|
||||
np.testing.assert_array_equal(recorded.get("x"), [101, 201, 301])
|
||||
np.testing.assert_array_equal(recorded.get("y"), [5, 6, 7])
|
||||
|
||||
|
||||
def test_update_async_curves(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Test that update_async_curves retrieves live data correctly and calls setData on async curves.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
||||
wf._async_curves = [c]
|
||||
wf.x_mode = "timestamp"
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
|
||||
recorded = {}
|
||||
|
||||
def fake_setData(x, y):
|
||||
recorded["x"] = x
|
||||
recorded["y"] = y
|
||||
|
||||
monkeypatch.setattr(c, "setData", fake_setData)
|
||||
|
||||
wf.update_async_curves()
|
||||
np.testing.assert_array_equal(recorded.get("x"), [11, 21, 31])
|
||||
np.testing.assert_array_equal(recorded.get("y"), [1, 2, 3])
|
||||
|
||||
|
||||
def test_get_x_data_custom(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Test that _get_x_data returns the correct custom signal data.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
# Set x_mode to a custom mode.
|
||||
wf.x_axis_mode["name"] = "custom_signal"
|
||||
wf.x_axis_mode["entry"] = "custom_entry"
|
||||
dummy_data = DummyData(val=[50, 60, 70], timestamps=[150, 160, 170])
|
||||
dummy_live = {"custom_signal": {"custom_entry": dummy_data}}
|
||||
monkeypatch.setattr(wf, "_fetch_scan_data_and_access", lambda: (dummy_live, "val"))
|
||||
x_data = wf._get_x_data("irrelevant", "irrelevant")
|
||||
np.testing.assert_array_equal(x_data, [50, 60, 70])
|
||||
|
||||
|
||||
def test_get_x_data_timestamp(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Test that _get_x_data returns the correct timestamp data.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_axis_mode["name"] = "timestamp"
|
||||
dummy_data = DummyData(val=[50, 60, 70], timestamps=[101, 202, 303])
|
||||
dummy_live = {"deviceX": {"entryX": dummy_data}}
|
||||
monkeypatch.setattr(wf, "_fetch_scan_data_and_access", lambda: (dummy_live, "val"))
|
||||
x_data = wf._get_x_data("deviceX", "entryX")
|
||||
np.testing.assert_array_equal(x_data, [101, 202, 303])
|
||||
|
||||
|
||||
def test_categorise_device_curves(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Test that _categorise_device_curves correctly categorizes curves.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
|
||||
c_sync = wf.plot(arg1="bpm4i", label="bpm4i-bpm4i")
|
||||
c_async = wf.plot(arg1="async_device", label="async_device-async_device")
|
||||
|
||||
mode = wf._categorise_device_curves()
|
||||
|
||||
assert mode == "mixed"
|
||||
assert c_sync in wf._sync_curves
|
||||
assert c_async in wf._async_curves
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["mode", "calls"], [("sync", (1, 0)), ("async", (0, 1)), ("mixed", (1, 1))]
|
||||
)
|
||||
def test_on_scan_status(qtbot, mocked_client, monkeypatch, mode, calls):
|
||||
"""
|
||||
Test that on_scan_status sets up a new scan correctly,
|
||||
categorizes curves, and triggers sync/async updates as needed.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
# Force creation of a couple of device curves
|
||||
if mode == "sync":
|
||||
wf.plot(arg1="bpm4i")
|
||||
elif mode == "async":
|
||||
wf.plot(arg1="async_device")
|
||||
else:
|
||||
wf.plot(arg1="bpm4i")
|
||||
wf.plot(arg1="async_device")
|
||||
|
||||
# We mock out the scan_item, pretending we found a new scan.
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
dummy_scan.metadata["bec"]["scan_id"] = "1234"
|
||||
monkeypatch.setattr(wf.queue.scan_storage, "find_scan_by_ID", lambda scan_id: dummy_scan)
|
||||
|
||||
# We'll track calls to sync_signal_update and async_signal_update
|
||||
sync_spy = MagicMock()
|
||||
async_spy = MagicMock()
|
||||
wf.sync_signal_update.connect(sync_spy)
|
||||
wf.async_signal_update.connect(async_spy)
|
||||
|
||||
# Prepare fake message data
|
||||
msg = {"scan_id": "1234"}
|
||||
meta = {}
|
||||
wf.on_scan_status(msg, meta)
|
||||
|
||||
assert wf.scan_id == "1234"
|
||||
assert wf.scan_item == dummy_scan
|
||||
assert wf._mode == mode
|
||||
|
||||
assert sync_spy.call_count == calls[0], "sync_signal_update should be called exactly once"
|
||||
assert async_spy.call_count == calls[1], "async_signal_update should be called exactly once"
|
||||
|
||||
|
||||
def test_add_dap_curve(qtbot, mocked_client_with_dap, monkeypatch):
|
||||
"""
|
||||
Test add_dap_curve creates a new DAP curve from an existing device curve
|
||||
and verifies that the DAP call doesn't fail due to mock-based plugin_info.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", label="bpm4i-bpm4i")
|
||||
|
||||
dap_curve = wf.add_dap_curve(device_label="bpm4i-bpm4i", dap_name="GaussianModel")
|
||||
assert dap_curve is not None
|
||||
assert dap_curve.config.source == "dap"
|
||||
assert dap_curve.config.signal.name == "bpm4i"
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_fetch_scan_data_and_access(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Test the _fetch_scan_data_and_access method returns live_data/val if in a live scan,
|
||||
or device dict/value if in a historical scan. Also test fallback if no scan_item.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
wf.scan_item = None
|
||||
|
||||
hist_mock = MagicMock()
|
||||
monkeypatch.setattr(wf, "update_with_scan_history", hist_mock)
|
||||
|
||||
wf._fetch_scan_data_and_access()
|
||||
hist_mock.assert_called_once_with(-1)
|
||||
|
||||
# Ckeck live mode
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
data_dict, access_key = wf._fetch_scan_data_and_access()
|
||||
assert data_dict == dummy_scan.live_data
|
||||
assert access_key == "val"
|
||||
|
||||
# Check history mode
|
||||
del dummy_scan.live_data
|
||||
dummy_scan.devices = {"some_device": {"some_entry": "some_value"}}
|
||||
data_dict, access_key = wf._fetch_scan_data_and_access()
|
||||
assert "some_device" in data_dict # from dummy_scan.devices
|
||||
assert access_key == "value"
|
||||
|
||||
|
||||
def test_setup_async_curve(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Test that _setup_async_curve properly disconnects old signals
|
||||
and re-connects the async readback for a new scan ID.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.old_scan_id = "111"
|
||||
wf.scan_id = "222"
|
||||
|
||||
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
||||
# check that it was placed in _async_curves or so
|
||||
wf._async_curves = [c]
|
||||
|
||||
# We'll spy on connect_slot
|
||||
connect_spy = MagicMock()
|
||||
monkeypatch.setattr(wf.bec_dispatcher, "connect_slot", connect_spy)
|
||||
|
||||
wf._setup_async_curve(c)
|
||||
connect_spy.assert_called_once()
|
||||
endpoint_called = connect_spy.call_args[0][1].endpoint
|
||||
# We expect MessageEndpoints.device_async_readback('222', 'async_device')
|
||||
assert "222" in endpoint_called
|
||||
assert "async_device" in endpoint_called
|
||||
|
||||
|
||||
@pytest.mark.parametrize("x_mode", ("timestamp", "index"))
|
||||
def test_on_async_readback(qtbot, mocked_client, x_mode):
|
||||
"""
|
||||
Test that on_async_readback extends or replaces async data depending on metadata instruction.
|
||||
For 'timestamp' mode, new timestamps are appended to x_data.
|
||||
For 'index' mode, x_data simply increases by integer index.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
|
||||
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
||||
wf._async_curves = [c]
|
||||
# Suppose existing data
|
||||
c.setData([0, 1, 2], [10, 11, 12])
|
||||
|
||||
# Set the x_axis_mode
|
||||
wf.x_axis_mode["name"] = x_mode
|
||||
|
||||
# Extend readback
|
||||
msg = {"signals": {"async_device": {"value": [100, 200], "timestamp": [1001, 1002]}}}
|
||||
metadata = {"async_update": {"max_shape": [None], "type": "add"}}
|
||||
wf.on_async_readback(msg, metadata)
|
||||
|
||||
x_data, y_data = c.get_data()
|
||||
assert len(x_data) == 5
|
||||
# Check x_data based on x_mode
|
||||
if x_mode == "timestamp":
|
||||
np.testing.assert_array_equal(x_data, [0, 1, 2, 1001, 1002])
|
||||
else: # x_mode == "index"
|
||||
np.testing.assert_array_equal(x_data, [0, 1, 2, 3, 4])
|
||||
|
||||
np.testing.assert_array_equal(y_data, [10, 11, 12, 100, 200])
|
||||
|
||||
# instruction='replace'
|
||||
msg2 = {"signals": {"async_device": {"value": [999], "timestamp": [555]}}}
|
||||
metadata2 = {"async_update": {"max_shape": [None], "type": "replace"}}
|
||||
wf.on_async_readback(msg2, metadata2)
|
||||
x_data2, y_data2 = c.get_data()
|
||||
if x_mode == "timestamp":
|
||||
np.testing.assert_array_equal(x_data2, [555])
|
||||
else:
|
||||
|
||||
np.testing.assert_array_equal(x_data2, [0])
|
||||
|
||||
np.testing.assert_array_equal(y_data2, [999])
|
||||
|
||||
|
||||
def test_get_x_data(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Test _get_x_data logic for multiple modes: 'timestamp', 'index', 'custom', 'auto'.
|
||||
Use a dummy scan_item that returns specific data for the requested signal.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
wf.scan_item = dummy_scan
|
||||
|
||||
# 1) x_mode == 'timestamp'
|
||||
wf.x_axis_mode["name"] = "timestamp"
|
||||
x_data = wf._get_x_data("bpm4i", "bpm4i")
|
||||
np.testing.assert_array_equal(x_data, [101, 201, 301])
|
||||
|
||||
# 2) x_mode == 'index' => returns None => means use Y data indexing
|
||||
wf.x_axis_mode["name"] = "index"
|
||||
x_data2 = wf._get_x_data("bpm4i", "bpm4i")
|
||||
assert x_data2 is None
|
||||
|
||||
# 3) custom x => e.g. "samx"
|
||||
wf.x_axis_mode["name"] = "samx"
|
||||
x_custom = wf._get_x_data("bpm4i", "bpm4i")
|
||||
# because dummy_scan.live_data["samx"]["samx"].val => [10,20,30]
|
||||
np.testing.assert_array_equal(x_custom, [10, 20, 30])
|
||||
|
||||
# 4) auto
|
||||
wf._async_curves.clear()
|
||||
wf._sync_curves = [MagicMock()] # pretend we have a sync device
|
||||
wf.x_axis_mode["name"] = "auto"
|
||||
x_auto = wf._get_x_data("bpm4i", "bpm4i")
|
||||
# By default it tries the "scan_report_devices" => "samx" => same as custom above
|
||||
np.testing.assert_array_equal(x_auto, [10, 20, 30])
|
||||
|
||||
|
||||
##################################################
|
||||
# The following tests are for the Curve class
|
||||
##################################################
|
||||
|
||||
|
||||
def test_curve_set_appearance_methods(qtbot, mocked_client):
|
||||
"""
|
||||
Test that the Curve appearance setter methods update the configuration properly.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="appearance_curve")
|
||||
c.set_color("#0000ff")
|
||||
c.set_symbol("x")
|
||||
c.set_symbol_color("#ff0000")
|
||||
c.set_symbol_size(10)
|
||||
c.set_pen_width(3)
|
||||
c.set_pen_style("dashdot")
|
||||
assert c.config.color == "#0000ff"
|
||||
assert c.config.symbol == "x"
|
||||
assert c.config.symbol_color == "#ff0000"
|
||||
assert c.config.symbol_size == 10
|
||||
assert c.config.pen_width == 3
|
||||
assert c.config.pen_style == "dashdot"
|
||||
|
||||
|
||||
def test_curve_set_custom_data(qtbot, mocked_client):
|
||||
"""
|
||||
Test that custom curves allow setting new data via set_data.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_data_curve")
|
||||
# Change data
|
||||
c.set_data([7, 8, 9], [10, 11, 12])
|
||||
x_data, y_data = c.get_data()
|
||||
np.testing.assert_array_equal(x_data, np.array([7, 8, 9]))
|
||||
np.testing.assert_array_equal(y_data, np.array([10, 11, 12]))
|
||||
|
||||
|
||||
def test_curve_set_data_error_non_custom(qtbot, mocked_client):
|
||||
"""
|
||||
Test that calling set_data on a non-custom (device) curve raises a ValueError.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
# Create a device curve by providing y_name (which makes source 'device')
|
||||
# Assume that entry_validator returns a valid entry.
|
||||
c = wf.plot(arg1="bpm4i", label="device_curve")
|
||||
with pytest.raises(ValueError):
|
||||
c.set_data([1, 2, 3], [4, 5, 6])
|
||||
|
||||
|
||||
def test_curve_remove(qtbot, mocked_client):
|
||||
"""
|
||||
Test that calling remove() on a Curve calls its parent's remove_curve method.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c1 = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve_1")
|
||||
c2 = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve_2")
|
||||
|
||||
assert len(wf.plot_item.curves) == 2
|
||||
c1.remove()
|
||||
assert len(wf.plot_item.curves) == 1
|
||||
assert c1 not in wf.plot_item.curves
|
||||
assert c2 in wf.plot_item.curves
|
||||
|
||||
|
||||
def test_curve_dap_params_and_summary(qtbot, mocked_client):
|
||||
"""
|
||||
Test that dap_params and dap_summary properties work as expected.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="dap_curve")
|
||||
c.dap_params = {"param": 1}
|
||||
c.dap_summary = {"summary": "test"}
|
||||
assert c.dap_params == {"param": 1}
|
||||
assert c.dap_summary == {"summary": "test"}
|
||||
|
||||
|
||||
def test_curve_set_method(qtbot, mocked_client):
|
||||
"""
|
||||
Test the convenience set(...) method of the Curve for updating appearance properties.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="set_method_curve")
|
||||
c.set(
|
||||
color="#123456",
|
||||
symbol="d",
|
||||
symbol_color="#654321",
|
||||
symbol_size=12,
|
||||
pen_width=5,
|
||||
pen_style="dot",
|
||||
)
|
||||
assert c.config.color == "#123456"
|
||||
assert c.config.symbol == "d"
|
||||
assert c.config.symbol_color == "#654321"
|
||||
assert c.config.symbol_size == 12
|
||||
assert c.config.pen_width == 5
|
||||
assert c.config.pen_style == "dot"
|
||||
|
||||
|
||||
##################################################
|
||||
# Settings and popups
|
||||
##################################################
|
||||
|
||||
|
||||
def test_show_curve_settings_popup(qtbot, mocked_client):
|
||||
"""
|
||||
Test that show_curve_settings_popup displays the settings dialog and toggles the toolbar icon.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
curve_action = wf.toolbar.widgets["curve"].action
|
||||
assert not curve_action.isChecked(), "Should start unchecked"
|
||||
|
||||
wf.show_curve_settings_popup()
|
||||
|
||||
assert wf.curve_settings_dialog is not None
|
||||
assert wf.curve_settings_dialog.isVisible()
|
||||
assert curve_action.isChecked()
|
||||
|
||||
wf.curve_settings_dialog.close()
|
||||
assert wf.curve_settings_dialog is None
|
||||
assert not curve_action.isChecked(), "Should be unchecked after closing dialog"
|
||||
|
||||
|
||||
def test_show_dap_summary_popup(qtbot, mocked_client):
|
||||
"""
|
||||
Test that show_dap_summary_popup displays the DAP summary dialog and toggles the 'fit_params' toolbar icon.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client, popups=True)
|
||||
|
||||
assert "fit_params" in wf.toolbar.widgets
|
||||
|
||||
fit_action = wf.toolbar.widgets["fit_params"].action
|
||||
assert fit_action.isChecked() is False
|
||||
|
||||
wf.show_dap_summary_popup()
|
||||
|
||||
assert wf.dap_summary_dialog is not None
|
||||
assert wf.dap_summary_dialog.isVisible()
|
||||
assert fit_action.isChecked() is True
|
||||
|
||||
wf.dap_summary_dialog.close()
|
||||
assert wf.dap_summary_dialog is None
|
||||
assert fit_action.isChecked() is False
|
Reference in New Issue
Block a user