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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user