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 +
color_button
+
+
+ + +
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