1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-05 00:12:49 +01:00

fix(motor_map): x/y motor are saved in properties

This commit is contained in:
2025-11-27 15:43:47 +01:00
committed by Jan Wyzula
parent 4c9fa27450
commit 5763830ef1
4 changed files with 294 additions and 55 deletions

View File

@@ -3480,6 +3480,34 @@ class MotorMap(RPCBase):
Set Y-axis to log scale if True, linear if False.
"""
@property
@rpc_call
def x_motor(self) -> "str":
"""
Name of the motor shown on the X axis.
"""
@x_motor.setter
@rpc_call
def x_motor(self) -> "str":
"""
Name of the motor shown on the X axis.
"""
@property
@rpc_call
def y_motor(self) -> "str":
"""
Name of the motor shown on the Y axis.
"""
@y_motor.setter
@rpc_call
def y_motor(self) -> "str":
"""
Name of the motor shown on the Y axis.
"""
@property
@rpc_call
def legend_label_size(self) -> "int":
@@ -3604,7 +3632,9 @@ class MotorMap(RPCBase):
"""
@rpc_call
def map(self, x_name: "str", y_name: "str", validate_bec: "bool" = True) -> "None":
def map(
self, x_name: "str", y_name: "str", validate_bec: "bool" = True, suppress_errors=False
) -> "None":
"""
Set the x and y motor names.
@@ -3612,6 +3642,7 @@ class MotorMap(RPCBase):
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.
suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting.
"""
@rpc_call

View File

