0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 19:21:50 +02:00

feat(widget): add 2d positioner box widget

This commit is contained in:
2025-01-10 10:32:45 +01:00
parent 3770db51be
commit d2ffddb6d8
15 changed files with 1215 additions and 76 deletions

View File

@ -34,6 +34,7 @@ class Widgets(str, enum.Enum):
Minesweeper = "Minesweeper"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerBox2D = "PositionerBox2D"
PositionerControlLine = "PositionerControlLine"
ResetButton = "ResetButton"
ResumeButton = "ResumeButton"
@ -3235,6 +3236,26 @@ class PositionerBox(RPCBase):
"""
class PositionerBox2D(RPCBase):
@rpc_call
def set_positioner_hor(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
@rpc_call
def set_positioner_ver(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
class PositionerBoxBase(RPCBase):
@property
@rpc_call

View File

@ -1,8 +1,11 @@
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box import (
PositionerBox,
)
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import (
PositionerBox2D,
)
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
PositionerControlLine,
)
__ALL__ = ["PositionerBox", "PositionerControlLine"]
__ALL__ = ["PositionerBox", "PositionerControlLine", "PositionerBox2D"]

View File

@ -1,19 +1,31 @@
from ast import Tuple
import uuid
from abc import abstractmethod
from ast import Tuple
from typing import Callable, TypedDict
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanQueueMessage
from qtpy.QtWidgets import QGroupBox, QDoubleSpinBox, QPushButton, QVBoxLayout, QLabel, QLineEdit
from qtpy.QtWidgets import (
QDialog,
QDoubleSpinBox,
QGroupBox,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
)
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator,
)
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
logger = bec_logger.logger
@ -26,6 +38,9 @@ class DeviceUpdateUIComponents(TypedDict):
position_indicator: PositionIndicator
step_size: QDoubleSpinBox
device_box: QGroupBox
stop: QPushButton
tweak_increase: QPushButton
tweak_decrease: QPushButton
class PositionerBoxBase(BECWidget, CompactPopupWidget):
@ -43,6 +58,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
"""
super().__init__(**kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
self._dialog = None
self.get_bec_shortcuts()
def _check_device_is_valid(self, device: str):
@ -178,3 +194,50 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
def _swap_readback_signal_connection(self, slot, old_device, new_device):
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
"""Toogle enable/disable on available buttons
Args:
enable (bool): Enable buttons
"""
ui["tweak_increase"].setEnabled(enable)
ui["tweak_decrease"].setEnabled(enable)
ui["stop"].setEnabled(enable)
ui["setpoint"].setEnabled(enable)
ui["step_size"].setEnabled(enable)
def _on_device_change(
self,
old_device: str,
new_device: str,
position_emit: Callable[[float], None],
limit_update: Callable[[tuple[float, float]], None],
on_device_readback: Callable,
ui: DeviceUpdateUIComponents,
):
logger.info(f"Device changed from {old_device} to {new_device}")
self._toggle_enable_buttons(ui, True)
self._init_device(new_device, position_emit, limit_update)
self._swap_readback_signal_connection(on_device_readback, old_device, new_device)
self._update_device_ui(new_device, ui)
def _open_dialog_selection(self, set_positioner: Callable):
def _ods():
"""Open dialog window for positioner selection"""
self._dialog = QDialog(self)
self._dialog.setWindowTitle("Positioner Selection")
layout = QVBoxLayout()
line_edit = DeviceLineEdit(
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
)
line_edit.textChanged.connect(set_positioner)
layout.addWidget(line_edit)
close_button = QPushButton("Close")
close_button.clicked.connect(self._dialog.accept)
layout.addWidget(close_button)
self._dialog.setLayout(layout)
self._dialog.exec()
self._dialog = None
return _ods

View File

@ -5,24 +5,19 @@ from __future__ import annotations
import os
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDialog, QDoubleSpinBox, QPushButton, QVBoxLayout
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import get_accent_colors, set_theme
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
DeviceUpdateUIComponents,
)
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
logger = bec_logger.logger
@ -53,7 +48,6 @@ class PositionerBox(PositionerBoxBase):
self._device = ""
self._limits = None
self._dialog = None
if self.current_path == "":
self.current_path = os.path.dirname(__file__)
@ -91,40 +85,14 @@ class PositionerBox(PositionerBoxBase):
self.setpoint_validator = QDoubleValidator()
self.ui.setpoint.setValidator(self.setpoint_validator)
self.ui.spinner_widget.start()
self.ui.tool_button.clicked.connect(self._open_dialog_selection)
self.ui.tool_button.clicked.connect(self._open_dialog_selection(self.set_positioner))
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
self.ui.tool_button.setIcon(icon)
def _open_dialog_selection(self):
"""Open dialog window for positioner selection"""
self._dialog = QDialog(self)
self._dialog.setWindowTitle("Positioner Selection")
layout = QVBoxLayout()
line_edit = DeviceLineEdit(
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
)
line_edit.textChanged.connect(self.set_positioner)
layout.addWidget(line_edit)
close_button = QPushButton("Close")
close_button.clicked.connect(self._dialog.accept)
layout.addWidget(close_button)
self._dialog.setLayout(layout)
self._dialog.exec()
self._dialog = None
def force_update_readback(self):
self._init_device(self.device, self.position_update.emit, self.update_limits)
def _toogle_enable_buttons(self, enable: bool) -> None:
"""Toogle enable/disable on available buttons
Args:
enable (bool): Enable buttons
"""
self.ui.tweak_left.setEnabled(enable)
self.ui.tweak_right.setEnabled(enable)
self.ui.stop.setEnabled(enable)
self.ui.setpoint.setEnabled(enable)
self.ui.step_size.setEnabled(enable)
@Property(str)
@SafeProperty(str)
def device(self):
"""Property to set the device"""
return self._device
@ -142,7 +110,7 @@ class PositionerBox(PositionerBoxBase):
self.label = value
self.device_changed.emit(old_device, value)
@Property(bool)
@SafeProperty(bool)
def hide_device_selection(self):
"""Hide the device selection"""
return not self.ui.tool_button.isVisible()
@ -152,7 +120,7 @@ class PositionerBox(PositionerBoxBase):
"""Set the device selection visibility"""
self.ui.tool_button.setVisible(not value)
@Slot(bool)
@SafeSlot(bool)
def show_device_selection(self, value: bool):
"""Show the device selection
@ -161,7 +129,7 @@ class PositionerBox(PositionerBoxBase):
"""
self.hide_device_selection = not value
@Slot(str)
@SafeSlot(str)
def set_positioner(self, positioner: str | Positioner):
"""Set the device
@ -172,7 +140,7 @@ class PositionerBox(PositionerBoxBase):
positioner = positioner.name
self.device = positioner
@Slot(str, str)
@SafeSlot(str, str)
def on_device_change(self, old_device: str, new_device: str):
"""Upon changing the device, a check will be performed if the device is a Positioner.
@ -182,11 +150,14 @@ class PositionerBox(PositionerBoxBase):
"""
if not self._check_device_is_valid(new_device):
return
logger.info(f"Device changed from {old_device} to {new_device}")
self._toogle_enable_buttons(True)
self._init_device(new_device, self.position_update.emit, self.update_limits)
self._swap_readback_signal_connection(self.on_device_readback, old_device, new_device)
self._update_device_ui(new_device, self._device_ui_components(new_device))
self._on_device_change(
old_device,
new_device,
self.position_update.emit,
self.update_limits,
self.on_device_readback,
self._device_ui_components(new_device),
)
def _device_ui_components(self, device: str) -> DeviceUpdateUIComponents:
return {
@ -196,9 +167,12 @@ class PositionerBox(PositionerBoxBase):
"setpoint": self.ui.setpoint,
"step_size": self.ui.step_size,
"device_box": self.ui.device_box,
"stop": self.ui.stop,
"tweak_increase": self.ui.tweak_right,
"tweak_decrease": self.ui.tweak_left,
}
@Slot(dict, dict)
@SafeSlot(dict, dict)
def on_device_readback(self, msg_content: dict, metadata: dict):
"""Callback for device readback.
@ -226,7 +200,7 @@ class PositionerBox(PositionerBoxBase):
self._limits = limits
self._update_limits_ui(limits, self.ui.position_indicator, self.setpoint_validator)
@Slot()
@SafeSlot()
def on_stop(self):
self._stop_device(self.device)
@ -235,17 +209,17 @@ class PositionerBox(PositionerBoxBase):
"""Step size for tweak"""
return self.ui.step_size.value()
@Slot()
@SafeSlot()
def on_tweak_right(self):
"""Tweak motor right"""
self.dev[self.device].move(self.step_size, relative=True)
@Slot()
@SafeSlot()
def on_tweak_left(self):
"""Tweak motor left"""
self.dev[self.device].move(-self.step_size, relative=True)
@Slot()
@SafeSlot()
def on_setpoint_change(self):
"""Change the setpoint for the motor"""
self.ui.setpoint.clearFocus()

