0
0
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:
2025-02-14 13:22:34 +01:00
parent 906ca03929
commit 0f4365bbb0
21 changed files with 4648 additions and 11 deletions

View File

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

View File

@ -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()

View File

@ -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

View 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
################################################################################

View File

@ -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)

View 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)

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
{'files': ['waveform.py']}

View File

@ -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()

View File

@ -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

View 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"

View 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