@@ -17,7 +17,9 @@ from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
from bec_widgets.widgets.plots.motor_map.settings.motor_map_settings import MotorMapSettings
from bec_widgets.widgets.plots.motor_map.toolbar_components.motor_selection import (
MotorSelectionAction,
MotorSelection,
MotorSelectionConnection,
motor_selection_bundle,
)
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
@@ -126,6 +128,10 @@ class MotorMap(PlotBase):
"x_log.setter",
"y_log",
"y_log.setter",
"x_motor",
"x_motor.setter",
"y_motor",
"y_motor.setter",
"legend_label_size",
"legend_label_size.setter",
"attach",
@@ -195,11 +201,10 @@ class MotorMap(PlotBase):
"""
Initialize the toolbar for the motor map widget.
"""
motor_selection = MotorSelectionAction(parent=self)
self.toolbar.add_action("motor_selection", motor_selection)
motor_selection.motor_x.currentTextChanged.connect(self.on_motor_selection_changed)
motor_selection.motor_y.currentTextChanged.connect(self.on_motor_selection_changed)
self.toolbar.add_bundle(motor_selection_bundle(self.toolbar.components))
self.toolbar.connect_bundle(
"motor_selection", MotorSelectionConnection(self.toolbar.components, target_widget=self)
)
self.toolbar.components.get_action("reset_legend").action.setVisible(False)
@@ -228,12 +233,19 @@ class MotorMap(PlotBase):
if self.ui_mode == UIMode.POPUP:
bundles.append("axis_popup")
self.toolbar.show_bundles(bundles)
self._sync_motor_map_selection_toolbar()
@SafeSlot()
def on_motor_selection_changed(self, _):
action: MotorSelectionAction = self.toolbar.components.get_action("motor_selection")
motor_x = action.motor_x.currentText()
motor_y = action.motor_y.currentText()
action = self.toolbar.components.get_action("motor_selection")
motor_selection: MotorSelection = action.widget
motor_x = motor_selection.motor_x.currentText()
motor_y = motor_selection.motor_y.currentText()
if motor_x and not self._validate_motor_name(motor_x):
return
if motor_y and not self._validate_motor_name(motor_y):
return
if motor_x != "" and motor_y != "":
if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name:
@@ -286,6 +298,36 @@ class MotorMap(PlotBase):
# Widget Specific Properties
################################################################################
@SafeProperty(str)
def x_motor(self) -> str:
"""Name of the motor shown on the X axis."""
return self.config.x_motor.name or ""
@x_motor.setter
def x_motor(self, motor_name: str) -> None:
motor_name = motor_name or ""
if motor_name == (self.config.x_motor.name or ""):
return
if motor_name and self.y_motor:
self.map(motor_name, self.y_motor, suppress_errors=True)
return
self._set_motor_name(axis="x", motor_name=motor_name)
@SafeProperty(str)
def y_motor(self) -> str:
"""Name of the motor shown on the Y axis."""
return self.config.y_motor.name or ""
@y_motor.setter
def y_motor(self, motor_name: str) -> None:
motor_name = motor_name or ""
if motor_name == (self.config.y_motor.name or ""):
return
if motor_name and self.x_motor:
self.map(self.x_motor, motor_name, suppress_errors=True)
return
self._set_motor_name(axis="y", motor_name=motor_name)
# color_scatter for designer, color for CLI to not bother users with QColor
@SafeProperty("QColor")
def color_scatter(self) -> QtGui.QColor:
@@ -427,11 +469,47 @@ class MotorMap(PlotBase):
self.update_signal.emit()
self.property_changed.emit("scatter_size", scatter_size)
def _validate_motor_name(self, motor_name: str) -> bool:
"""
Check motor validity against BEC without raising.
Args:
motor_name(str): Name of the motor to validate.
Returns:
bool: True if motor is valid, False otherwise.
"""
if not motor_name:
return False
try:
self.entry_validator.validate_signal(motor_name, None)
return True
except Exception: # noqa: BLE001 - validator can raise multiple error types
return False
def _set_motor_name(self, axis: str, motor_name: str, *, sync_toolbar: bool = True) -> None:
"""
Update stored motor name for given axis and optionally refresh the toolbar selection.
"""
motor_name = motor_name or ""
motor_config = self.config.x_motor if axis == "x" else self.config.y_motor
if motor_config.name == motor_name:
return
motor_config.name = motor_name
self.property_changed.emit(f"{axis}_motor", motor_name)
if sync_toolbar:
self._sync_motor_map_selection_toolbar()
################################################################################
# High Level methods for API
################################################################################
@SafeSlot()
def map(self, x_name: str, y_name: str, validate_bec: bool = True) -> None:
def map(
self, x_name: str, y_name: str, validate_bec: bool = True, suppress_errors=False
) -> None:
"""
Set the x and y motor names.
@@ -439,15 +517,23 @@ class MotorMap(PlotBase):
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.
suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied.
"""
self.plot_item.clear()
if validate_bec:
self.entry_validator.validate_signal(x_name, None)
self.entry_validator.validate_signal(y_name, None)
if suppress_errors:
try:
self.entry_validator.validate_signal(x_name, None)
self.entry_validator.validate_signal(y_name, None)
except Exception:
return
else:
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
self._set_motor_name(axis="x", motor_name=x_name, sync_toolbar=False)
self._set_motor_name(axis="y", motor_name=y_name, sync_toolbar=False)
motor_x_limit = self._get_motor_limit(self.config.x_motor.name)
motor_y_limit = self._get_motor_limit(self.config.y_motor.name)
@@ -774,21 +860,24 @@ class MotorMap(PlotBase):
"""
Sync the motor map selection toolbar with the current motor map.
"""
motor_selection = self.toolbar.components.get_action("motor_selection")
try:
motor_selection_action = self.toolbar.components.get_action("motor_selection")
except Exception: # noqa: BLE001 - toolbar might not be ready during early init
logger.warning(f"MotorMap ({self.object_name}) toolbar was not ready during init.")
return
if motor_selection_action is None:
return
motor_selection: MotorSelection = motor_selection_action.widget
target_x = self.config.x_motor.name or ""
target_y = self.config.y_motor.name or ""
motor_x = motor_selection.motor_x.currentText()
motor_y = motor_selection.motor_y.currentText()
if (
motor_selection.motor_x.currentText() == target_x
and motor_selection.motor_y.currentText() == target_y
):
return
if motor_x != self.config.x_motor.name:
motor_selection.motor_x.blockSignals(True)
motor_selection.motor_x.set_device(self.config.x_motor.name)
motor_selection.motor_x.check_validity(self.config.x_motor.name)
motor_selection.motor_x.blockSignals(False)
if motor_y != self.config.y_motor.name:
motor_selection.motor_y.blockSignals(True)
motor_selection.motor_y.set_device(self.config.y_motor.name)
motor_selection.motor_y.check_validity(self.config.y_motor.name)
motor_selection.motor_y.blockSignals(False)
motor_selection.set_motors(target_x, target_y)
################################################################################
# Export Methods

View File

