0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 19:21:50 +02:00

feat(multi_waveform): multi-waveform widget based on new PlotBase

This commit is contained in:
2025-03-20 14:39:40 +01:00
parent 1cc2a98489
commit 77f96160ab
13 changed files with 1701 additions and 0 deletions

View File

@ -32,6 +32,7 @@ class Widgets(str, enum.Enum):
LogPanel = "LogPanel"
Minesweeper = "Minesweeper"
MotorMap = "MotorMap"
MultiWaveform = "MultiWaveform"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerBox2D = "PositionerBox2D"
@ -3662,6 +3663,415 @@ class MotorMap(RPCBase):
"""
class MultiWaveform(RPCBase):
@property
@rpc_call
def enable_toolbar(self) -> "bool":
"""
Show Toolbar.
"""
@enable_toolbar.setter
@rpc_call
def enable_toolbar(self) -> "bool":
"""
Show Toolbar.
"""
@property
@rpc_call
def enable_side_panel(self) -> "bool":
"""
Show Side Panel
"""
@enable_side_panel.setter
@rpc_call
def enable_side_panel(self) -> "bool":
"""
Show Side Panel
"""
@property
@rpc_call
def enable_fps_monitor(self) -> "bool":
"""
Enable the FPS monitor.
"""
@enable_fps_monitor.setter
@rpc_call
def enable_fps_monitor(self) -> "bool":
"""
Enable the FPS monitor.
"""
@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:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@property
@rpc_call
def title(self) -> "str":
"""
Set title of the plot.
"""
@title.setter
@rpc_call
def title(self) -> "str":
"""
Set title of the plot.
"""
@property
@rpc_call
def x_label(self) -> "str":
"""
The set label for the x-axis.
"""
@x_label.setter
@rpc_call
def x_label(self) -> "str":
"""
The set label for the x-axis.
"""
@property
@rpc_call
def y_label(self) -> "str":
"""
The set label for the y-axis.
"""
@y_label.setter
@rpc_call
def y_label(self) -> "str":
"""
The set label for the y-axis.
"""
@property
@rpc_call
def x_limits(self) -> "QPointF":
"""
Get the x limits of the plot.
"""
@x_limits.setter
@rpc_call
def x_limits(self) -> "QPointF":
"""
Get the x limits of the plot.
"""
@property
@rpc_call
def y_limits(self) -> "QPointF":
"""
Get the y limits of the plot.
"""
@y_limits.setter
@rpc_call
def y_limits(self) -> "QPointF":
"""
Get the y limits of the plot.
"""
@property
@rpc_call
def x_grid(self) -> "bool":
"""
Show grid on the x-axis.
"""
@x_grid.setter
@rpc_call
def x_grid(self) -> "bool":
"""
Show grid on the x-axis.
"""
@property
@rpc_call
def y_grid(self) -> "bool":
"""
Show grid on the y-axis.
"""
@y_grid.setter
@rpc_call
def y_grid(self) -> "bool":
"""
Show grid on the y-axis.
"""
@property
@rpc_call
def inner_axes(self) -> "bool":
"""
Show inner axes of the plot widget.
"""
@inner_axes.setter
@rpc_call
def inner_axes(self) -> "bool":
"""
Show inner axes of the plot widget.
"""
@property
@rpc_call
def outer_axes(self) -> "bool":
"""
Show the outer axes of the plot widget.
"""
@outer_axes.setter
@rpc_call
def outer_axes(self) -> "bool":
"""
Show the outer axes of the plot widget.
"""
@property
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Lock aspect ratio of the plot widget.
"""
@lock_aspect_ratio.setter
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Lock aspect ratio of the plot widget.
"""
@property
@rpc_call
def auto_range_x(self) -> "bool":
"""
Set auto range for the x-axis.
"""
@auto_range_x.setter
@rpc_call
def auto_range_x(self) -> "bool":
"""
Set auto range for the x-axis.
"""
@property
@rpc_call
def auto_range_y(self) -> "bool":
"""
Set auto range for the y-axis.
"""
@auto_range_y.setter
@rpc_call
def auto_range_y(self) -> "bool":
"""
Set auto range for the y-axis.
"""
@property
@rpc_call
def x_log(self) -> "bool":
"""
Set X-axis to log scale if True, linear if False.
"""
@x_log.setter
@rpc_call
def x_log(self) -> "bool":
"""
Set X-axis to log scale if True, linear if False.
"""
@property
@rpc_call
def y_log(self) -> "bool":
"""
Set Y-axis to log scale if True, linear if False.
"""
@y_log.setter
@rpc_call
def y_log(self) -> "bool":
"""
Set Y-axis to log scale if True, linear if False.
"""
@property
@rpc_call
def legend_label_size(self) -> "int":
"""
The font size of the legend font.
"""
@legend_label_size.setter
@rpc_call
def legend_label_size(self) -> "int":
"""
The font size of the legend font.
"""
@property
@rpc_call
def highlighted_index(self):
"""
None
"""
@highlighted_index.setter
@rpc_call
def highlighted_index(self):
"""
None
"""
@property
@rpc_call
def highlight_last_curve(self) -> "bool":
"""
Get the highlight_last_curve property.
Returns:
bool: The highlight_last_curve property.
"""
@highlight_last_curve.setter
@rpc_call
def highlight_last_curve(self) -> "bool":
"""
Get the highlight_last_curve property.
Returns:
bool: The highlight_last_curve property.
"""
@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.
"""
@property
@rpc_call
def opacity(self) -> "int":
"""
The opacity of the figure widget.
"""
@opacity.setter
@rpc_call
def opacity(self) -> "int":
"""
The opacity of the figure widget.
"""
@property
@rpc_call
def flush_buffer(self) -> "bool":
"""
The flush_buffer property.
"""
@flush_buffer.setter
@rpc_call
def flush_buffer(self) -> "bool":
"""
The flush_buffer property.
"""
@property
@rpc_call
def max_trace(self) -> "int":
"""
The maximum number of traces to display on the plot.
"""
@max_trace.setter
@rpc_call
def max_trace(self) -> "int":
"""
The maximum number of traces to display on the plot.
"""
@property
@rpc_call
def monitor(self) -> "str":
"""
The monitor of the figure widget.
"""
@monitor.setter
@rpc_call
def monitor(self) -> "str":
"""
The monitor of the figure widget.
"""
@rpc_call
def set_curve_limit(self, max_trace: "int", flush_buffer: "bool"):
"""
Set the maximum number of traces to display on the plot.
Args:
max_trace (int): The maximum number of traces to display.
flush_buffer (bool): Flush the buffer.
"""
@rpc_call
def plot(self, monitor: "str", color_palette: "str | None" = "magma"):
"""
Create a plot for the given monitor.
Args:
monitor (str): The monitor to set.
color_palette (str|None): The color palette to use for the plot.
"""
@rpc_call
def set_curve_highlight(self, index: "int"):
"""
Set the curve highlight based on visible curves.
Args:
index (int): The index of the curve to highlight among visible curves.
"""
@rpc_call
def clear_curves(self):
"""
Remove all curves from the plot, excluding crosshair items.
"""
class PositionIndicator(RPCBase):
@rpc_call
def set_value(self, position: float):

