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

refactor(bec_figure): BECFigure removed

This commit is contained in:
2025-03-20 20:46:57 +01:00
parent 6c90ca3107
commit f76d9319bd
31 changed files with 146 additions and 8036 deletions

View File

@ -20,7 +20,6 @@ from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
messages = lazy_import("bec_lib.messages")
@ -58,7 +57,7 @@ class BECWidgetsCLIServer:
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: Union[BECFigure, BECDockArea] = BECDockArea,
gui_class: BECDockArea = BECDockArea,
gui_class_id: str = "bec",
) -> None:
self.status = messages.BECStatus.BUSY
@ -205,10 +204,7 @@ class SimpleFileLikeFromLogOutputFunc:
def _start_server(
gui_id: str,
gui_class: Union[BECFigure, BECDockArea],
gui_class_id: str = "bec",
config: str | None = None,
gui_id: str, gui_class: BECDockArea, gui_class_id: str = "bec", config: str | None = None
):
if config:
try:
@ -268,8 +264,6 @@ def main():
if args.gui_class == "BECDockArea":
gui_class = BECDockArea
elif args.gui_class == "BECFigure":
gui_class = BECFigure
else:
print(
"Please specify a valid gui_class to run. Use -h for help."

View File

@ -17,7 +17,6 @@ from qtpy.QtWidgets import (
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.widgets.containers.dock import BECDockArea
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.image.image import Image
@ -43,18 +42,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"np": np,
"pg": pg,
"wh": wh,
"fig": self.figure,
"dock": self.dock,
"w1": self.w1,
"w2": self.w2,
"w3": self.w3,
"w4": self.w4,
"w5": self.w5,
"w6": self.w6,
"w7": self.w7,
"w8": self.w8,
"w9": self.w9,
"w10": self.w10,
"im": self.im,
"mi": self.mi,
"mm": self.mm,
@ -89,12 +77,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
second_tab = QWidget()
second_tab_layout = QVBoxLayout(second_tab)
self.figure = BECFigure(parent=self, gui_id="figure")
second_tab_layout.addWidget(self.figure)
tab_widget.addTab(second_tab, "BEC Figure")
third_tab = QWidget()
third_tab_layout = QVBoxLayout(third_tab)
self.lm = LayoutManagerWidget()
@ -164,79 +146,16 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# add stuff to the new Waveform widget
self._init_waveform()
# add stuff to figure
self._init_figure()
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.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
self.wf.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(
title="Standard Plot with sync device, custom labels - w1",
x_label="Motor Position",
y_label="Intensity (A.U.)",
)
self.w2 = self.figure.motor_map("samx", "samy", row=0, col=1)
self.w3 = self.figure.image(
"eiger", color_map="viridis", vrange=(0, 100), title="Eiger Image - w3", row=0, col=2
)
self.w4 = self.figure.plot(
x_name="samx",
y_name="samy",
z_name="bpm4i",
color_map_z="magma",
new=True,
title="2D scatter plot - w4",
row=0,
col=3,
)
self.w5 = self.figure.plot(
y_name="bpm4i",
new=True,
title="Best Effort Plot - w5",
dap="GaussianModel",
row=1,
col=0,
)
self.w6 = self.figure.plot(
x_name="timestamp", y_name="bpm4i", new=True, title="Timestamp Plot - w6", row=1, col=1
)
self.w7 = self.figure.plot(
x_name="index", y_name="bpm4i", new=True, title="Index Plot - w7", row=1, col=2
)
self.w8 = self.figure.plot(
y_name="monitor_async", new=True, title="Async Plot - Best Effort - w8", row=2, col=0
)
self.w9 = self.figure.plot(
x_name="timestamp",
y_name="monitor_async",
new=True,
title="Async Plot - timestamp - w9",
row=2,
col=1,
)
self.w10 = self.figure.plot(
x_name="index",
y_name="monitor_async",
new=True,
title="Async Plot - index - w10",
row=2,
col=2,
)
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
self.dock.cleanup()
self.dock.close()
self.figure.cleanup()
self.figure.close()
self.console.close()
super().closeEvent(event)

View File

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

View File

@ -1,789 +0,0 @@
# pylint: disable = no-name-in-module,missing-module-docstring
from __future__ import annotations
import uuid
from collections import defaultdict
from typing import Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import QWidget
from typeguard import typechecked
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow, ImageConfig
from bec_widgets.widgets.containers.figure.plots.motor_map.motor_map import (
BECMotorMap,
MotorMapConfig,
)
from bec_widgets.widgets.containers.figure.plots.multi_waveform.multi_waveform import (
BECMultiWaveform,
BECMultiWaveformConfig,
)
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import (
BECWaveform,
Waveform1DConfig,
)
logger = bec_logger.logger
class FigureConfig(ConnectionConfig):
"""Configuration for BECFigure. Inheriting from ConnectionConfig widget_class and gui_id"""
theme: Literal["dark", "light"] = Field("dark", description="The theme of the figure widget.")
num_cols: int = Field(1, description="The number of columns in the figure widget.")
num_rows: int = Field(1, description="The number of rows in the figure widget.")
widgets: dict[str, Waveform1DConfig | ImageConfig | MotorMapConfig | SubplotConfig] = Field(
{}, description="The list of widgets to be added to the figure widget."
)
@field_validator("widgets", mode="before")
@classmethod
def validate_widgets(cls, v):
"""Validate the widgets configuration."""
widget_class_map = {
"BECWaveform": Waveform1DConfig,
"BECImageShow": ImageConfig,
"BECMotorMap": MotorMapConfig,
}
validated_widgets = {}
for key, widget_config in v.items():
if "widget_class" not in widget_config:
raise ValueError(f"Widget config for {key} does not contain 'widget_class'.")
widget_class = widget_config["widget_class"]
if widget_class not in widget_class_map:
raise ValueError(f"Unknown widget_class '{widget_class}' for widget '{key}'.")
config_class = widget_class_map[widget_class]
validated_widgets[key] = config_class(**widget_config)
return validated_widgets
class WidgetHandler:
"""Factory for creating and configuring BEC widgets for BECFigure."""
def __init__(self):
self.widget_factory = {
"BECPlotBase": (BECPlotBase, SubplotConfig),
"BECWaveform": (BECWaveform, Waveform1DConfig),
"BECImageShow": (BECImageShow, ImageConfig),
"BECMotorMap": (BECMotorMap, MotorMapConfig),
"BECMultiWaveform": (BECMultiWaveform, BECMultiWaveformConfig),
}
def create_widget(
self, widget_type: str, parent_figure, parent_id: str, config: dict = None, **axis_kwargs
) -> BECPlotBase:
"""
Create and configure a widget based on its type.
Args:
widget_type (str): The type of the widget to create.
widget_id (str): Unique identifier for the widget.
parent_id (str): Identifier of the parent figure.
config (dict, optional): Additional configuration for the widget.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECPlotBase: The created and configured widget instance.
"""
entry = self.widget_factory.get(widget_type)
if not entry:
raise ValueError(f"Unsupported widget type: {widget_type}")
widget_class, config_class = entry
if config is not None and isinstance(config, config_class):
config = config.model_dump()
widget_config_dict = {
"widget_class": widget_class.__name__,
"parent_id": parent_id,
**(config if config is not None else {}),
}
widget_config = config_class(**widget_config_dict)
widget = widget_class(
config=widget_config, parent_figure=parent_figure, client=parent_figure.client
)
if axis_kwargs:
widget.set(**axis_kwargs)
return widget
class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"_get_all_rpc",
"axes",
"widgets",
"plot",
"image",
"motor_map",
"remove",
"change_layout",
"change_theme",
"export",
"clear_all",
"widget_list",
]
subplot_map = {
"PlotBase": BECPlotBase,
"BECWaveform": BECWaveform,
"BECImageShow": BECImageShow,
"BECMotorMap": BECMotorMap,
"BECMultiWaveform": BECMultiWaveform,
}
widget_method_map = {
"BECWaveform": "plot",
"BECImageShow": "image",
"BECMotorMap": "motor_map",
"BECMultiWaveform": "multi_waveform",
}
clean_signal = pyqtSignal()
def __init__(
self,
parent: Optional[QWidget] = None,
config: Optional[FigureConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
) -> None:
if config is None:
config = FigureConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = FigureConfig(**config)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
pg.GraphicsLayoutWidget.__init__(self, parent)
self.widget_handler = WidgetHandler()
# Widget container to reference widgets by 'widget_id'
self._widgets = defaultdict(dict)
# Container to keep track of the grid
self.grid = []
# Create config and apply it
self.apply_config(config)
def __getitem__(self, key: tuple | str):
if isinstance(key, tuple) and len(key) == 2:
return self.axes(*key)
if isinstance(key, str):
widget = self._widgets.get(key)
if widget is None:
raise KeyError(f"No widget with ID {key}")
return self._widgets.get(key)
else:
raise TypeError(
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
)
def apply_config(self, config: dict | FigureConfig): # ,generate_new_id: bool = False):
if isinstance(config, dict):
try:
config = FigureConfig(**config)
except ValidationError as e:
logger.error(f"Error in applying config: {e}")
return
self.config = config
# widget_config has to be reset for not have each widget config twice when added to the figure
widget_configs = list(self.config.widgets.values())
self.config.widgets = {}
for widget_config in widget_configs:
getattr(self, self.widget_method_map[widget_config.widget_class])(
config=widget_config.model_dump(), row=widget_config.row, col=widget_config.col
)
@property
def widget_list(self) -> list[BECPlotBase]:
"""
Access all widget in BECFigure as a list
Returns:
list[BECPlotBase]: List of all widgets in the figure.
"""
axes = [value for value in self._widgets.values() if isinstance(value, BECPlotBase)]
return axes
@widget_list.setter
def widget_list(self, value: list[BECPlotBase]):
"""
Access all widget in BECFigure as a list
Returns:
list[BECPlotBase]: List of all widgets in the figure.
"""
self._axes = value
@property
def widgets(self) -> dict:
"""
All widgets within the figure with gui ids as keys.
Returns:
dict: All widgets within the figure.
"""
return self._widgets
@widgets.setter
def widgets(self, value: dict):
"""
All widgets within the figure with gui ids as keys.
Returns:
dict: All widgets within the figure.
"""
self._widgets = value
def export(self):
"""Export the plot widget."""
try:
plot_item = self.widget_list[0]
except Exception as exc:
raise ValueError("No plot widget available to export.") from exc
scene = plot_item.scene()
scene.contextMenuItem = plot_item
scene.showExportDialog()
@typechecked
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,
z_name: str | None = None,
x_entry: str | None = None,
y_entry: str | None = None,
z_entry: str | None = None,
color: str | None = None,
color_map_z: str | None = "magma",
label: str | None = None,
validate: bool = True,
new: bool = False,
row: int | None = None,
col: int | None = None,
dap: str | None = None,
config: dict | None = None, # TODO make logic more transparent
**axis_kwargs,
) -> BECWaveform:
"""
Add a 1D waveform plot to the figure. Always access the first waveform widget in the figure.
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 x data to plot.
x_name(str): The name of the device for the x-axis.
y_name(str): The name of the device for the y-axis.
z_name(str): The name of the device for the z-axis.
x_entry(str): The name of the entry for the x-axis.
y_entry(str): The name of the entry for the y-axis.
z_entry(str): The name of the entry for the z-axis.
color(str): The color of the curve.
color_map_z(str): The color map to use for the z-axis.
label(str): The label of the curve.
validate(bool): If True, validate the device names and entries.
new(bool): If True, create a new plot instead of using the first plot.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
dap(str): The DAP model to use for the curve.
config(dict): Recreates the whole BECWaveform widget from provided configuration.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECWaveform: The waveform plot widget.
"""
waveform = self.subplot_factory(
widget_type="BECWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
)
if config is not None:
return waveform
if arg1 is not None or y_name is not None or (y is not None and x is not None):
waveform.plot(
arg1=arg1,
y=y,
x=x,
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
color=color,
color_map_z=color_map_z,
label=label,
validate=validate,
dap=dap,
)
return waveform
def _init_image(
self,
image,
monitor: str = None,
monitor_type: Literal["1d", "2d"] = "2d",
color_bar: Literal["simple", "full"] = "full",
color_map: str = "magma",
data: np.ndarray = None,
vrange: tuple[float, float] = None,
) -> BECImageShow:
"""
Configure the image based on the provided parameters.
Args:
image (BECImageShow): The image to configure.
monitor (str): The name of the monitor to display.
color_bar (Literal["simple","full"]): The type of color bar to display.
color_map (str): The color map to use for the image.
data (np.ndarray): Custom data to display.
"""
if monitor is not None and data is None:
image.image(
monitor=monitor,
monitor_type=monitor_type,
color_map=color_map,
vrange=vrange,
color_bar=color_bar,
)
elif data is not None and monitor is None:
image.add_custom_image(
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
)
elif data is None and monitor is None:
# Setting appearance
if vrange is not None:
image.set_vrange(vmin=vrange[0], vmax=vrange[1])
if color_map is not None:
image.set_color_map(color_map)
else:
raise ValueError("Invalid input. Provide either monitor name or custom data.")
return image
def image(
self,
monitor: str = None,
monitor_type: Literal["1d", "2d"] = "2d",
color_bar: Literal["simple", "full"] = "full",
color_map: str = "magma",
data: np.ndarray = None,
vrange: tuple[float, float] = None,
new: bool = False,
row: int | None = None,
col: int | None = None,
config: dict | None = None,
**axis_kwargs,
) -> BECImageShow:
"""
Add an image to the figure. Always access the first image widget in the figure.
Args:
monitor(str): The name of the monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
color_map(str): The color map to use for the image.
data(np.ndarray): Custom data to display.
vrange(tuple[float, float]): The range of values to display.
new(bool): If True, create a new plot instead of using the first plot.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Recreates the whole BECImageShow widget from provided configuration.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECImageShow: The image widget.
"""
image = self.subplot_factory(
widget_type="BECImageShow", config=config, row=row, col=col, new=new, **axis_kwargs
)
if config is not None:
return image
image = self._init_image(
image=image,
monitor=monitor,
monitor_type=monitor_type,
color_bar=color_bar,
color_map=color_map,
data=data,
vrange=vrange,
)
return image
def motor_map(
self,
motor_x: str = None,
motor_y: str = None,
new: bool = False,
row: int | None = None,
col: int | None = None,
config: dict | None = None,
**axis_kwargs,
) -> BECMotorMap:
"""
Add a motor map to the figure. Always access the first motor map widget in the figure.
Args:
motor_x(str): The name of the motor for the X axis.
motor_y(str): The name of the motor for the Y axis.
new(bool): If True, create a new plot instead of using the first plot.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Recreates the whole BECImageShow widget from provided configuration.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECMotorMap: The motor map widget.
"""
motor_map = self.subplot_factory(
widget_type="BECMotorMap", config=config, row=row, col=col, new=new, **axis_kwargs
)
if config is not None:
return motor_map
if motor_x is not None and motor_y is not None:
motor_map.change_motors(motor_x, motor_y)
return motor_map
def multi_waveform(
self,
monitor: str = None,
new: bool = False,
row: int | None = None,
col: int | None = None,
config: dict | None = None,
**axis_kwargs,
):
multi_waveform = self.subplot_factory(
widget_type="BECMultiWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
)
if config is not None:
return multi_waveform
multi_waveform.set_monitor(monitor)
return multi_waveform
def subplot_factory(
self,
widget_type: Literal[
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
] = "BECPlotBase",
row: int = None,
col: int = None,
config=None,
new: bool = False,
**axis_kwargs,
) -> BECPlotBase:
# Case 1 - config provided, new plot, possible to define coordinates
if config is not None:
widget_cls = config["widget_class"]
if widget_cls != widget_type:
raise ValueError(
f"Widget type '{widget_type}' does not match the provided configuration ({widget_cls})."
)
widget = self.add_widget(
widget_type=widget_type, config=config, row=row, col=col, **axis_kwargs
)
return widget
# Case 2 - find first plot or create first plot if no plot available, no config provided, no coordinates
if new is False and (row is None or col is None):
widget = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, self.subplot_map[widget_type], can_fail=True
)
if widget is not None:
if axis_kwargs:
widget.set(**axis_kwargs)
else:
widget = self.add_widget(widget_type=widget_type, **axis_kwargs)
return widget
# Case 3 - modifying existing plot wit coordinates provided
if new is False and (row is not None and col is not None):
try:
widget = self.axes(row, col)
except ValueError:
widget = None
if widget is not None:
if axis_kwargs:
widget.set(**axis_kwargs)
else:
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
return widget
# Case 4 - no previous plot or new plot, no config provided, possible to define coordinates
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
return widget
def add_widget(
self,
widget_type: Literal[
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
] = "BECPlotBase",
widget_id: str = None,
row: int = None,
col: int = None,
config=None,
**axis_kwargs,
) -> BECPlotBase:
"""
Add a widget to the figure at the specified position.
Args:
widget_type(Literal["PlotBase","Waveform1D"]): The type of the widget to add.
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
"""
if not widget_id:
widget_id = str(uuid.uuid4())
if widget_id in self._widgets:
raise ValueError(f"Widget with ID '{widget_id}' already exists.")
# Check if position is occupied
if row is not None and col is not None:
if self.getItem(row, col):
raise ValueError(f"Position at row {row} and column {col} is already occupied.")
else:
row, col = self._find_next_empty_position()
widget = self.widget_handler.create_widget(
widget_type=widget_type,
parent_figure=self,
parent_id=self.gui_id,
config=config,
**axis_kwargs,
)
widget_id = widget.gui_id
widget.config.row = row
widget.config.col = col
# Add widget to the figure
self.addItem(widget, row=row, col=col)
# Update num_cols and num_rows based on the added widget
self.config.num_rows = max(self.config.num_rows, row + 1)
self.config.num_cols = max(self.config.num_cols, col + 1)
# Saving config for future referencing
self.config.widgets[widget_id] = widget.config
self._widgets[widget_id] = widget
# Reflect the grid coordinates
self._change_grid(widget_id, row, col)
return widget
def remove(
self,
row: int = None,
col: int = None,
widget_id: str = None,
coordinates: tuple[int, int] = None,
) -> None:
"""
Remove a widget from the figure. Can be removed by its unique identifier or by its coordinates.
Args:
row(int): The row coordinate of the widget to remove.
col(int): The column coordinate of the widget to remove.
widget_id(str): The unique identifier of the widget to remove.
coordinates(tuple[int, int], optional): The coordinates of the widget to remove.
"""
if widget_id:
self._remove_by_id(widget_id)
elif row is not None and col is not None:
self._remove_by_coordinates(row, col)
elif coordinates:
self._remove_by_coordinates(*coordinates)
else:
raise ValueError("Must provide either widget_id or coordinates for removal.")
def change_theme(self, theme: Literal["dark", "light"]) -> None:
"""
Change the theme of the figure widget.
Args:
theme(Literal["dark","light"]): The theme to set for the figure widget.
"""
self.config.theme = theme
apply_theme(theme)
for plot in self.widget_list:
plot.set_x_label(plot.plot_item.getAxis("bottom").label.toPlainText())
plot.set_y_label(plot.plot_item.getAxis("left").label.toPlainText())
if plot.plot_item.titleLabel.text:
plot.set_title(plot.plot_item.titleLabel.text)
plot.set_legend_label_size()
def _remove_by_coordinates(self, row: int, col: int) -> None:
"""
Remove a widget from the figure by its coordinates.
Args:
row(int): The row coordinate of the widget to remove.
col(int): The column coordinate of the widget to remove.
"""
widget = self.axes(row, col)
if widget:
widget_id = widget.config.gui_id
if widget_id in self._widgets:
self._remove_by_id(widget_id)
def _remove_by_id(self, widget_id: str) -> None:
"""
Remove a widget from the figure by its unique identifier.
Args:
widget_id(str): The unique identifier of the widget to remove.
"""
if widget_id in self._widgets:
widget = self._widgets.pop(widget_id)
widget.cleanup_pyqtgraph()
widget.cleanup()
self.removeItem(widget)
self.grid[widget.config.row][widget.config.col] = None
self._reindex_grid()
if widget_id in self.config.widgets:
self.config.widgets.pop(widget_id)
widget.deleteLater()
else:
raise ValueError(f"Widget with ID '{widget_id}' does not exist.")
def axes(self, row: int, col: int) -> BECPlotBase:
"""
Get widget by its coordinates in the figure.
Args:
row(int): the row coordinate
col(int): the column coordinate
Returns:
BECPlotBase: the widget at the given coordinates
"""
widget = self.getItem(row, col)
if widget is None:
raise ValueError(f"No widget at coordinates ({row}, {col})")
return widget
def _find_next_empty_position(self):
"""Find the next empty position (new row) in the figure."""
row, col = 0, 0
while self.getItem(row, col):
row += 1
return row, col
def _change_grid(self, widget_id: str, row: int, col: int):
"""
Change the grid to reflect the new position of the widget.
Args:
widget_id(str): The unique identifier of the widget.
row(int): The new row coordinate of the widget in the figure.
col(int): The new column coordinate of the widget in the figure.
"""
while len(self.grid) <= row:
self.grid.append([])
row = self.grid[row]
while len(row) <= col:
row.append(None)
row[col] = widget_id
def _reindex_grid(self):
"""Reindex the grid to remove empty rows and columns."""
new_grid = []
for row in self.grid:
new_row = [widget for widget in row if widget is not None]
if new_row:
new_grid.append(new_row)
#
# Update the config of each object to reflect its new position
for row_idx, row in enumerate(new_grid):
for col_idx, widget in enumerate(row):
self._widgets[widget].config.row, self._widgets[widget].config.col = (
row_idx,
col_idx,
)
self.grid = new_grid
self._replot_layout()
def _replot_layout(self):
"""Replot the layout based on the current grid configuration."""
self.clear()
for row_idx, row in enumerate(self.grid):
for col_idx, widget in enumerate(row):
self.addItem(self._widgets[widget], row=row_idx, col=col_idx)
def change_layout(self, max_columns=None, max_rows=None):
"""
Reshuffle the layout of the figure to adjust to a new number of max_columns or max_rows.
If both max_columns and max_rows are provided, max_rows is ignored.
Args:
max_columns (Optional[int]): The new maximum number of columns in the figure.
max_rows (Optional[int]): The new maximum number of rows in the figure.
"""
# Calculate total number of widgets
total_widgets = len(self._widgets)
if max_columns:
# Calculate the required number of rows based on max_columns
required_rows = (total_widgets + max_columns - 1) // max_columns
new_grid = [[None for _ in range(max_columns)] for _ in range(required_rows)]
elif max_rows:
# Calculate the required number of columns based on max_rows
required_columns = (total_widgets + max_rows - 1) // max_rows
new_grid = [[None for _ in range(required_columns)] for _ in range(max_rows)]
else:
# If neither max_columns nor max_rows is specified, just return without changing the layout
return
# Populate the new grid with widgets' IDs
current_idx = 0
for widget_id in self._widgets:
row = current_idx // len(new_grid[0])
col = current_idx % len(new_grid[0])
new_grid[row][col] = widget_id
current_idx += 1
self.config.num_rows = row
self.config.num_cols = col
# Update widgets' positions and replot them according to the new grid
self.grid = new_grid
self._reindex_grid() # This method should be updated to handle reshuffling correctly
self._replot_layout() # Assumes this method re-adds widgets to the layout based on self.grid
def clear_all(self):
"""Clear all widgets from the figure and reset to default state"""
for widget in list(self._widgets.values()):
widget.remove()
self._widgets.clear()
self.grid = []
theme = self.config.theme
self.config = FigureConfig(
widget_class=self.__class__.__name__, gui_id=self.gui_id, theme=theme
)
def cleanup_pyqtgraph_all_widgets(self):
"""Clean up the pyqtgraph widget."""
for widget in self.widget_list:
widget.cleanup_pyqtgraph()
def cleanup(self):
"""Close the figure widget."""
self.cleanup_pyqtgraph_all_widgets()

