diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py
index 056bab35..efde94b3 100644
--- a/bec_widgets/cli/client.py
+++ b/bec_widgets/cli/client.py
@@ -32,6 +32,7 @@ class Widgets(str, enum.Enum):
LMFitDialog = "LMFitDialog"
LogPanel = "LogPanel"
Minesweeper = "Minesweeper"
+ MotorMap = "MotorMap"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerBox2D = "PositionerBox2D"
@@ -2727,6 +2728,14 @@ class Image(RPCBase):
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
+ - title: str
+ - x_label: str
+ - y_label: str
+ - x_scale: Literal["linear", "log"]
+ - y_scale: Literal["linear", "log"]
+ - x_lim: tuple
+ - y_lim: tuple
+ - legend_label_size: int
"""
@property
@@ -3353,6 +3362,395 @@ class LogPanel(RPCBase):
class Minesweeper(RPCBase): ...
+class MotorMap(RPCBase):
+ @property
+ @rpc_call
+ def enable_toolbar(self) -> "bool":
+ """
+ Show Toolbar.
+ """
+
+ @enable_toolbar.setter
+ @rpc_call
+ def enable_toolbar(self) -> "bool":
+ """
+ Show Toolbar.
+ """
+
+ @property
+ @rpc_call
+ def enable_side_panel(self) -> "bool":
+ """
+ Show Side Panel
+ """
+
+ @enable_side_panel.setter
+ @rpc_call
+ def enable_side_panel(self) -> "bool":
+ """
+ Show Side Panel
+ """
+
+ @property
+ @rpc_call
+ def enable_fps_monitor(self) -> "bool":
+ """
+ Enable the FPS monitor.
+ """
+
+ @enable_fps_monitor.setter
+ @rpc_call
+ def enable_fps_monitor(self) -> "bool":
+ """
+ Enable the FPS monitor.
+ """
+
+ @rpc_call
+ def set(self, **kwargs):
+ """
+ Set the properties of the plot widget.
+
+ Args:
+ **kwargs: Keyword arguments for the properties to be set.
+
+ Possible properties:
+ - title: str
+ - x_label: str
+ - y_label: str
+ - x_scale: Literal["linear", "log"]
+ - y_scale: Literal["linear", "log"]
+ - x_lim: tuple
+ - y_lim: tuple
+ - legend_label_size: int
+ """
+
+ @property
+ @rpc_call
+ def title(self) -> "str":
+ """
+ Set title of the plot.
+ """
+
+ @title.setter
+ @rpc_call
+ def title(self) -> "str":
+ """
+ Set title of the plot.
+ """
+
+ @property
+ @rpc_call
+ def x_label(self) -> "str":
+ """
+ The set label for the x-axis.
+ """
+
+ @x_label.setter
+ @rpc_call
+ def x_label(self) -> "str":
+ """
+ The set label for the x-axis.
+ """
+
+ @property
+ @rpc_call
+ def y_label(self) -> "str":
+ """
+ The set label for the y-axis.
+ """
+
+ @y_label.setter
+ @rpc_call
+ def y_label(self) -> "str":
+ """
+ The set label for the y-axis.
+ """
+
+ @property
+ @rpc_call
+ def x_limits(self) -> "QPointF":
+ """
+ Get the x limits of the plot.
+ """
+
+ @x_limits.setter
+ @rpc_call
+ def x_limits(self) -> "QPointF":
+ """
+ Get the x limits of the plot.
+ """
+
+ @property
+ @rpc_call
+ def y_limits(self) -> "QPointF":
+ """
+ Get the y limits of the plot.
+ """
+
+ @y_limits.setter
+ @rpc_call
+ def y_limits(self) -> "QPointF":
+ """
+ Get the y limits of the plot.
+ """
+
+ @property
+ @rpc_call
+ def x_grid(self) -> "bool":
+ """
+ Show grid on the x-axis.
+ """
+
+ @x_grid.setter
+ @rpc_call
+ def x_grid(self) -> "bool":
+ """
+ Show grid on the x-axis.
+ """
+
+ @property
+ @rpc_call
+ def y_grid(self) -> "bool":
+ """
+ Show grid on the y-axis.
+ """
+
+ @y_grid.setter
+ @rpc_call
+ def y_grid(self) -> "bool":
+ """
+ Show grid on the y-axis.
+ """
+
+ @property
+ @rpc_call
+ def inner_axes(self) -> "bool":
+ """
+ Show inner axes of the plot widget.
+ """
+
+ @inner_axes.setter
+ @rpc_call
+ def inner_axes(self) -> "bool":
+ """
+ Show inner axes of the plot widget.
+ """
+
+ @property
+ @rpc_call
+ def outer_axes(self) -> "bool":
+ """
+ Show the outer axes of the plot widget.
+ """
+
+ @outer_axes.setter
+ @rpc_call
+ def outer_axes(self) -> "bool":
+ """
+ Show the outer axes of the plot widget.
+ """
+
+ @property
+ @rpc_call
+ def lock_aspect_ratio(self) -> "bool":
+ """
+ Lock aspect ratio of the plot widget.
+ """
+
+ @lock_aspect_ratio.setter
+ @rpc_call
+ def lock_aspect_ratio(self) -> "bool":
+ """
+ Lock aspect ratio of the plot widget.
+ """
+
+ @property
+ @rpc_call
+ def auto_range_x(self) -> "bool":
+ """
+ Set auto range for the x-axis.
+ """
+
+ @auto_range_x.setter
+ @rpc_call
+ def auto_range_x(self) -> "bool":
+ """
+ Set auto range for the x-axis.
+ """
+
+ @property
+ @rpc_call
+ def auto_range_y(self) -> "bool":
+ """
+ Set auto range for the y-axis.
+ """
+
+ @auto_range_y.setter
+ @rpc_call
+ def auto_range_y(self) -> "bool":
+ """
+ Set auto range for the y-axis.
+ """
+
+ @property
+ @rpc_call
+ def x_log(self) -> "bool":
+ """
+ Set X-axis to log scale if True, linear if False.
+ """
+
+ @x_log.setter
+ @rpc_call
+ def x_log(self) -> "bool":
+ """
+ Set X-axis to log scale if True, linear if False.
+ """
+
+ @property
+ @rpc_call
+ def y_log(self) -> "bool":
+ """
+ Set Y-axis to log scale if True, linear if False.
+ """
+
+ @y_log.setter
+ @rpc_call
+ def y_log(self) -> "bool":
+ """
+ Set Y-axis to log scale if True, linear if False.
+ """
+
+ @property
+ @rpc_call
+ def legend_label_size(self) -> "int":
+ """
+ The font size of the legend font.
+ """
+
+ @legend_label_size.setter
+ @rpc_call
+ def legend_label_size(self) -> "int":
+ """
+ The font size of the legend font.
+ """
+
+ @property
+ @rpc_call
+ def color(self) -> "tuple":
+ """
+ Get the color of the motor trace.
+
+ Returns:
+ tuple: Color of the motor trace.
+ """
+
+ @color.setter
+ @rpc_call
+ def color(self) -> "tuple":
+ """
+ Get the color of the motor trace.
+
+ Returns:
+ tuple: Color of the motor trace.
+ """
+
+ @property
+ @rpc_call
+ def max_points(self) -> "int":
+ """
+ Get the maximum number of points to display.
+ """
+
+ @max_points.setter
+ @rpc_call
+ def max_points(self) -> "int":
+ """
+ Get the maximum number of points to display.
+ """
+
+ @property
+ @rpc_call
+ def precision(self) -> "int":
+ """
+ Set the decimal precision of the motor position.
+ """
+
+ @precision.setter
+ @rpc_call
+ def precision(self) -> "int":
+ """
+ Set the decimal precision of the motor position.
+ """
+
+ @property
+ @rpc_call
+ def num_dim_points(self) -> "int":
+ """
+ Get the number of dim points for the motor map.
+ """
+
+ @num_dim_points.setter
+ @rpc_call
+ def num_dim_points(self) -> "int":
+ """
+ Get the number of dim points for the motor map.
+ """
+
+ @property
+ @rpc_call
+ def background_value(self) -> "int":
+ """
+ Get the background value of the motor map.
+ """
+
+ @background_value.setter
+ @rpc_call
+ def background_value(self) -> "int":
+ """
+ Get the background value of the motor map.
+ """
+
+ @property
+ @rpc_call
+ def scatter_size(self) -> "int":
+ """
+ Get the scatter size of the motor map plot.
+ """
+
+ @scatter_size.setter
+ @rpc_call
+ def scatter_size(self) -> "int":
+ """
+ Get the scatter size of the motor map plot.
+ """
+
+ @rpc_call
+ def map(self, x_name: "str", y_name: "str", validate_bec: "bool" = True) -> "None":
+ """
+ Set the x and y motor names.
+
+ Args:
+ x_name(str): The name of the x motor.
+ y_name(str): The name of the y motor.
+ validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
+ """
+
+ @rpc_call
+ def reset_history(self):
+ """
+ Reset the history of the motor map.
+ """
+
+ @rpc_call
+ def get_data(self) -> "dict":
+ """
+ Get the data of the motor map.
+
+ Returns:
+ dict: Data of the motor map.
+ """
+
+
class PositionIndicator(RPCBase):
@rpc_call
def set_value(self, position: float):
@@ -3780,6 +4178,8 @@ class ScanMetadata(RPCBase):
class ScatterCurve(RPCBase):
+ """Scatter curve item for the scatter waveform widget."""
+
@property
@rpc_call
def color_map(self) -> "str":
@@ -3840,6 +4240,14 @@ class ScatterWaveform(RPCBase):
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
+ - title: str
+ - x_label: str
+ - y_label: str
+ - x_scale: Literal["linear", "log"]
+ - y_scale: Literal["linear", "log"]
+ - x_lim: tuple
+ - y_lim: tuple
+ - legend_label_size: int
"""
@property
@@ -4232,6 +4640,14 @@ class Waveform(RPCBase):
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
+ - title: str
+ - x_label: str
+ - y_label: str
+ - x_scale: Literal["linear", "log"]
+ - y_scale: Literal["linear", "log"]
+ - x_lim: tuple
+ - y_lim: tuple
+ - legend_label_size: int
"""
@property
diff --git a/bec_widgets/widgets/plots_next_gen/motor_map/__init__.py b/bec_widgets/widgets/plots_next_gen/motor_map/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bec_widgets/widgets/plots_next_gen/motor_map/motor_map.py b/bec_widgets/widgets/plots_next_gen/motor_map/motor_map.py
new file mode 100644
index 00000000..bcfb186c
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/motor_map/motor_map.py
@@ -0,0 +1,793 @@
+from __future__ import annotations
+
+import numpy as np
+import pyqtgraph as pg
+from bec_lib import bec_logger
+from bec_lib.endpoints import MessageEndpoints
+from pydantic import BaseModel, Field, field_validator
+from pydantic_core import PydanticCustomError
+from qtpy import QtCore, QtGui
+from qtpy.QtCore import Signal
+from qtpy.QtGui import QColor
+from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
+
+from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
+from bec_widgets.qt_utils.settings_dialog import SettingsDialog
+from bec_widgets.qt_utils.toolbar import MaterialIconAction
+from bec_widgets.utils import Colors, ConnectionConfig
+from bec_widgets.utils.colors import set_theme
+from bec_widgets.widgets.plots_next_gen.motor_map.settings.motor_map_settings import (
+ MotorMapSettings,
+)
+from bec_widgets.widgets.plots_next_gen.motor_map.toolbar_bundles.motor_selection import (
+ MotorSelectionToolbarBundle,
+)
+from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
+
+logger = bec_logger.logger
+
+
+class MotorConfig(BaseModel):
+ name: str | None = Field(None, description="Motor name.")
+ limits: list[float] | None = Field(None, description="Motor limits.")
+
+
+# noinspection PyDataclass
+class MotorMapConfig(ConnectionConfig):
+ x_motor: MotorConfig = Field(default_factory=MotorConfig, description="Motor X name.")
+ y_motor: MotorConfig = Field(default_factory=MotorConfig, description="Motor Y name.")
+ color: str | tuple | None = Field(
+ (255, 255, 255, 255), description="The color of the last point of current position."
+ )
+ scatter_size: int | None = Field(5, description="Size of the scatter points.")
+ max_points: int | None = Field(5000, description="Maximum number of points to display.")
+ num_dim_points: int | None = Field(
+ 100,
+ description="Number of points to dim before the color remains same for older recorded position.",
+ )
+ precision: int | None = Field(2, description="Decimal precision of the motor position.")
+ background_value: int | None = 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 MotorMap(PlotBase):
+ PLUGIN = True
+ RPC = True
+ ICON_NAME = "my_location"
+ USER_ACCESS = [
+ # General PlotBase Settings
+ "enable_toolbar",
+ "enable_toolbar.setter",
+ "enable_side_panel",
+ "enable_side_panel.setter",
+ "enable_fps_monitor",
+ "enable_fps_monitor.setter",
+ "set",
+ "title",
+ "title.setter",
+ "x_label",
+ "x_label.setter",
+ "y_label",
+ "y_label.setter",
+ "x_limits",
+ "x_limits.setter",
+ "y_limits",
+ "y_limits.setter",
+ "x_grid",
+ "x_grid.setter",
+ "y_grid",
+ "y_grid.setter",
+ "inner_axes",
+ "inner_axes.setter",
+ "outer_axes",
+ "outer_axes.setter",
+ "lock_aspect_ratio",
+ "lock_aspect_ratio.setter",
+ "auto_range_x",
+ "auto_range_x.setter",
+ "auto_range_y",
+ "auto_range_y.setter",
+ "x_log",
+ "x_log.setter",
+ "y_log",
+ "y_log.setter",
+ "legend_label_size",
+ "legend_label_size.setter",
+ # motor_map specific
+ "color",
+ "color.setter",
+ "max_points",
+ "max_points.setter",
+ "precision",
+ "precision.setter",
+ "num_dim_points",
+ "num_dim_points.setter",
+ "background_value",
+ "background_value.setter",
+ "scatter_size",
+ "scatter_size.setter",
+ "map",
+ "reset_history",
+ "get_data",
+ ]
+
+ update_signal = Signal()
+ """Motor map widget for plotting motor positions."""
+
+ def __init__(
+ self,
+ parent: QWidget | None = None,
+ config: MotorMapConfig | None = None,
+ client=None,
+ gui_id: str | None = None,
+ popups: bool = True,
+ **kwargs,
+ ):
+ if config is None:
+ config = MotorMapConfig(widget_class=self.__class__.__name__)
+ super().__init__(
+ parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
+ )
+
+ # For PropertyManager identification
+ self.setObjectName("MotorMap")
+
+ # Default values for PlotBase
+ self.x_grid = True
+ self.y_grid = True
+
+ # Gui specific
+ self._buffer = {"x": [], "y": []}
+ self._limit_map = None
+ self._trace = None
+ self.v_line = None
+ self.h_line = None
+ self.coord_label = None
+ self.motor_map_settings = None
+
+ # Connect slots
+ self.proxy_update_plot = pg.SignalProxy(
+ self.update_signal, rateLimit=25, slot=self._update_plot
+ )
+ self._add_motor_map_settings()
+
+ ################################################################################
+ # Widget Specific GUI interactions
+ ################################################################################
+
+ def _init_toolbar(self):
+ """
+ Initialize the toolbar for the motor map widget.
+ """
+ self.motor_selection_bundle = MotorSelectionToolbarBundle(
+ bundle_id="motor_selection", target_widget=self
+ )
+ self.toolbar.add_bundle(self.motor_selection_bundle, target_widget=self)
+ super()._init_toolbar()
+ self.toolbar.widgets["reset_legend"].action.setVisible(False)
+
+ self.reset_legend_action = MaterialIconAction(
+ icon_name="history", tooltip="Reset the position of legend."
+ )
+ self.toolbar.add_action_to_bundle(
+ bundle_id="roi",
+ action_id="motor_map_history",
+ action=self.reset_legend_action,
+ target_widget=self,
+ )
+ self.reset_legend_action.action.triggered.connect(self.reset_history)
+
+ def _add_motor_map_settings(self):
+ """Add the motor map settings to the side panel."""
+ motor_map_settings = MotorMapSettings(target_widget=self, popup=False)
+ self.side_panel.add_menu(
+ action_id="motor_map_settings",
+ icon_name="settings_brightness",
+ tooltip="Show Motor Map Settings",
+ widget=motor_map_settings,
+ title="Motor Map Settings",
+ )
+
+ def add_popups(self):
+ """
+ Add popups to the ScatterWaveform widget.
+ """
+ super().add_popups()
+ scatter_curve_setting_action = MaterialIconAction(
+ icon_name="settings_brightness",
+ tooltip="Show Motor Map Settings",
+ checkable=True,
+ parent=self,
+ )
+ self.toolbar.add_action_to_bundle(
+ bundle_id="popup_bundle",
+ action_id="motor_map_settings",
+ action=scatter_curve_setting_action,
+ target_widget=self,
+ )
+ self.toolbar.widgets["motor_map_settings"].action.triggered.connect(
+ self.show_motor_map_settings
+ )
+
+ def show_motor_map_settings(self):
+ """
+ Show the DAP summary popup.
+ """
+ action = self.toolbar.widgets["motor_map_settings"].action
+ if self.motor_map_settings is None or not self.motor_map_settings.isVisible():
+ motor_map_settings = MotorMapSettings(target_widget=self, popup=True)
+ self.motor_map_settings = SettingsDialog(
+ self,
+ settings_widget=motor_map_settings,
+ window_title="Motor Map Settings",
+ modal=False,
+ )
+ self.motor_map_settings.setFixedSize(250, 300)
+ # When the dialog is closed, update the toolbar icon and clear the reference
+ self.motor_map_settings.finished.connect(self._motor_map_settings_closed)
+ self.motor_map_settings.show()
+ action.setChecked(True)
+ else:
+ # If already open, bring it to the front
+ self.motor_map_settings.raise_()
+ self.motor_map_settings.activateWindow()
+ action.setChecked(True) # keep it toggled
+
+ def _motor_map_settings_closed(self):
+ """
+ Slot for when the axis settings dialog is closed.
+ """
+ self.motor_map_settings.deleteLater()
+ self.motor_map_settings = None
+ self.toolbar.widgets["motor_map_settings"].action.setChecked(False)
+
+ ################################################################################
+ # Widget Specific Properties
+ ################################################################################
+
+ # color_scatter for designer, color for CLI to not bother users with QColor
+ @SafeProperty("QColor")
+ def color_scatter(self) -> QtGui.QColor:
+ """
+ Get the color of the motor trace.
+
+ Returns:
+ QColor: Color of the motor trace.
+ """
+ return QColor(*self.config.color)
+
+ @color_scatter.setter
+ def color_scatter(self, color: str | tuple | QColor) -> None:
+ """
+ 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.hex_to_rgba(color, 255)
+ if isinstance(color, QColor):
+ color = (color.red(), color.green(), color.blue(), color.alpha())
+ color = Colors.validate_color(color)
+ self.config.color = color
+ self.update_signal.emit()
+ self.property_changed.emit("color_scatter", color)
+
+ @property
+ def color(self) -> tuple:
+ """
+ Get the color of the motor trace.
+
+ Returns:
+ tuple: Color of the motor trace.
+ """
+ return self.config.color
+
+ @color.setter
+ def color(self, color: str | tuple) -> None:
+ """
+ Set color of the motor trace.
+
+ Args:
+ color(str|tuple): Color of the motor trace. Can be HEX(str) or RGBA(tuple).
+ """
+ self.color_scatter = color
+
+ @SafeProperty(int)
+ def max_points(self) -> int:
+ """Get the maximum number of points to display."""
+ return self.config.max_points
+
+ @max_points.setter
+ def 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()
+ self.property_changed.emit("max_points", max_points)
+
+ @SafeProperty(int)
+ def precision(self) -> int:
+ """
+ Set the decimal precision of the motor position.
+ """
+ return self.config.precision
+
+ @precision.setter
+ def 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()
+ self.property_changed.emit("precision", precision)
+
+ @SafeProperty(int)
+ def num_dim_points(self) -> int:
+ """
+ Get the number of dim points for the motor map.
+ """
+ return self.config.num_dim_points
+
+ @num_dim_points.setter
+ def 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()
+ self.property_changed.emit("num_dim_points", num_dim_points)
+
+ @SafeProperty(int)
+ def background_value(self) -> int:
+ """
+ Get the background value of the motor map.
+ """
+ return self.config.background_value
+
+ @background_value.setter
+ def 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()
+ self.property_changed.emit("background_value", background_value)
+
+ @SafeProperty(int)
+ def scatter_size(self) -> int:
+ """
+ Get the scatter size of the motor map plot.
+ """
+ return self.config.scatter_size
+
+ @scatter_size.setter
+ def 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()
+ self.property_changed.emit("scatter_size", scatter_size)
+
+ ################################################################################
+ # High Level methods for API
+ ################################################################################
+ @SafeSlot()
+ def map(self, x_name: str, y_name: str, validate_bec: bool = True) -> None:
+ """
+ Set the x and y motor names.
+
+ Args:
+ x_name(str): The name of the x motor.
+ y_name(str): The name of the y motor.
+ validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
+ """
+ self.plot_item.clear()
+
+ if validate_bec:
+ self.entry_validator.validate_signal(x_name, None)
+ self.entry_validator.validate_signal(y_name, None)
+
+ self.config.x_motor.name = x_name
+ self.config.y_motor.name = y_name
+
+ motor_x_limit = self._get_motor_limit(self.config.x_motor.name)
+ motor_y_limit = self._get_motor_limit(self.config.y_motor.name)
+
+ self.config.x_motor.limits = motor_x_limit
+ self.config.y_motor.limits = motor_y_limit
+
+ # reconnect the signals
+ self._connect_motor_to_slots()
+
+ # Reset the buffer
+ self._buffer = {"x": [], "y": []}
+
+ # Redraw the motor map
+ self._make_motor_map()
+
+ self._sync_motor_map_selection_toolbar()
+
+ def reset_history(self):
+ """
+ Reset the history of the motor map.
+ """
+ self._buffer["x"] = [self._buffer["x"][-1]]
+ self._buffer["y"] = [self._buffer["y"][-1]]
+ self.update_signal.emit()
+
+ ################################################################################
+ # BEC Update Methods
+ ################################################################################
+ @SafeSlot()
+ def _update_plot(self, _=None):
+ """Update the motor map plot."""
+ if self._trace is None:
+ return
+ # If the number of points exceeds max_points, delete the oldest points
+ if len(self._buffer["x"]) > self.config.max_points:
+ self._buffer["x"] = self._buffer["x"][-self.config.max_points :]
+ self._buffer["y"] = self._buffer["y"][-self.config.max_points :]
+
+ x = self._buffer["x"]
+ y = self._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._trace.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._set_motor_indicator_position(current_x, current_y)
+
+ @SafeSlot(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.
+ """
+ x_motor = self.config.x_motor.name
+ y_motor = self.config.y_motor.name
+
+ if x_motor is None or y_motor is None:
+ return
+
+ if x_motor in msg["signals"]:
+ x = msg["signals"][x_motor]["value"]
+ self._buffer["x"].append(x)
+ self._buffer["y"].append(self._buffer["y"][-1])
+
+ elif y_motor in msg["signals"]:
+ y = msg["signals"][y_motor]["value"]
+ self._buffer["y"].append(y)
+ self._buffer["x"].append(self._buffer["x"][-1])
+
+ self.update_signal.emit()
+
+ def _connect_motor_to_slots(self):
+ """Connect motors to slots."""
+ self._disconnect_current_motors()
+
+ endpoints_readback = [
+ MessageEndpoints.device_readback(self.config.x_motor.name),
+ MessageEndpoints.device_readback(self.config.y_motor.name),
+ ]
+ endpoints_limits = [
+ MessageEndpoints.device_limits(self.config.x_motor.name),
+ MessageEndpoints.device_limits(self.config.y_motor.name),
+ ]
+
+ self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints_readback)
+ self.bec_dispatcher.connect_slot(self.on_device_limits, endpoints_limits)
+
+ def _disconnect_current_motors(self):
+ """Disconnect the current motors from the slots."""
+ if self.config.x_motor.name is not None and self.config.y_motor.name is not None:
+ endpoints_readback = [
+ MessageEndpoints.device_readback(self.config.x_motor.name),
+ MessageEndpoints.device_readback(self.config.y_motor.name),
+ ]
+ endpoints_limits = [
+ MessageEndpoints.device_limits(self.config.x_motor.name),
+ MessageEndpoints.device_limits(self.config.y_motor.name),
+ ]
+ self.bec_dispatcher.disconnect_slot(self.on_device_readback, endpoints_readback)
+ self.bec_dispatcher.disconnect_slot(self.on_device_limits, endpoints_limits)
+
+ ################################################################################
+ # Utility Methods
+ ################################################################################
+ @SafeSlot(dict, dict)
+ def on_device_limits(self, msg: dict, metadata: dict) -> None:
+ """
+ Update the motor limits in the config.
+
+ Args:
+ msg(dict): Message from the device limits.
+ metadata(dict): Metadata of the message.
+ """
+ self.config.x_motor.limits = self._get_motor_limit(self.config.x_motor.name)
+ self.config.y_motor.limits = self._get_motor_limit(self.config.y_motor.name)
+ self._swap_limit_map()
+
+ def _get_motor_limit(self, motor: str) -> list | None:
+ """
+ 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
+
+ def _make_motor_map(self) -> None:
+ """
+ Make the motor map.
+ """
+
+ motor_x_limit = self.config.x_motor.limits
+ motor_y_limit = self.config.y_motor.limits
+
+ if motor_x_limit is not None or motor_y_limit is not None:
+ self._limit_map = self._make_limit_map(motor_x_limit, motor_y_limit)
+ self.plot_item.addItem(self._limit_map)
+ self._limit_map.setZValue(-1)
+
+ # Create scatter plot
+ scatter_size = self.config.scatter_size
+ self._trace = pg.ScatterPlotItem(size=scatter_size, brush=pg.mkBrush(255, 255, 255, 255))
+ self.plot_item.addItem(self._trace)
+ self._trace.setZValue(0)
+
+ # Add the crosshair for initial motor coordinates
+ initial_position_x = self._get_motor_init_position(
+ self.config.x_motor.name, self.config.precision
+ )
+ initial_position_y = self._get_motor_init_position(
+ self.config.y_motor.name, self.config.precision
+ )
+
+ self._buffer["x"] = [initial_position_x]
+ self._buffer["y"] = [initial_position_y]
+
+ self._trace.setData([initial_position_x], [initial_position_y])
+
+ # Add initial crosshair
+ self._add_coordinates_crosshair(initial_position_x, initial_position_y)
+
+ # Set default labels for the plot
+ self.set_x_label_suffix(f"[{self.config.x_motor.name}-{self.config.x_motor.name}]")
+ self.set_y_label_suffix(f"[{self.config.y_motor.name}-{self.config.y_motor.name}]")
+
+ self.update_signal.emit()
+
+ def _add_coordinates_crosshair(self, x: float, y: float) -> None:
+ """
+ Add position crosshair indicator to the plot.
+
+ Args:
+ x(float): X coordinate of the crosshair.
+ y(float): Y coordinate of the crosshair.
+ """
+ if self.v_line is not None and self.h_line is not None and self.coord_label is not None:
+ self.plot_item.removeItem(self.h_line)
+ self.plot_item.removeItem(self.v_line)
+ self.plot_item.removeItem(self.coord_label)
+
+ self.h_line = pg.InfiniteLine(
+ angle=0, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
+ )
+ self.v_line = pg.InfiniteLine(
+ angle=90, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
+ )
+
+ self.coord_label = pg.TextItem("", anchor=(1, 1), fill=(0, 0, 0, 100))
+
+ # Add crosshair to the plot
+ self.plot_item.addItem(self.h_line)
+ self.plot_item.addItem(self.v_line)
+ self.plot_item.addItem(self.coord_label)
+
+ self._set_motor_indicator_position(x, y)
+
+ def _set_motor_indicator_position(self, x: float, y: float) -> None:
+ """
+ Set the position of the motor indicator.
+
+ Args:
+ x(float): X coordinate of the motor indicator.
+ y(float): Y coordinate of the motor indicator.
+ """
+ if self.v_line is None or self.h_line is None or self.coord_label is None:
+ return
+
+ text = f"({x:.{self.config.precision}f}, {y:.{self.config.precision}f})"
+
+ self.v_line.setPos(x)
+ self.h_line.setPos(y)
+ self.coord_label.setText(text)
+ self.coord_label.setPos(x, 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 _swap_limit_map(self):
+ """Swap the limit map."""
+ self.plot_item.removeItem(self._limit_map)
+ x_limits = self.config.x_motor.limits
+ y_limits = self.config.y_motor.limits
+ if x_limits is not None and y_limits is not None:
+ self._limit_map = self._make_limit_map(x_limits, y_limits)
+ self._limit_map.setZValue(-1)
+ self.plot_item.addItem(self._limit_map)
+
+ def _get_motor_init_position(self, name: str, precision: int) -> float:
+ """
+ Get the motor initial position from the config.
+
+ Args:
+ name(str): Motor name.
+ precision(int): Decimal precision of the motor position.
+
+ Returns:
+ float: Motor initial position.
+ """
+ entry = self.entry_validator.validate_signal(name, None)
+ init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
+ return init_position
+
+ def _sync_motor_map_selection_toolbar(self):
+ """
+ Sync the motor map selection toolbar with the current motor map.
+ """
+ if self.motor_selection_bundle is not None:
+ motor_x = self.motor_selection_bundle.motor_x.currentText()
+ motor_y = self.motor_selection_bundle.motor_y.currentText()
+
+ if motor_x != self.config.x_motor.name:
+ self.motor_selection_bundle.motor_x.blockSignals(True)
+ self.motor_selection_bundle.motor_x.set_device(self.config.x_motor.name)
+ self.motor_selection_bundle.motor_x.check_validity(self.config.x_motor.name)
+ self.motor_selection_bundle.motor_x.blockSignals(False)
+ if motor_y != self.config.y_motor.name:
+ self.motor_selection_bundle.motor_y.blockSignals(True)
+ self.motor_selection_bundle.motor_y.set_device(self.config.y_motor.name)
+ self.motor_selection_bundle.motor_y.check_validity(self.config.y_motor.name)
+ self.motor_selection_bundle.motor_y.blockSignals(False)
+
+ ################################################################################
+ # Export Methods
+ ################################################################################
+
+ def get_data(self) -> dict:
+ """
+ Get the data of the motor map.
+
+ Returns:
+ dict: Data of the motor map.
+ """
+ data = {"x": self._buffer["x"], "y": self._buffer["y"]}
+ return data
+
+
+class DemoApp(QMainWindow): # pragma: no cover
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("Waveform Demo")
+ self.resize(800, 600)
+ self.main_widget = QWidget()
+ self.layout = QHBoxLayout(self.main_widget)
+ self.setCentralWidget(self.main_widget)
+
+ self.motor_map_popup = MotorMap(popups=True)
+ self.motor_map_popup.map(x_name="samx", y_name="samy", validate_bec=True)
+
+ self.motor_map_side = MotorMap(popups=False)
+ self.motor_map_side.map(x_name="samx", y_name="samy", validate_bec=True)
+
+ self.layout.addWidget(self.motor_map_side)
+ self.layout.addWidget(self.motor_map_popup)
+
+
+if __name__ == "__main__": # pragma: no cover
+ import sys
+
+ from qtpy.QtWidgets import QApplication
+
+ app = QApplication(sys.argv)
+ set_theme("dark")
+ widget = DemoApp()
+ widget.show()
+ widget.resize(1400, 600)
+ sys.exit(app.exec_())
diff --git a/bec_widgets/widgets/plots_next_gen/motor_map/motor_map.pyproject b/bec_widgets/widgets/plots_next_gen/motor_map/motor_map.pyproject
new file mode 100644
index 00000000..bb03b387
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/motor_map/motor_map.pyproject
@@ -0,0 +1 @@
+{'files': ['motor_map.py']}
\ No newline at end of file
diff --git a/bec_widgets/widgets/plots_next_gen/motor_map/motor_map_plugin.py b/bec_widgets/widgets/plots_next_gen/motor_map/motor_map_plugin.py
new file mode 100644
index 00000000..bcfc5968
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/motor_map/motor_map_plugin.py
@@ -0,0 +1,54 @@
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
+
+from qtpy.QtDesigner import QDesignerCustomWidgetInterface
+
+from bec_widgets.utils.bec_designer import designer_material_icon
+from bec_widgets.widgets.plots_next_gen.motor_map.motor_map import MotorMap
+
+DOM_XML = """
+
+
+
+
+"""
+
+
+class MotorMapPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
+ def __init__(self):
+ super().__init__()
+ self._form_editor = None
+
+ def createWidget(self, parent):
+ t = MotorMap(parent)
+ return t
+
+ def domXml(self):
+ return DOM_XML
+
+ def group(self):
+ return "Plot Widgets Next Gen"
+
+ def icon(self):
+ return designer_material_icon(MotorMap.ICON_NAME)
+
+ def includeFile(self):
+ return "motor_map"
+
+ def initialize(self, form_editor):
+ self._form_editor = form_editor
+
+ def isContainer(self):
+ return False
+
+ def isInitialized(self):
+ return self._form_editor is not None
+
+ def name(self):
+ return "MotorMap"
+
+ def toolTip(self):
+ return "MotorMap"
+
+ def whatsThis(self):
+ return self.toolTip()
diff --git a/bec_widgets/widgets/plots_next_gen/motor_map/register_motor_map.py b/bec_widgets/widgets/plots_next_gen/motor_map/register_motor_map.py
new file mode 100644
index 00000000..db80dabb
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/motor_map/register_motor_map.py
@@ -0,0 +1,15 @@
+def main(): # pragma: no cover
+ from qtpy import PYSIDE6
+
+ if not PYSIDE6:
+ print("PYSIDE6 is not available in the environment. Cannot patch designer.")
+ return
+ from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
+
+ from bec_widgets.widgets.plots_next_gen.motor_map.motor_map_plugin import MotorMapPlugin
+
+ QPyDesignerCustomWidgetCollection.addCustomWidget(MotorMapPlugin())
+
+
+if __name__ == "__main__": # pragma: no cover
+ main()
diff --git a/bec_widgets/widgets/plots_next_gen/motor_map/settings/__init__.py b/bec_widgets/widgets/plots_next_gen/motor_map/settings/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bec_widgets/widgets/plots_next_gen/motor_map/settings/motor_map_settings.py b/bec_widgets/widgets/plots_next_gen/motor_map/settings/motor_map_settings.py
new file mode 100644
index 00000000..4920157d
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/motor_map/settings/motor_map_settings.py
@@ -0,0 +1,130 @@
+import os
+
+from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
+
+from bec_widgets.qt_utils.error_popups import SafeSlot
+from bec_widgets.qt_utils.settings_dialog import SettingWidget
+from bec_widgets.utils import UILoader
+from bec_widgets.utils.widget_io import WidgetIO
+
+
+class MotorMapSettings(SettingWidget):
+ """
+ A settings widget for the MotorMap widget.
+
+ The widget has skip_settings property set to True, which means it should not be saved
+ in the settings file. It is used to mirror the properties of the target widget.
+ """
+
+ def __init__(self, parent=None, target_widget=None, popup=False, *args, **kwargs):
+ super().__init__(parent=parent, *args, **kwargs)
+
+ self.setProperty("skip_settings", True)
+ self.setObjectName("MotorMapSettings")
+ current_path = os.path.dirname(__file__)
+
+ form = UILoader().load_ui(os.path.join(current_path, "motor_map_settings.ui"), self)
+
+ self.target_widget = target_widget
+ self.popup = popup
+
+ # # Scroll area
+ self.scroll_area = QScrollArea(self)
+ self.scroll_area.setWidgetResizable(True)
+ self.scroll_area.setFrameShape(QFrame.NoFrame)
+ self.scroll_area.setWidget(form)
+
+ self.layout = QVBoxLayout(self)
+ self.layout.setContentsMargins(0, 0, 0, 0)
+ self.layout.addWidget(self.scroll_area)
+ self.ui = form
+
+ self.ui_widget_list = [
+ self.ui.max_points,
+ self.ui.num_dim_points,
+ self.ui.precision,
+ self.ui.scatter_size,
+ self.ui.background_value,
+ ]
+
+ if self.target_widget is not None and self.popup is False:
+ self.connect_all_signals()
+ self.target_widget.property_changed.connect(self.update_property)
+
+ self.fetch_all_properties()
+
+ def connect_all_signals(self):
+ for widget in self.ui_widget_list:
+ WidgetIO.connect_widget_change_signal(widget, self.set_property)
+ self.ui.color_scatter.color_selected.connect(
+ lambda color: self.target_widget.setProperty("color_scatter", color)
+ )
+
+ @SafeSlot()
+ def set_property(self, widget: QWidget, value):
+ """
+ Set property of the target widget based on the widget that emitted the signal.
+ The name of the property has to be the same as the objectName of the widget
+ and compatible with WidgetIO.
+
+ Args:
+ widget(QWidget): The widget that emitted the signal.
+ value(): The value to set the property to.
+ """
+
+ try: # to avoid crashing when the widget is not found in Designer
+ property_name = widget.objectName()
+ setattr(self.target_widget, property_name, value)
+ except RuntimeError:
+ return
+ if property_name == "color_scatter":
+ # Update the color scatter button
+ self.ui.color_scatter.set_color(value)
+
+ @SafeSlot()
+ def update_property(self, property_name: str, value):
+ """
+ Update the value of the widget based on the property name and value.
+ The name of the property has to be the same as the objectName of the widget
+ and compatible with WidgetIO.
+
+ Args:
+ property_name(str): The name of the property to update.
+ value: The value to set the property to.
+ """
+ try: # to avoid crashing when the widget is not found in Designer
+ widget_to_set = self.ui.findChild(QWidget, property_name)
+ except RuntimeError:
+ return
+ if widget_to_set is None:
+ return
+ if widget_to_set is self.ui.color_scatter:
+ # Update the color scatter button
+ self.ui.color_scatter.set_color(value)
+ return
+ # Block signals to avoid triggering set_property again
+ was_blocked = widget_to_set.blockSignals(True)
+ WidgetIO.set_value(widget_to_set, value)
+ widget_to_set.blockSignals(was_blocked)
+
+ def fetch_all_properties(self):
+ """
+ Fetch all properties from the target widget and update the settings widget.
+ """
+ for widget in self.ui_widget_list:
+ property_name = widget.objectName()
+ value = getattr(self.target_widget, property_name)
+ WidgetIO.set_value(widget, value)
+
+ self.ui.color_scatter.set_color(self.target_widget.color)
+
+ def accept_changes(self):
+ """
+ Apply all properties from the settings widget to the target widget.
+ """
+ for widget in self.ui_widget_list:
+ property_name = widget.objectName()
+ value = WidgetIO.get_value(widget)
+ setattr(self.target_widget, property_name, value)
+
+ self.target_widget.color_scatter = self.ui.color_scatter.get_color()
diff --git a/bec_widgets/widgets/plots_next_gen/motor_map/settings/motor_map_settings.ui b/bec_widgets/widgets/plots_next_gen/motor_map/settings/motor_map_settings.ui
new file mode 100644
index 00000000..c320a5f0
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/motor_map/settings/motor_map_settings.ui
@@ -0,0 +1,120 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 235
+ 228
+
+
+
+
+ 0
+ 228
+
+
+
+
+ 16777215
+ 228
+
+
+
+ Form
+
+
+ -
+
+
+ Max Points
+
+
+
+ -
+
+
+ 10000
+
+
+
+ -
+
+
+ Trace Dim
+
+
+
+ -
+
+
+ 1000
+
+
+
+ -
+
+
+ Precision
+
+
+
+ -
+
+
+ 15
+
+
+
+ -
+
+
+ Scatter Size
+
+
+
+ -
+
+
+ 20
+
+
+
+ -
+
+
+ Background Intensity
+
+
+
+ -
+
+
+ 100
+
+
+
+ -
+
+
+ Color
+
+
+
+ -
+
+
+
+
+
+
+ ColorButton
+ QWidget
+
+
+
+
+
+
diff --git a/bec_widgets/widgets/plots_next_gen/motor_map/toolbar_bundles/__init__.py b/bec_widgets/widgets/plots_next_gen/motor_map/toolbar_bundles/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bec_widgets/widgets/plots_next_gen/motor_map/toolbar_bundles/motor_selection.py b/bec_widgets/widgets/plots_next_gen/motor_map/toolbar_bundles/motor_selection.py
new file mode 100644
index 00000000..d68d5749
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/motor_map/toolbar_bundles/motor_selection.py
@@ -0,0 +1,60 @@
+from bec_lib.device import ReadoutPriority
+from qtpy.QtCore import Qt
+from qtpy.QtWidgets import QStyledItemDelegate
+
+from bec_widgets.qt_utils.error_popups import SafeSlot
+from bec_widgets.qt_utils.toolbar import ToolbarBundle, WidgetAction
+from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
+from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
+
+
+class NoCheckDelegate(QStyledItemDelegate):
+ """To reduce space in combo boxes by removing the checkmark."""
+
+ def initStyleOption(self, option, index):
+ super().initStyleOption(option, index)
+ # Remove any check indicator
+ option.checkState = Qt.Unchecked
+
+
+class MotorSelectionToolbarBundle(ToolbarBundle):
+ """
+ A bundle of actions for a toolbar that selects motors.
+ """
+
+ def __init__(self, bundle_id="motor_selection", target_widget=None, **kwargs):
+ super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
+ self.target_widget = target_widget
+
+ # Motor X
+ self.motor_x = DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
+ self.motor_x.addItem("", None)
+ self.motor_x.setCurrentText("")
+ self.motor_x.setToolTip("Select Motor X")
+ self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
+
+ # Motor X
+ self.motor_y = DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
+ self.motor_y.addItem("", None)
+ self.motor_y.setCurrentText("")
+ self.motor_y.setToolTip("Select Motor Y")
+ self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y))
+
+ self.add_action("motor_x", WidgetAction(widget=self.motor_x, adjust_size=False))
+ self.add_action("motor_y", WidgetAction(widget=self.motor_y, adjust_size=False))
+
+ # Connect slots, a device will be connected upon change of any combobox
+ self.motor_x.currentTextChanged.connect(lambda: self.connect_motors())
+ self.motor_y.currentTextChanged.connect(lambda: self.connect_motors())
+
+ @SafeSlot()
+ def connect_motors(self):
+ motor_x = self.motor_x.currentText()
+ motor_y = self.motor_y.currentText()
+
+ if motor_x != "" and motor_y != "":
+ if (
+ motor_x != self.target_widget.config.x_motor.name
+ or motor_y != self.target_widget.config.y_motor.name
+ ):
+ self.target_widget.map(motor_x, motor_y)
diff --git a/bec_widgets/widgets/plots_next_gen/plot_base.py b/bec_widgets/widgets/plots_next_gen/plot_base.py
index 0e76cf8d..a993c7bc 100644
--- a/bec_widgets/widgets/plots_next_gen/plot_base.py
+++ b/bec_widgets/widgets/plots_next_gen/plot_base.py
@@ -378,6 +378,14 @@ class PlotBase(BECWidget, QWidget):
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
+ - title: str
+ - x_label: str
+ - y_label: str
+ - x_scale: Literal["linear", "log"]
+ - y_scale: Literal["linear", "log"]
+ - x_lim: tuple
+ - y_lim: tuple
+ - legend_label_size: int
"""
property_map = {
diff --git a/tests/unit_tests/test_motor_map_next_gen.py b/tests/unit_tests/test_motor_map_next_gen.py
new file mode 100644
index 00000000..3f5a2a2a
--- /dev/null
+++ b/tests/unit_tests/test_motor_map_next_gen.py
@@ -0,0 +1,322 @@
+import numpy as np
+import pyqtgraph as pg
+
+from bec_widgets.widgets.plots_next_gen.motor_map.motor_map import MotorMap
+from tests.unit_tests.client_mocks import mocked_client
+
+from .conftest import create_widget
+
+
+def test_motor_map_initialization(qtbot, mocked_client):
+ """Test the initialization of the MotorMap widget."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+
+ # test default values
+ assert mm.config.widget_class == "MotorMap"
+ assert mm.config.scatter_size == 5
+ assert mm.config.max_points == 5000
+ assert mm.config.num_dim_points == 100
+ assert mm.x_grid is True
+ assert mm.y_grid is True
+
+
+def test_motor_map_select_motor(qtbot, mocked_client):
+ """Test selecting motors for the motor map."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+
+ mm.map(x_name="samx", y_name="samy", validate_bec=True)
+
+ assert mm.config.x_motor.name == "samx"
+ assert mm.config.y_motor.name == "samy"
+ assert mm.config.x_motor.limits == [-10, 10]
+ assert mm.config.y_motor.limits == [-5, 5]
+ assert mm.config.scatter_size == 5
+ assert mm.config.max_points == 5000
+ assert mm.config.num_dim_points == 100
+ assert mm.x_grid is True
+ assert mm.y_grid is True
+
+
+def test_motor_map_properties(qtbot, mocked_client):
+ """Test setting and getting properties of MotorMap."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+ mm.map(x_name="samx", y_name="samy")
+
+ # Test color property
+ mm.color = (100, 150, 200, 255)
+ assert mm.color == (100, 150, 200, 255)
+
+ mm.color = "#FF5500" # Test hex color
+ assert mm.color[0] == 255
+ assert mm.color[1] == 85
+ assert mm.color[2] == 0
+
+ # Test scatter_size property
+ mm.scatter_size = 10
+ qtbot.wait(200)
+ assert mm.scatter_size == 10
+ assert mm.config.scatter_size == 10
+ assert mm._trace.opts["size"] == 10
+
+ # Test max_points property
+ mm.max_points = 2000
+ assert mm.max_points == 2000
+ assert mm.config.max_points == 2000
+
+ # Test precision property
+ mm.precision = 3
+ assert mm.precision == 3
+ assert mm.config.precision == 3
+
+ # Test num_dim_points property
+ mm.num_dim_points = 50
+ assert mm.num_dim_points == 50
+ assert mm.config.num_dim_points == 50
+
+ # Test background_value property
+ mm.background_value = 40
+ qtbot.wait(200)
+ assert mm.background_value == 40
+ assert mm.config.background_value == 40
+ np.all(mm._limit_map.image == 40.0)
+
+
+def test_motor_map_get_limits(qtbot, mocked_client):
+ """Test getting motor limits."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+ mm.map(x_name="samx", y_name="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):
+ """Test getting the initial position of motors."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+ mm.map("samx", "samy")
+ mm.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, 2)
+ assert actual_position == expected_position
+
+
+def test_motor_map_reset_history(qtbot, mocked_client):
+ """Test resetting the history of motor positions."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+ mm.map(motor_x="samx", motor_y="samy")
+
+ # Simulate some motor movement history
+ mm._buffer = {"x": [1.0, 2.0, 3.0, 4.0], "y": [5.0, 6.0, 7.0, 8.0]}
+
+ # Reset history
+ mm.reset_history()
+
+ # Should keep only the last point
+ assert len(mm._buffer["x"]) == 1
+ assert len(mm._buffer["y"]) == 1
+ assert mm._buffer["x"][0] == 4.0
+ assert mm._buffer["y"][0] == 8.0
+
+
+def test_motor_map_on_device_readback(qtbot, mocked_client):
+ """Test the motor map updates when receiving device readback."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+ mm.map(x_name="samx", y_name="samy")
+
+ # Clear the buffer and add initial position
+ mm._buffer = {"x": [1.0], "y": [2.0]}
+
+ # Simulate device readback for x motor
+ msg_x = {"signals": {"samx": {"value": 3.0}}}
+ mm.on_device_readback(msg_x, {})
+ qtbot.wait(200) # Allow time for the update to process
+
+ assert len(mm._buffer["x"]) == 2
+ assert len(mm._buffer["y"]) == 2
+ assert mm._buffer["x"][1] == 3.0
+ assert mm._buffer["y"][1] == 2.0 # Y should remain the same
+
+ # Simulate device readback for y motor
+ msg_y = {"signals": {"samy": {"value": 4.0}}}
+ mm.on_device_readback(msg_y, {})
+
+ assert len(mm._buffer["x"]) == 3
+ assert len(mm._buffer["y"]) == 3
+ assert mm._buffer["x"][2] == 3.0 # X should remain the same
+ assert mm._buffer["y"][2] == 4.0
+
+
+def test_motor_map_max_points_limit(qtbot, mocked_client):
+ """Test that the buffer doesn't exceed max_points."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+ mm.map(x_name="samx", y_name="samy")
+
+ # Add more points than max_points
+ mm._buffer = {"x": [1.0, 2.0, 3.0, 4.0], "y": [5.0, 6.0, 7.0, 8.0]}
+
+ mm.config.max_points = 3
+ # Trigger update that should trim buffer
+ mm._update_plot()
+
+ # Check that buffer was trimmed to max_points
+ assert len(mm._buffer["x"]) == 3
+ assert len(mm._buffer["y"]) == 3
+ # Should keep the most recent points
+ assert mm._buffer["x"] == [2.0, 3.0, 4.0]
+ assert mm._buffer["y"] == [6.0, 7.0, 8.0]
+
+
+def test_motor_map_crosshair_creation(qtbot, mocked_client):
+ """Test the creation of the coordinate crosshair."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+
+ # The Initial state should be None
+ assert mm.v_line is None
+ assert mm.h_line is None
+ assert mm.coord_label is None
+
+ # Create the crosshair
+ mm._add_coordinates_crosshair(3.0, 4.0)
+
+ # Check if crosshair elements were created
+ assert mm.v_line is not None
+ assert mm.h_line is not None
+ assert mm.coord_label is not None
+
+ # Test position
+ assert mm.v_line.value() == 3.0
+ assert mm.h_line.value() == 4.0
+
+
+def test_motor_map_limit_map(qtbot, mocked_client):
+ """Test the creation of the limit map."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+
+ # Create a limit map
+ limit_map = mm._make_limit_map([0, 10], [0, 5])
+
+ # Check that the limit map was created with the right type
+ assert isinstance(limit_map, pg.ImageItem)
+
+ # Check the dimensions of the image data
+ image_data = limit_map.image
+ assert image_data.shape[0] == 11 # 0 to 10 inclusive
+ assert image_data.shape[1] == 6 # 0 to 5 inclusive
+
+
+def test_motor_map_change_limits(qtbot, mocked_client):
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+ mm.map(x_name="samx", y_name="samy")
+
+ # Original mocked limits are
+ # samx: [-10, 10]
+ # samy: [-5, 5]
+
+ # Original Limits Map
+ assert mm._limit_map.image.shape[0] == 21 # -10 to 10 inclusive
+ assert mm._limit_map.image.shape[1] == 11 # -5 to 5 inclusive
+ assert mm.config.x_motor.limits == [-10, 10]
+ assert mm.config.y_motor.limits == [-5, 5]
+
+ # Change the limits of the samx motor
+ mm.dev["samx"].limits = [-20, 20]
+ msg = {"signals": {"high": {"value": 20}, "low": {"value": -20}}}
+ mm.on_device_limits(msg, {})
+ qtbot.wait(200) # Allow time for the update to process
+
+ # Check that the limits map was updated
+ assert mm.config.x_motor.limits == [-20, 20]
+ assert mm.config.y_motor.limits == [-5, 5]
+ assert mm._limit_map.image.shape[0] == 41 # -20 to 20 inclusive
+ assert mm._limit_map.image.shape[1] == 11 # -5 to 5 inclusive -> same as before
+
+ # Change back the limits
+ mm.dev["samx"].limits = [-10, 10]
+
+
+def test_motor_map_get_data(qtbot, mocked_client):
+ """Test getting data from the motor map."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+
+ # Set up some test data
+ mm._buffer = {"x": [1.0, 2.0, 3.0], "y": [4.0, 5.0, 6.0]}
+
+ # Get data
+ data = mm.get_data()
+
+ # Check that the data is correct
+ assert "x" in data
+ assert "y" in data
+ assert data["x"] == [1.0, 2.0, 3.0]
+ assert data["y"] == [4.0, 5.0, 6.0]
+
+
+def test_motor_map_toolbar_selection(qtbot, mocked_client):
+ """Test motor selection via the toolbar bundle."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client)
+
+ # Verify toolbar bundle was created during initialization
+ assert hasattr(mm, "motor_selection_bundle")
+ assert mm.motor_selection_bundle is not None
+
+ mm.motor_selection_bundle.motor_x.setCurrentText("samx")
+ mm.motor_selection_bundle.motor_y.setCurrentText("samy")
+
+ assert mm.config.x_motor.name == "samx"
+ assert mm.config.y_motor.name == "samy"
+
+ mm.motor_selection_bundle.motor_y.setCurrentText("samz")
+
+ assert mm.config.x_motor.name == "samx"
+ assert mm.config.y_motor.name == "samz"
+
+
+def test_motor_map_settings_dialog(qtbot, mocked_client):
+ """Test the settings dialog for the motor map."""
+ mm = create_widget(qtbot, MotorMap, client=mocked_client, popups=True)
+
+ assert "popup_bundle" in mm.toolbar.bundles
+ for action_id in mm.toolbar.bundles["popup_bundle"]:
+ assert mm.toolbar.widgets[action_id].action.isVisible() is True
+
+ # set properties to be fetched by dialog
+ mm.map(x_name="samx", y_name="samy")
+ mm.precision = 2
+ mm.max_points = 1000
+ mm.scatter_size = 10
+ mm.background_value = 50
+ mm.num_dim_points = 20
+ mm.color = (255, 0, 0, 255)
+
+ mm.show_motor_map_settings()
+ qtbot.wait(200)
+
+ assert mm.motor_map_settings is not None
+ assert mm.motor_map_settings.isVisible() is True
+
+ # Check that the settings dialog has the correct values
+ assert mm.motor_map_settings.widget.ui.precision.value() == 2
+ assert mm.motor_map_settings.widget.ui.max_points.value() == 1000
+ assert mm.motor_map_settings.widget.ui.scatter_size.value() == 10
+ assert mm.motor_map_settings.widget.ui.background_value.value() == 50
+ assert mm.motor_map_settings.widget.ui.num_dim_points.value() == 20
+ assert mm.motor_map_settings.widget.ui.color_scatter.get_color(format="RGBA") == (
+ 255,
+ 0,
+ 0,
+ 255,
+ )
+
+ mm.motor_map_settings.close()
+ qtbot.wait(200)
+ assert mm.motor_map_settings is None