View File

@ -22,6 +22,7 @@ from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutM
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
from bec_widgets.widgets.plots_next_gen.image.image import Image
from bec_widgets.widgets.plots_next_gen.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots_next_gen.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
@ -69,6 +70,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"wf": self.wf,
"scatter": self.scatter,
"scatter_mi": self.scatter,
"mwf": self.mwf,
}
)
@ -152,6 +154,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
tab_widget.addTab(eighth_tab, "Motor Map")
tab_widget.setCurrentIndex(7)
ninth_tab = QWidget()
ninth_tab_layout = QVBoxLayout(ninth_tab)
self.mwf = MultiWaveform()
ninth_tab_layout.addWidget(self.mwf)
tab_widget.addTab(ninth_tab, "MultiWaveform")
tab_widget.setCurrentIndex(8)
# add stuff to the new Waveform widget
self._init_waveform()

View File

@ -0,0 +1,501 @@
from __future__ import annotations
from collections import deque
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.qt_utils.side_panel import SidePanel
from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.widgets.plots_next_gen.multi_waveform.settings.control_panel import (
MultiWaveformControlPanel,
)
from bec_widgets.widgets.plots_next_gen.multi_waveform.toolbar_bundles.monitor_selection import (
MultiWaveformSelectionToolbarBundle,
)
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
logger = bec_logger.logger
class MultiWaveformConfig(ConnectionConfig):
color_palette: str | None = Field(
"magma", description="The color palette of the figure widget.", validate_default=True
)
curve_limit: int | None = Field(
200, description="The maximum number of curves to display on the plot."
)
flush_buffer: bool | None = Field(
False, description="Flush the buffer of the plot widget when the curve limit is reached."
)
monitor: str | None = Field(None, description="The monitor to set for the plot widget.")
curve_width: int | None = Field(1, description="The width of the curve on the plot.")
opacity: int | None = Field(50, description="The opacity of the curve on the plot.")
highlight_last_curve: bool | None = Field(
True, description="Highlight the last curve on the plot."
)
model_config: dict = {"validate_assignment": True}
_validate_color_map_z = field_validator("color_palette")(Colors.validate_color_map)
class MultiWaveform(PlotBase):
PLUGIN = True
RPC = True
ICON_NAME = "ssid_chart"
USER_ACCESS = [
# General PlotBase Settings
"enable_toolbar",
"enable_toolbar.setter",
"enable_side_panel",
"enable_side_panel.setter",
"enable_fps_monitor",
"enable_fps_monitor.setter",
"set",
"title",
"title.setter",
"x_label",
"x_label.setter",
"y_label",
"y_label.setter",
"x_limits",
"x_limits.setter",
"y_limits",
"y_limits.setter",
"x_grid",
"x_grid.setter",
"y_grid",
"y_grid.setter",
"inner_axes",
"inner_axes.setter",
"outer_axes",
"outer_axes.setter",
"lock_aspect_ratio",
"lock_aspect_ratio.setter",
"auto_range_x",
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"x_log",
"x_log.setter",
"y_log",
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
# MultiWaveform Specific RPC Access
"highlighted_index",
"highlighted_index.setter",
"highlight_last_curve",
"highlight_last_curve.setter",
"color_palette",
"color_palette.setter",
"opacity",
"opacity.setter",
"flush_buffer",
"flush_buffer.setter",
"max_trace",
"max_trace.setter",
"monitor",
"monitor.setter",
"set_curve_limit",
"plot",
"set_curve_highlight",
"clear_curves",
]
monitor_signal_updated = Signal()
highlighted_curve_index_changed = Signal(int)
def __init__(
self,
parent: QWidget | None = None,
config: MultiWaveformConfig | None = None,
client=None,
gui_id: str | None = None,
popups: bool = True,
**kwargs,
):
if config is None:
config = MultiWaveformConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
# For PropertyManager identification
self.setObjectName("MultiWaveform")
# Scan Data
self.old_scan_id = None
self.scan_id = None
self.connected = False
self._current_highlight_index = 0
self._curves = deque()
self.visible_curves = []
self.number_of_visible_curves = 0
self._init_control_panel()
################################################################################
# Widget Specific GUI interactions
################################################################################
def _init_toolbar(self):
self.monitor_selection_bundle = MultiWaveformSelectionToolbarBundle(
bundle_id="motor_selection", target_widget=self
)
self.toolbar.add_bundle(self.monitor_selection_bundle, target_widget=self)
super()._init_toolbar()
self.toolbar.widgets["reset_legend"].action.setVisible(False)
def _init_control_panel(self):
self.control_panel = SidePanel(self, orientation="top", panel_max_width=90)
self.layout_manager.add_widget_relative(
self.control_panel, self.round_plot_widget, "bottom"
)
self.controls = MultiWaveformControlPanel(target_widget=self)
self.control_panel.add_menu(
action_id="control",
icon_name="tune",
tooltip="Show Control panel",
widget=self.controls,
title=None,
)
self.control_panel.toolbar.widgets["control"].action.trigger()
################################################################################
# Widget Specific Properties
################################################################################
@property
def curves(self) -> deque:
"""
Get the curves of the plot widget as a deque.
Returns:
deque: Deque of curves.
"""
return self._curves
@curves.setter
def curves(self, value: deque):
self._curves = value
@SafeProperty(int, designable=False)
def highlighted_index(self):
return self._current_highlight_index
@highlighted_index.setter
def highlighted_index(self, value: int):
self._current_highlight_index = value
self.property_changed.emit("highlighted_index", value)
self.set_curve_highlight(value)
@SafeProperty(bool)
def highlight_last_curve(self) -> bool:
"""
Get the highlight_last_curve property.
Returns:
bool: The highlight_last_curve property.
"""
return self.config.highlight_last_curve
@highlight_last_curve.setter
def highlight_last_curve(self, value: bool):
self.config.highlight_last_curve = value
self.property_changed.emit("highlight_last_curve", value)
self.set_curve_highlight(-1)
@SafeProperty(str)
def color_palette(self) -> str:
"""
The color palette of the figure widget.
"""
return self.config.color_palette
@color_palette.setter
def color_palette(self, value: str):
"""
Set the color palette of the figure widget.
Args:
value(str): The color palette to set.
"""
try:
self.config.color_palette = value
except ValidationError:
return
self.set_curve_highlight(self._current_highlight_index)
self._sync_monitor_selection_toolbar()
@SafeProperty(int)
def opacity(self) -> int:
"""
The opacity of the figure widget.
"""
return self.config.opacity
@opacity.setter
def opacity(self, value: int):
"""
Set the opacity of the figure widget.
Args:
value(int): The opacity to set.
"""
self.config.opacity = max(0, min(100, value))
self.property_changed.emit("opacity", value)
self.set_curve_highlight(self._current_highlight_index)
@SafeProperty(bool)
def flush_buffer(self) -> bool:
"""
The flush_buffer property.
"""
return self.config.flush_buffer
@flush_buffer.setter
def flush_buffer(self, value: bool):
self.config.flush_buffer = value
self.property_changed.emit("flush_buffer", value)
self.set_curve_limit(
max_trace=self.config.curve_limit, flush_buffer=self.config.flush_buffer
)
@SafeProperty(int)
def max_trace(self) -> int:
"""
The maximum number of traces to display on the plot.
"""
return self.config.curve_limit
@max_trace.setter
def max_trace(self, value: int):
"""
Set the maximum number of traces to display on the plot.
Args:
value(int): The maximum number of traces to display.
"""
self.config.curve_limit = value
self.property_changed.emit("max_trace", value)
self.set_curve_limit(
max_trace=self.config.curve_limit, flush_buffer=self.config.flush_buffer
)
@SafeProperty(str)
def monitor(self) -> str:
"""
The monitor of the figure widget.
"""
return self.config.monitor
@monitor.setter
def monitor(self, value: str):
"""
Set the monitor of the figure widget.
Args:
value(str): The monitor to set.
"""
self.plot(value)
################################################################################
# High Level methods for API
################################################################################
@SafeSlot(popup_error=True)
def plot(self, monitor: str, color_palette: str | None = "magma"):
"""
Create a plot for the given monitor.
Args:
monitor (str): The monitor to set.
color_palette (str|None): The color palette to use for the plot.
"""
self.entry_validator.validate_monitor(monitor)
self._disconnect_monitor()
self.config.monitor = monitor
self._connect_monitor()
if color_palette is not None:
self.color_palette = color_palette
self._sync_monitor_selection_toolbar()
@SafeSlot(int, bool)
def set_curve_limit(self, max_trace: int, flush_buffer: bool):
"""
Set the maximum number of traces to display on the plot.
Args:
max_trace (int): The maximum number of traces to display.
flush_buffer (bool): Flush the buffer.
"""
if max_trace != self.config.curve_limit:
self.config.curve_limit = max_trace
if flush_buffer != self.config.flush_buffer:
self.config.flush_buffer = flush_buffer
if self.config.curve_limit is None:
self.scale_colors()
return
if self.config.flush_buffer:
# Remove excess curves from the plot and the deque
while len(self.curves) > self.config.curve_limit:
curve = self.curves.popleft()
self.plot_item.removeItem(curve)
else:
# Hide or show curves based on the new max_trace
num_curves_to_show = min(self.config.curve_limit, len(self.curves))
for i, curve in enumerate(self.curves):
if i < len(self.curves) - num_curves_to_show:
curve.hide()
else:
curve.show()
self.scale_colors()
self.monitor_signal_updated.emit()
################################################################################
# BEC Update Methods
################################################################################
@SafeSlot(dict, dict)
def on_monitor_1d_update(self, msg: dict, metadata: dict):
"""
Update the plot widget with the monitor data.
Args:
msg(dict): The message data.
metadata(dict): The metadata of the message.
"""
data = msg.get("data", None)
current_scan_id = metadata.get("scan_id", None)
if current_scan_id != self.scan_id:
self.scan_id = current_scan_id
self.clear_curves()
self.curves.clear()
if self.crosshair:
self.crosshair.clear_markers()
# Always create a new curve and add it
curve = pg.PlotDataItem()
curve.setData(data)
self.plot_item.addItem(curve)
self.curves.append(curve)
# Max Trace and scale colors
self.set_curve_limit(self.config.curve_limit, self.config.flush_buffer)
@SafeSlot(int)
def set_curve_highlight(self, index: int):
"""
Set the curve highlight based on visible curves.
Args:
index (int): The index of the curve to highlight among visible curves.
"""
self.plot_item.visible_curves = [curve for curve in self.curves if curve.isVisible()]
num_visible_curves = len(self.plot_item.visible_curves)
self.number_of_visible_curves = num_visible_curves
if num_visible_curves == 0:
return # No curves to highlight
if index >= num_visible_curves:
index = num_visible_curves - 1
elif index < 0:
index = num_visible_curves + index
self._current_highlight_index = index
num_colors = num_visible_curves
colors = Colors.evenly_spaced_colors(
colormap=self.config.color_palette, num=num_colors, format="HEX"
)
for i, curve in enumerate(self.plot_item.visible_curves):
curve.setPen()
if i == self._current_highlight_index:
curve.setPen(pg.mkPen(color=colors[i], width=5))
curve.setAlpha(alpha=1, auto=False)
curve.setZValue(1)
else:
curve.setPen(pg.mkPen(color=colors[i], width=1))
curve.setAlpha(alpha=self.config.opacity / 100, auto=False)
curve.setZValue(0)
self.highlighted_curve_index_changed.emit(self._current_highlight_index)
def _disconnect_monitor(self):
try:
previous_monitor = self.config.monitor
except AttributeError:
previous_monitor = None
if previous_monitor and self.connected is True:
self.bec_dispatcher.disconnect_slot(
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(previous_monitor)
)
self.connected = False
def _connect_monitor(self):
"""
Connect the monitor to the plot widget.
"""
if self.config.monitor and self.connected is False:
self.bec_dispatcher.connect_slot(
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(self.config.monitor)
)
self.connected = True
################################################################################
# Utility Methods
################################################################################
def scale_colors(self):
"""
Scale the colors of the curves based on the current colormap.
"""
# TODO probably has to be changed to property
if self.config.highlight_last_curve:
self.set_curve_highlight(-1) # Use -1 to highlight the last visible curve
else:
self.set_curve_highlight(self._current_highlight_index)
def hook_crosshair(self) -> None:
"""
Specific hookfor crosshair, since it is for multiple curves.
"""
super().hook_crosshair()
if self.crosshair:
self.highlighted_curve_index_changed.connect(self.crosshair.update_highlighted_curve)
if self.curves:
self.crosshair.update_highlighted_curve(self._current_highlight_index)
def clear_curves(self):
"""
Remove all curves from the plot, excluding crosshair items.
"""
items_to_remove = []
for item in self.plot_item.items:
if not getattr(item, "is_crosshair", False) and isinstance(item, pg.PlotDataItem):
items_to_remove.append(item)
for item in items_to_remove:
self.plot_item.removeItem(item)
def _sync_monitor_selection_toolbar(self):
"""
Sync the motor map selection toolbar with the current motor map.
"""
if self.monitor_selection_bundle is not None:
monitor = self.monitor_selection_bundle.monitor.currentText()
color_palette = self.monitor_selection_bundle.colormap_widget.colormap
if monitor != self.config.monitor:
self.monitor_selection_bundle.monitor.blockSignals(True)
self.monitor_selection_bundle.monitor.set_device(self.config.monitor)
self.monitor_selection_bundle.monitor.check_validity(self.config.monitor)
self.monitor_selection_bundle.monitor.blockSignals(False)
if color_palette != self.config.color_palette:
self.monitor_selection_bundle.colormap_widget.blockSignals(True)
self.monitor_selection_bundle.colormap_widget.colormap = self.config.color_palette
self.monitor_selection_bundle.colormap_widget.blockSignals(False)