View File

@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_plugin import (
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box_plugin import (
PositionerBoxPlugin,
)

View File

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

View File

@ -4,36 +4,38 @@
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import (
PositionerBox2D,
)
DOM_XML = """
<ui language='c++'>
<widget class='SignalComboBox' name='signal_combo_box'>
<widget class='PositionerBox2D' name='positioner_box2_d'>
</widget>
</ui>
"""
class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = SignalComboBox(parent)
t = PositionerBox2D(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Input Widgets"
return "Device Control"
def icon(self):
return designer_material_icon(SignalComboBox.ICON_NAME)
return designer_material_icon(PositionerBox2D.ICON_NAME)
def includeFile(self):
return "signal_combo_box"
return "positioner_box2_d"
def initialize(self, form_editor):
self._form_editor = form_editor
@ -45,10 +47,10 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return self._form_editor is not None
def name(self):
return "SignalComboBox"
return "PositionerBox2D"
def toolTip(self):
return ""
return "Simple Widget to control two positioners in box form"
def whatsThis(self):
return self.toolTip()

View File

@ -0,0 +1,482 @@
""" Module for a PositionerBox2D widget to control two positioner devices."""
from __future__ import annotations
import os
from typing import Literal
from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
DeviceUpdateUIComponents,
)
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
DeviceId = Literal["horizontal", "vertical"]
class PositionerBox2D(PositionerBoxBase):
"""Simple Widget to control two positioners in box form"""
ui_file = "positioner_box_2d.ui"
PLUGIN = True
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"]
device_changed_hor = Signal(str, str)
device_changed_ver = Signal(str, str)
# Signals emitted to inform listeners about a position update
position_update_hor = Signal(float)
position_update_ver = Signal(float)
def __init__(
self,
parent=None,
device_hor: Positioner | str | None = None,
device_ver: Positioner | str | None = None,
**kwargs,
):
"""Initialize the PositionerBox widget.
Args:
parent: The parent widget.
device_hor (Positioner | str): The first device to control - assigned the horizontal axis.
device_ver (Positioner | str): The second device to control - assigned the vertical axis.
"""
super().__init__(parent=parent, **kwargs)
self._device_hor = ""
self._device_ver = ""
self._limits_hor = None
self._limits_ver = None
self._dialog = None
if self.current_path == "":
self.current_path = os.path.dirname(__file__)
self.init_ui()
self.device_hor = device_hor
self.device_ver = device_ver
self.connect_ui()
def init_ui(self):
"""Init the ui"""
self.device_changed_hor.connect(self.on_device_change_hor)
self.device_changed_ver.connect(self.on_device_change_ver)
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
self.setpoint_validator_hor = QDoubleValidator()
self.setpoint_validator_ver = QDoubleValidator()
def connect_ui(self):
"""Connect the UI components to signals, data, or routines"""
self.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
def _init_ui(val: QDoubleValidator, device_id: DeviceId):
ui = self._device_ui_components_hv(device_id)
tweak_inc = (
self.on_tweak_inc_hor if device_id == "horizontal" else self.on_tweak_inc_ver
)
tweak_dec = (
self.on_tweak_dec_hor if device_id == "horizontal" else self.on_tweak_dec_ver
)
ui["setpoint"].setValidator(val)
ui["setpoint"].returnPressed.connect(
self.on_setpoint_change_hor
if device_id == "horizontal"
else self.on_setpoint_change_ver
)
ui["stop"].setToolTip("Stop")
ui["step_size"].setStepType(QDoubleSpinBox.StepType.AdaptiveDecimalStepType)
ui["tweak_increase"].clicked.connect(tweak_inc)
ui["tweak_decrease"].clicked.connect(tweak_dec)
_init_ui(self.setpoint_validator_hor, "horizontal")
_init_ui(self.setpoint_validator_ver, "vertical")
self.ui.stop_button.button.clicked.connect(self.on_stop)
self.ui.step_decrease_hor.clicked.connect(self.on_step_dec_hor)
self.ui.step_decrease_ver.clicked.connect(self.on_step_dec_ver)
self.ui.step_increase_hor.clicked.connect(self.on_step_inc_hor)
self.ui.step_increase_ver.clicked.connect(self.on_step_inc_ver)
self.ui.tool_button_hor.clicked.connect(
self._open_dialog_selection(self.set_positioner_hor)
)
self.ui.tool_button_ver.clicked.connect(
self._open_dialog_selection(self.set_positioner_ver)
)
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
self.ui.tool_button_hor.setIcon(icon)
self.ui.tool_button_ver.setIcon(icon)
step_tooltip = "Step by the step size"
tweak_tooltip = "Tweak by 1/10th the step size"
for b in [
self.ui.step_increase_hor,
self.ui.step_increase_ver,
self.ui.step_decrease_hor,
self.ui.step_decrease_ver,
]:
b.setToolTip(step_tooltip)
for b in [
self.ui.tweak_increase_hor,
self.ui.tweak_increase_ver,
self.ui.tweak_decrease_hor,
self.ui.tweak_decrease_ver,
]:
b.setToolTip(tweak_tooltip)
icon_options = {"size": (16, 16), "convert_to_pixmap": False}
self.ui.tweak_increase_hor.setIcon(
material_icon(icon_name="keyboard_arrow_right", **icon_options)
)
self.ui.step_increase_hor.setIcon(
material_icon(icon_name="keyboard_double_arrow_right", **icon_options)
)
self.ui.tweak_decrease_hor.setIcon(
material_icon(icon_name="keyboard_arrow_left", **icon_options)
)
self.ui.step_decrease_hor.setIcon(
material_icon(icon_name="keyboard_double_arrow_left", **icon_options)
)
self.ui.tweak_increase_ver.setIcon(
material_icon(icon_name="keyboard_arrow_up", **icon_options)
)
self.ui.step_increase_ver.setIcon(
material_icon(icon_name="keyboard_double_arrow_up", **icon_options)
)
self.ui.tweak_decrease_ver.setIcon(
material_icon(icon_name="keyboard_arrow_down", **icon_options)
)
self.ui.step_decrease_ver.setIcon(
material_icon(icon_name="keyboard_double_arrow_down", **icon_options)
)
@SafeProperty(str)
def device_hor(self):
"""SafeProperty to set the device"""
return self._device_hor
@device_hor.setter
def device_hor(self, value: str):
"""Setter, checks if device is a string"""
if not value or not isinstance(value, str):
return
if not self._check_device_is_valid(value):
return
if value == self.device_ver:
return
old_device = self._device_hor
self._device_hor = value
self.label = f"{self._device_hor}, {self._device_ver}"
self.device_changed_hor.emit(old_device, value)
self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor)
@SafeProperty(str)
def device_ver(self):
"""SafeProperty to set the device"""
return self._device_ver
@device_ver.setter
def device_ver(self, value: str):
"""Setter, checks if device is a string"""
if not value or not isinstance(value, str):
return
if not self._check_device_is_valid(value):
return
if value == self.device_hor:
return
old_device = self._device_ver
self._device_ver = value
self.label = f"{self._device_hor}, {self._device_ver}"
self.device_changed_ver.emit(old_device, value)
self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver)
@SafeProperty(bool)
def hide_device_selection(self):
"""Hide the device selection"""
return not self.ui.tool_button_hor.isVisible()
@hide_device_selection.setter
def hide_device_selection(self, value: bool):
"""Set the device selection visibility"""
self.ui.tool_button_hor.setVisible(not value)
self.ui.tool_button_ver.setVisible(not value)
@SafeProperty(bool)
def hide_device_boxes(self):
"""Hide the device selection"""
return not self.ui.device_box_hor.isVisible()
@hide_device_boxes.setter
def hide_device_boxes(self, value: bool):
"""Set the device selection visibility"""
self.ui.device_box_hor.setVisible(not value)
self.ui.device_box_ver.setVisible(not value)
@SafeSlot(bool)
def show_device_selection(self, value: bool):
"""Show the device selection
Args:
value (bool): Show the device selection
"""
self.hide_device_selection = not value
@SafeSlot(str)
def set_positioner_hor(self, positioner: str | Positioner):
"""Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
if isinstance(positioner, Positioner):
positioner = positioner.name
self.device_hor = positioner
@SafeSlot(str)
def set_positioner_ver(self, positioner: str | Positioner):
"""Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
if isinstance(positioner, Positioner):
positioner = positioner.name
self.device_ver = positioner
@SafeSlot(str, str)
def on_device_change_hor(self, old_device: str, new_device: str):
"""Upon changing the device, a check will be performed if the device is a Positioner.
Args:
old_device (str): The old device name.
new_device (str): The new device name.
"""
if not self._check_device_is_valid(new_device):
return
self._on_device_change(
old_device,
new_device,
self.position_update_hor.emit,
self.update_limits_hor,
self.on_device_readback_hor,
self._device_ui_components_hv("horizontal"),
)
@SafeSlot(str, str)
def on_device_change_ver(self, old_device: str, new_device: str):
"""Upon changing the device, a check will be performed if the device is a Positioner.
Args:
old_device (str): The old device name.
new_device (str): The new device name.
"""
if not self._check_device_is_valid(new_device):
return
self._on_device_change(
old_device,
new_device,
self.position_update_ver.emit,
self.update_limits_ver,
self.on_device_readback_ver,
self._device_ui_components_hv("vertical"),
)
def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents:
if device == "horizontal":
return {
"spinner": self.ui.spinner_widget_hor,
"position_indicator": self.ui.position_indicator_hor,
"readback": self.ui.readback_hor,
"setpoint": self.ui.setpoint_hor,
"step_size": self.ui.step_size_hor,
"device_box": self.ui.device_box_hor,
"stop": self.ui.stop_button,
"tweak_increase": self.ui.tweak_increase_hor,
"tweak_decrease": self.ui.tweak_decrease_hor,
}
elif device == "vertical":
return {
"spinner": self.ui.spinner_widget_ver,
"position_indicator": self.ui.position_indicator_ver,
"readback": self.ui.readback_ver,
"setpoint": self.ui.setpoint_ver,
"step_size": self.ui.step_size_ver,
"device_box": self.ui.device_box_ver,
"stop": self.ui.stop_button,
"tweak_increase": self.ui.tweak_increase_ver,
"tweak_decrease": self.ui.tweak_decrease_ver,
}
else:
raise ValueError(f"Device {device} is not represented by this UI")
def _device_ui_components(self, device: str):
if device == self.device_hor:
return self._device_ui_components_hv("horizontal")
if device == self.device_ver:
return self._device_ui_components_hv("vertical")
@SafeSlot(dict, dict)
def on_device_readback_hor(self, msg_content: dict, metadata: dict):
"""Callback for device readback.
Args:
msg_content (dict): The message content.
metadata (dict): The message metadata.
"""
self._on_device_readback(
self.device_hor,
self._device_ui_components_hv("horizontal"),
msg_content,
metadata,
self.position_update_hor.emit,
self.update_limits_hor,
)
@SafeSlot(dict, dict)
def on_device_readback_ver(self, msg_content: dict, metadata: dict):
"""Callback for device readback.
Args:
msg_content (dict): The message content.
metadata (dict): The message metadata.
"""
self._on_device_readback(
self.device_ver,
self._device_ui_components_hv("vertical"),
msg_content,
metadata,
self.position_update_ver.emit,
self.update_limits_ver,
)
def update_limits_hor(self, limits: tuple):
"""Update limits
Args:
limits (tuple): Limits of the positioner
"""
if limits == self._limits_hor:
return
self._limits_hor = limits
self._update_limits_ui(limits, self.ui.position_indicator_hor, self.setpoint_validator_hor)
def update_limits_ver(self, limits: tuple):
"""Update limits
Args:
limits (tuple): Limits of the positioner
"""
if limits == self._limits_ver:
return
self._limits_ver = limits
self._update_limits_ui(limits, self.ui.position_indicator_ver, self.setpoint_validator_ver)
@SafeSlot()
def on_stop(self):
self._stop_device(f"{self.device_hor} or {self.device_ver}")
@SafeProperty(float)
def step_size_hor(self):
"""Step size for tweak"""
return self.ui.step_size_hor.value()
@step_size_hor.setter
def step_size_hor(self, val: float):
"""Step size for tweak"""
self.ui.step_size_hor.setValue(val)
@SafeProperty(float)
def step_size_ver(self):
"""Step size for tweak"""
return self.ui.step_size_ver.value()
@step_size_ver.setter
def step_size_ver(self, val: float):
"""Step size for tweak"""
self.ui.step_size_ver.setValue(val)
@SafeSlot()
def on_tweak_inc_hor(self):
"""Tweak device a up"""
self.dev[self.device_hor].move(self.step_size_hor / 10, relative=True)
@SafeSlot()
def on_tweak_dec_hor(self):
"""Tweak device a down"""
self.dev[self.device_hor].move(-self.step_size_hor / 10, relative=True)
@SafeSlot()
def on_step_inc_hor(self):
"""Tweak device a up"""
self.dev[self.device_hor].move(self.step_size_hor, relative=True)
@SafeSlot()
def on_step_dec_hor(self):
"""Tweak device a down"""
self.dev[self.device_hor].move(-self.step_size_hor, relative=True)
@SafeSlot()
def on_tweak_inc_ver(self):
"""Tweak device a up"""
self.dev[self.device_ver].move(self.step_size_ver / 10, relative=True)
@SafeSlot()
def on_tweak_dec_ver(self):
"""Tweak device b down"""
self.dev[self.device_ver].move(-self.step_size_ver / 10, relative=True)
@SafeSlot()
def on_step_inc_ver(self):
"""Tweak device b up"""
self.dev[self.device_ver].move(self.step_size_ver, relative=True)
@SafeSlot()
def on_step_dec_ver(self):
"""Tweak device a down"""
self.dev[self.device_ver].move(-self.step_size_ver, relative=True)
@SafeSlot()
def on_setpoint_change_hor(self):
"""Change the setpoint for device a"""
self.ui.setpoint_hor.clearFocus()
setpoint = self.ui.setpoint_hor.text()
self.dev[self.device_hor].move(float(setpoint), relative=False)
@SafeSlot()
def on_setpoint_change_ver(self):
"""Change the setpoint for device b"""
self.ui.setpoint_ver.clearFocus()
setpoint = self.ui.setpoint_ver.text()
self.dev[self.device_ver].move(float(setpoint), relative=False)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
set_theme("dark")
widget = PositionerBox2D()
widget.show()
sys.exit(app.exec_())