@@ -1,43 +1,55 @@
from qtpy.QtWidgets import QHBoxLayout, QToolBar, QWidget
from qtpy.QtWidgets import QHBoxLayout, QWidget
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, ToolBarAction
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
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 MotorSelectionAction(ToolBarAction):
class MotorSelection(QWidget):
def __init__(self, parent=None):
super().__init__(icon_path=None, tooltip=None, checkable=False)
self.motor_x = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
super().__init__(parent=parent)
self.motor_x = DeviceComboBox(parent=self, 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))
self.motor_y = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
self.motor_x.setEditable(True)
self.motor_y = DeviceComboBox(parent=self, 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.motor_y.setEditable(True)
self.container = QWidget(parent)
layout = QHBoxLayout(self.container)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.motor_x)
layout.addWidget(self.motor_y)
self.container.setLayout(layout)
self.action = self.container
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the widget to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the widget to.
target (QWidget): The target widget for the action.
"""
toolbar.addWidget(self.container)
def set_motors(self, motor_x: str | None, motor_y: str | None) -> None:
"""Set the displayed motors without emitting selection signals."""
motor_x = motor_x or ""
motor_y = motor_y or ""
self.motor_x.blockSignals(True)
self.motor_y.blockSignals(True)
try:
if motor_x:
self.motor_x.set_device(motor_x)
self.motor_x.check_validity(motor_x)
else:
self.motor_x.setCurrentText("")
if motor_y:
self.motor_y.set_device(motor_y)
self.motor_y.check_validity(motor_y)
else:
self.motor_y.setCurrentText("")
finally:
self.motor_x.blockSignals(False)
self.motor_y.blockSignals(False)
def cleanup(self):
"""
@@ -47,5 +59,57 @@ class MotorSelectionAction(ToolBarAction):
self.motor_x.deleteLater()
self.motor_y.close()
self.motor_y.deleteLater()
self.container.close()
self.container.deleteLater()
def motor_selection_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a workspace toolbar bundle for MotorMap.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The workspace toolbar bundle.
"""
motor_selection_widget = MotorSelection(parent=components.toolbar)
components.add_safe(
"motor_selection", WidgetAction(widget=motor_selection_widget, adjust_size=False)
)
bundle = ToolbarBundle("motor_selection", components)
bundle.add_action("motor_selection")
return bundle
class MotorSelectionConnection(BundleConnection):
"""
Connection helper for the motor selection bundle.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
super().__init__(parent=components.toolbar)
self.bundle_name = "motor_selection"
self.components = components
self.target_widget = target_widget
self._connected = False
def _widget(self) -> MotorSelection:
return self.components.get_action("motor_selection").widget
def connect(self):
if self._connected:
return
widget = self._widget()
widget.motor_x.currentTextChanged.connect(self.target_widget.on_motor_selection_changed)
widget.motor_y.currentTextChanged.connect(self.target_widget.on_motor_selection_changed)
self._connected = True
def disconnect(self):
if not self._connected:
return
widget = self._widget()
widget.motor_x.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed)
widget.motor_y.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed)
self._connected = False
widget.cleanup()

View File

@@ -1,5 +1,4 @@
import numpy as np
import pyqtgraph as pg
from qtpy.QtTest import QSignalSpy
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from tests.unit_tests.client_mocks import mocked_client
@@ -274,18 +273,74 @@ def test_motor_map_toolbar_selection(qtbot, mocked_client):
# Verify toolbar bundle was created during initialization
motor_selection = mm.toolbar.components.get_action("motor_selection")
motor_selection.motor_x.setCurrentText("samx")
motor_selection.motor_y.setCurrentText("samy")
motor_selection.widget.motor_x.setCurrentText("samx")
motor_selection.widget.motor_y.setCurrentText("samy")
assert mm.config.x_motor.name == "samx"
assert mm.config.y_motor.name == "samy"
motor_selection.motor_y.setCurrentText("samz")
motor_selection.widget.motor_y.setCurrentText("samz")
assert mm.config.x_motor.name == "samx"
assert mm.config.y_motor.name == "samz"
def test_motor_selection_set_motors_blocks_signals(qtbot, mocked_client):
"""Ensure set_motors updates both comboboxes without emitting change signals."""
mm = create_widget(qtbot, MotorMap, client=mocked_client)
motor_selection = mm.toolbar.components.get_action("motor_selection").widget
spy_x = QSignalSpy(motor_selection.motor_x.currentTextChanged)
spy_y = QSignalSpy(motor_selection.motor_y.currentTextChanged)
motor_selection.set_motors("samx", "samy")
assert motor_selection.motor_x.currentText() == "samx"
assert motor_selection.motor_y.currentText() == "samy"
assert spy_x.count() == 0
assert spy_y.count() == 0
def test_motor_properties_partial_then_complete_map(qtbot, mocked_client):
"""Setting x then y via properties should map once both are valid."""
mm = create_widget(qtbot, MotorMap, client=mocked_client)
spy = QSignalSpy(mm.property_changed)
mm.x_motor = "samx"
assert mm.config.x_motor.name == "samx"
assert mm.config.y_motor.name is None
assert mm._trace is None # map not triggered yet
assert spy.at(0) == ["x_motor", "samx"]
mm.y_motor = "samy"
assert mm.config.x_motor.name == "samx"
assert mm.config.y_motor.name == "samy"
assert mm._trace is not None # map called once both valid
assert spy.at(1) == ["y_motor", "samy"]
assert len(mm._buffer["x"]) == 1
assert len(mm._buffer["y"]) == 1
def test_set_motor_name_emits_and_syncs_toolbar(qtbot, mocked_client):
"""_set_motor_name should emit property changes and sync toolbar widgets."""
mm = create_widget(qtbot, MotorMap, client=mocked_client)
motor_selection = mm.toolbar.components.get_action("motor_selection").widget
spy = QSignalSpy(mm.property_changed)
mm._set_motor_name("x", "samx")
assert mm.config.x_motor.name == "samx"
assert motor_selection.motor_x.currentText() == "samx"
assert spy.at(0) == ["x_motor", "samx"]
# Calling with same name should be a no-op
initial_count = spy.count()
mm._set_motor_name("x", "samx")
assert spy.count() == initial_count
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)