View File

@ -0,0 +1 @@
{'files': ['multi_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.multi_waveform.multi_waveform import MultiWaveform
DOM_XML = """
<ui language='c++'>
<widget class='MultiWaveform' name='multi_waveform'>
</widget>
</ui>
"""
class MultiWaveformPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = MultiWaveform(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "Plot Widgets Next Gen"
def icon(self):
return designer_material_icon(MultiWaveform.ICON_NAME)
def includeFile(self):
return "multi_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 "MultiWaveform"
def toolTip(self):
return "MultiWaveform"
def whatsThis(self):
return self.toolTip()

View File

@ -0,0 +1,17 @@
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.multi_waveform.multi_waveform_plugin import (
MultiWaveformPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(MultiWaveformPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@ -0,0 +1,145 @@
import os
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.widget_io import WidgetIO
class MultiWaveformControlPanel(SettingWidget):
"""
A settings widget MultiWaveformControlPanel that allows the user to modify the properties.
The widget has skip_settings property set to True, which means it should not be saved
in the settings file. It is used to mirror the properties of the target widget.
"""
def __init__(self, parent=None, target_widget=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("MultiWaveformControlPanel")
current_path = os.path.dirname(__file__)
form = UILoader().load_ui(os.path.join(current_path, "multi_waveform_controls.ui"), self)
self.target_widget = target_widget
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.ui = form
self.ui_widget_list = [
self.ui.opacity,
self.ui.highlighted_index,
self.ui.highlight_last_curve,
self.ui.flush_buffer,
self.ui.max_trace,
]
if self.target_widget is not None:
self.connect_all_signals()
self.target_widget.property_changed.connect(self.update_property)
self.target_widget.monitor_signal_updated.connect(self.update_controls_limits)
self.ui.highlight_last_curve.toggled.connect(self.set_highlight_last_curve)
self.fetch_all_properties()
def connect_all_signals(self):
for widget in self.ui_widget_list:
WidgetIO.connect_widget_change_signal(widget, self.set_property)
@SafeSlot()
def set_property(self, widget: QWidget, value):
"""
Set property of the target widget based on the widget that emitted the signal.
The name of the property has to be the same as the objectName of the widget
and compatible with WidgetIO.
Args:
widget(QWidget): The widget that emitted the signal.
value(): The value to set the property to.
"""
try: # to avoid crashing when the widget is not found in Designer
property_name = widget.objectName()
setattr(self.target_widget, property_name, value)
except RuntimeError:
return
@SafeSlot()
def update_property(self, property_name: str, value):
"""
Update the value of the widget based on the property name and value.
The name of the property has to be the same as the objectName of the widget
and compatible with WidgetIO.
Args:
property_name(str): The name of the property to update.
value: The value to set the property to.
"""
try: # to avoid crashing when the widget is not found in Designer
widget_to_set = self.ui.findChild(QWidget, property_name)
except RuntimeError:
return
if widget_to_set is None:
return
WidgetIO.set_value(widget_to_set, value)
def fetch_all_properties(self):
"""
Fetch all properties from the target widget and update the settings widget.
"""
for widget in self.ui_widget_list:
property_name = widget.objectName()
value = getattr(self.target_widget, property_name)
WidgetIO.set_value(widget, value)
def accept_changes(self):
"""
Apply all properties from the settings widget to the target widget.
"""
for widget in self.ui_widget_list:
property_name = widget.objectName()
value = WidgetIO.get_value(widget)
setattr(self.target_widget, property_name, value)
@SafeSlot()
def update_controls_limits(self):
"""
Update the limits of the controls.
"""
num_curves = len(self.target_widget.curves)
if num_curves == 0:
num_curves = 1 # Avoid setting max to 0
current_index = num_curves - 1
self.ui.highlighted_index.setMinimum(0)
self.ui.highlighted_index.setMaximum(self.target_widget.number_of_visible_curves - 1)
self.ui.spinbox_index.setMaximum(self.target_widget.number_of_visible_curves - 1)
if self.ui.highlight_last_curve.isChecked():
self.ui.highlighted_index.setValue(current_index)
self.ui.spinbox_index.setValue(current_index)
@SafeSlot(bool)
def set_highlight_last_curve(self, enable: bool) -> None:
"""
Enable or disable highlighting of the last curve.
Args:
enable(bool): True to enable highlighting of the last curve, False to disable.
"""
self.target_widget.config.highlight_last_curve = enable
if enable:
self.ui.highlighted_index.setEnabled(False)
self.ui.spinbox_index.setEnabled(False)
self.ui.highlight_last_curve.setChecked(True)
self.target_widget.set_curve_highlight(-1)
else:
self.ui.highlighted_index.setEnabled(True)
self.ui.spinbox_index.setEnabled(True)
self.ui.highlight_last_curve.setChecked(False)
index = self.ui.spinbox_index.value()
self.target_widget.set_curve_highlight(index)

View File

@ -0,0 +1,164 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>561</width>
<height>86</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_curve_index">
<property name="text">
<string>Curve Index</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSlider" name="highlighted_index">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QSpinBox" name="spinbox_index"/>
</item>
<item row="0" column="3" colspan="3">
<widget class="QCheckBox" name="highlight_last_curve">
<property name="text">
<string>Highlight always last curve</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_opacity">
<property name="text">
<string>Opacity</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSlider" name="opacity">
<property name="maximum">
<number>100</number>
</property>
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="QLabel" name="label_max_trace">
<property name="text">
<string>Max Trace</string>
</property>
</widget>
</item>
<item row="1" column="4">
<widget class="QSpinBox" name="max_trace">
<property name="toolTip">
<string>How many curves should be displayed</string>
</property>
<property name="maximum">
<number>500</number>
</property>
<property name="value">
<number>200</number>
</property>
</widget>
</item>
<item row="1" column="5">
<widget class="QCheckBox" name="flush_buffer">
<property name="toolTip">
<string>If hiddne curves should be deleted.</string>
</property>
<property name="text">
<string>Flush Buffer</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QSpinBox" name="spinbox_opacity">
<property name="maximum">
<number>100</number>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>opacity</sender>
<signal>valueChanged(int)</signal>
<receiver>spinbox_opacity</receiver>
<slot>setValue(int)</slot>
<hints>
<hint type="sourcelabel">
<x>211</x>
<y>66</y>
</hint>
<hint type="destinationlabel">
<x>260</x>
<y>59</y>
</hint>
</hints>
</connection>
<connection>
<sender>spinbox_opacity</sender>
<signal>valueChanged(int)</signal>
<receiver>opacity</receiver>
<slot>setValue(int)</slot>
<hints>
<hint type="sourcelabel">
<x>269</x>
<y>62</y>
</hint>
<hint type="destinationlabel">
<x>182</x>
<y>62</y>
</hint>
</hints>
</connection>
<connection>
<sender>highlighted_index</sender>
<signal>valueChanged(int)</signal>
<receiver>spinbox_index</receiver>
<slot>setValue(int)</slot>
<hints>
<hint type="sourcelabel">
<x>191</x>
<y>27</y>
</hint>
<hint type="destinationlabel">
<x>256</x>
<y>27</y>
</hint>
</hints>
</connection>
<connection>
<sender>spinbox_index</sender>
<signal>valueChanged(int)</signal>
<receiver>highlighted_index</receiver>
<slot>setValue(int)</slot>
<hints>
<hint type="sourcelabel">
<x>264</x>
<y>20</y>
</hint>
<hint type="destinationlabel">
<x>195</x>
<y>24</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@ -0,0 +1,58 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QStyledItemDelegate
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import ToolbarBundle, WidgetAction
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
class NoCheckDelegate(QStyledItemDelegate):
"""To reduce space in combo boxes by removing the checkmark."""
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
# Remove any check indicator
option.checkState = Qt.Unchecked
class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that selects motors.
"""
def __init__(self, bundle_id="monitor_selection", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Monitor Selection
self.monitor = DeviceComboBox(
device_filter=BECDeviceFilter.DEVICE, readout_priority_filter=ReadoutPriority.ASYNC
)
self.monitor.addItem("", None)
self.monitor.setCurrentText("")
self.monitor.setToolTip("Select Monitor")
self.monitor.setItemDelegate(NoCheckDelegate(self.monitor))
self.add_action("monitor", WidgetAction(widget=self.monitor, adjust_size=False))
# Colormap Selection
self.colormap_widget = BECColorMapWidget(cmap="magma")
self.add_action("color_map", WidgetAction(widget=self.colormap_widget, adjust_size=False))
# Connect slots, a device will be connected upon change of any combobox
self.monitor.currentTextChanged.connect(lambda: self.connect())
self.colormap_widget.colormap_changed_signal.connect(self.change_colormap)
@SafeSlot()
def connect(self):
monitor = self.monitor.currentText()
if monitor != "":
if monitor != self.target_widget.config.monitor:
self.target_widget.monitor = monitor
@SafeSlot(str)
def change_colormap(self, colormap: str):
self.target_widget.color_palette = colormap

View File

@ -0,0 +1,342 @@
import numpy as np
from bec_widgets.widgets.plots_next_gen.multi_waveform.multi_waveform import MultiWaveform
from tests.unit_tests.client_mocks import mocked_client
from .conftest import create_widget
##################################################
# MultiWaveform widget base functionality tests
##################################################
def test_multiwaveform_initialization(qtbot, mocked_client):
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
assert mw.objectName() == "MultiWaveform"
# Inherited from PlotBase
assert mw.title == ""
assert mw.x_label == ""
assert mw.y_label == ""
# No crosshair or FPS monitor by default
assert mw.crosshair is None
assert mw.fps_monitor is None
# No curves initially
assert len(mw.plot_item.curves) == 0
# Multiwaveform specific
assert mw.monitor is None
assert mw.color_palette == "magma"
assert mw.max_trace == 200
assert mw.flush_buffer is False
assert mw.highlight_last_curve is True
assert mw.opacity == 50
assert mw.scan_id is None
assert mw.highlighted_index == 0
def test_multiwaveform_set_monitor(qtbot, mocked_client):
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
assert mw.monitor is None
# Set a monitor
mw.plot("waveform1d")
assert mw.monitor == "waveform1d"
assert mw.config.monitor == "waveform1d"
assert mw.connected is True
def test_multiwaveform_set_properties(qtbot, mocked_client):
"""Check that MultiWaveform properties can be set and retrieved correctly."""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
# Default checks
assert mw.color_palette == "magma"
assert mw.max_trace == 200
assert mw.flush_buffer is False
assert mw.highlight_last_curve is True
assert mw.opacity == 50
# Change properties
mw.color_palette = "viridis"
mw.max_trace = 10
mw.flush_buffer = True
mw.highlight_last_curve = False
mw.opacity = 75
# Verify that changes took effect
assert mw.color_palette == "viridis"
assert mw.max_trace == 10
assert mw.flush_buffer is True
assert mw.highlight_last_curve is False
assert mw.opacity == 75
def test_multiwaveform_curve_limit_no_flush(qtbot, mocked_client):
"""Check that limiting the number of curves without flush simply hides older ones."""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
mw.max_trace = 3
mw.flush_buffer = False
# Simulate updates that create multiple curves
for i in range(5):
msg_data = {"data": np.array([i, i + 0.5, i + 1])}
mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_1"})
# There should be 5 curves in total, but only the last 3 are visible
assert len(mw.curves) == 5
visible_curves = [c for c in mw.curves if c.isVisible()]
assert len(visible_curves) == 3
def test_multiwaveform_curve_limit_flush(qtbot, mocked_client):
"""Check that limiting the number of curves with flush removes older ones."""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
mw.max_trace = 3
mw.flush_buffer = True
# Simulate adding multiple curves
for i in range(5):
msg_data = {"data": np.array([i, i + 0.5, i + 1])}
mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_1"})
# Only 3 curves remain after flush
assert len(mw.curves) == 3
# They should match the last 3 that were inserted
x_data, y_data = mw.curves[0].getData()
assert np.array_equal(y_data, [2, 2.5, 3])
x_data, y_data = mw.curves[1].getData()
assert np.array_equal(y_data, [3, 3.5, 4])
x_data, y_data = mw.curves[2].getData()
assert np.array_equal(y_data, [4, 4.5, 5])
def test_multiwaveform_highlight_last_curve(qtbot, mocked_client):
"""Check highlight_last_curve behavior."""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
mw.max_trace = 5
mw.flush_buffer = False
# Simulate adding multiple curves
for i in range(3):
msg_data = {"data": np.array([i, i + 1, i + 2])}
mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_1"})
# Initially highlight_last_curve is True, so the last visible curve is highlighted
# The highlight index should be -1 in the code's logic
assert mw.highlight_last_curve is True
# Disable highlight_last_curve
mw.highlight_last_curve = False
# Force highlight of the 1st visible curve (index 0 among visible)
mw.set_curve_highlight(0)
assert mw.highlighted_index == 0
def test_multiwaveform_opacity_changes(qtbot, mocked_client):
"""Check changing opacity affects existing curves."""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
mw.plot("waveform1d")
# Add one curve
msg_data = {"data": np.array([10, 20, 30])}
mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_1"})
assert len(mw.curves) == 1
# Default opacity is 50
assert mw.opacity == 50
# Change opacity
mw.opacity = 80
assert mw.opacity == 80
def test_multiwaveform_set_colormap(qtbot, mocked_client):
"""Check that setting a new colormap updates curve colors."""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
mw.plot("waveform1d")
# Simulate multiple curve updates
for i in range(3):
msg_data = {"data": np.array([i, i + 1, i + 2])}
mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_1"})
# Default color_palette is "magma"
assert mw.color_palette == "magma"
# Now change to a new colormap
mw.color_palette = "viridis"
assert mw.color_palette == "viridis"
def test_multiwaveform_simulate_updates(qtbot, mocked_client):
"""Simulate a series of 1D updates to ensure the data is appended and the correct number of curves appear."""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
mw.plot("waveform1d")
data_series = [np.random.rand(5), np.random.rand(5), np.random.rand(5)]
for idx, arr in enumerate(data_series):
msg_data = {"data": arr}
mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_99"})
# Each update should add a new curve
assert len(mw.curves) == idx + 1
x_data, y_data = mw.curves[-1].getData()
assert np.array_equal(y_data, arr)
# Check that the scan_id was updated
assert mw.scan_id == "scan_99"
##################################################
# MultiWaveform control panel and toolbar
##################################################
def test_control_panel_updates_widget(qtbot, mocked_client):
"""
Interact with the control panels UI elements and confirm the widgets properties are updated.
"""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
assert mw.opacity == 50
assert mw.flush_buffer is False
assert mw.max_trace == 200
assert mw.highlight_last_curve is True
mw.controls.ui.opacity.setValue(80)
assert mw.opacity == 80
mw.controls.ui.flush_buffer.setChecked(True)
assert mw.flush_buffer is True
mw.controls.ui.max_trace.setValue(12)
assert mw.max_trace == 12
mw.controls.ui.highlight_last_curve.setChecked(False)
assert mw.highlight_last_curve is False
def test_widget_updates_control_panel(qtbot, mocked_client):
"""
Change properties directly on the MultiWaveform and verify the control panel UI reflects those changes.
"""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
mw.opacity = 25
qtbot.wait(100)
assert mw.controls.ui.opacity.value() == 25
mw.flush_buffer = True
qtbot.wait(100)
assert mw.controls.ui.flush_buffer.isChecked() is True
mw.max_trace = 9
qtbot.wait(100)
assert mw.controls.ui.max_trace.value() == 9
mw.highlight_last_curve = False
qtbot.wait(100)
assert mw.controls.ui.highlight_last_curve.isChecked() is False
def test_selection_toolbar_updates_widget(qtbot, mocked_client):
"""
Confirm that selecting a monitor and a colormap from the selection toolbar
updates the widget properties.
"""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
toolbar = mw.monitor_selection_bundle
monitor_combo = toolbar.monitor
colormap_widget = toolbar.colormap_widget
monitor_combo.addItem("waveform1d")
monitor_combo.setCurrentText("waveform1d")
assert mw.monitor == "waveform1d"
colormap_widget.colormap = "viridis"
assert mw.color_palette == "viridis"
def test_control_panel_opacity_slider_spinbox(qtbot, mocked_client):
"""
Verify that when the user moves the opacity slider or spinbox, the widget's
opacity property updates, and vice versa. Also confirm they stay in sync.
"""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
slider_opacity = mw.controls.ui.opacity
spinbox_opacity = mw.controls.ui.spinbox_opacity
# Default
assert mw.opacity == 50
assert slider_opacity.value() == 50
assert spinbox_opacity.value() == 50
# Move the slider
slider_opacity.setValue(75)
assert mw.opacity == 75
assert spinbox_opacity.value() == 75
# Move the spinbox
spinbox_opacity.setValue(20)
assert mw.opacity == 20
assert slider_opacity.value() == 20
mw.opacity = 95
qtbot.wait(100)
assert slider_opacity.value() == 95
assert spinbox_opacity.value() == 95
def test_control_panel_highlight_slider_spinbox(qtbot, mocked_client):
"""
Test that the slider and spinbox for curve highlighting update
the widgets highlighted_index property, and are disabled if
highlight_last_curve is True.
"""
mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
slider_index = mw.controls.ui.highlighted_index
spinbox_index = mw.controls.ui.spinbox_index
checkbox_highlight_last = mw.controls.ui.highlight_last_curve
# By default highlight_last_curve is True, so slider/spinbox are disabled:
assert checkbox_highlight_last.isChecked() is True
assert not slider_index.isEnabled()
assert not spinbox_index.isEnabled()
# Uncheck highlight_last_curve -> slider/spinbox become enabled
checkbox_highlight_last.setChecked(False)
assert checkbox_highlight_last.isChecked() is False
assert slider_index.isEnabled()
assert spinbox_index.isEnabled()
# Simulate a few curves so there's something to highlight
data_arrays = [np.array([0, 1, 2]), np.array([3, 4, 5]), np.array([6, 7, 8])]
for arr in data_arrays:
mw.on_monitor_1d_update({"data": arr}, {"scan_id": "scan_123"})
# The number_of_visible_curves == 3 now
max_index = mw.number_of_visible_curves - 1
assert max_index == 2
# Move the slider to index 1
slider_index.setValue(1)
assert mw.highlighted_index == 1
assert spinbox_index.value() == 1
# Move the spinbox to index 2
spinbox_index.setValue(2)
assert mw.highlighted_index == 2
assert slider_index.value() == 2
# Directly set mw.highlighted_index
mw.highlighted_index = 0
qtbot.wait(100)
assert slider_index.value() == 0
assert spinbox_index.value() == 0
# Re-check highlight_last_curve -> slider/spinbox disabled again
checkbox_highlight_last.setChecked(True)
assert not slider_index.isEnabled()
assert not spinbox_index.isEnabled()
assert mw.highlighted_index == 2