View File

@ -1,91 +0,0 @@
import os
from qtpy.QtWidgets import QVBoxLayout
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.widget_io import WidgetIO
class AxisSettings(SettingWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
current_path = os.path.dirname(__file__)
self.ui = UILoader().load_ui(os.path.join(current_path, "axis_settings.ui"), self)
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.ui)
# Hardcoded values for best appearance
self.setMinimumHeight(280)
self.setMaximumHeight(280)
self.resize(380, 280)
@Slot(dict)
def display_current_settings(self, axis_config: dict):
if axis_config == {}:
return
# Top Box
WidgetIO.set_value(self.ui.plot_title, axis_config["title"])
self.ui.switch_outer_axes.checked = axis_config["outer_axes"]
# X Axis Box
WidgetIO.set_value(self.ui.x_label, axis_config["x_label"])
WidgetIO.set_value(self.ui.x_scale, axis_config["x_scale"])
WidgetIO.set_value(self.ui.x_grid, axis_config["x_grid"])
if axis_config["x_lim"] is not None:
WidgetIO.check_and_adjust_limits(self.ui.x_min, axis_config["x_lim"][0])
WidgetIO.check_and_adjust_limits(self.ui.x_max, axis_config["x_lim"][1])
WidgetIO.set_value(self.ui.x_min, axis_config["x_lim"][0])
WidgetIO.set_value(self.ui.x_max, axis_config["x_lim"][1])
if axis_config["x_lim"] is None:
x_range = self.target_widget.fig.widget_list[0].plot_item.viewRange()[0]
WidgetIO.set_value(self.ui.x_min, x_range[0])
WidgetIO.set_value(self.ui.x_max, x_range[1])
# Y Axis Box
WidgetIO.set_value(self.ui.y_label, axis_config["y_label"])
WidgetIO.set_value(self.ui.y_scale, axis_config["y_scale"])
WidgetIO.set_value(self.ui.y_grid, axis_config["y_grid"])
if axis_config["y_lim"] is not None:
WidgetIO.check_and_adjust_limits(self.ui.y_min, axis_config["y_lim"][0])
WidgetIO.check_and_adjust_limits(self.ui.y_max, axis_config["y_lim"][1])
WidgetIO.set_value(self.ui.y_min, axis_config["y_lim"][0])
WidgetIO.set_value(self.ui.y_max, axis_config["y_lim"][1])
if axis_config["y_lim"] is None:
y_range = self.target_widget.fig.widget_list[0].plot_item.viewRange()[1]
WidgetIO.set_value(self.ui.y_min, y_range[0])
WidgetIO.set_value(self.ui.y_max, y_range[1])
@Slot()
def accept_changes(self):
title = WidgetIO.get_value(self.ui.plot_title)
outer_axes = self.ui.switch_outer_axes.checked
# X Axis
x_label = WidgetIO.get_value(self.ui.x_label)
x_scale = self.ui.x_scale.currentText()
x_grid = WidgetIO.get_value(self.ui.x_grid)
x_lim = (WidgetIO.get_value(self.ui.x_min), WidgetIO.get_value(self.ui.x_max))
# Y Axis
y_label = WidgetIO.get_value(self.ui.y_label)
y_scale = self.ui.y_scale.currentText()
y_grid = WidgetIO.get_value(self.ui.y_grid)
y_lim = (WidgetIO.get_value(self.ui.y_min), WidgetIO.get_value(self.ui.y_max))
self.target_widget.set(
title=title,
x_label=x_label,
x_scale=x_scale,
x_lim=x_lim,
y_label=y_label,
y_scale=y_scale,
y_lim=y_lim,
)
self.target_widget.set_grid(x_grid, y_grid)
self.target_widget.set_outer_axes(outer_axes)

View File