View File

@ -0,0 +1,504 @@
<?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>326</width>
<height>323</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="1">
<layout class="QGridLayout" name="gridLayout_4">
<item row="0" column="1">
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="2">
<widget class="QGroupBox" name="device_box_ver">
<property name="title">
<string>No positioner selected</string>
</property>
<layout class="QGridLayout" name="gridLayout_6" rowstretch="0,0,0,0,0,0">
<property name="topMargin">
<number>0</number>
</property>
<item row="1" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<widget class="QToolButton" name="tool_button_ver">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="readback_ver">
<property name="text">
<string>Position</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="SpinnerWidget" name="spinner_widget_ver">
<property name="minimumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLineEdit" name="setpoint_ver">
<property name="enabled">
<bool>false</bool>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="5" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QDoubleSpinBox" name="step_size_ver">
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item row="0" column="1">
<widget class="QGroupBox" name="device_box_hor">
<property name="title">
<string>No positioner selected</string>
</property>
<layout class="QGridLayout" name="gridLayout_5" rowstretch="0,0,0,0,0,0,0,0,0,0,0">
<property name="topMargin">
<number>0</number>
</property>
<item row="9" column="0">
<widget class="QLineEdit" name="setpoint_hor">
<property name="enabled">
<bool>false</bool>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<widget class="QToolButton" name="tool_button_hor">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="readback_hor">
<property name="text">
<string>Position</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="SpinnerWidget" name="spinner_widget_hor">
<property name="minimumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item row="10" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_7">
<item>
<widget class="QDoubleSpinBox" name="step_size_hor">
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item row="4" column="0">
<widget class="PositionIndicator" name="position_indicator_ver">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximum" stdset="0">
<double>1.000000000000000</double>
</property>
<property name="vertical" stdset="0">
<bool>true</bool>
</property>
<property name="value" stdset="0">
<double>0.500000000000000</double>
</property>
<property name="indicator_width" stdset="0">
<number>4</number>
</property>
<property name="rounded_corners" stdset="0">
<number>4</number>
</property>
</widget>
</item>
<item row="4" column="1">
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="3">
<widget class="QPushButton" name="step_increase_ver">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="2" column="5">
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="6" column="3">
<widget class="QPushButton" name="step_decrease_ver">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="5">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="6" column="2">
<spacer name="horizontalSpacer_16">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="1">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="2">
<spacer name="horizontalSpacer_14">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="1">
<spacer name="horizontalSpacer_17">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="2">
<spacer name="horizontalSpacer_7">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="6" column="1">
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="3">
<spacer name="horizontalSpacer_6">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="5">
<widget class="QPushButton" name="step_increase_hor">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="6" column="4">
<spacer name="horizontalSpacer_10">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="2">
<widget class="QPushButton" name="tweak_decrease_hor">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="2">
<spacer name="horizontalSpacer_15">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="1">
<spacer name="horizontalSpacer_35">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="3">
<widget class="QPushButton" name="tweak_decrease_ver">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QPushButton" name="step_decrease_hor">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="3" column="4">
<widget class="QPushButton" name="tweak_increase_hor">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="2" column="3">
<widget class="QPushButton" name="tweak_increase_ver">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="2" column="4">
<spacer name="horizontalSpacer_12">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="4">
<spacer name="horizontalSpacer_8">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="4">
<spacer name="horizontalSpacer_11">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="5">
<spacer name="horizontalSpacer_5">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="6" column="5">
<widget class="StopButton" name="stop_button"/>
</item>
</layout>
</item>
<item row="2" column="1">
<widget class="PositionIndicator" name="position_indicator_hor">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximum" stdset="0">
<double>1.000000000000000</double>
</property>
<property name="value" stdset="0">
<double>0.500000000000000</double>
</property>
<property name="indicator_width" stdset="0">
<number>4</number>
</property>
<property name="rounded_corners" stdset="0">
<number>4</number>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>StopButton</class>
<extends>QWidget</extends>
<header>stop_button</header>
</customwidget>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<header>position_indicator</header>
</customwidget>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<header>spinner_widget</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@ -6,11 +6,11 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combo_box_plugin import (
SignalComboBoxPlugin,
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box2_d_plugin import (
PositionerBox2DPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(SignalComboBoxPlugin())
QPyDesignerCustomWidgetCollection.addCustomWidget(PositionerBox2DPlugin())
if __name__ == "__main__": # pragma: no cover

View File

@ -4,9 +4,10 @@ from __future__ import annotations
from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, QSize, Signal, Slot
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QGridLayout, QGroupBox, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
@ -32,7 +33,7 @@ class PositionerGroupBox(QGroupBox):
self.widget.position_update.connect(self._on_position_update)
self.widget.expand.connect(self._on_expand)
self.setTitle(self.device_name)
self.widget.init_device() # force readback
self.widget.force_update_readback()
def _on_expand(self, expand):
if expand:
@ -82,7 +83,7 @@ class PositionerGroup(BECWidget, QWidget):
def minimumSizeHint(self):
return QSize(300, 30)
@Slot(str)
@SafeSlot(str)
def set_positioners(self, device_names: str):
"""Redraw grid with positioners from device_names string
@ -130,7 +131,7 @@ class PositionerGroup(BECWidget, QWidget):
widget = self.sender()
self.device_position_update.emit(widget.title(), pos)
@Property(str)
@SafeProperty(str)
def devices_list(self):
"""Device names string separated by space"""
return " ".join(self._device_widgets)
@ -144,7 +145,7 @@ class PositionerGroup(BECWidget, QWidget):
return
self.set_positioners(device_names)
@Property(int)
@SafeProperty(int)
def grid_max_cols(self):
"""Max number of columns for widgets grid"""
return self._grid_ncols

View File

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

View File

@ -158,3 +158,10 @@ def test_positioner_box_open_dialog_selection(qtbot, positioner_box):
QTimer.singleShot(100, close_dialog)
qtbot.mouseClick(positioner_box.ui.tool_button, Qt.LeftButton)
assert positioner_box.device == "samy"
def test_device_validity_check_rejects_non_positioner():
# isinstance checks for PositionerBox are mocked out in the mock client
positioner_box = mock.MagicMock(spec=PositionerBox)
positioner_box.dev = {"test": 5.123}
assert not PositionerBox._check_device_is_valid(positioner_box, "test")

View File

@ -0,0 +1,82 @@
from unittest import mock
import pytest
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox2D
from .client_mocks import mocked_client
from .conftest import create_widget
@pytest.fixture
def positioner_box_2d(qtbot, mocked_client):
"""Fixture for PositionerBox widget"""
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.uuid.uuid4"
) as mock_uuid:
mock_uuid.return_value = "fake_uuid"
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base.PositionerBoxBase._check_device_is_valid",
return_value=True,
):
db = create_widget(
qtbot, PositionerBox2D, device_hor="samx", device_ver="samy", client=mocked_client
)
yield db
def test_positioner_box_2d(positioner_box_2d):
"""Test init of 2D positioner box"""
assert positioner_box_2d.device_hor == "samx"
assert positioner_box_2d.device_ver == "samy"
data_hor = positioner_box_2d.dev["samx"].read()
data_ver = positioner_box_2d.dev["samy"].read()
# Avoid check for Positioner class from BEC in _init_device
setpoint_hor_text = positioner_box_2d.ui.setpoint_hor.text()
setpoint_ver_text = positioner_box_2d.ui.setpoint_ver.text()
# check that the setpoint is taken correctly after init
assert float(setpoint_hor_text) == data_hor["samx_setpoint"]["value"]
assert float(setpoint_ver_text) == data_ver["samy_setpoint"]["value"]
# check that the precision is taken correctly after init
precision_hor = positioner_box_2d.dev["samx"].precision
precision_ver = positioner_box_2d.dev["samy"].precision
assert setpoint_hor_text == f"{data_hor['samx_setpoint']['value']:.{precision_hor}f}"
assert setpoint_ver_text == f"{data_ver['samy_setpoint']['value']:.{precision_ver}f}"
# check that the step size is set according to the device precision
assert positioner_box_2d.ui.step_size_hor.value() == 10**-precision_hor * 10
assert positioner_box_2d.ui.step_size_ver.value() == 10**-precision_ver * 10
def test_positioner_box_move_hor_does_not_affect_ver(positioner_box_2d):
"""Test that moving one positioner doesn't affect the other"""
with (
mock.patch.object(positioner_box_2d.dev["samx"], "move") as mock_move_hor,
mock.patch.object(positioner_box_2d.dev["samy"], "move") as mock_move_ver,
):
positioner_box_2d.ui.step_size_hor.setValue(0.1)
positioner_box_2d.on_tweak_inc_hor()
mock_move_hor.assert_called_once_with(0.01, relative=True)
mock_move_ver.assert_not_called()
with (
mock.patch.object(positioner_box_2d.dev["samx"], "move") as mock_move_hor,
mock.patch.object(positioner_box_2d.dev["samy"], "move") as mock_move_ver,
):
positioner_box_2d.ui.step_size_ver.setValue(0.1)
positioner_box_2d.on_step_dec_ver()
mock_move_ver.assert_called_once_with(-0.1, relative=True)
mock_move_hor.assert_not_called()
def test_positioner_box_setpoint_changes(positioner_box_2d: PositionerBox2D):
"""Test positioner box setpoint change"""
with mock.patch.object(positioner_box_2d.dev["samx"], "move") as mock_move:
positioner_box_2d.ui.setpoint_hor.setText("100")
positioner_box_2d.on_setpoint_change_hor()
mock_move.assert_called_once_with(100, relative=False)
with mock.patch.object(positioner_box_2d.dev["samy"], "move") as mock_move:
positioner_box_2d.ui.setpoint_ver.setText("100")
positioner_box_2d.on_setpoint_change_ver()
mock_move.assert_called_once_with(100, relative=False)