0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 03:31:50 +02:00

feat(motor_map): new MotorMap widget based on PlotBase

This commit is contained in:
2025-03-14 17:24:30 +01:00
parent 3606053bf0
commit 98b6790223
13 changed files with 1919 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.plots_next_gen.motor_map.motor_map import MotorMap
DOM_XML = """
<ui language='c++'>
<widget class='MotorMap' name='motor_map'>
</widget>
</ui>
"""
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()

View File

@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.plots_next_gen.motor_map.motor_map_plugin import MotorMapPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(MotorMapPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

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

View File

@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>235</width>
<height>228</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>228</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>228</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="max_point_label">
<property name="text">
<string>Max Points</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="max_points">
<property name="maximum">
<number>10000</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="trace_label">
<property name="text">
<string>Trace Dim</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="num_dim_points">
<property name="maximum">
<number>1000</number>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="precision_label">
<property name="text">
<string>Precision</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="precision">
<property name="maximum">
<number>15</number>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="scatter_size_label">
<property name="text">
<string>Scatter Size</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QSpinBox" name="scatter_size">
<property name="maximum">
<number>20</number>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="background_label">
<property name="text">
<string>Background Intensity</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QSpinBox" name="background_value">
<property name="maximum">
<number>100</number>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="color_label">
<property name="text">
<string>Color</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="ColorButton" name="color_scatter"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ColorButton</class>
<extends>QWidget</extends>
<header>color_button</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

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

View File

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

View File

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