@ -1,256 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>427</width>
<height>270</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>250</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>278</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="plot_title"/>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="3" column="2">
<widget class="QComboBox" name="y_scale">
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>log</string>
</property>
</item>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="y_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="y_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="y_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="y_label"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="y_scale_label">
<property name="text">
<string>Scale</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="y_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="y_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QCheckBox" name="y_grid">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="y_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="2" column="0">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Scale</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="x_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="x_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QComboBox" name="x_scale">
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>log</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QCheckBox" name="x_grid">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="switch_outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@ -1,773 +0,0 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any, Literal, Optional
import numpy as np
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError
from qtpy.QtCore import QThread, Slot
from qtpy.QtWidgets import QWidget
# from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import EntryValidator
from bec_widgets.widgets.containers.figure.plots.image.image_item import (
BECImageItem,
ImageItemConfig,
)
from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
ImageProcessor,
ImageStats,
ProcessorWorker,
)
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
logger = bec_logger.logger
class ImageConfig(SubplotConfig):
images: dict[str, ImageItemConfig] = Field(
{},
description="The configuration of the images. The key is the name of the image (source).",
)
# TODO old version will be deprecated
class BECImageShow(BECPlotBase):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"add_image_by_config",
"image",
"add_custom_image",
"set_vrange",
"set_color_map",
"set_autorange",
"set_autorange_mode",
"set_monitor",
"set_processing",
"set_image_properties",
"set_fft",
"set_log",
"set_rotation",
"set_transpose",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"remove",
"images",
]
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[ImageConfig] = None,
client=None,
gui_id: Optional[str] = None,
single_image: bool = True,
):
if config is None:
config = ImageConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.single_image = single_image
self.image_type = "device_monitor_2d"
self.scan_id = None
self.get_bec_shortcuts()
self.entry_validator = EntryValidator(self.dev)
self._images = defaultdict(dict)
self.apply_config(self.config)
self.processor = ImageProcessor()
self.use_threading = False # TODO WILL be moved to the init method and to figure method
def _create_thread_worker(self, device: str, image: np.ndarray):
thread = QThread()
worker = ProcessorWorker(self.processor)
worker.moveToThread(thread)
# Connect signals and slots
thread.started.connect(lambda: worker.process_image(device, image))
worker.processed.connect(self.update_image)
worker.stats.connect(self.update_vrange)
worker.finished.connect(thread.quit)
worker.finished.connect(thread.wait)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.start()
def find_image_by_monitor(self, item_id: str) -> BECImageItem:
"""
Find the image item by its gui_id.
Args:
item_id(str): The gui_id of the widget.
Returns:
BECImageItem: The widget with the given gui_id.
"""
for source, images in self._images.items():
for key, value in images.items():
if key == item_id and isinstance(value, BECImageItem):
return value
elif isinstance(value, dict):
result = self.find_image_by_monitor(item_id)
if result is not None:
return result
def apply_config(self, config: dict | SubplotConfig):
"""
Apply the configuration to the 1D waveform widget.
Args:
config(dict|SubplotConfig): Configuration settings.
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
"""
if isinstance(config, dict):
try:
config = ImageConfig(**config)
except ValidationError as e:
logger.error(f"Validation error when applying config to BECImageShow: {e}")
return
self.config = config
self.plot_item.clear()
self.apply_axis_config()
self._images = defaultdict(dict)
for image_id, image_config in config.images.items():
self.add_image_by_config(image_config)
def change_gui_id(self, new_gui_id: str):
"""
Change the GUI ID of the image widget and update the parent_id in all associated curves.
Args:
new_gui_id (str): The new GUI ID to be set for the image widget.
"""
self.gui_id = new_gui_id
self.config.gui_id = new_gui_id
for source, images in self._images.items():
for id, image_item in images.items():
image_item.config.parent_id = new_gui_id
def add_image_by_config(self, config: ImageItemConfig | dict) -> BECImageItem:
"""
Add an image to the widget by configuration.
Args:
config(ImageItemConfig|dict): The configuration of the image.
Returns:
BECImageItem: The image object.
"""
if isinstance(config, dict):
config = ImageItemConfig(**config)
config.parent_id = self.gui_id
name = config.monitor if config.monitor is not None else config.gui_id
image = self._add_image_object(source=config.source, name=name, config=config)
return image
def get_image_config(self, image_id, dict_output: bool = True) -> ImageItemConfig | dict:
"""
Get the configuration of the image.
Args:
image_id(str): The ID of the image.
dict_output(bool): Whether to return the configuration as a dictionary. Defaults to True.
Returns:
ImageItemConfig|dict: The configuration of the image.
"""
for source, images in self._images.items():
for id, image in images.items():
if id == image_id:
if dict_output:
return image.config.dict()
else:
return image.config # TODO check if this works
@property
def images(self) -> list[BECImageItem]:
"""
Get the list of images.
Returns:
list[BECImageItem]: The list of images.
"""
images = []
for source, images_dict in self._images.items():
for id, image in images_dict.items():
images.append(image)
return images
@images.setter
def images(self, value: dict[str, dict[str, BECImageItem]]):
"""
Set the images from a dictionary.
Args:
value (dict[str, dict[str, BECImageItem]]): The images to set, organized by source and id.
"""
self._images = value
def get_image_dict(self) -> dict[str, dict[str, BECImageItem]]:
"""
Get all images.
Returns:
dict[str, dict[str, BECImageItem]]: The dictionary of images.
"""
return self._images
def image(
self,
monitor: str,
monitor_type: Literal["1d", "2d"] = "2d",
color_map: Optional[str] = "magma",
color_bar: Optional[Literal["simple", "full"]] = "full",
downsample: Optional[bool] = True,
opacity: Optional[float] = 1.0,
vrange: Optional[tuple[int, int]] = None,
# post_processing: Optional[PostProcessingConfig] = None,
**kwargs,
) -> BECImageItem:
"""
Add an image to the figure. Always access the first image widget in the figure.
Args:
monitor(str): The name of the monitor to display.
monitor_type(Literal["1d","2d"]): The type of monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
color_map(str): The color map to use for the image.
data(np.ndarray): Custom data to display.
vrange(tuple[float, float]): The range of values to display.
Returns:
BECImageItem: The image item.
"""
if monitor_type == "1d":
image_source = "device_monitor_1d"
self.image_type = "device_monitor_1d"
elif monitor_type == "2d":
image_source = "device_monitor_2d"
self.image_type = "device_monitor_2d"
image_exits = self._check_image_id(monitor, self._images)
if image_exits:
# raise ValueError(
# f"Monitor with ID '{monitor}' already exists in widget '{self.gui_id}'."
# )
return
# monitor = self.entry_validator.validate_monitor(monitor)
image_config = ImageItemConfig(
widget_class="BECImageItem",
parent_id=self.gui_id,
color_map=color_map,
color_bar=color_bar,
downsample=downsample,
opacity=opacity,
vrange=vrange,
source=image_source,
monitor=monitor,
# post_processing=post_processing,
**kwargs,
)
image = self._add_image_object(source=image_source, name=monitor, config=image_config)
return image
def add_custom_image(
self,
name: str,
data: Optional[np.ndarray] = None,
color_map: Optional[str] = "magma",
color_bar: Optional[Literal["simple", "full"]] = "full",
downsample: Optional[bool] = True,
opacity: Optional[float] = 1.0,
vrange: Optional[tuple[int, int]] = None,
# post_processing: Optional[PostProcessingConfig] = None,
**kwargs,
):
image_source = "custom"
image_exits = self._check_image_id(name, self._images)
if image_exits:
raise ValueError(f"Monitor with ID '{name}' already exists in widget '{self.gui_id}'.")
image_config = ImageItemConfig(
widget_class="BECImageItem",
parent_id=self.gui_id,
monitor=name,
color_map=color_map,
color_bar=color_bar,
downsample=downsample,
opacity=opacity,
vrange=vrange,
# post_processing=post_processing,
**kwargs,
)
image = self._add_image_object(
source=image_source, name=name, config=image_config, data=data
)
return image
def apply_setting_to_images(
self, setting_method_name: str, args: list, kwargs: dict, image_id: str = None
):
"""
Apply a setting to all images or a specific image by its ID.
Args:
setting_method_name (str): The name of the method to apply (e.g., 'set_color_map').
args (list): Positional arguments for the setting method.
kwargs (dict): Keyword arguments for the setting method.
image_id (str, optional): The ID of the specific image to apply the setting to. If None, applies to all images.
"""
if image_id:
image = self.find_image_by_monitor(image_id)
if image:
getattr(image, setting_method_name)(*args, **kwargs)
else:
for source, images in self._images.items():
for _, image in images.items():
getattr(image, setting_method_name)(*args, **kwargs)
self.refresh_image()
def set_vrange(self, vmin: float, vmax: float, name: str = None):
"""
Set the range of the color bar.
If name is not specified, then set vrange for all images.
Args:
vmin(float): Minimum value of the color bar.
vmax(float): Maximum value of the color bar.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_vrange", args=[vmin, vmax], kwargs={}, image_id=name)
def set_color_map(self, cmap: str, name: str = None):
"""
Set the color map of the image.
If name is not specified, then set color map for all images.
Args:
cmap(str): The color map of the image.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_color_map", args=[cmap], kwargs={}, image_id=name)
def set_autorange(self, enable: bool = False, name: str = None):
"""
Set the autoscale of the image.
Args:
enable(bool): Whether to autoscale the color bar.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_autorange", args=[enable], kwargs={}, image_id=name)
def set_autorange_mode(self, mode: Literal["max", "mean"], name: str = None):
"""
Set the autoscale mode of the image, that decides how the vrange of the color bar is scaled.
Choose betwen 'max' -> min/max of the data, 'mean' -> mean +/- fudge_factor*std of the data (fudge_factor~2).
Args:
mode(str): The autoscale mode of the image.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_autorange_mode", args=[mode], kwargs={}, image_id=name)
def set_monitor(self, monitor: str, name: str = None):
"""
Set the monitor of the image.
If name is not specified, then set monitor for all images.
Args:
monitor(str): The name of the monitor.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_monitor", args=[monitor], kwargs={}, image_id=name)
def set_processing(self, name: str = None, **kwargs):
"""
Set the post processing of the image.
If name is not specified, then set post processing for all images.
Args:
name(str): The name of the image. If None, apply to all images.
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- fft: bool
- log: bool
- rot: int
- transpose: bool
"""
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
def set_image_properties(self, name: str = None, **kwargs):
"""
Set the properties of the image.
Args:
name(str): The name of the image. If None, apply to all images.
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- downsample: bool
- color_map: str
- monitor: str
- opacity: float
- vrange: tuple[int,int]
- fft: bool
- log: bool
- rot: int
- transpose: bool
"""
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
def set_fft(self, enable: bool = False, name: str = None):
"""
Set the FFT of the image.
If name is not specified, then set FFT for all images.
Args:
enable(bool): Whether to perform FFT on the monitor data.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_fft", args=[enable], kwargs={}, image_id=name)
def set_log(self, enable: bool = False, name: str = None):
"""
Set the log of the image.
If name is not specified, then set log for all images.
Args:
enable(bool): Whether to perform log on the monitor data.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_log", args=[enable], kwargs={}, image_id=name)
def set_rotation(self, deg_90: int = 0, name: str = None):
"""
Set the rotation of the image.
If name is not specified, then set rotation for all images.
Args:
deg_90(int): The rotation angle of the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_rotation", args=[deg_90], kwargs={}, image_id=name)
def set_transpose(self, enable: bool = False, name: str = None):
"""
Set the transpose of the image.
If name is not specified, then set transpose for all images.
Args:
enable(bool): Whether to transpose the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_transpose", args=[enable], kwargs={}, image_id=name)
def toggle_threading(self, use_threading: bool):
"""
Toggle threading for the widgets postprocessing and updating.
Args:
use_threading(bool): Whether to use threading.
"""
self.use_threading = use_threading
if self.use_threading is False and self.thread.isRunning():
self.cleanup()
def process_image(self, device: str, image: BECImageItem, data: np.ndarray):
"""
Process the image data.
Args:
device(str): The name of the device - image_id of image.
image(np.ndarray): The image data to be processed.
data(np.ndarray): The image data to be processed.
Returns:
np.ndarray: The processed image data.
"""
processing_config = image.config.processing
self.processor.set_config(processing_config)
if self.use_threading:
self._create_thread_worker(device, data)
else:
data = self.processor.process_image(data)
self.update_image(device, data)
self.update_vrange(device, self.processor.config.stats)
@Slot(dict, dict)
def on_image_update(self, msg: dict, metadata: dict):
"""
Update the image of the device monitor from bec.
Args:
msg(dict): The message from bec.
metadata(dict): The metadata of the message.
"""
data = msg["data"]
device = msg["device"]
if self.image_type == "device_monitor_1d":
image = self._images["device_monitor_1d"][device]
current_scan_id = metadata.get("scan_id", None)
if current_scan_id is None:
return
if current_scan_id != self.scan_id:
self.reset()
self.scan_id = current_scan_id
image.image_buffer_list = []
image.max_len = 0
image_buffer = self.adjust_image_buffer(image, data)
image.raw_data = image_buffer
self.process_image(device, image, image_buffer)
elif self.image_type == "device_monitor_2d":
image = self._images["device_monitor_2d"][device]
image.raw_data = data
self.process_image(device, image, data)
def adjust_image_buffer(self, image: BECImageItem, new_data: np.ndarray) -> np.ndarray:
"""
Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length.
Args:
image: The image object (used to store buffer list and max_len).
new_data (np.ndarray): The new incoming 1D waveform data.
Returns:
np.ndarray: The updated image buffer with adjusted shapes.
"""
new_len = new_data.shape[0]
if not hasattr(image, "image_buffer_list"):
image.image_buffer_list = []
image.max_len = 0
if new_len > image.max_len:
image.max_len = new_len
for i in range(len(image.image_buffer_list)):
wf = image.image_buffer_list[i]
pad_width = image.max_len - wf.shape[0]
if pad_width > 0:
image.image_buffer_list[i] = np.pad(
wf, (0, pad_width), mode="constant", constant_values=0
)
image.image_buffer_list.append(new_data)
else:
pad_width = image.max_len - new_len
if pad_width > 0:
new_data = np.pad(new_data, (0, pad_width), mode="constant", constant_values=0)
image.image_buffer_list.append(new_data)
image_buffer = np.array(image.image_buffer_list)
return image_buffer
@Slot(str, np.ndarray)
def update_image(self, device: str, data: np.ndarray):
"""
Update the image of the device monitor.
Args:
device(str): The name of the device.
data(np.ndarray): The data to be updated.
"""
image_to_update = self._images[self.image_type][device]
image_to_update.updateImage(data, autoLevels=image_to_update.config.autorange)
@Slot(str, ImageStats)
def update_vrange(self, device: str, stats: ImageStats):
"""
Update the scaling of the image.
Args:
stats(ImageStats): The statistics of the image.
"""
image_to_update = self._images[self.image_type][device]
if image_to_update.config.autorange:
image_to_update.auto_update_vrange(stats)
def refresh_image(self):
"""
Refresh the image.
"""
for source, images in self._images.items():
for image_id, image in images.items():
data = image.raw_data
self.process_image(image_id, image, data)
def _connect_device_monitor(self, monitor: str):
"""
Connect to the device monitor.
Args:
monitor(str): The name of the monitor.
"""
image_item = self.find_image_by_monitor(monitor)
try:
previous_monitor = image_item.config.monitor
except AttributeError:
previous_monitor = None
if previous_monitor and image_item.connected is True:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(previous_monitor)
)
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_2d(previous_monitor)
)
image_item.connected = False
if monitor and image_item.connected is False:
self.entry_validator.validate_monitor(monitor)
if self.image_type == "device_monitor_1d":
self.bec_dispatcher.connect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)
)
elif self.image_type == "device_monitor_2d":
self.bec_dispatcher.connect_slot(
self.on_image_update, MessageEndpoints.device_monitor_2d(monitor)
)
image_item.set_monitor(monitor)
image_item.connected = True
def _add_image_object(
self, source: str, name: str, config: ImageItemConfig, data=None
) -> BECImageItem:
config.parent_id = self.gui_id
if self.single_image is True and len(self.images) > 0:
self.remove_image(0)
image = BECImageItem(config=config, parent_image=self)
self.plot_item.addItem(image)
self._images[source][name] = image
self._connect_device_monitor(config.monitor)
self.config.images[name] = config
if data is not None:
image.setImage(data)
return image
def _check_image_id(self, val: Any, dict_to_check: dict) -> bool:
"""
Check if val is in the values of the dict_to_check or in the values of the nested dictionaries.
Args:
val(Any): Value to check.
dict_to_check(dict): Dictionary to check.
Returns:
bool: True if val is in the values of the dict_to_check or in the values of the nested dictionaries, False otherwise.
"""
if val in dict_to_check.keys():
return True
for key in dict_to_check:
if isinstance(dict_to_check[key], dict):
if self._check_image_id(val, dict_to_check[key]):
return True
return False
def remove_image(self, *identifiers):
"""
Remove an image from the plot widget.
Args:
*identifiers: Identifier of the image to be removed. Can be either an integer (index) or a string (image_id).
"""
for identifier in identifiers:
if isinstance(identifier, int):
self._remove_image_by_order(identifier)
elif isinstance(identifier, str):
self._remove_image_by_id(identifier)
else:
raise ValueError(
"Each identifier must be either an integer (index) or a string (image_id)."
)
def _remove_image_by_id(self, image_id):
for source, images in self._images.items():
if image_id in images:
self._disconnect_monitor(image_id)
image = images.pop(image_id)
self.removeItem(image.color_bar)
self.plot_item.removeItem(image)
del self.config.images[image_id]
if image in self.images:
self.images.remove(image)
return
raise KeyError(f"Image with ID '{image_id}' not found.")
def _remove_image_by_order(self, N):
"""
Remove an image by its order from the plot widget.
Args:
N(int): Order of the image to be removed.
"""
if N < len(self.images):
image = self.images[N]
image_id = image.config.monitor
self._disconnect_monitor(image_id)
self.removeItem(image.color_bar)
self.plot_item.removeItem(image)
del self.config.images[image_id]
for source, images in self._images.items():
if image_id in images:
del images[image_id]
break
else:
raise IndexError(f"Image order {N} out of range.")
def _disconnect_monitor(self, image_id):
"""
Disconnect the monitor from the device.
Args:
image_id(str): The ID of the monitor.
"""
image = self.find_image_by_monitor(image_id)
if image:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(image.config.monitor)
)
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_2d(image.config.monitor)
)
def cleanup(self):
"""
Clean up the widget.
"""
for monitor in self._images[self.image_type]:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)
)
self.images.clear()
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
super().cleanup_pyqtgraph()
item = self.plot_item
if not item.items:
return
cbar = item.items[0].color_bar
cbar.vb.menu.close()
cbar.vb.menu.deleteLater()
cbar.gradient.menu.close()
cbar.gradient.menu.deleteLater()
cbar.gradient.colorDialog.close()
cbar.gradient.colorDialog.deleteLater()

View File

@ -1,338 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import Field
from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
ImageStats,
ProcessingConfig,
)
if TYPE_CHECKING:
from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow
logger = bec_logger.logger
class ImageItemConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the image.")
monitor: Optional[str] = Field(None, description="The name of the monitor.")
source: Optional[str] = Field(None, description="The source of the curve.")
color_map: Optional[str] = Field("magma", description="The color map of the image.")
downsample: Optional[bool] = Field(True, description="Whether to downsample the image.")
opacity: Optional[float] = Field(1.0, description="The opacity of the image.")
vrange: Optional[tuple[float | int, float | int]] = Field(
None, description="The range of the color bar. If None, the range is automatically set."
)
color_bar: Optional[Literal["simple", "full"]] = Field(
"simple", description="The type of the color bar."
)
autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.")
autorange_mode: Optional[Literal["max", "mean"]] = Field(
"mean", description="Whether to use the mean of the image for autoscaling."
)
processing: ProcessingConfig = Field(
default_factory=ProcessingConfig, description="The post processing of the image."
)
# TODO old version will be deprecated
class BECImageItem(BECConnector, pg.ImageItem):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"set",
"set_fft",
"set_log",
"set_rotation",
"set_transpose",
"set_opacity",
"set_autorange",
"set_autorange_mode",
"set_color_map",
"set_auto_downsample",
"set_monitor",
"set_vrange",
"get_data",
]
def __init__(
self,
config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None,
parent_image: Optional[BECImageShow] = None,
**kwargs,
):
if config is None:
config = ImageItemConfig(widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
super().__init__(config=config, gui_id=gui_id, **kwargs)
pg.ImageItem.__init__(self)
self.parent_image = parent_image
self.colorbar_bar = None
self._raw_data = None
self._add_color_bar(
self.config.color_bar, self.config.vrange
) # TODO can also support None to not have any colorbar
self.apply_config()
if kwargs:
self.set(**kwargs)
self.connected = False
@property
def raw_data(self) -> np.ndarray:
return self._raw_data
@raw_data.setter
def raw_data(self, data: np.ndarray):
self._raw_data = data
def apply_config(self):
"""
Apply current configuration.
"""
self.set_color_map(self.config.color_map)
self.set_auto_downsample(self.config.downsample)
if self.config.vrange is not None:
self.set_vrange(vrange=self.config.vrange)
def set(self, **kwargs):
"""
Set the properties of the image.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- downsample
- color_map
- monitor
- opacity
- vrange
- fft
- log
- rot
- transpose
- autorange_mode
"""
method_map = {
"downsample": self.set_auto_downsample,
"color_map": self.set_color_map,
"monitor": self.set_monitor,
"opacity": self.set_opacity,
"vrange": self.set_vrange,
"fft": self.set_fft,
"log": self.set_log,
"rot": self.set_rotation,
"transpose": self.set_transpose,
"autorange_mode": self.set_autorange_mode,
}
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_fft(self, enable: bool = False):
"""
Set the FFT of the image.
Args:
enable(bool): Whether to perform FFT on the monitor data.
"""
self.config.processing.fft = enable
def set_log(self, enable: bool = False):
"""
Set the log of the image.
Args:
enable(bool): Whether to perform log on the monitor data.
"""
self.config.processing.log = enable
if enable and self.color_bar and self.config.color_bar == "full":
self.color_bar.autoHistogramRange()
def set_rotation(self, deg_90: int = 0):
"""
Set the rotation of the image.
Args:
deg_90(int): The rotation angle of the monitor data before displaying.
"""
self.config.processing.rotation = deg_90
def set_transpose(self, enable: bool = False):
"""
Set the transpose of the image.
Args:
enable(bool): Whether to transpose the image.
"""
self.config.processing.transpose = enable
def set_opacity(self, opacity: float = 1.0):
"""
Set the opacity of the image.
Args:
opacity(float): The opacity of the image.
"""
self.setOpacity(opacity)
self.config.opacity = opacity
def set_autorange(self, autorange: bool = False):
"""
Set the autorange of the color bar.
Args:
autorange(bool): Whether to autorange the color bar.
"""
self.config.autorange = autorange
if self.color_bar and autorange:
self.color_bar.autoHistogramRange()
def set_autorange_mode(self, mode: Literal["max", "mean"] = "mean"):
"""
Set the autorange mode to scale the vrange of the color bar. Choose between min/max or mean +/- std.
Args:
mode(Literal["max","mean"]): Max for min/max or mean for mean +/- std.
"""
self.config.autorange_mode = mode
def set_color_map(self, cmap: str = "magma"):
"""
Set the color map of the image.
Args:
cmap(str): The color map of the image.
"""
self.setColorMap(cmap)
if self.color_bar is not None:
if self.config.color_bar == "simple":
self.color_bar.setColorMap(cmap)
elif self.config.color_bar == "full":
self.color_bar.gradient.loadPreset(cmap)
self.config.color_map = cmap
def set_auto_downsample(self, auto: bool = True):
"""
Set the auto downsample of the image.
Args:
auto(bool): Whether to downsample the image.
"""
self.setAutoDownsample(auto)
self.config.downsample = auto
def set_monitor(self, monitor: str):
"""
Set the monitor of the image.
Args:
monitor(str): The name of the monitor.
"""
self.config.monitor = monitor
def auto_update_vrange(self, stats: ImageStats) -> None:
"""Auto update of the vrange base on the stats of the image.
Args:
stats(ImageStats): The stats of the image.
"""
fumble_factor = 2
if self.config.autorange_mode == "mean":
vmin = max(stats.mean - fumble_factor * stats.std, 0)
vmax = stats.mean + fumble_factor * stats.std
self.set_vrange(vmin, vmax, change_autorange=False)
return
if self.config.autorange_mode == "max":
self.set_vrange(max(stats.minimum, 0), stats.maximum, change_autorange=False)
return
def set_vrange(
self,
vmin: float = None,
vmax: float = None,
vrange: tuple[float, float] = None,
change_autorange: bool = True,
):
"""
Set the range of the color bar.
Args:
vmin(float): Minimum value of the color bar.
vmax(float): Maximum value of the color bar.
"""
if vrange is not None:
vmin, vmax = vrange
self.setLevels([vmin, vmax])
self.config.vrange = (vmin, vmax)
if change_autorange:
self.config.autorange = False
if self.color_bar is not None:
if self.config.color_bar == "simple":
self.color_bar.setLevels(low=vmin, high=vmax)
elif self.config.color_bar == "full":
# pylint: disable=unexpected-keyword-arg
self.color_bar.setLevels(min=vmin, max=vmax)
self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
def get_data(self) -> np.ndarray:
"""
Get the data of the image.
Returns:
np.ndarray: The data of the image.
"""
return self.image
def _add_color_bar(
self, color_bar_style: str = "simple", vrange: Optional[tuple[int, int]] = None
):
"""
Add color bar to the layout.
Args:
style(Literal["simple,full"]): The style of the color bar.
vrange(tuple[int,int]): The range of the color bar.
"""
if color_bar_style == "simple":
self.color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
if vrange is not None:
self.color_bar.setLevels(low=vrange[0], high=vrange[1])
self.color_bar.setImageItem(self)
self.parent_image.addItem(self.color_bar, row=1, col=1)
self.config.color_bar = "simple"
elif color_bar_style == "full":
# Setting histogram
self.color_bar = pg.HistogramLUTItem()
self.color_bar.setImageItem(self)
self.color_bar.gradient.loadPreset(self.config.color_map)
if vrange is not None:
self.color_bar.setLevels(min=vrange[0], max=vrange[1])
self.color_bar.setHistogramRange(
vrange[0] - 0.1 * vrange[0], vrange[1] + 0.1 * vrange[1]
)
# Adding histogram to the layout
self.parent_image.addItem(self.color_bar, row=1, col=1)
# save settings
self.config.color_bar = "full"
else:
raise ValueError("style should be 'simple' or 'full'")
def remove(self):
"""Remove the curve from the plot."""
self.parent_image.remove_image(self.config.monitor)
self.rpc_register.remove_rpc(self)

View File

@ -1,185 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np
from pydantic import BaseModel, Field
from qtpy.QtCore import QObject, Signal, Slot
# TODO will be deleted
@dataclass
class ImageStats:
"""Container to store stats of an image."""
maximum: float
minimum: float
mean: float
std: float
class ProcessingConfig(BaseModel):
fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.")
log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.")
center_of_mass: Optional[bool] = Field(
False, description="Whether to calculate the center of mass of the monitor data."
)
transpose: Optional[bool] = Field(
False, description="Whether to transpose the monitor data before displaying."
)
rotation: Optional[int] = Field(
None, description="The rotation angle of the monitor data before displaying."
)
model_config: dict = {"validate_assignment": True}
stats: ImageStats = Field(
ImageStats(maximum=0, minimum=0, mean=0, std=0),
description="The statistics of the image data.",
)
class ImageProcessor:
"""
Class for processing the image data.
"""
def __init__(self, config: ProcessingConfig = None):
if config is None:
config = ProcessingConfig()
self.config = config
def set_config(self, config: ProcessingConfig):
"""
Set the configuration of the processor.
Args:
config(ProcessingConfig): The configuration of the processor.
"""
self.config = config
def FFT(self, data: np.ndarray) -> np.ndarray:
"""
Perform FFT on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.abs(np.fft.fftshift(np.fft.fft2(data)))
def rotation(self, data: np.ndarray, rotate_90: int) -> np.ndarray:
"""
Rotate the data by 90 degrees n times.
Args:
data(np.ndarray): The data to be processed.
rotate_90(int): The number of 90 degree rotations.
Returns:
np.ndarray: The processed data.
"""
return np.rot90(data, k=rotate_90, axes=(0, 1))
def transpose(self, data: np.ndarray) -> np.ndarray:
"""
Transpose the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.transpose(data)
def log(self, data: np.ndarray) -> np.ndarray:
"""
Perform log on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
# TODO this is not final solution -> data should stay as int16
data = data.astype(np.float32)
offset = 1e-6
data_offset = data + offset
return np.log10(data_offset)
# def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality
# return np.unravel_index(np.argmax(data), data.shape)
def update_image_stats(self, data: np.ndarray) -> None:
"""Get the statistics of the image data.
Args:
data(np.ndarray): The image data.
"""
self.config.stats.maximum = np.max(data)
self.config.stats.minimum = np.min(data)
self.config.stats.mean = np.mean(data)
self.config.stats.std = np.std(data)
def process_image(self, data: np.ndarray) -> np.ndarray:
"""
Process the data according to the configuration.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
if self.config.fft:
data = self.FFT(data)
if self.config.rotation is not None:
data = self.rotation(data, self.config.rotation)
if self.config.transpose:
data = self.transpose(data)
if self.config.log:
data = self.log(data)
self.update_image_stats(data)
return data
class ProcessorWorker(QObject):
"""
Worker for processing the image data.
"""
processed = Signal(str, np.ndarray)
stats = Signal(str, ImageStats)
stopRequested = Signal()
finished = Signal()
def __init__(self, processor):
super().__init__()
self.processor = processor
self._isRunning = False
self.stopRequested.connect(self.stop)
@Slot(str, np.ndarray)
def process_image(self, device: str, image: np.ndarray):
"""
Process the image data.
Args:
device(str): The name of the device.
image(np.ndarray): The image data.
"""
self._isRunning = True
processed_image = self.processor.process_image(image)
self._isRunning = False
if not self._isRunning:
self.processed.emit(device, processed_image)
self.stats.emit(self.processor.config.stats)
self.finished.emit()
def stop(self):
self._isRunning = False

View File

@ -1,526 +0,0 @@
from __future__ import annotations
from collections import defaultdict
from typing import Optional, Union
import numpy as np
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 pydantic_core import PydanticCustomError
from qtpy import QtCore, QtGui
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import Colors, EntryValidator
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import Signal, SignalData
logger = bec_logger.logger
class MotorMapConfig(SubplotConfig):
signals: Optional[Signal] = Field(None, description="Signals of the motor map")
color: Optional[str | tuple] = Field(
(255, 255, 255, 255), description="The color of the last point of current position."
)
scatter_size: Optional[int] = Field(5, description="Size of the scatter points.")
max_points: Optional[int] = Field(5000, description="Maximum number of points to display.")
num_dim_points: Optional[int] = Field(
100,
description="Number of points to dim before the color remains same for older recorded position.",
)
precision: Optional[int] = Field(2, description="Decimal precision of the motor position.")
background_value: Optional[int] = Field(
25, description="Background value of the motor map. Has to be between 0 and 255."
)
model_config: dict = {"validate_assignment": True}
_validate_color = field_validator("color")(Colors.validate_color)
@field_validator("background_value")
def validate_background_value(cls, value):
if not 0 <= value <= 255:
raise PydanticCustomError(
"wrong_value", f"'{value}' hs to be between 0 and 255.", {"wrong_value": value}
)
return value
class BECMotorMap(BECPlotBase):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"change_motors",
"set_max_points",
"set_precision",
"set_num_dim_points",
"set_background_value",
"set_scatter_size",
"get_data",
"export",
"remove",
"reset_history",
]
# QT Signals
update_signal = pyqtSignal()
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[MotorMapConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = MotorMapConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.get_bec_shortcuts()
self.entry_validator = EntryValidator(self.dev)
# connect update signal to update plot
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self._update_plot
)
self.apply_config(self.config)
def apply_config(self, config: dict | MotorMapConfig):
"""
Apply the config to the motor map.
Args:
config(dict|MotorMapConfig): Config to be applied.
"""
if isinstance(config, dict):
try:
config = MotorMapConfig(**config)
except ValidationError as e:
logger.error(f"Error in applying config: {e}")
return
self.config = config
self.plot_item.clear()
self.motor_x = None
self.motor_y = None
self.database_buffer = {"x": [], "y": []}
self.plot_components = defaultdict(dict) # container for plot components
self.apply_axis_config()
if self.config.signals is not None:
self.change_motors(
motor_x=self.config.signals.x.name,
motor_y=self.config.signals.y.name,
motor_x_entry=self.config.signals.x.entry,
motor_y_entry=self.config.signals.y.entry,
)
@Slot(str, str, str, str, bool)
def change_motors(
self,
motor_x: str,
motor_y: str,
motor_x_entry: str = None,
motor_y_entry: str = None,
validate_bec: bool = True,
) -> None:
"""
Change the active motors for the plot.
Args:
motor_x(str): Motor name for the X axis.
motor_y(str): Motor name for the Y axis.
motor_x_entry(str): Motor entry for the X axis.
motor_y_entry(str): Motor entry for the Y axis.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
"""
self.plot_item.clear()
motor_x_entry, motor_y_entry = self._validate_signal_entries(
motor_x, motor_y, motor_x_entry, motor_y_entry, validate_bec
)
motor_x_limit = self._get_motor_limit(motor_x)
motor_y_limit = self._get_motor_limit(motor_y)
signal = Signal(
source="device_readback",
x=SignalData(name=motor_x, entry=motor_x_entry, limits=motor_x_limit),
y=SignalData(name=motor_y, entry=motor_y_entry, limits=motor_y_limit),
)
self.config.signals = signal
# reconnect the signals
self._connect_motor_to_slots()
self.database_buffer = {"x": [], "y": []}
# Redraw the motor map
self._make_motor_map()
def get_data(self) -> dict:
"""
Get the data of the motor map.
Returns:
dict: Data of the motor map.
"""
data = {"x": self.database_buffer["x"], "y": self.database_buffer["y"]}
return data
def reset_history(self):
"""
Reset the history of the motor map.
"""
self.database_buffer["x"] = [self.database_buffer["x"][-1]]
self.database_buffer["y"] = [self.database_buffer["y"][-1]]
self.update_signal.emit()
def set_color(self, color: str | tuple):
"""
Set color of the motor trace.
Args:
color(str|tuple): Color of the motor trace. Can be HEX(str) or RGBA(tuple).
"""
if isinstance(color, str):
color = Colors.validate_color(color)
color = Colors.hex_to_rgba(color, 255)
self.config.color = color
self.update_signal.emit()
def set_max_points(self, max_points: int) -> None:
"""
Set the maximum number of points to display.
Args:
max_points(int): Maximum number of points to display.
"""
self.config.max_points = max_points
self.update_signal.emit()
def set_precision(self, precision: int) -> None:
"""
Set the decimal precision of the motor position.
Args:
precision(int): Decimal precision of the motor position.
"""
self.config.precision = precision
self.update_signal.emit()
def set_num_dim_points(self, num_dim_points: int) -> None:
"""
Set the number of dim points for the motor map.
Args:
num_dim_points(int): Number of dim points.
"""
self.config.num_dim_points = num_dim_points
self.update_signal.emit()
def set_background_value(self, background_value: int) -> None:
"""
Set the background value of the motor map.
Args:
background_value(int): Background value of the motor map.
"""
self.config.background_value = background_value
self._swap_limit_map()
def set_scatter_size(self, scatter_size: int) -> None:
"""
Set the scatter size of the motor map plot.
Args:
scatter_size(int): Size of the scatter points.
"""
self.config.scatter_size = scatter_size
self.update_signal.emit()
def _disconnect_current_motors(self):
"""Disconnect the current motors from the slots."""
if self.motor_x is not None and self.motor_y is not None:
endpoints = [
MessageEndpoints.device_readback(self.motor_x),
MessageEndpoints.device_readback(self.motor_y),
]
self.bec_dispatcher.disconnect_slot(self.on_device_readback, endpoints)
def _connect_motor_to_slots(self):
"""Connect motors to slots."""
self._disconnect_current_motors()
self.motor_x = self.config.signals.x.name
self.motor_y = self.config.signals.y.name
endpoints = [
MessageEndpoints.device_readback(self.motor_x),
MessageEndpoints.device_readback(self.motor_y),
]
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints)
def _swap_limit_map(self):
"""Swap the limit map."""
self.plot_item.removeItem(self.plot_components["limit_map"])
if self.config.signals.x.limits is not None and self.config.signals.y.limits is not None:
self.plot_components["limit_map"] = self._make_limit_map(
self.config.signals.x.limits, self.config.signals.y.limits
)
self.plot_components["limit_map"].setZValue(-1)
self.plot_item.addItem(self.plot_components["limit_map"])
def _make_motor_map(self):
"""
Create the motor map plot.
"""
# Create limit map
motor_x_limit = self.config.signals.x.limits
motor_y_limit = self.config.signals.y.limits
if motor_x_limit is not None or motor_y_limit is not None:
self.plot_components["limit_map"] = self._make_limit_map(motor_x_limit, motor_y_limit)
self.plot_item.addItem(self.plot_components["limit_map"])
self.plot_components["limit_map"].setZValue(-1)
# Create scatter plot
scatter_size = self.config.scatter_size
self.plot_components["scatter"] = pg.ScatterPlotItem(
size=scatter_size, brush=pg.mkBrush(255, 255, 255, 255)
)
self.plot_item.addItem(self.plot_components["scatter"])
self.plot_components["scatter"].setZValue(0)
# Enable Grid
self.set_grid(True, True)
# Add the crosshair for initial motor coordinates
initial_position_x = self._get_motor_init_position(
self.motor_x, self.config.signals.x.entry, self.config.precision
)
initial_position_y = self._get_motor_init_position(
self.motor_y, self.config.signals.y.entry, self.config.precision
)
self.database_buffer["x"] = [initial_position_x]
self.database_buffer["y"] = [initial_position_y]
self.plot_components["scatter"].setData([initial_position_x], [initial_position_y])
self._add_coordinantes_crosshair(initial_position_x, initial_position_y)
# Set default labels for the plot
self.set(x_label=f"Motor X ({self.motor_x})", y_label=f"Motor Y ({self.motor_y})")
self.update_signal.emit()
def _add_coordinantes_crosshair(self, x: float, y: float) -> None:
"""
Add crosshair to the plot to highlight the current position.
Args:
x(float): X coordinate.
y(float): Y coordinate.
"""
# Crosshair to highlight the current position
highlight_H = pg.InfiniteLine(
angle=0, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
)
highlight_V = pg.InfiniteLine(
angle=90, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
)
# Add crosshair to the curve list for future referencing
self.plot_components["highlight_H"] = highlight_H
self.plot_components["highlight_V"] = highlight_V
# Add crosshair to the plot
self.plot_item.addItem(highlight_H)
self.plot_item.addItem(highlight_V)
highlight_V.setPos(x)
highlight_H.setPos(y)
def _make_limit_map(self, limits_x: list, limits_y: list) -> pg.ImageItem:
"""
Create a limit map for the motor map plot.
Args:
limits_x(list): Motor limits for the x axis.
limits_y(list): Motor limits for the y axis.
Returns:
pg.ImageItem: Limit map.
"""
limit_x_min, limit_x_max = limits_x
limit_y_min, limit_y_max = limits_y
map_width = int(limit_x_max - limit_x_min + 1)
map_height = int(limit_y_max - limit_y_min + 1)
# Create limits map
background_value = self.config.background_value
limit_map_data = np.full((map_width, map_height), background_value, dtype=np.float32)
limit_map = pg.ImageItem()
limit_map.setImage(limit_map_data)
# Translate and scale the image item to match the motor coordinates
tr = QtGui.QTransform()
tr.translate(limit_x_min, limit_y_min)
limit_map.setTransform(tr)
return limit_map
def _get_motor_init_position(self, name: str, entry: str, precision: int) -> float:
"""
Get the motor initial position from the config.
Args:
name(str): Motor name.
entry(str): Motor entry.
precision(int): Decimal precision of the motor position.
Returns:
float: Motor initial position.
"""
init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
return init_position
def _validate_signal_entries(
self,
x_name: str,
y_name: str,
x_entry: str | None,
y_entry: str | None,
validate_bec: bool = True,
) -> tuple[str, str]:
"""
Validate the signal name and entry.
Args:
x_name(str): Name of the x signal.
y_name(str): Name of the y signal.
x_entry(str|None): Entry of the x signal.
y_entry(str|None): Entry of the y signal.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
Returns:
tuple[str,str]: Validated x and y entries.
"""
if validate_bec:
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
else:
x_entry = x_name if x_entry is None else x_entry
y_entry = y_name if y_entry is None else y_entry
return x_entry, y_entry
def _get_motor_limit(self, motor: str) -> Union[list | None]: # TODO check if works correctly
"""
Get the motor limit from the config.
Args:
motor(str): Motor name.
Returns:
float: Motor limit.
"""
try:
limits = self.dev[motor].limits
if limits == [0, 0]:
return None
return limits
except AttributeError: # TODO maybe not needed, if no limits it returns [0,0]
# If the motor doesn't have a 'limits' attribute, return a default value or raise a custom exception
logger.error(f"The device '{motor}' does not have defined limits.")
return None
@Slot()
def _update_plot(self, _=None):
"""Update the motor map plot."""
# If the number of points exceeds max_points, delete the oldest points
if len(self.database_buffer["x"]) > self.config.max_points:
self.database_buffer["x"] = self.database_buffer["x"][-self.config.max_points :]
self.database_buffer["y"] = self.database_buffer["y"][-self.config.max_points :]
x = self.database_buffer["x"]
y = self.database_buffer["y"]
# Setup gradient brush for history
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(x)
# RGB color
r, g, b, a = self.config.color
# Calculate the decrement step based on self.num_dim_points
num_dim_points = self.config.num_dim_points
decrement_step = (255 - 50) / num_dim_points
for i in range(1, min(num_dim_points + 1, len(x) + 1)):
brightness = max(60, 255 - decrement_step * (i - 1))
dim_r = int(r * (brightness / 255))
dim_g = int(g * (brightness / 255))
dim_b = int(b * (brightness / 255))
brushes[-i] = pg.mkBrush(dim_r, dim_g, dim_b, a)
brushes[-1] = pg.mkBrush(r, g, b, a) # Newest point is always full brightness
scatter_size = self.config.scatter_size
# Update the scatter plot
self.plot_components["scatter"].setData(
x=x, y=y, brush=brushes, pen=None, size=scatter_size
)
# Get last know position for crosshair
current_x = x[-1]
current_y = y[-1]
# Update the crosshair
self.plot_components["highlight_V"].setPos(current_x)
self.plot_components["highlight_H"].setPos(current_y)
# TODO not update title but some label
# Update plot title
precision = self.config.precision
self.set_title(
f"Motor position: ({round(float(current_x),precision)}, {round(float(current_y),precision)})"
)
@Slot(dict, dict)
def on_device_readback(self, msg: dict, metadata: dict) -> None:
"""
Update the motor map plot with the new motor position.
Args:
msg(dict): Message from the device readback.
metadata(dict): Metadata of the message.
"""
if self.motor_x is None or self.motor_y is None:
return
if self.motor_x in msg["signals"]:
x = msg["signals"][self.motor_x]["value"]
self.database_buffer["x"].append(x)
self.database_buffer["y"].append(self.database_buffer["y"][-1])
elif self.motor_y in msg["signals"]:
y = msg["signals"][self.motor_y]["value"]
self.database_buffer["y"].append(y)
self.database_buffer["x"].append(self.database_buffer["x"][-1])
self.update_signal.emit()
def cleanup(self):
"""Cleanup the widget."""
self._disconnect_current_motors()

View File

@ -1,340 +0,0 @@
from collections import deque
from typing import Literal, Optional
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field, field_validator
from pyqtgraph.exporters import MatplotlibExporter
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import Colors
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
logger = bec_logger.logger
class BECMultiWaveformConfig(SubplotConfig):
color_palette: Optional[str] = Field(
"magma", description="The color palette of the figure widget.", validate_default=True
)
curve_limit: Optional[int] = Field(
200, description="The maximum number of curves to display on the plot."
)
flush_buffer: Optional[bool] = Field(
False, description="Flush the buffer of the plot widget when the curve limit is reached."
)
monitor: Optional[str] = Field(
None, description="The monitor to set for the plot widget."
) # TODO validate monitor in bec -> maybe make it as SignalData class for validation purpose
curve_width: Optional[int] = Field(1, description="The width of the curve on the plot.")
opacity: Optional[int] = Field(50, description="The opacity of the curve on the plot.")
highlight_last_curve: Optional[bool] = 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 BECMultiWaveform(BECPlotBase):
monitor_signal_updated = Signal()
highlighted_curve_index_changed = Signal(int)
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"curves",
"set_monitor",
"set_opacity",
"set_curve_limit",
"set_curve_highlight",
"set_colormap",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"set_colormap",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"get_all_data",
"remove",
]
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[BECMultiWaveformConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = BECMultiWaveformConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
self.old_scan_id = None
self.scan_id = None
self.monitor = None
self.connected = False
self.current_highlight_index = 0
self._curves = deque()
self.visible_curves = []
self.number_of_visible_curves = 0
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.get_bec_shortcuts()
@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
@property
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
def set_monitor(self, monitor: str):
"""
Set the monitor for the plot widget.
Args:
monitor (str): The monitor to set.
"""
self.config.monitor = monitor
self._connect_monitor()
def _connect_monitor(self):
"""
Connect the monitor to the plot widget.
"""
try:
previous_monitor = self.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)
)
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
self.monitor = self.config.monitor
@Slot(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)
self.monitor_signal_updated.emit()
@Slot(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)
@Slot(int)
def set_opacity(self, opacity: int):
"""
Set the opacity of the curve on the plot.
Args:
opacity(int): The opacity of the curve. 0-100.
"""
self.config.opacity = max(0, min(100, opacity))
self.set_curve_highlight(self.current_highlight_index)
@Slot(int, bool)
def set_curve_limit(self, max_trace: int, flush_buffer: bool = False):
"""
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.
"""
self.config.curve_limit = max_trace
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()
def scale_colors(self):
"""
Scale the colors of the curves based on the current colormap.
"""
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 set_colormap(self, colormap: str):
"""
Set the colormap for the curves.
Args:
colormap(str): Colormap for the curves.
"""
self.config.color_palette = colormap
self.set_curve_highlight(self.current_highlight_index)
def hook_crosshair(self) -> None:
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 get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict:
"""
Extract all curve data into a dictionary or a pandas DataFrame.
Args:
output (Literal["dict", "pandas"]): Format of the output data.
Returns:
dict | pd.DataFrame: Data of all curves in the specified format.
"""
data = {}
try:
import pandas as pd
except ImportError:
pd = None
if output == "pandas":
logger.warning(
"Pandas is not installed. "
"Please install pandas using 'pip install pandas'."
"Output will be dictionary instead."
)
output = "dict"
curve_keys = []
curves_list = list(self.curves)
for i, curve in enumerate(curves_list):
x_data, y_data = curve.getData()
if x_data is not None or y_data is not None:
key = f"curve_{i}"
curve_keys.append(key)
if output == "dict":
data[key] = {"x": x_data.tolist(), "y": y_data.tolist()}
elif output == "pandas" and pd is not None:
data[key] = pd.DataFrame({"x": x_data, "y": y_data})
if output == "pandas" and pd is not None:
combined_data = pd.concat([data[key] for key in curve_keys], axis=1, keys=curve_keys)
return combined_data
return data
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 export_to_matplotlib(self):
"""
Export current waveform to matplotlib GUI. Available only if matplotlib is installed in the environment.
"""
MatplotlibExporter(self.plot_item).export()

View File

@ -1,505 +0,0 @@
from __future__ import annotations
from typing import Literal, Optional
import bec_qthemes
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import BaseModel, Field
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.utils.crosshair import Crosshair
from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
logger = bec_logger.logger
class AxisConfig(BaseModel):
title: Optional[str] = Field(None, description="The title of the axes.")
title_size: Optional[int] = Field(None, description="The font size of the title.")
x_label: Optional[str] = Field(None, description="The label for the x-axis.")
x_label_size: Optional[int] = Field(None, description="The font size of the x-axis label.")
y_label: Optional[str] = Field(None, description="The label for the y-axis.")
y_label_size: Optional[int] = Field(None, description="The font size of the y-axis label.")
legend_label_size: Optional[int] = Field(
None, description="The font size of the legend labels."
)
x_scale: Literal["linear", "log"] = Field("linear", description="The scale of the x-axis.")
y_scale: Literal["linear", "log"] = Field("linear", description="The scale of the y-axis.")
x_lim: Optional[tuple] = Field(None, description="The limits of the x-axis.")
y_lim: Optional[tuple] = Field(None, description="The limits of the y-axis.")
x_grid: bool = Field(False, description="Show grid on the x-axis.")
y_grid: bool = Field(False, description="Show grid on the y-axis.")
outer_axes: bool = Field(False, description="Show the outer axes of the plot widget.")
model_config: dict = {"validate_assignment": True}
class SubplotConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent figure of the plot.")
# Coordinates in the figure
row: int = Field(0, description="The row coordinate in the figure.")
col: int = Field(0, description="The column coordinate in the figure.")
# Appearance settings
axis: AxisConfig = Field(
default_factory=AxisConfig, description="The axis configuration of the plot."
)
class BECViewBox(pg.ViewBox):
sigPaint = Signal()
def paint(self, painter, opt, widget):
super().paint(painter, opt, widget)
self.sigPaint.emit()
def itemBoundsChanged(self, item):
self._itemBoundsCache.pop(item, None)
if (self.state["autoRange"][0] is not False) or (self.state["autoRange"][1] is not False):
# check if the call is coming from a mouse-move event
if hasattr(item, "skip_auto_range") and item.skip_auto_range:
return
self._autoRangeNeedsUpdate = True
self.update()
class BECPlotBase(BECConnector, pg.GraphicsLayout):
crosshair_position_changed = Signal(tuple)
crosshair_position_clicked = Signal(tuple)
crosshair_coordinates_changed = Signal(tuple)
crosshair_coordinates_clicked = Signal(tuple)
USER_ACCESS = [
"_config_dict",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"set_outer_axes",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"remove",
"set_legend_label_size",
]
def __init__(
self,
parent: Optional[QWidget] = None, # TODO decide if needed for this class
parent_figure=None,
config: Optional[SubplotConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = SubplotConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
pg.GraphicsLayout.__init__(self, parent)
self.figure = parent_figure
self.plot_item = pg.PlotItem(viewBox=BECViewBox(parent=self, enableMenu=True), parent=self)
self.addItem(self.plot_item, row=1, col=0)
self.add_legend()
self.crosshair = None
self.fps_monitor = None
self.fps_label = None
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item)
self._connect_to_theme_change()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
@Slot(str)
def _update_theme(self, theme: str):
"""Update the theme."""
if theme is None:
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
theme = qapp.theme.theme
else:
theme = "dark"
self.apply_theme(theme)
def apply_theme(self, theme: str):
"""
Apply the theme to the plot widget.
Args:
theme(str, optional): The theme to be applied.
"""
palette = bec_qthemes.load_palette(theme)
text_pen = pg.mkPen(color=palette.text().color())
for axis in ["left", "bottom", "right", "top"]:
self.plot_item.getAxis(axis).setPen(text_pen)
self.plot_item.getAxis(axis).setTextPen(text_pen)
if self.plot_item.legend is not None:
for sample, label in self.plot_item.legend.items:
label.setText(label.text, color=palette.text().color())
def set(self, **kwargs) -> None:
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
# Mapping of keywords to setter methods
method_map = {
"title": self.set_title,
"x_label": self.set_x_label,
"y_label": self.set_y_label,
"x_scale": self.set_x_scale,
"y_scale": self.set_y_scale,
"x_lim": self.set_x_lim,
"y_lim": self.set_y_lim,
"legend_label_size": self.set_legend_label_size,
}
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 apply_axis_config(self):
"""Apply the axis configuration to the plot widget."""
config_mappings = {
"title": self.config.axis.title,
"x_label": self.config.axis.x_label,
"y_label": self.config.axis.y_label,
"x_scale": self.config.axis.x_scale,
"y_scale": self.config.axis.y_scale,
"x_lim": self.config.axis.x_lim,
"y_lim": self.config.axis.y_lim,
}
self.set(**{k: v for k, v in config_mappings.items() if v is not None})
def set_legend_label_size(self, size: int = None):
"""
Set the font size of the legend.
Args:
size(int): Font size of the legend.
"""
if not self.plot_item.legend:
return
if self.config.axis.legend_label_size or size:
if size:
self.config.axis.legend_label_size = size
scale = (
size / 9
) # 9 is the default font size of the legend, so we always scale it against 9
self.plot_item.legend.setScale(scale)
def get_text_color(self):
return "#FFF" if self.figure.config.theme == "dark" else "#000"
def set_title(self, title: str, size: int = None):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
if self.config.axis.title_size or size:
if size:
self.config.axis.title_size = size
style = {"color": self.get_text_color(), "size": f"{self.config.axis.title_size}pt"}
else:
style = {}
self.plot_item.setTitle(title, **style)
self.config.axis.title = title
def set_x_label(self, label: str, size: int = None):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
if self.config.axis.x_label_size or size:
if size:
self.config.axis.x_label_size = size
style = {
"color": self.get_text_color(),
"font-size": f"{self.config.axis.x_label_size}pt",
}
else:
style = {}
self.plot_item.setLabel("bottom", label, **style)
self.config.axis.x_label = label
def set_y_label(self, label: str, size: int = None):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
if self.config.axis.y_label_size or size:
if size:
self.config.axis.y_label_size = size
color = self.get_text_color()
style = {"color": color, "font-size": f"{self.config.axis.y_label_size}pt"}
else:
style = {}
self.plot_item.setLabel("left", label, **style)
self.config.axis.y_label = label
def set_x_scale(self, scale: Literal["linear", "log"] = "linear"):
"""
Set the scale of the x-axis.
Args:
scale(Literal["linear", "log"]): Scale of the x-axis.
"""
self.plot_item.setLogMode(x=(scale == "log"))
self.config.axis.x_scale = scale
def set_y_scale(self, scale: Literal["linear", "log"] = "linear"):
"""
Set the scale of the y-axis.
Args:
scale(Literal["linear", "log"]): Scale of the y-axis.
"""
self.plot_item.setLogMode(y=(scale == "log"))
self.config.axis.y_scale = scale
def set_x_lim(self, *args) -> None:
"""
Set the limits of the x-axis. This method can accept either two separate arguments
for the minimum and maximum x-axis values, or a single tuple containing both limits.
Usage:
set_x_lim(x_min, x_max)
set_x_lim((x_min, x_max))
Args:
*args: A variable number of arguments. Can be two integers (x_min and x_max)
or a single tuple with two integers.
"""
if len(args) == 1 and isinstance(args[0], tuple):
x_min, x_max = args[0]
elif len(args) == 2:
x_min, x_max = args
else:
raise ValueError("set_x_lim expects either two separate arguments or a single tuple")
self.plot_item.setXRange(x_min, x_max)
self.config.axis.x_lim = (x_min, x_max)
def set_y_lim(self, *args) -> None:
"""
Set the limits of the y-axis. This method can accept either two separate arguments
for the minimum and maximum y-axis values, or a single tuple containing both limits.
Usage:
set_y_lim(y_min, y_max)
set_y_lim((y_min, y_max))
Args:
*args: A variable number of arguments. Can be two integers (y_min and y_max)
or a single tuple with two integers.
"""
if len(args) == 1 and isinstance(args[0], tuple):
y_min, y_max = args[0]
elif len(args) == 2:
y_min, y_max = args
else:
raise ValueError("set_y_lim expects either two separate arguments or a single tuple")
self.plot_item.setYRange(y_min, y_max)
self.config.axis.y_lim = (y_min, y_max)
def set_grid(self, x: bool = False, y: bool = False):
"""
Set the grid of the plot widget.
Args:
x(bool): Show grid on the x-axis.
y(bool): Show grid on the y-axis.
"""
self.plot_item.showGrid(x, y)
self.config.axis.x_grid = x
self.config.axis.y_grid = y
def set_outer_axes(self, show: bool = True):
"""
Set the outer axes of the plot widget.
Args:
show(bool): Show the outer axes.
"""
self.plot_item.showAxis("top", show)
self.plot_item.showAxis("right", show)
self.config.axis.outer_axes = show
def add_legend(self):
"""Add legend to the plot"""
self.plot_item.addLegend()
def lock_aspect_ratio(self, lock):
"""
Lock aspect ratio.
Args:
lock(bool): True to lock, False to unlock.
"""
self.plot_item.setAspectLocked(lock)
def set_auto_range(self, enabled: bool, axis: str = "xy"):
"""
Set the auto range of the plot widget.
Args:
enabled(bool): If True, enable the auto range.
axis(str, optional): The axis to enable the auto range.
- "xy": Enable auto range for both x and y axis.
- "x": Enable auto range for x axis.
- "y": Enable auto range for y axis.
"""
self.plot_item.enableAutoRange(axis, enabled)
############################################################
###################### Crosshair ###########################
############################################################
def hook_crosshair(self) -> None:
"""Hook the crosshair to all plots."""
if self.crosshair is None:
self.crosshair = Crosshair(self.plot_item, precision=3)
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked1D.connect(self.crosshair_coordinates_clicked)
self.crosshair.coordinatesChanged2D.connect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked2D.connect(self.crosshair_coordinates_clicked)
def unhook_crosshair(self) -> None:
"""Unhook the crosshair from all plots."""
if self.crosshair is not None:
self.crosshair.crosshairChanged.disconnect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.disconnect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.disconnect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked1D.disconnect(self.crosshair_coordinates_clicked)
self.crosshair.coordinatesChanged2D.disconnect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked2D.disconnect(self.crosshair_coordinates_clicked)
self.crosshair.cleanup()
self.crosshair.deleteLater()
self.crosshair = None
def toggle_crosshair(self) -> None:
"""Toggle the crosshair on all plots."""
if self.crosshair is None:
return self.hook_crosshair()
self.unhook_crosshair()
@Slot()
def reset(self) -> None:
"""Reset the plot widget."""
if self.crosshair is not None:
self.crosshair.clear_markers()
self.crosshair.update_markers()
############################################################
##################### FPS Counter ##########################
############################################################
def update_fps_label(self, fps: float) -> None:
"""
Update the FPS label.
Args:
fps(float): The frames per second.
"""
if self.fps_label:
self.fps_label.setText(f"FPS: {fps:.2f}")
def hook_fps_monitor(self):
"""Hook the FPS monitor to the plot."""
if self.fps_monitor is None:
# text_color = self.get_text_color()#TODO later
self.fps_monitor = FPSCounter(self.plot_item.vb) # text_color=text_color)
self.fps_label = pg.LabelItem(justify="right")
self.addItem(self.fps_label, row=0, col=0)
self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label)
def unhook_fps_monitor(self, delete_label=True):
"""Unhook the FPS monitor from the plot."""
if self.fps_monitor is not None:
# Remove Monitor
self.fps_monitor.cleanup()
self.fps_monitor.deleteLater()
self.fps_monitor = None
if self.fps_label is not None and delete_label:
# Remove Label
self.removeItem(self.fps_label)
self.fps_label.deleteLater()
self.fps_label = None
def enable_fps_monitor(self, enable: bool = True):
"""
Enable the FPS monitor.
Args:
enable(bool): True to enable, False to disable.
"""
if enable and self.fps_monitor is None:
self.hook_fps_monitor()
elif not enable and self.fps_monitor is not None:
self.unhook_fps_monitor()
def export(self):
"""Show the Export Dialog of the plot widget."""
scene = self.plot_item.scene()
scene.contextMenuItem = self.plot_item
scene.showExportDialog()
def remove(self):
"""Remove the plot widget from the figure."""
if self.figure is not None:
self.figure.remove(widget_id=self.gui_id)
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
self.unhook_crosshair()
self.unhook_fps_monitor(delete_label=False)
self.tick_item.cleanup()
self.arrow_item.cleanup()
item = self.plot_item
item.vb.menu.close()
item.vb.menu.deleteLater()
item.ctrlMenu.close()
item.ctrlMenu.deleteLater()

View File

@ -1,277 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib.logger 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:
from bec_widgets.widgets.containers.figure.plots.waveform import BECWaveform1D
logger = bec_logger.logger
class SignalData(BaseModel):
"""The data configuration of a signal in the 1D waveform widget for x and y axis."""
name: str
entry: str
unit: Optional[str] = None # todo implement later
modifier: Optional[str] = None # todo implement later
limits: Optional[list[float]] = None # todo implement later
model_config: dict = {"validate_assignment": True}
class Signal(BaseModel):
"""The configuration of a signal in the 1D waveform widget."""
source: str
x: Optional[SignalData] = None
y: SignalData
z: Optional[SignalData] = None
dap: Optional[str] = None
model_config: dict = {"validate_assignment": True}
class CurveConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the curve.")
label: Optional[str] = Field(None, description="The label of the curve.")
color: Optional[str | tuple] = Field(None, description="The color of the curve.")
symbol: Optional[str | None] = Field("o", description="The symbol of the curve.")
symbol_color: Optional[str | tuple] = Field(
None, description="The color of the symbol of the curve."
)
symbol_size: Optional[int] = Field(7, description="The size of the symbol of the curve.")
pen_width: Optional[int] = Field(4, description="The width of the pen of the curve.")
pen_style: Optional[Literal["solid", "dash", "dot", "dashdot"]] = Field(
"solid", description="The style of the pen of the curve."
)
source: Optional[str] = Field(None, description="The source of the curve.")
signals: Optional[Signal] = Field(None, description="The signal of the curve.")
color_map_z: Optional[str] = Field(
"magma", description="The colormap of the curves z gradient.", validate_default=True
)
model_config: dict = {"validate_assignment": True}
_validate_color_map_z = field_validator("color_map_z")(Colors.validate_color_map)
_validate_color = field_validator("color")(Colors.validate_color)
_validate_symbol_color = field_validator("symbol_color")(Colors.validate_color)
class BECCurve(BECConnector, pg.PlotDataItem):
USER_ACCESS = [
"remove",
"dap_params",
"_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",
]
def __init__(
self,
name: Optional[str] = None,
config: Optional[CurveConfig] = None,
gui_id: Optional[str] = None,
parent_item: Optional[BECWaveform1D] = None,
**kwargs,
):
if config is None:
config = CurveConfig(label=name, widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
# config.widget_class = self.__class__.__name__
super().__init__(config=config, gui_id=gui_id, **kwargs)
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):
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):
return self._dap_params
@dap_params.setter
def dap_params(self, value):
self._dap_params = value
@property
def dap_summary(self):
return self._dap_report
@dap_summary.setter
def dap_summary(self, value):
self._dap_report = value
def set_data(self, x, y):
if self.config.source == "custom":
self.setData(x, y)
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
def set(self, **kwargs):
"""
Set the properties of the curve.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- color: str
- symbol: str
- symbol_color: str
- symbol_size: int
- pen_width: int
- pen_style: Literal["solid", "dash", "dot", "dashdot"]
"""
# Mapping of keywords to setter methods
method_map = {
"color": self.set_color,
"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: Optional[str] = None):
"""
Change the color of the curve.
Args:
color(str): Color of the curve.
symbol_color(str, optional): Color of the symbol. Defaults to None.
"""
self.config.color = color
self.config.symbol_color = symbol_color or color
self.apply_config()
def set_symbol(self, symbol: str):
"""
Change the symbol of the curve.
Args:
symbol(str): Symbol of the curve.
"""
self.config.symbol = symbol
self.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.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):
self.setData([], [])
def remove(self):
"""Remove the curve from the plot."""
# self.parent_item.removeItem(self)
self.parent_item.remove_curve(self.name())
super().remove()

View File

@ -5,6 +5,8 @@ import random
import pytest
from bec_widgets.cli.client_utils import BECGuiClient
from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process
from bec_widgets.utils import BECDispatcher
# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
@ -23,6 +25,26 @@ def threads_check_fixture(threads_check):
@pytest.fixture
def gui_id():
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturbate
@contextmanager
def plot_server(gui_id, klass, client_lib):
dispatcher = BECDispatcher(client=client_lib) # Has to init singleton with fixture client
process, _ = _start_plot_process(
gui_id, klass, gui_class_id="bec", config=client_lib._client._service_config.config_path
)
try:
while client_lib._client.connector.get(MessageEndpoints.gui_heartbeat(gui_id)) is None:
time.sleep(0.3)
yield gui_id
finally:
process.terminate()
process.wait()
dispatcher.disconnect_all()
dispatcher.reset_singleton()
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
return f"figure_{random.randint(0,100)}"

View File

@ -1,242 +0,0 @@
# import time
# import numpy as np
# import pytest
# from bec_lib.endpoints import MessageEndpoints
# from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
# from bec_widgets.cli.rpc.rpc_base import RPCReference
# from bec_widgets.tests.utils import check_remote_data_size
# # pylint: disable=protected-access
# @pytest.fixture
# def connected_figure(connected_client_gui_obj):
# gui = connected_client_gui_obj
# dock = gui.window_list[0].new("dock")
# fig = dock.new(name="fig", widget="BECFigure")
# return fig
# def test_rpc_waveform1d_custom_curve(connected_figure):
# fig = connected_figure
# ax = fig.plot()
# curve = ax.plot(x=[1, 2, 3], y=[1, 2, 3])
# curve.set_color("red")
# curve = ax.curves[0]
# curve.set_color("blue")
# assert len(fig.widgets) == 1
# assert len(fig.widgets[ax._rpc_id].curves) == 1
# def test_rpc_plotting_shortcuts_init_configs(connected_figure, qtbot):
# fig = connected_figure
# plt = fig.plot(x_name="samx", y_name="bpm4i")
# im = fig.image("eiger")
# motor_map = fig.motor_map("samx", "samy")
# plt_z = fig.plot(x_name="samx", y_name="samy", z_name="bpm4i", new=True)
# # Checking if classes are correctly initialised
# assert len(fig.widgets) == 4
# assert plt.__class__.__name__ == "RPCReference"
# assert plt.__class__ == RPCReference
# assert plt._root._ipython_registry[plt._gui_id].__class__ == BECWaveform
# assert im.__class__.__name__ == "RPCReference"
# assert im.__class__ == RPCReference
# assert im._root._ipython_registry[im._gui_id].__class__ == BECImageShow
# assert motor_map.__class__.__name__ == "RPCReference"
# assert motor_map.__class__ == RPCReference
# assert motor_map._root._ipython_registry[motor_map._gui_id].__class__ == BECMotorMap
# # check if the correct devices are set
# # plot
# assert plt._config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
# "dap": None,
# "source": "scan_segment",
# "x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
# "y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
# "z": None,
# }
# # image
# assert im._config_dict["images"]["eiger"]["monitor"] == "eiger"
# # motor map
# assert motor_map._config_dict["signals"] == {
# "dap": None,
# "source": "device_readback",
# "x": {
# "name": "samx",
# "entry": "samx",
# "unit": None,
# "modifier": None,
# "limits": [-50.0, 50.0],
# },
# "y": {
# "name": "samy",
# "entry": "samy",
# "unit": None,
# "modifier": None,
# "limits": [-50.0, 50.0],
# },
# "z": None,
# }
# # plot with z scatter
# assert plt_z._config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
# "dap": None,
# "source": "scan_segment",
# "x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
# "y": {"name": "samy", "entry": "samy", "unit": None, "modifier": None, "limits": None},
# "z": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
# }
# def test_rpc_waveform_scan(qtbot, connected_figure, bec_client_lib):
# fig = connected_figure
# # add 3 different curves to track
# plt = fig.plot(x_name="samx", y_name="bpm4i")
# fig.plot(x_name="samx", y_name="bpm3a")
# fig.plot(x_name="samx", y_name="bpm4d")
# client = bec_client_lib
# dev = client.device_manager.devices
# scans = client.scans
# queue = client.queue
# status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
# status.wait()
# item = queue.scan_storage.storage[-1]
# last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
# num_elements = 10
# for plot_name in ["bpm4i-bpm4i", "bpm3a-bpm3a", "bpm4d-bpm4d"]:
# qtbot.waitUntil(lambda: check_remote_data_size(plt, plot_name, num_elements))
# # get data from curves
# plt_data = plt.get_all_data()
# # check plotted data
# assert plt_data["bpm4i-bpm4i"]["x"] == last_scan_data["samx"]["samx"].val
# assert plt_data["bpm4i-bpm4i"]["y"] == last_scan_data["bpm4i"]["bpm4i"].val
# assert plt_data["bpm3a-bpm3a"]["x"] == last_scan_data["samx"]["samx"].val
# assert plt_data["bpm3a-bpm3a"]["y"] == last_scan_data["bpm3a"]["bpm3a"].val
# assert plt_data["bpm4d-bpm4d"]["x"] == last_scan_data["samx"]["samx"].val
# assert plt_data["bpm4d-bpm4d"]["y"] == last_scan_data["bpm4d"]["bpm4d"].val
# def test_rpc_image(connected_figure, bec_client_lib):
# fig = connected_figure
# im = fig.image("eiger")
# client = bec_client_lib
# dev = client.device_manager.devices
# scans = client.scans
# status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
# status.wait()
# last_image_device = client.connector.get_last(MessageEndpoints.device_monitor_2d("eiger"))[
# "data"
# ].data
# last_image_plot = im.images[0].get_data()
# # check plotted data
# np.testing.assert_equal(last_image_device, last_image_plot)
# def test_rpc_motor_map(connected_figure, bec_client_lib):
# fig = connected_figure
# motor_map = fig.motor_map("samx", "samy")
# client = bec_client_lib
# dev = client.device_manager.devices
# scans = client.scans
# initial_pos_x = dev.samx.read()["samx"]["value"]
# initial_pos_y = dev.samy.read()["samy"]["value"]
# status = scans.mv(dev.samx, 1, dev.samy, 2, relative=True)
# status.wait()
# final_pos_x = dev.samx.read()["samx"]["value"]
# final_pos_y = dev.samy.read()["samy"]["value"]
# # check plotted data
# motor_map_data = motor_map.get_data()
# np.testing.assert_equal(
# [motor_map_data["x"][0], motor_map_data["y"][0]], [initial_pos_x, initial_pos_y]
# )
# np.testing.assert_equal(
# [motor_map_data["x"][-1], motor_map_data["y"][-1]], [final_pos_x, final_pos_y]
# )
# def test_dap_rpc(connected_figure, bec_client_lib, qtbot):
# fig = connected_figure
# plt = fig.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
# client = bec_client_lib
# dev = client.device_manager.devices
# scans = client.scans
# dev.bpm4i.sim.select_model("GaussianModel")
# params = dev.bpm4i.sim.params
# params.update(
# {"noise": "uniform", "noise_multiplier": 10, "center": 5, "sigma": 1, "amplitude": 200}
# )
# dev.bpm4i.sim.params = params
# time.sleep(1)
# res = scans.line_scan(dev.samx, 0, 8, steps=50, relative=False)
# res.wait()
# # especially on slow machines, the fit might not be done yet
# # so we wait until the fit reaches the expected value
# def wait_for_fit():
# dap_curve = plt.get_curve("bpm4i-bpm4i-GaussianModel")
# fit_params = dap_curve.dap_params
# if fit_params is None:
# return False
# print(fit_params)
# return np.isclose(fit_params["center"], 5, atol=0.5)
# qtbot.waitUntil(wait_for_fit, timeout=10000)
# # Repeat fit after adding a region of interest
# plt.select_roi(region=(3, 7))
# res = scans.line_scan(dev.samx, 0, 8, steps=50, relative=False)
# res.wait()
# qtbot.waitUntil(wait_for_fit, timeout=10000)
# def test_removing_subplots(connected_figure, bec_client_lib):
# fig = connected_figure
# plt = fig.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
# # Registry can't handle multiple subplots on one widget, BECFigure will be deprecated though
# # im = fig.image(monitor="eiger")
# # mm = fig.motor_map(motor_x="samx", motor_y="samy")
# assert len(fig.widget_list) == 1
# # removing curves
# assert len(plt.curves) == 2
# plt.curves[0].remove()
# assert len(plt.curves) == 1
# plt.remove_curve("bpm4i-bpm4i")
# assert len(plt.curves) == 0
# # removing all subplots from figure
# plt.remove()
# # im.remove()
# # mm.remove()
# assert len(fig.widget_list) == 0

View File

@ -57,26 +57,6 @@ def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot):
assert d2.name() in dict(bec_dock_area.dock_area.docks)
def test_add_remove_bec_figure_to_dock(bec_dock_area):
d0 = bec_dock_area.new()
fig = d0.new("BECFigure")
plt = fig.plot(x_name="samx", y_name="bpm4i")
im = fig.image("eiger")
mm = fig.motor_map("samx", "samy")
mw = fig.multi_waveform("waveform1d")
assert len(bec_dock_area.dock_area.docks) == 1
assert len(d0.elements) == 1
assert len(d0.element_list) == 1
assert len(fig.widgets) == 4
assert fig.config.widget_class == "BECFigure"
assert plt.config.widget_class == "BECWaveform"
assert im.config.widget_class == "BECImageShow"
assert mm.config.widget_class == "BECMotorMap"
assert mw.config.widget_class == "BECMultiWaveform"
def test_close_docks(bec_dock_area, qtbot):
d0 = bec_dock_area.new(name="dock_0")
d1 = bec_dock_area.new(name="dock_1")

View File

@ -1,275 +0,0 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import numpy as np
import pytest
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow
from bec_widgets.widgets.containers.figure.plots.motor_map.motor_map import BECMotorMap
from bec_widgets.widgets.containers.figure.plots.multi_waveform.multi_waveform import (
BECMultiWaveform,
)
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import BECWaveform
from .client_mocks import mocked_client
from .conftest import create_widget
def test_bec_figure_init(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
assert bec_figure is not None
assert bec_figure.client is not None
assert isinstance(bec_figure, BECFigure)
assert bec_figure.config.widget_class == "BECFigure"
def test_bec_figure_init_with_config(mocked_client):
config = {"widget_class": "BECFigure", "gui_id": "test_gui_id", "theme": "dark"}
widget = BECFigure(client=mocked_client, config=config)
assert widget.config.gui_id == "test_gui_id"
assert widget.config.theme == "dark"
def test_bec_figure_add_remove_plot(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
initial_count = len(bec_figure._widgets)
# Adding 3 widgets - 2 WaveformBase and 1 PlotBase
w0 = bec_figure.plot(new=True)
w1 = bec_figure.plot(new=True)
w2 = bec_figure.add_widget(widget_type="BECPlotBase")
# Check if the widgets were added
assert len(bec_figure._widgets) == initial_count + 3
assert w0.gui_id in bec_figure._widgets
assert w1.gui_id in bec_figure._widgets
assert w2.gui_id in bec_figure._widgets
assert bec_figure._widgets[w0.gui_id].config.widget_class == "BECWaveform"
assert bec_figure._widgets[w1.gui_id].config.widget_class == "BECWaveform"
assert bec_figure._widgets[w2.gui_id].config.widget_class == "BECPlotBase"
# Check accessing positions by the grid in figure
assert bec_figure[0, 0] == w0
assert bec_figure[1, 0] == w1
assert bec_figure[2, 0] == w2
# Removing 1 widget
bec_figure.remove(widget_id=w0.gui_id)
assert len(bec_figure._widgets) == initial_count + 2
assert w0.gui_id not in bec_figure._widgets
assert w2.gui_id in bec_figure._widgets
assert bec_figure._widgets[w1.gui_id].config.widget_class == "BECWaveform"
def test_add_different_types_of_widgets(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plt = bec_figure.plot(x_name="samx", y_name="bpm4i")
im = bec_figure.image("eiger")
motor_map = bec_figure.motor_map("samx", "samy")
multi_waveform = bec_figure.multi_waveform("waveform")
assert plt.__class__ == BECWaveform
assert im.__class__ == BECImageShow
assert motor_map.__class__ == BECMotorMap
assert multi_waveform.__class__ == BECMultiWaveform
def test_access_widgets_access_errors(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
bec_figure.plot(row=0, col=0)
# access widget by non-existent coordinates
with pytest.raises(ValueError) as excinfo:
bec_figure[0, 2]
assert "No widget at coordinates (0, 2)" in str(excinfo.value)
# access widget by non-existent widget_id
with pytest.raises(KeyError) as excinfo:
bec_figure["non_existent_widget"]
assert "Widget with id 'non_existent_widget' not found" in str(excinfo.value)
# access widget by wrong type
with pytest.raises(TypeError) as excinfo:
bec_figure[1.2]
assert (
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
in str(excinfo.value)
)
def test_add_plot_to_occupied_position(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
bec_figure.plot(row=0, col=0)
with pytest.raises(ValueError) as excinfo:
bec_figure.plot(row=0, col=0, new=True)
assert "Position at row 0 and column 0 is already occupied." in str(excinfo.value)
def test_remove_plots(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot(row=0, col=0)
w2 = bec_figure.plot(row=0, col=1)
w3 = bec_figure.plot(row=1, col=0)
w4 = bec_figure.plot(row=1, col=1)
assert bec_figure[0, 0] == w1
assert bec_figure[0, 1] == w2
assert bec_figure[1, 0] == w3
assert bec_figure[1, 1] == w4
# remove by coordinates
bec_figure[0, 0].remove()
assert w1.gui_id not in bec_figure._widgets
# remove by widget_id
bec_figure.remove(widget_id=w2.gui_id)
assert w2.gui_id not in bec_figure._widgets
# remove by widget object
w3.remove()
assert w3.gui_id not in bec_figure._widgets
# check the remaining widget 4
assert bec_figure[0, 0] == w4
assert bec_figure[w4.gui_id] == w4
assert w4.gui_id in bec_figure._widgets
assert len(bec_figure._widgets) == 1
def test_remove_plots_by_coordinates_ints(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot(row=0, col=0)
w2 = bec_figure.plot(row=0, col=1)
bec_figure.remove(row=0, col=0)
assert w1.gui_id not in bec_figure._widgets
assert w2.gui_id in bec_figure._widgets
assert bec_figure[0, 0] == w2
assert len(bec_figure._widgets) == 1
def test_remove_plots_by_coordinates_tuple(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot(row=0, col=0)
w2 = bec_figure.plot(row=0, col=1)
bec_figure.remove(coordinates=(0, 0))
assert w1.gui_id not in bec_figure._widgets
assert w2.gui_id in bec_figure._widgets
assert bec_figure[0, 0] == w2
assert len(bec_figure._widgets) == 1
def test_remove_plot_by_id_error(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
bec_figure.plot()
with pytest.raises(ValueError) as excinfo:
bec_figure.remove(widget_id="non_existent_widget")
assert "Widget with ID 'non_existent_widget' does not exist." in str(excinfo.value)
def test_remove_plot_by_coordinates_error(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
bec_figure.plot(row=0, col=0)
with pytest.raises(ValueError) as excinfo:
bec_figure.remove(0, 1)
assert "No widget at coordinates (0, 1)" in str(excinfo.value)
def test_remove_plot_by_providing_nothing(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
bec_figure.plot(row=0, col=0)
with pytest.raises(ValueError) as excinfo:
bec_figure.remove()
assert "Must provide either widget_id or coordinates for removal." in str(excinfo.value)
# def test_change_theme(bec_figure): #TODO do no work at python 3.12
# bec_figure.change_theme("dark")
# assert bec_figure.config.theme == "dark"
# assert bec_figure.backgroundBrush().color().name() == "#000000"
# bec_figure.change_theme("light")
# assert bec_figure.config.theme == "light"
# assert bec_figure.backgroundBrush().color().name() == "#ffffff"
# bec_figure.change_theme("dark")
# assert bec_figure.config.theme == "dark"
# assert bec_figure.backgroundBrush().color().name() == "#000000"
def test_change_layout(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot(row=0, col=0)
w2 = bec_figure.plot(row=0, col=1)
w3 = bec_figure.plot(row=1, col=0)
w4 = bec_figure.plot(row=1, col=1)
bec_figure.change_layout(max_columns=1)
assert np.shape(bec_figure.grid) == (4, 1)
assert bec_figure[0, 0] == w1
assert bec_figure[1, 0] == w2
assert bec_figure[2, 0] == w3
assert bec_figure[3, 0] == w4
bec_figure.change_layout(max_rows=1)
assert np.shape(bec_figure.grid) == (1, 4)
assert bec_figure[0, 0] == w1
assert bec_figure[0, 1] == w2
assert bec_figure[0, 2] == w3
assert bec_figure[0, 3] == w4
def test_clear_all(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
bec_figure.plot(row=0, col=0)
bec_figure.plot(row=0, col=1)
bec_figure.plot(row=1, col=0)
bec_figure.plot(row=1, col=1)
bec_figure.clear_all()
assert len(bec_figure._widgets) == 0
assert np.shape(bec_figure.grid) == (0,)
def test_shortcuts(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plt = bec_figure.plot(x_name="samx", y_name="bpm4i")
im = bec_figure.image("eiger")
motor_map = bec_figure.motor_map("samx", "samy")
assert plt.config.widget_class == "BECWaveform"
assert plt.__class__ == BECWaveform
assert im.config.widget_class == "BECImageShow"
assert im.__class__ == BECImageShow
assert motor_map.config.widget_class == "BECMotorMap"
assert motor_map.__class__ == BECMotorMap
def test_plot_access_factory(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plt_00 = bec_figure.plot(x_name="samx", y_name="bpm4i")
plt_01 = bec_figure.plot(x_name="samx", y_name="bpm4i", row=0, col=1)
plt_10 = bec_figure.plot(new=True)
assert bec_figure.widget_list[0] == plt_00
assert bec_figure.widget_list[1] == plt_01
assert bec_figure.widget_list[2] == plt_10
assert bec_figure.axes(row=0, col=0) == plt_00
assert bec_figure.axes(row=0, col=1) == plt_01
assert bec_figure.axes(row=1, col=0) == plt_10
assert len(plt_00.curves) == 1
assert len(plt_01.curves) == 1
assert len(plt_10.curves) == 0
# update plt_00
bec_figure.plot(x_name="samx", y_name="bpm3a")
bec_figure.plot(x=[1, 2, 3], y=[1, 2, 3], row=0, col=0)
assert len(plt_00.curves) == 3

View File

@ -1,97 +0,0 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import numpy as np
from bec_lib import messages
from bec_widgets.widgets.containers.figure import BECFigure
from .client_mocks import mocked_client
from .conftest import create_widget
def test_on_image_update(qtbot, mocked_client):
bec_image_show = create_widget(qtbot, BECFigure, client=mocked_client).image("eiger")
data = np.random.rand(100, 100)
msg = messages.DeviceMonitor2DMessage(device="eiger", data=data, metadata={"scan_id": "12345"})
bec_image_show.on_image_update(msg.content, msg.metadata)
img = bec_image_show.images[0]
assert np.array_equal(img.get_data(), data)
def test_autorange_on_image_update(qtbot, mocked_client):
bec_image_show = create_widget(qtbot, BECFigure, client=mocked_client).image("eiger")
# Check if autorange mode "mean" works, should be default
data = np.random.rand(100, 100)
msg = messages.DeviceMonitor2DMessage(device="eiger", data=data, metadata={"scan_id": "12345"})
bec_image_show.on_image_update(msg.content, msg.metadata)
img = bec_image_show.images[0]
assert np.array_equal(img.get_data(), data)
vmin = max(np.mean(data) - 2 * np.std(data), 0)
vmax = np.mean(data) + 2 * np.std(data)
assert np.isclose(img.color_bar.getLevels(), (vmin, vmax), rtol=(1e-5, 1e-5)).all()
# Test general update with autorange True, mode "max"
bec_image_show.set_autorange_mode("max")
bec_image_show.on_image_update(msg.content, msg.metadata)
img = bec_image_show.images[0]
vmin = np.min(data)
vmax = np.max(data)
assert np.array_equal(img.get_data(), data)
assert np.isclose(img.color_bar.getLevels(), (vmin, vmax), rtol=(1e-5, 1e-5)).all()
# Change the input data, and switch to autorange False, colormap levels should stay untouched
data *= 100
msg = messages.DeviceMonitor2DMessage(device="eiger", data=data, metadata={"scan_id": "12345"})
bec_image_show.set_autorange(False)
bec_image_show.on_image_update(msg.content, msg.metadata)
img = bec_image_show.images[0]
assert np.array_equal(img.get_data(), data)
assert np.isclose(img.color_bar.getLevels(), (vmin, vmax), rtol=(1e-3, 1e-3)).all()
# Reactivate autorange, should now scale the new data
bec_image_show.set_autorange(True)
bec_image_show.set_autorange_mode("mean")
bec_image_show.on_image_update(msg.content, msg.metadata)
img = bec_image_show.images[0]
vmin = max(np.mean(data) - 2 * np.std(data), 0)
vmax = np.mean(data) + 2 * np.std(data)
assert np.isclose(img.color_bar.getLevels(), (vmin, vmax), rtol=(1e-5, 1e-5)).all()
def test_on_image_update_variable_length(qtbot, mocked_client):
"""
Test the on_image_update slot with data arrays of varying lengths for 'device_monitor_1d' image type.
"""
# Create the widget and set image_type to 'device_monitor_1d'
bec_image_show = create_widget(qtbot, BECFigure, client=mocked_client).image("waveform1d", "1d")
# Generate data arrays of varying lengths
data_lengths = [10, 15, 12, 20, 5, 8, 1, 21]
data_arrays = [np.random.rand(length) for length in data_lengths]
# Simulate sending messages with these data arrays
device = "waveform1d"
for data in data_arrays:
msg = messages.DeviceMonitor1DMessage(
device=device, data=data, metadata={"scan_id": "12345"}
)
bec_image_show.on_image_update(msg.content, msg.metadata)
# After processing all data, retrieve the image and its data
img = bec_image_show.images[0]
image_buffer = img.get_data()
# The image_buffer should be a 2D array with number of rows equal to number of data arrays
# and number of columns equal to the maximum data length
expected_num_rows = len(data_arrays)
expected_num_cols = max(data_lengths)
assert image_buffer.shape == (
expected_num_rows,
expected_num_cols,
), f"Expected image buffer shape {(expected_num_rows, expected_num_cols)}, got {image_buffer.shape}"
# Check that each row in image_buffer corresponds to the padded data arrays
for i, data in enumerate(data_arrays):
padded_data = np.pad(
data, (0, expected_num_cols - len(data)), mode="constant", constant_values=0
)
assert np.array_equal(
image_buffer[i], padded_data
), f"Row {i} in image buffer does not match expected padded data"

View File

@ -1,282 +0,0 @@
import numpy as np
from bec_lib.messages import DeviceMessage
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.figure.plots.motor_map.motor_map import MotorMapConfig
from bec_widgets.widgets.containers.figure.plots.waveform.waveform_curve import SignalData
from .client_mocks import mocked_client
from .conftest import create_widget
def test_motor_map_init(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
default_config = MotorMapConfig(widget_class="BECMotorMap")
mm = bec_figure.motor_map(config=default_config.model_dump())
default_config.gui_id = mm.gui_id
assert mm.config == default_config
def test_motor_map_change_motors(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
mm = bec_figure.motor_map("samx", "samy")
assert mm.motor_x == "samx"
assert mm.motor_y == "samy"
assert mm.config.signals.x == SignalData(name="samx", entry="samx", limits=[-10, 10])
assert mm.config.signals.y == SignalData(name="samy", entry="samy", limits=[-5, 5])
mm.change_motors("samx", "samz")
assert mm.config.signals.x == SignalData(name="samx", entry="samx", limits=[-10, 10])
assert mm.config.signals.y == SignalData(name="samz", entry="samz", limits=[-8, 8])
def test_motor_map_get_limits(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
mm = bec_figure.motor_map("samx", "samy")
expected_limits = {"samx": [-10, 10], "samy": [-5, 5]}
for motor_name, expected_limit in expected_limits.items():
actual_limit = mm._get_motor_limit(motor_name)
assert actual_limit == expected_limit
def test_motor_map_get_init_position(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
mm = bec_figure.motor_map("samx", "samy")
mm.set_precision(2)
motor_map_dev = mm.client.device_manager.devices
expected_positions = {
("samx", "samx"): motor_map_dev["samx"].read()["samx"]["value"],
("samy", "samy"): motor_map_dev["samy"].read()["samy"]["value"],
}
for (motor_name, entry), expected_position in expected_positions.items():
actual_position = mm._get_motor_init_position(motor_name, entry, 2)
assert actual_position == expected_position
def test_motor_movement_updates_position_and_database(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
mm = bec_figure.motor_map("samx", "samy")
motor_map_dev = mm.client.device_manager.devices
init_positions = {
"samx": [motor_map_dev["samx"].read()["samx"]["value"]],
"samy": [motor_map_dev["samy"].read()["samy"]["value"]],
}
mm.change_motors("samx", "samy")
assert mm.database_buffer["x"] == init_positions["samx"]
assert mm.database_buffer["y"] == init_positions["samy"]
# Simulate motor movement for 'samx' only
new_position_samx = 4.0
msg = DeviceMessage(signals={"samx": {"value": new_position_samx}}, metadata={})
mm.on_device_readback(msg.content, msg.metadata)
init_positions["samx"].append(new_position_samx)
init_positions["samy"].append(init_positions["samy"][-1])
# Verify database update for 'samx'
assert mm.database_buffer["x"] == init_positions["samx"]
# Verify 'samy' retains its last known position
assert mm.database_buffer["y"] == init_positions["samy"]
def test_scatter_plot_rendering(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
mm = bec_figure.motor_map("samx", "samy")
motor_map_dev = mm.client.device_manager.devices
init_positions = {
"samx": [motor_map_dev["samx"].read()["samx"]["value"]],
"samy": [motor_map_dev["samy"].read()["samy"]["value"]],
}
mm.change_motors("samx", "samy")
# Simulate motor movement for 'samx' only
new_position_samx = 4.0
msg = DeviceMessage(signals={"samx": {"value": new_position_samx}}, metadata={})
mm.on_device_readback(msg.content, msg.metadata)
mm._update_plot()
# Get the scatter plot item
scatter_plot_item = mm.plot_components["scatter"]
# Check the scatter plot item properties
assert len(scatter_plot_item.data) > 0, "Scatter plot data is empty"
x_data = scatter_plot_item.data["x"]
y_data = scatter_plot_item.data["y"]
assert x_data[-1] == new_position_samx, "Scatter plot X data not updated correctly"
assert (
y_data[-1] == init_positions["samy"][-1]
), "Scatter plot Y data should retain last known position"
def test_plot_visualization_consistency(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
mm = bec_figure.motor_map("samx", "samy")
mm.change_motors("samx", "samy")
# Simulate updating the plot with new data
msg = DeviceMessage(signals={"samx": {"value": 5}}, metadata={})
mm.on_device_readback(msg.content, msg.metadata)
msg = DeviceMessage(signals={"samy": {"value": 9}}, metadata={})
mm.on_device_readback(msg.content, msg.metadata)
mm._update_plot()
scatter_plot_item = mm.plot_components["scatter"]
# Check if the scatter plot reflects the new data correctly
assert (
scatter_plot_item.data["x"][-1] == 5 and scatter_plot_item.data["y"][-1] == 9
), "Plot not updated correctly with new data"
def test_change_background_value(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
mm = bec_figure.motor_map("samx", "samy")
assert mm.config.background_value == 25
assert np.all(mm.plot_components["limit_map"].image == 25.0)
mm.set_background_value(50)
qtbot.wait(200)
assert mm.config.background_value == 50
assert np.all(mm.plot_components["limit_map"].image == 50.0)
def test_motor_map_init_from_config(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
config = {
"widget_class": "BECMotorMap",
"gui_id": "mm_id",
"parent_id": bec_figure.gui_id,
"row": 0,
"col": 0,
"axis": {
"title": "Motor position: (-0.0, 0.0)",
"title_size": None,
"x_label": "Motor X (samx)",
"x_label_size": None,
"y_label": "Motor Y (samy)",
"y_label_size": None,
"legend_label_size": None,
"x_scale": "linear",
"y_scale": "linear",
"x_lim": None,
"y_lim": None,
"x_grid": True,
"y_grid": True,
"outer_axes": False,
},
"signals": {
"source": "device_readback",
"x": {
"name": "samx",
"entry": "samx",
"unit": None,
"modifier": None,
"limits": [-10.0, 10.0],
},
"y": {
"name": "samy",
"entry": "samy",
"unit": None,
"modifier": None,
"limits": [-5.0, 5.0],
},
"z": None,
"dap": None,
},
"color": (255, 255, 255, 255),
"scatter_size": 5,
"max_points": 50,
"num_dim_points": 10,
"precision": 5,
"background_value": 50,
}
mm = bec_figure.motor_map(config=config)
config["gui_id"] = mm.gui_id
assert mm._config_dict == config
def test_motor_map_set_scatter_size(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
mm = bec_figure.motor_map("samx", "samy")
assert mm.config.scatter_size == 5
assert mm.plot_components["scatter"].opts["size"] == 5
mm.set_scatter_size(10)
qtbot.wait(200)
assert mm.config.scatter_size == 10
assert mm.plot_components["scatter"].opts["size"] == 10
def test_motor_map_change_precision(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
mm = bec_figure.motor_map("samx", "samy")
assert mm.config.precision == 2
mm.set_precision(10)
assert mm.config.precision == 10
def test_motor_map_set_color(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
mm = bec_figure.motor_map("samx", "samy")
assert mm.config.color == (255, 255, 255, 255)
mm.set_color((0, 0, 0, 255))
qtbot.wait(200)
assert mm.config.color == (0, 0, 0, 255)
def test_motor_map_get_data_max_points(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
mm = bec_figure.motor_map("samx", "samy")
motor_map_dev = mm.client.device_manager.devices
init_positions = {
"samx": [motor_map_dev["samx"].read()["samx"]["value"]],
"samy": [motor_map_dev["samy"].read()["samy"]["value"]],
}
msg = DeviceMessage(signals={"samx": {"value": 5.0}}, metadata={})
mm.on_device_readback(msg.content, msg.metadata)
msg = DeviceMessage(signals={"samy": {"value": 9.0}}, metadata={})
mm.on_device_readback(msg.content, msg.metadata)
msg = DeviceMessage(signals={"samx": {"value": 6.0}}, metadata={})
mm.on_device_readback(msg.content, msg.metadata)
msg = DeviceMessage(signals={"samy": {"value": 7.0}}, metadata={})
mm.on_device_readback(msg.content, msg.metadata)
expected_x = [init_positions["samx"][-1], 5.0, 5.0, 6.0, 6.0]
expected_y = [init_positions["samy"][-1], init_positions["samy"][-1], 9.0, 9.0, 7.0]
get_data = mm.get_data()
assert mm.database_buffer["x"] == expected_x
assert mm.database_buffer["y"] == expected_y
assert get_data["x"] == expected_x
assert get_data["y"] == expected_y
mm.set_max_points(3)
qtbot.wait(200)
get_data = mm.get_data()
assert len(get_data["x"]) == 3
assert len(get_data["y"]) == 3
assert get_data["x"] == expected_x[-3:]
assert get_data["y"] == expected_y[-3:]
assert mm.database_buffer["x"] == expected_x[-3:]
assert mm.database_buffer["y"] == expected_y[-3:]

View File

@ -1,253 +0,0 @@
from unittest import mock
import numpy as np
import pytest
from bec_lib.endpoints import messages
from bec_widgets.utils import Colors
from bec_widgets.widgets.containers.figure import BECFigure
from .client_mocks import mocked_client
from .conftest import create_widget
def test_set_monitor(qtbot, mocked_client):
"""Test that setting the monitor connects the appropriate slot."""
# Create a BECFigure
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
# Add a multi_waveform plot
multi_waveform = bec_figure.multi_waveform()
multi_waveform.set_monitor("waveform1d")
assert multi_waveform.config.monitor == "waveform1d"
assert multi_waveform.connected is True
data_0 = np.random.rand(100)
msg = messages.DeviceMonitor1DMessage(
device="waveform1d", data=data_0, metadata={"scan_id": "12345"}
)
multi_waveform.on_monitor_1d_update(msg.content, msg.metadata)
data_waveform = multi_waveform.get_all_data()
print(data_waveform)
assert len(data_waveform) == 1
assert np.array_equal(data_waveform["curve_0"]["y"], data_0)
data_1 = np.random.rand(100)
msg = messages.DeviceMonitor1DMessage(
device="waveform1d", data=data_1, metadata={"scan_id": "12345"}
)
multi_waveform.on_monitor_1d_update(msg.content, msg.metadata)
data_waveform = multi_waveform.get_all_data()
assert len(data_waveform) == 2
assert np.array_equal(data_waveform["curve_0"]["y"], data_0)
assert np.array_equal(data_waveform["curve_1"]["y"], data_1)
def test_on_monitor_1d_update(qtbot, mocked_client):
"""Test that data updates add curves to the plot."""
# Create a BECFigure
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
# Add a multi_waveform plot
multi_waveform = bec_figure.multi_waveform()
multi_waveform.set_monitor("test_monitor")
# Simulate receiving data updates
test_data = np.array([1, 2, 3, 4, 5])
msg = {"data": test_data}
metadata = {"scan_id": "scan_1"}
# Call the on_monitor_1d_update method
multi_waveform.on_monitor_1d_update(msg, metadata)
# Check that a curve has been added
assert len(multi_waveform.curves) == 1
# Check that the data in the curve is correct
curve = multi_waveform.curves[-1]
x_data, y_data = curve.getData()
assert np.array_equal(y_data, test_data)
# Simulate another data update
test_data_2 = np.array([6, 7, 8, 9, 10])
msg2 = {"data": test_data_2}
metadata2 = {"scan_id": "scan_1"}
multi_waveform.on_monitor_1d_update(msg2, metadata2)
# Check that another curve has been added
assert len(multi_waveform.curves) == 2
# Check that the data in the curve is correct
curve2 = multi_waveform.curves[-1]
x_data2, y_data2 = curve2.getData()
assert np.array_equal(y_data2, test_data_2)
def test_set_curve_limit_no_flush(qtbot, mocked_client):
"""Test set_curve_limit with flush_buffer=False."""
# Create a BECFigure
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
# Add a multi_waveform plot
multi_waveform = bec_figure.multi_waveform()
multi_waveform.set_monitor("test_monitor")
# Simulate adding multiple curves
for i in range(5):
test_data = np.array([i, i + 1, i + 2])
msg = {"data": test_data}
metadata = {"scan_id": "scan_1"}
multi_waveform.on_monitor_1d_update(msg, metadata)
# Check that there are 5 curves
assert len(multi_waveform.curves) == 5
# Set curve limit to 3 with flush_buffer=False
multi_waveform.set_curve_limit(3, flush_buffer=False)
# Check that curves are hidden, but not removed
assert len(multi_waveform.curves) == 5
visible_curves = [curve for curve in multi_waveform.curves if curve.isVisible()]
assert len(visible_curves) == 3
# The first two curves should be hidden
assert not multi_waveform.curves[0].isVisible()
assert not multi_waveform.curves[1].isVisible()
assert multi_waveform.curves[2].isVisible()
assert multi_waveform.curves[3].isVisible()
assert multi_waveform.curves[4].isVisible()
def test_set_curve_limit_flush(qtbot, mocked_client):
"""Test set_curve_limit with flush_buffer=True."""
# Create a BECFigure
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
# Add a multi_waveform plot
multi_waveform = bec_figure.multi_waveform()
multi_waveform.set_monitor("test_monitor")
# Simulate adding multiple curves
for i in range(5):
test_data = np.array([i, i + 1, i + 2])
msg = {"data": test_data}
metadata = {"scan_id": "scan_1"}
multi_waveform.on_monitor_1d_update(msg, metadata)
# Check that there are 5 curves
assert len(multi_waveform.curves) == 5
# Set curve limit to 3 with flush_buffer=True
multi_waveform.set_curve_limit(3, flush_buffer=True)
# Check that only 3 curves remain
assert len(multi_waveform.curves) == 3
# The curves should be the last 3 added
x_data, y_data = multi_waveform.curves[0].getData()
assert np.array_equal(y_data, [2, 3, 4])
x_data, y_data = multi_waveform.curves[1].getData()
assert np.array_equal(y_data, [3, 4, 5])
x_data, y_data = multi_waveform.curves[2].getData()
assert np.array_equal(y_data, [4, 5, 6])
def test_set_curve_highlight(qtbot, mocked_client):
"""Test that the correct curve is highlighted."""
# Create a BECFigure
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
# Add a multi_waveform plot
multi_waveform = bec_figure.multi_waveform()
multi_waveform.set_monitor("test_monitor")
# Simulate adding multiple curves
for i in range(3):
test_data = np.array([i, i + 1, i + 2])
msg = {"data": test_data}
metadata = {"scan_id": "scan_1"}
multi_waveform.on_monitor_1d_update(msg, metadata)
# Set highlight_last_curve to False
multi_waveform.highlight_last_curve = False
multi_waveform.set_curve_highlight(1) # Highlight the second curve (index 1)
# Check that the second curve is highlighted
visible_curves = [curve for curve in multi_waveform.curves if curve.isVisible()]
# Reverse the list to match indexing in set_curve_highlight
visible_curves = list(reversed(visible_curves))
for i, curve in enumerate(visible_curves):
pen = curve.opts["pen"]
width = pen.width()
if i == 1:
# Highlighted curve should have width 5
assert width == 5
else:
assert width == 1
def test_set_opacity(qtbot, mocked_client):
"""Test that setting opacity updates the curves."""
# Create a BECFigure
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
# Add a multi_waveform plot
multi_waveform = bec_figure.multi_waveform()
multi_waveform.set_monitor("waveform1d")
# Simulate adding a curve
test_data = np.array([1, 2, 3])
msg = {"data": test_data}
metadata = {"scan_id": "scan_1"}
multi_waveform.on_monitor_1d_update(msg, metadata)
# Set opacity to 30
multi_waveform.set_opacity(30)
assert multi_waveform.config.opacity == 30
def test_set_colormap(qtbot, mocked_client):
"""Test that setting the colormap updates the curve colors."""
# Create a BECFigure
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
# Add a multi_waveform plot
multi_waveform = bec_figure.multi_waveform()
multi_waveform.set_monitor("waveform1d")
# Simulate adding multiple curves
for i in range(3):
test_data = np.array([i, i + 1, i + 2])
msg = {"data": test_data}
metadata = {"scan_id": "scan_1"}
multi_waveform.on_monitor_1d_update(msg, metadata)
# Set a new colormap
multi_waveform.set_opacity(100)
multi_waveform.set_colormap("viridis")
# Check that the colors of the curves have changed accordingly
visible_curves = [curve for curve in multi_waveform.curves if curve.isVisible()]
# Get the colors applied
colors = Colors.evenly_spaced_colors(colormap="viridis", num=len(visible_curves), format="HEX")
for i, curve in enumerate(visible_curves):
pen = curve.opts["pen"]
pen_color = pen.color().name()
expected_color = colors[i]
# Compare pen color to expected color
assert pen_color.lower() == expected_color.lower()
def test_export_to_matplotlib(qtbot, mocked_client):
"""Test that export_to_matplotlib can be called without errors."""
try:
import matplotlib
except ImportError:
pytest.skip("Matplotlib not installed")
# Create a BECFigure
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
# Add a multi_waveform plot
multi_waveform = bec_figure.multi_waveform()
multi_waveform.set_monitor("test_monitor")
# Simulate adding a curve
test_data = np.array([1, 2, 3])
msg = {"data": test_data}
metadata = {"scan_id": "scan_1"}
multi_waveform.on_monitor_1d_update(msg, metadata)
# Call export_to_matplotlib
with mock.patch("pyqtgraph.exporters.MatplotlibExporter.export") as mock_export:
multi_waveform.export_to_matplotlib()
mock_export.assert_called_once()

View File

@ -1,247 +0,0 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest import mock
import pytest
from bec_widgets.widgets.containers.figure import BECFigure
from .client_mocks import mocked_client
from .conftest import create_widget
def test_init_plot_base(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
assert plot_base is not None
assert plot_base.config.widget_class == "BECPlotBase"
assert plot_base.config.gui_id == plot_base.gui_id
def test_plot_base_axes_by_separate_methods(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
plot_base.set_title("Test Title")
plot_base.set_x_label("Test x Label")
plot_base.set_y_label("Test y Label")
plot_base.set_x_lim(1, 100)
plot_base.set_y_lim(5, 500)
plot_base.set_grid(True, True)
plot_base.set_x_scale("log")
plot_base.set_y_scale("log")
assert plot_base.plot_item.titleLabel.text == "Test Title"
assert plot_base.config.axis.title == "Test Title"
assert plot_base.plot_item.getAxis("bottom").labelText == "Test x Label"
assert plot_base.config.axis.x_label == "Test x Label"
assert plot_base.plot_item.getAxis("left").labelText == "Test y Label"
assert plot_base.config.axis.y_label == "Test y Label"
assert plot_base.config.axis.x_lim == (1, 100)
assert plot_base.config.axis.y_lim == (5, 500)
assert plot_base.plot_item.ctrl.xGridCheck.isChecked() == True
assert plot_base.plot_item.ctrl.yGridCheck.isChecked() == True
assert plot_base.plot_item.ctrl.logXCheck.isChecked() == True
assert plot_base.plot_item.ctrl.logYCheck.isChecked() == True
# Check the font size by mocking the set functions
# I struggled retrieving it from the QFont object directly
# thus I mocked the set functions to check internally the functionality
with (
mock.patch.object(plot_base.plot_item, "setLabel") as mock_set_label,
mock.patch.object(plot_base.plot_item, "setTitle") as mock_set_title,
):
plot_base.set_x_label("Test x Label", 20)
plot_base.set_y_label("Test y Label", 16)
assert mock_set_label.call_count == 2
assert plot_base.config.axis.x_label_size == 20
assert plot_base.config.axis.y_label_size == 16
col = plot_base.get_text_color()
calls = []
style = {"color": col, "font-size": "20pt"}
calls.append(mock.call("bottom", "Test x Label", **style))
style = {"color": col, "font-size": "16pt"}
calls.append(mock.call("left", "Test y Label", **style))
assert mock_set_label.call_args_list == calls
plot_base.set_title("Test Title", 16)
style = {"color": col, "size": "16pt"}
call = mock.call("Test Title", **style)
assert mock_set_title.call_args == call
def test_plot_base_axes_added_by_kwargs(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
plot_base.set(
title="Test Title",
x_label="Test x Label",
y_label="Test y Label",
x_lim=(1, 100),
y_lim=(5, 500),
x_scale="log",
y_scale="log",
)
assert plot_base.plot_item.titleLabel.text == "Test Title"
assert plot_base.config.axis.title == "Test Title"
assert plot_base.plot_item.getAxis("bottom").labelText == "Test x Label"
assert plot_base.config.axis.x_label == "Test x Label"
assert plot_base.plot_item.getAxis("left").labelText == "Test y Label"
assert plot_base.config.axis.y_label == "Test y Label"
assert plot_base.config.axis.x_lim == (1, 100)
assert plot_base.config.axis.y_lim == (5, 500)
assert plot_base.plot_item.ctrl.logXCheck.isChecked() == True
assert plot_base.plot_item.ctrl.logYCheck.isChecked() == True
def test_lock_aspect_ratio(qtbot, mocked_client):
"""
Test locking and unlocking the aspect ratio of the plot.
"""
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
# Lock the aspect ratio
plot_base.lock_aspect_ratio(True)
assert plot_base.plot_item.vb.state["aspectLocked"] == 1
# Unlock the aspect ratio
plot_base.lock_aspect_ratio(False)
assert plot_base.plot_item.vb.state["aspectLocked"] == 0
def test_set_auto_range(qtbot, mocked_client):
"""
Test enabling and disabling auto range for the plot.
"""
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
# Enable auto range for both axes
plot_base.set_auto_range(True, axis="xy")
assert plot_base.plot_item.vb.state["autoRange"] == [True, True]
# Disable auto range for x-axis
plot_base.set_auto_range(False, axis="x")
assert plot_base.plot_item.vb.state["autoRange"] == [False, True]
# Disable auto range for y-axis
plot_base.set_auto_range(False, axis="y")
assert plot_base.plot_item.vb.state["autoRange"] == [False, False]
def test_set_outer_axes(qtbot, mocked_client):
"""
Test showing and hiding the outer axes of the plot.
"""
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
# Show outer axes
plot_base.set_outer_axes(True)
assert plot_base.plot_item.getAxis("top").isVisible()
assert plot_base.plot_item.getAxis("right").isVisible()
assert plot_base.config.axis.outer_axes is True
# Hide outer axes
plot_base.set_outer_axes(False)
assert not plot_base.plot_item.getAxis("top").isVisible()
assert not plot_base.plot_item.getAxis("right").isVisible()
assert plot_base.config.axis.outer_axes is False
def test_toggle_crosshair(qtbot, mocked_client):
"""
Test toggling the crosshair on and off.
"""
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
# Toggle crosshair on
plot_base.toggle_crosshair()
assert plot_base.crosshair is not None
# Toggle crosshair off
plot_base.toggle_crosshair()
assert plot_base.crosshair is None
def test_invalid_scale_input(qtbot, mocked_client):
"""
Test setting an invalid scale for x and y axes.
"""
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
with pytest.raises(ValueError):
plot_base.set_x_scale("invalid_scale")
with pytest.raises(ValueError):
plot_base.set_y_scale("invalid_scale")
def test_set_x_lim_invalid_arguments(qtbot, mocked_client):
"""
Test passing invalid arguments to set_x_lim.
"""
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
with pytest.raises(ValueError):
plot_base.set_x_lim(1)
with pytest.raises(ValueError):
plot_base.set_x_lim((1, 2, 3))
def test_set_y_lim_invalid_arguments(qtbot, mocked_client):
"""
Test passing invalid arguments to set_y_lim.
"""
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
with pytest.raises(ValueError):
plot_base.set_y_lim(1)
with pytest.raises(ValueError):
plot_base.set_y_lim((1, 2, 3))
def test_remove_plot(qtbot, mocked_client):
"""
Test removing the plot widget from the figure.
"""
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
with mock.patch.object(bec_figure, "remove") as mock_remove:
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
plot_base.remove()
mock_remove.assert_called_once_with(widget_id=plot_base.gui_id)
def test_add_fps_monitor(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
plot_base.enable_fps_monitor(True)
assert plot_base.fps_monitor is not None
assert plot_base.fps_monitor.view_box is plot_base.plot_item.getViewBox()
assert plot_base.fps_monitor.timer.isActive() == True
assert plot_base.fps_monitor.timer.interval() == 1000
assert plot_base.fps_monitor.sigFpsUpdate is not None
assert plot_base.fps_monitor.sigFpsUpdate.connect is not None
def test_hook_unhook_fps_monitor(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
plot_base.enable_fps_monitor(True)
assert plot_base.fps_monitor is not None
plot_base.enable_fps_monitor(False)
assert plot_base.fps_monitor is None
plot_base.enable_fps_monitor(True)
assert plot_base.fps_monitor is not None

View File

@ -6,7 +6,7 @@ def test_client_generator_classes():
connector_cls_names = [cls.__name__ for cls in out.connector_classes]
plugins = [cls.__name__ for cls in out.plugins]
assert "BECFigure" in connector_cls_names
assert "BECWaveform" in connector_cls_names
assert "Image" in connector_cls_names
assert "Waveform" in connector_cls_names
assert "BECDockArea" in plugins
assert "BECWaveform" not in plugins
assert "NonExisting" not in plugins

View File

@ -3,7 +3,7 @@ from unittest import mock
import pytest
from bec_widgets.cli.server import _start_server
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.dock import BECDockArea
@pytest.fixture
@ -20,9 +20,9 @@ def test_rpc_server_start_server_without_service_config(mocked_cli_server):
"""
mock_server, mock_config, _ = mocked_cli_server
_start_server("gui_id", BECFigure, config=None)
_start_server("gui_id", BECDockArea, config=None)
mock_server.assert_called_once_with(
gui_id="gui_id", config=mock_config(), gui_class=BECFigure, gui_class_id="bec"
gui_id="gui_id", config=mock_config(), gui_class=BECDockArea, gui_class_id="bec"
)
@ -39,7 +39,7 @@ def test_rpc_server_start_server_with_service_config(mocked_cli_server, config,
"""
mock_server, mock_config, _ = mocked_cli_server
config = mock_config(**call_config)
_start_server("gui_id", BECFigure, config=config)
_start_server("gui_id", BECDockArea, config=config)
mock_server.assert_called_once_with(
gui_id="gui_id", config=config, gui_class=BECFigure, gui_class_id="bec"
gui_id="gui_id", config=config, gui_class=BECDockArea, gui_class_id="bec"
)

View File

@ -1,113 +1,114 @@
import pytest
from qtpy.QtCore import QPointF
from bec_widgets.widgets.containers.figure import BECFigure
from .client_mocks import mocked_client
@pytest.fixture
def plot_widget_with_arrow_item(qtbot, mocked_client):
widget = BECFigure(client=mocked_client())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
waveform = widget.plot()
yield waveform.arrow_item, waveform.plot_item
@pytest.fixture
def plot_widget_with_tick_item(qtbot, mocked_client):
widget = BECFigure(client=mocked_client())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
waveform = widget.plot()
yield waveform.tick_item, waveform.plot_item
def test_arrow_item_add_to_plot(plot_widget_with_arrow_item):
"""Test the add_to_plot method"""
arrow_item, plot_item = plot_widget_with_arrow_item
assert arrow_item.plot_item is not None
assert arrow_item.plot_item.items == []
arrow_item.add_to_plot()
assert arrow_item.plot_item.items == [arrow_item.arrow_item]
arrow_item.remove_from_plot()
def test_arrow_item_set_position(plot_widget_with_arrow_item):
"""Test the set_position method"""
arrow_item, plot_item = plot_widget_with_arrow_item
container = []
def signal_callback(tup: tuple):
container.append(tup)
arrow_item.add_to_plot()
arrow_item.position_changed.connect(signal_callback)
arrow_item.set_position(pos=(1, 1))
point = QPointF(1.0, 1.0)
assert arrow_item.arrow_item.pos() == point
arrow_item.set_position(pos=(2, 2))
point = QPointF(2.0, 2.0)
assert arrow_item.arrow_item.pos() == point
assert container == [(1, 1), (2, 2)]
arrow_item.remove_from_plot()
def test_arrow_item_cleanup(plot_widget_with_arrow_item):
"""Test cleanup procedure"""
arrow_item, plot_item = plot_widget_with_arrow_item
arrow_item.add_to_plot()
assert arrow_item.item_on_plot is True
arrow_item.cleanup()
assert arrow_item.plot_item.items == []
assert arrow_item.item_on_plot is False
assert arrow_item.arrow_item is None
def test_tick_item_add_to_plot(plot_widget_with_tick_item):
"""Test the add_to_plot method"""
tick_item, plot_item = plot_widget_with_tick_item
assert tick_item.plot_item is not None
assert tick_item.plot_item.items == []
tick_item.add_to_plot()
assert tick_item.plot_item.layout.itemAt(2, 1) == tick_item.tick_item
assert tick_item.item_on_plot is True
new_pos = plot_item.vb.geometry().bottom()
pos = tick_item.tick.pos()
new_pos = tick_item.tick_item.mapFromParent(QPointF(pos.x(), new_pos))
assert new_pos.y() == pos.y()
tick_item.remove_from_plot()
def test_tick_item_set_position(plot_widget_with_tick_item):
"""Test the set_position method"""
tick_item, plot_item = plot_widget_with_tick_item
container = []
def signal_callback(val: float):
container.append(val)
tick_item.add_to_plot()
tick_item.position_changed.connect(signal_callback)
tick_item.set_position(pos=1)
assert tick_item._pos == 1
tick_item.set_position(pos=2)
assert tick_item._pos == 2
assert container == [1.0, 2.0]
tick_item.remove_from_plot()
def test_tick_item_cleanup(plot_widget_with_tick_item):
"""Test cleanup procedure"""
tick_item, plot_item = plot_widget_with_tick_item
tick_item.add_to_plot()
assert tick_item.item_on_plot is True
tick_item.cleanup()
ticks = getattr(tick_item.plot_item.layout.itemAt(3, 1), "ticks", None)
assert ticks == None
assert tick_item.item_on_plot is False
assert tick_item.tick_item is None
# TODO temporary disabled until migrate tick and arrow items to new system
# import pytest
# from qtpy.QtCore import QPointF
#
# from bec_widgets.widgets.containers.figure import BECFigure
#
# from .client_mocks import mocked_client
#
#
# @pytest.fixture
# def plot_widget_with_arrow_item(qtbot, mocked_client):
# widget = BECFigure(client=mocked_client())
# qtbot.addWidget(widget)
# qtbot.waitExposed(widget)
# waveform = widget.plot()
#
# yield waveform.arrow_item, waveform.plot_item
#
#
# @pytest.fixture
# def plot_widget_with_tick_item(qtbot, mocked_client):
# widget = BECFigure(client=mocked_client())
# qtbot.addWidget(widget)
# qtbot.waitExposed(widget)
# waveform = widget.plot()
#
# yield waveform.tick_item, waveform.plot_item
#
#
# def test_arrow_item_add_to_plot(plot_widget_with_arrow_item):
# """Test the add_to_plot method"""
# arrow_item, plot_item = plot_widget_with_arrow_item
# assert arrow_item.plot_item is not None
# assert arrow_item.plot_item.items == []
# arrow_item.add_to_plot()
# assert arrow_item.plot_item.items == [arrow_item.arrow_item]
# arrow_item.remove_from_plot()
#
#
# def test_arrow_item_set_position(plot_widget_with_arrow_item):
# """Test the set_position method"""
# arrow_item, plot_item = plot_widget_with_arrow_item
# container = []
#
# def signal_callback(tup: tuple):
# container.append(tup)
#
# arrow_item.add_to_plot()
# arrow_item.position_changed.connect(signal_callback)
# arrow_item.set_position(pos=(1, 1))
# point = QPointF(1.0, 1.0)
# assert arrow_item.arrow_item.pos() == point
# arrow_item.set_position(pos=(2, 2))
# point = QPointF(2.0, 2.0)
# assert arrow_item.arrow_item.pos() == point
# assert container == [(1, 1), (2, 2)]
# arrow_item.remove_from_plot()
#
#
# def test_arrow_item_cleanup(plot_widget_with_arrow_item):
# """Test cleanup procedure"""
# arrow_item, plot_item = plot_widget_with_arrow_item
# arrow_item.add_to_plot()
# assert arrow_item.item_on_plot is True
# arrow_item.cleanup()
# assert arrow_item.plot_item.items == []
# assert arrow_item.item_on_plot is False
# assert arrow_item.arrow_item is None
#
#
# def test_tick_item_add_to_plot(plot_widget_with_tick_item):
# """Test the add_to_plot method"""
# tick_item, plot_item = plot_widget_with_tick_item
# assert tick_item.plot_item is not None
# assert tick_item.plot_item.items == []
# tick_item.add_to_plot()
# assert tick_item.plot_item.layout.itemAt(2, 1) == tick_item.tick_item
# assert tick_item.item_on_plot is True
# new_pos = plot_item.vb.geometry().bottom()
# pos = tick_item.tick.pos()
# new_pos = tick_item.tick_item.mapFromParent(QPointF(pos.x(), new_pos))
# assert new_pos.y() == pos.y()
# tick_item.remove_from_plot()
#
#
# def test_tick_item_set_position(plot_widget_with_tick_item):
# """Test the set_position method"""
# tick_item, plot_item = plot_widget_with_tick_item
# container = []
#
# def signal_callback(val: float):
# container.append(val)
#
# tick_item.add_to_plot()
# tick_item.position_changed.connect(signal_callback)
#
# tick_item.set_position(pos=1)
# assert tick_item._pos == 1
# tick_item.set_position(pos=2)
# assert tick_item._pos == 2
# assert container == [1.0, 2.0]
# tick_item.remove_from_plot()
#
#
# def test_tick_item_cleanup(plot_widget_with_tick_item):
# """Test cleanup procedure"""
# tick_item, plot_item = plot_widget_with_tick_item
# tick_item.add_to_plot()
# assert tick_item.item_on_plot is True
# tick_item.cleanup()
# ticks = getattr(tick_item.plot_item.layout.itemAt(3, 1), "ticks", None)
# assert ticks == None
# assert tick_item.item_on_plot is False
# assert tick_item.tick_item is None

View File

@ -1,766 +0,0 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest import mock
import numpy as np
import pytest
from bec_lib.scan_items import ScanItem
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.figure.plots.waveform.waveform_curve import (
CurveConfig,
Signal,
SignalData,
)
from .client_mocks import mocked_client
from .conftest import create_widget
def test_adding_curve_to_waveform(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
# adding curve which is in bec - only names
c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i")
assert c1.config.label == "bpm4i-bpm4i"
# adding curve which is in bec - names and entry
c2 = w1.add_curve_bec(x_name="samx", x_entry="samx", y_name="bpm3a", y_entry="bpm3a")
assert c2.config.label == "bpm3a-bpm3a"
# adding curve which is not in bec
with pytest.raises(ValueError) as excinfo:
w1.add_curve_bec(x_name="non_existent_device", y_name="non_existent_device")
assert "Device 'non_existent_device' not found in current BEC session" in str(excinfo.value)
# adding wrong entry for samx
with pytest.raises(ValueError) as excinfo:
w1.add_curve_bec(
x_name="samx", x_entry="non_existent_entry", y_name="bpm3a", y_entry="bpm3a"
)
assert "Entry 'non_existent_entry' not found in device 'samx' signals" in str(excinfo.value)
# adding wrong device with validation switched off
c3 = w1.add_curve_bec(x_name="samx", y_name="non_existent_device", validate_bec=False)
assert c3.config.label == "non_existent_device-non_existent_device"
def test_adding_curve_with_same_id(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i", gui_id="test_curve")
with pytest.raises(ValueError) as excinfo:
w1.add_curve_bec(x_name="samx", y_name="bpm4i", gui_id="test_curve")
assert "Curve with ID 'test_curve' already exists." in str(excinfo.value)
def test_create_waveform1D_by_config(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1_config_input = {
"widget_class": "BECWaveform",
"gui_id": "widget_1",
"parent_id": "BECFigure_1708689320.788527",
"row": 0,
"col": 0,
"axis": {
"title": "Widget 1",
"title_size": None,
"x_label": None,
"x_label_size": None,
"y_label": None,
"y_label_size": None,
"legend_label_size": None,
"x_scale": "linear",
"y_scale": "linear",
"x_lim": (1, 10),
"y_lim": None,
"x_grid": False,
"y_grid": False,
"outer_axes": False,
},
"color_palette": "magma",
"curves": {
"bpm4i-bpm4i": {
"widget_class": "BECCurve",
"gui_id": "BECCurve_1708689321.226847",
"parent_id": "widget_1",
"label": "bpm4i-bpm4i",
"color": "#cc4778",
"color_map_z": "magma",
"symbol": "o",
"symbol_color": None,
"symbol_size": 7,
"pen_width": 4,
"pen_style": "dash",
"source": "scan_segment",
"signals": {
"dap": None,
"source": "scan_segment",
"x": {
"name": "samx",
"entry": "samx",
"unit": None,
"modifier": None,
"limits": None,
},
"y": {
"name": "bpm4i",
"entry": "bpm4i",
"unit": None,
"modifier": None,
"limits": None,
},
"z": None,
},
},
"curve-custom": {
"widget_class": "BECCurve",
"gui_id": "BECCurve_1708689321.22867",
"parent_id": "widget_1",
"label": "curve-custom",
"color": "blue",
"color_map_z": "magma",
"symbol": "o",
"symbol_color": None,
"symbol_size": 7,
"pen_width": 5,
"pen_style": "dashdot",
"source": "custom",
"signals": None,
},
},
}
w1 = bec_figure.plot(config=w1_config_input)
w1_config_output = w1.get_config()
w1_config_input["gui_id"] = w1.gui_id
assert w1_config_input == w1_config_output
assert w1.plot_item.titleLabel.text == "Widget 1"
assert w1.config.axis.title == "Widget 1"
def test_change_gui_id(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i")
w1.change_gui_id("new_id")
assert w1.config.gui_id == "new_id"
assert c1.config.parent_id == "new_id"
def test_getting_curve(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i", gui_id="test_curve")
c1_expected_config_dark = CurveConfig(
widget_class="BECCurve",
gui_id="test_curve",
parent_id=w1.gui_id,
label="bpm4i-bpm4i",
color="#3b0f70",
symbol="o",
symbol_color=None,
symbol_size=7,
pen_width=4,
pen_style="solid",
source="scan_segment",
signals=Signal(
source="scan_segment",
x=SignalData(name="samx", entry="samx", unit=None, modifier=None),
y=SignalData(name="bpm4i", entry="bpm4i", unit=None, modifier=None),
),
)
c1_expected_config_light = CurveConfig(
widget_class="BECCurve",
gui_id="test_curve",
parent_id=w1.gui_id,
label="bpm4i-bpm4i",
color="#000004",
symbol="o",
symbol_color=None,
symbol_size=7,
pen_width=4,
pen_style="solid",
source="scan_segment",
signals=Signal(
source="scan_segment",
x=SignalData(name="samx", entry="samx", unit=None, modifier=None),
y=SignalData(name="bpm4i", entry="bpm4i", unit=None, modifier=None),
),
)
assert (
w1.curves[0].config == c1_expected_config_dark
or w1.curves[0].config == c1_expected_config_light
)
assert (
w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config_dark
or w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config == c1_expected_config_light
)
assert (
w1.get_curve(0).config == c1_expected_config_dark
or w1.get_curve(0).config == c1_expected_config_light
)
assert (
w1.get_curve_config("bpm4i-bpm4i", dict_output=True) == c1_expected_config_dark.model_dump()
or w1.get_curve_config("bpm4i-bpm4i", dict_output=True)
== c1_expected_config_light.model_dump()
)
assert (
w1.get_curve_config("bpm4i-bpm4i", dict_output=False) == c1_expected_config_dark
or w1.get_curve_config("bpm4i-bpm4i", dict_output=False) == c1_expected_config_light
)
assert (
w1.get_curve("bpm4i-bpm4i").config == c1_expected_config_dark
or w1.get_curve("bpm4i-bpm4i").config == c1_expected_config_light
)
assert (
c1.get_config(False) == c1_expected_config_dark
or c1.get_config(False) == c1_expected_config_light
)
assert (
c1.get_config() == c1_expected_config_dark.model_dump()
or c1.get_config() == c1_expected_config_light.model_dump()
)
def test_getting_curve_errors(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i", gui_id="test_curve")
with pytest.raises(ValueError) as excinfo:
w1.get_curve("non_existent_curve")
assert "Curve with ID 'non_existent_curve' not found." in str(excinfo.value)
with pytest.raises(IndexError) as excinfo:
w1.get_curve(1)
assert "list index out of range" in str(excinfo.value)
with pytest.raises(ValueError) as excinfo:
w1.get_curve(1.2)
assert "Identifier must be either an integer (index) or a string (curve_id)." in str(
excinfo.value
)
def test_add_curve(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i")
assert len(w1.curves) == 1
assert w1._curves_data["scan_segment"] == {"bpm4i-bpm4i": c1}
assert c1.config.label == "bpm4i-bpm4i"
assert c1.config.source == "scan_segment"
def test_change_legend_font_size(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
plot = bec_figure.plot()
w1 = plot.add_curve_bec(x_name="samx", y_name="bpm4i")
my_func = plot.plot_item.legend
with mock.patch.object(my_func, "setScale") as mock_set_scale:
plot.set_legend_label_size(18)
assert plot.config.axis.legend_label_size == 18
assert mock_set_scale.call_count == 1
assert mock_set_scale.call_args == mock.call(2)
def test_remove_curve(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
w1.add_curve_bec(x_name="samx", y_name="bpm4i")
w1.add_curve_bec(x_name="samx", y_name="bpm3a")
w1.remove_curve(0)
w1.remove_curve("bpm3a-bpm3a")
assert len(w1.plot_item.curves) == 0
assert w1._curves_data["scan_segment"] == {}
with pytest.raises(ValueError) as excinfo:
w1.remove_curve(1.2)
assert "Each identifier must be either an integer (index) or a string (curve_id)." in str(
excinfo.value
)
def test_change_curve_appearance_methods(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i")
c1.set_color("#0000ff")
c1.set_symbol("x")
c1.set_symbol_color("#ff0000")
c1.set_symbol_size(10)
c1.set_pen_width(3)
c1.set_pen_style("dashdot")
qtbot.wait(500)
assert c1.config.color == "#0000ff"
assert c1.config.symbol == "x"
assert c1.config.symbol_color == "#ff0000"
assert c1.config.symbol_size == 10
assert c1.config.pen_width == 3
assert c1.config.pen_style == "dashdot"
assert c1.config.source == "scan_segment"
assert c1.config.signals.model_dump() == {
"dap": None,
"source": "scan_segment",
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
"y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
"z": None,
}
def test_change_curve_appearance_args(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i")
c1.set(
color="#0000ff",
symbol="x",
symbol_color="#ff0000",
symbol_size=10,
pen_width=3,
pen_style="dashdot",
)
assert c1.config.color == "#0000ff"
assert c1.config.symbol == "x"
assert c1.config.symbol_color == "#ff0000"
assert c1.config.symbol_size == 10
assert c1.config.pen_width == 3
assert c1.config.pen_style == "dashdot"
assert c1.config.source == "scan_segment"
assert c1.config.signals.model_dump() == {
"dap": None,
"source": "scan_segment",
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
"y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
"z": None,
}
def test_set_custom_curve_data(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_custom(
x=[1, 2, 3],
y=[4, 5, 6],
label="custom_curve",
color="#0000ff",
symbol="x",
symbol_color="#ff0000",
symbol_size=10,
pen_width=3,
pen_style="dashdot",
)
x_init, y_init = c1.get_data()
assert np.array_equal(x_init, [1, 2, 3])
assert np.array_equal(y_init, [4, 5, 6])
assert c1.config.label == "custom_curve"
assert c1.config.color == "#0000ff"
assert c1.config.symbol == "x"
assert c1.config.symbol_color == "#ff0000"
assert c1.config.symbol_size == 10
assert c1.config.pen_width == 3
assert c1.config.pen_style == "dashdot"
assert c1.config.source == "custom"
assert c1.config.signals == None
c1.set_data(x=[4, 5, 6], y=[7, 8, 9])
x_new, y_new = c1.get_data()
assert np.array_equal(x_new, [4, 5, 6])
assert np.array_equal(y_new, [7, 8, 9])
def test_custom_data_2D_array(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
data = np.random.rand(10, 2)
plt = bec_figure.plot(data)
x, y = plt.curves[0].get_data()
assert np.array_equal(x, data[:, 0])
assert np.array_equal(y, data[:, 1])
def test_get_all_data(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_custom(
x=[1, 2, 3],
y=[4, 5, 6],
label="custom_curve-1",
color="#0000ff",
symbol="x",
symbol_color="#ff0000",
symbol_size=10,
pen_width=3,
pen_style="dashdot",
)
c2 = w1.add_curve_custom(
x=[4, 5, 6],
y=[7, 8, 9],
label="custom_curve-2",
color="#00ff00",
symbol="o",
symbol_color="#00ff00",
symbol_size=20,
pen_width=4,
pen_style="dash",
)
all_data = w1.get_all_data()
assert all_data == {
"custom_curve-1": {"x": [1, 2, 3], "y": [4, 5, 6]},
"custom_curve-2": {"x": [4, 5, 6], "y": [7, 8, 9]},
}
def test_curve_add_by_config(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1_config_input = {
"widget_class": "BECCurve",
"gui_id": "BECCurve_1708689321.226847",
"parent_id": "widget_1",
"label": "bpm4i-bpm4i",
"color": "#cc4778",
"color_map_z": "magma",
"symbol": "o",
"symbol_color": None,
"symbol_size": 7,
"pen_width": 4,
"pen_style": "dash",
"source": "scan_segment",
"signals": {
"dap": None,
"source": "scan_segment",
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
"y": {
"name": "bpm4i",
"entry": "bpm4i",
"unit": None,
"modifier": None,
"limits": None,
},
"z": None,
},
}
c1 = w1.add_curve_by_config(c1_config_input)
c1_config_dict = c1.get_config()
assert c1_config_dict == c1_config_input
assert c1.config == CurveConfig(**c1_config_input)
assert c1.get_config(False) == CurveConfig(**c1_config_input)
def test_scan_update(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_bec(x_name="samx", y_name="bpm4i")
msg_waveform = {
"data": {
"samx": {"samx": {"value": 10}},
"bpm4i": {"bpm4i": {"value": 5}},
"gauss_bpm": {"gauss_bpm": {"value": 6}},
"gauss_adc1": {"gauss_adc1": {"value": 8}},
"gauss_adc2": {"gauss_adc2": {"value": 9}},
},
"scan_id": 1,
}
# Mock scan_storage.find_scan_by_ID
mock_scan_data_waveform = mock.MagicMock(spec=ScanItem)
mock_scan_data_waveform.live_data = {
device_name: {
entry: mock.MagicMock(val=[msg_waveform["data"][device_name][entry]["value"]])
for entry in msg_waveform["data"][device_name]
}
for device_name in msg_waveform["data"]
}
metadata_waveform = {"scan_name": "line_scan"}
w1.queue.scan_storage.find_scan_by_ID.return_value = mock_scan_data_waveform
w1.on_scan_segment(msg_waveform, metadata_waveform)
qtbot.wait(500)
assert c1.get_data() == ([10], [5])
def test_scan_history_with_val_access(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
w1.plot(x_name="samx", y_name="bpm4i")
mock_scan_data = {
"samx": {"samx": mock.MagicMock(val=np.array([1, 2, 3]))}, # Use mock.MagicMock for .val
"bpm4i": {"bpm4i": mock.MagicMock(val=np.array([4, 5, 6]))}, # Use mock.MagicMock for .val
}
mock_scan_storage = mock.MagicMock()
scan_item_mock = mock.MagicMock(spec=ScanItem)
scan_item_mock.data = mock_scan_data
mock_scan_storage.find_scan_by_ID.return_value = scan_item_mock
w1.queue.scan_storage = mock_scan_storage
fake_scan_id = "fake_scan_id"
w1.scan_history(scan_id=fake_scan_id)
qtbot.wait(500)
x_data, y_data = w1.curves[0].get_data()
assert np.array_equal(x_data, [1, 2, 3])
assert np.array_equal(y_data, [4, 5, 6])
def test_scatter_2d_update(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
c1 = w1.add_curve_bec(x_name="samx", y_name="samx", z_name="bpm4i")
msg = {
"data": {
"samx": {"samx": {"value": [1, 2, 3]}},
"samy": {"samy": {"value": [4, 5, 6]}},
"bpm4i": {"bpm4i": {"value": [1, 3, 2]}},
},
"scan_id": 1,
}
msg_metadata = {"scan_name": "line_scan"}
mock_scan_item = mock.MagicMock(spec=ScanItem)
mock_scan_item.live_data = {
device_name: {
entry: mock.MagicMock(val=msg["data"][device_name][entry]["value"])
for entry in msg["data"][device_name]
}
for device_name in msg["data"]
}
w1.queue.scan_storage.find_scan_by_ID.return_value = mock_scan_item
w1.on_scan_segment(msg, msg_metadata)
qtbot.wait(500)
data = c1.get_data()
expected_x_y_data = ([1, 2, 3], [1, 2, 3])
expected_z_colors = w1._make_z_gradient([1, 3, 2], "magma")
scatter_points = c1.scatter.points()
colors = [point.brush().color() for point in scatter_points]
assert np.array_equal(data, expected_x_y_data)
assert colors == expected_z_colors
def test_waveform_single_arg_inputs(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
w1.plot("bpm4i")
w1.plot([1, 2, 3], label="just_y")
w1.plot([3, 4, 5], [7, 8, 9], label="x_y")
w1.plot(x=[1, 2, 3], y=[4, 5, 6], label="x_y_kwargs")
data_array_1D = np.random.rand(10)
data_array_2D = np.random.rand(10, 2)
w1.plot(data_array_1D, label="np_ndarray 1D")
w1.plot(data_array_2D, label="np_ndarray 2D")
qtbot.wait(200)
assert w1._curves_data["scan_segment"]["bpm4i-bpm4i"].config.label == "bpm4i-bpm4i"
assert w1._curves_data["custom"]["just_y"].config.label == "just_y"
assert w1._curves_data["custom"]["x_y"].config.label == "x_y"
assert w1._curves_data["custom"]["x_y_kwargs"].config.label == "x_y_kwargs"
assert np.array_equal(w1._curves_data["custom"]["just_y"].get_data(), ([0, 1, 2], [1, 2, 3]))
assert np.array_equal(w1._curves_data["custom"]["just_y"].get_data(), ([0, 1, 2], [1, 2, 3]))
assert np.array_equal(w1._curves_data["custom"]["x_y"].get_data(), ([3, 4, 5], [7, 8, 9]))
assert np.array_equal(
w1._curves_data["custom"]["x_y_kwargs"].get_data(), ([1, 2, 3], [4, 5, 6])
)
assert np.array_equal(
w1._curves_data["custom"]["np_ndarray 1D"].get_data(),
(np.arange(data_array_1D.size), data_array_1D.T),
)
assert np.array_equal(w1._curves_data["custom"]["np_ndarray 2D"].get_data(), data_array_2D.T)
def test_waveform_set_x_sync(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot()
custom_label = "custom_label"
w1.plot("bpm4i")
w1.set_x_label(custom_label)
scan_item_mock = mock.MagicMock(spec=ScanItem)
mock_data = {
"samx": {"samx": mock.MagicMock(val=np.array([1, 2, 3]))},
"samy": {"samy": mock.MagicMock(val=np.array([4, 5, 6]))},
"bpm4i": {
"bpm4i": mock.MagicMock(
val=np.array([7, 8, 9]),
timestamps=np.array([1720520189.959115, 1720520189.986618, 1720520190.0157812]),
)
},
}
scan_item_mock.live_data = mock_data
scan_item_mock.status_message = mock.MagicMock()
scan_item_mock.status_message.info = {"scan_report_devices": ["samx"]}
w1.queue.scan_storage.find_scan_by_ID.return_value = scan_item_mock
w1.on_scan_segment({"scan_id": 1}, {})
qtbot.wait(200)
# Best effort - samx
x_data, y_data = w1.curves[0].get_data()
assert np.array_equal(x_data, [1, 2, 3])
assert np.array_equal(y_data, [7, 8, 9])
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [auto: samx-samx]"
# Change to samy
w1.set_x("samy")
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()
assert np.array_equal(x_data, [4, 5, 6])
assert np.array_equal(y_data, [7, 8, 9])
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [samy-samy]"
# change to index
w1.set_x("index")
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()
assert np.array_equal(x_data, [0, 1, 2])
assert np.array_equal(y_data, [7, 8, 9])
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [index]"
# change to timestamp
w1.set_x("timestamp")
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()
assert np.allclose(x_data, np.array([1.72052019e09, 1.72052019e09, 1.72052019e09]))
assert np.array_equal(y_data, [7, 8, 9])
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [timestamp]"
def test_waveform_async_data_update(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot("async_device")
custom_label = "custom_label"
w1.set_x_label(custom_label)
# scan_item_mock = mock.MagicMock()
# mock_data = {
# "async_device": {
# "async_device": mock.MagicMock(
# val=np.array([7, 8, 9]),
# timestamps=np.array([1720520189.959115, 1720520189.986618, 1720520190.0157812]),
# )
# }
# }
#
# scan_item_mock.async_data = mock_data
# w1.queue.scan_storage.find_scan_by_ID.return_value = scan_item_mock
msg_1 = {"signals": {"async_device": {"value": [7, 8, 9]}}}
metadata_1 = {"async_update": {"max_shape": [None], "type": "add"}}
w1.on_async_readback(msg_1, metadata_1)
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()
assert np.array_equal(x_data, [0, 1, 2])
assert np.array_equal(y_data, [7, 8, 9])
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [best_effort]"
msg_2 = {"signals": {"async_device": {"value": [10, 11, 12]}}}
w1.on_async_readback(msg_2, metadata_1)
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()
assert np.array_equal(x_data, [0, 1, 2, 3, 4, 5])
assert np.array_equal(y_data, [7, 8, 9, 10, 11, 12])
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [best_effort]"
msg_3 = {"signals": {"async_device": {"value": [20, 21, 22]}}}
metadata_3 = {"async_update": {"max_shape": [None], "type": "replace"}}
w1.on_async_readback(msg_3, metadata_3)
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()
assert np.array_equal(x_data, [0, 1, 2])
assert np.array_equal(y_data, [20, 21, 22])
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [best_effort]"
def test_waveform_set_x_async(qtbot, mocked_client):
bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
w1 = bec_figure.plot("async_device")
custom_label = "custom_label"
w1.set_x_label(custom_label)
scan_item_mock = mock.MagicMock()
mock_data = {
"async_device": {
"async_device": {
"value": np.array([7, 8, 9]),
"timestamp": np.array([1720520189.959115, 1720520189.986618, 1720520190.0157812]),
}
}
}
scan_item_mock.async_data = mock_data
w1.queue.scan_storage.find_scan_by_ID.return_value = scan_item_mock
w1.on_scan_status({"scan_id": 1})
w1.replot_async_curve()
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()
assert np.array_equal(x_data, [0, 1, 2])
assert np.array_equal(y_data, [7, 8, 9])
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [best_effort]"
w1.set_x("timestamp")
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()
assert np.allclose(x_data, np.array([1.72052019e09, 1.72052019e09, 1.72052019e09]))
assert np.array_equal(y_data, [7, 8, 9])
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [timestamp]"
w1.set_x("index")
qtbot.wait(200)
x_data, y_data = w1.curves[0].get_data()
assert np.array_equal(x_data, [0, 1, 2])
assert np.array_equal(y_data, [7, 8, 9])
assert w1.plot_item.getAxis("bottom").labelText == custom_label + " [index]"