feat: stage motor entry values, apply moves in one click
CI / lint (push) Skipped
CI / test (3.11) (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
CI / lint (push) Skipped
CI / test (3.11) (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
Chi/Phi boxes in the Smargon tab no longer move on Enter: edits stage the target (orange), the Move button sends all staged axes at once (green), and boxes return to neutral when the motor reports arrival. Out-of-range entries stay red and are never sent; limit tips pop as tooltips. MotorMoveGroup is opt-in per box so other panels can adopt the same logic later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ from PySide6.QtCore import Signal, Slot
|
||||
from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QWidget
|
||||
|
||||
from aare.gui.widgets.button_with_payload import ButtonWithPayload
|
||||
from aare.gui.widgets.motor_move_group import MotorMoveGroup
|
||||
from aare.gui.widgets.number_line_edit import NumberLineEdit
|
||||
from aare.gui.widgets.title_label import TitleLabel
|
||||
|
||||
@@ -59,28 +60,34 @@ class SmargonPanel(QWidget):
|
||||
grid_layout = QGridLayout(self)
|
||||
|
||||
grid_layout.addWidget(
|
||||
TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 0, 0, 1, 6
|
||||
TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 0, 0, 1, 7
|
||||
)
|
||||
|
||||
grid_layout.addWidget(QLabel("Chi", parent=self), 1, 0)
|
||||
self.chi_enter = NumberLineEdit(-0.2, 40, decimals=1, parent=self)
|
||||
self.chi_enter.newValue.connect(self.chi)
|
||||
grid_layout.addWidget(self.chi_enter, 1, 1)
|
||||
grid_layout.addWidget(QLabel("°", parent=self), 1, 2)
|
||||
|
||||
grid_layout.addWidget(QLabel("Phi", parent=self), 1, 3)
|
||||
self.phi_enter = NumberLineEdit(-0.2, 360, decimals=1, parent=self)
|
||||
self.phi_enter.newValue.connect(self.phi)
|
||||
grid_layout.addWidget(self.phi_enter, 1, 4)
|
||||
|
||||
grid_layout.addWidget(QLabel("°", parent=self), 1, 5)
|
||||
|
||||
# Chi/Phi are staged (orange) and only sent on Move (green until the
|
||||
# motor reports the target reached) — see MotorMoveGroup.
|
||||
self.move_group = MotorMoveGroup(parent=self)
|
||||
self.move_group.add_box("chi", self.chi_enter)
|
||||
self.move_group.add_box("phi", self.phi_enter)
|
||||
self.move_group.applied.connect(self._move_axes)
|
||||
grid_layout.addWidget(self.move_group.button, 1, 6)
|
||||
|
||||
self.home_button = QPushButton("Move home", parent=self)
|
||||
grid_layout.addWidget(self.home_button, 2, 0, 1, 6)
|
||||
grid_layout.addWidget(self.home_button, 2, 0, 1, 7)
|
||||
self.home_button.clicked.connect(self.home)
|
||||
|
||||
self.move_panel = SmargonMoveWidget(parent=self)
|
||||
grid_layout.addWidget(self.move_panel, 3, 0, 1, 6)
|
||||
grid_layout.addWidget(self.move_panel, 3, 0, 1, 7)
|
||||
self.move_panel.smargon_rel.connect(self.smargon_rel)
|
||||
|
||||
grid_layout.addWidget(QLabel("Step", parent=self), 4, 0)
|
||||
@@ -98,18 +105,17 @@ class SmargonPanel(QWidget):
|
||||
# TODO move SMARGON_HOME to REDIS, allow GUI to read this value
|
||||
self.smargon.emit(SmargonCoordinate(sh_mm=Coordinate(x=0, y=0, z=18), phi_deg=0, chi_deg=0))
|
||||
|
||||
@Slot(float)
|
||||
def phi(self, f: float):
|
||||
self.smargon.emit(SmargonCoordinate(phi_deg=f))
|
||||
|
||||
@Slot(float)
|
||||
def chi(self, f: float):
|
||||
self.smargon.emit(SmargonCoordinate(chi_deg=f))
|
||||
@Slot(dict)
|
||||
def _move_axes(self, targets: dict):
|
||||
# only the staged axes move — absent keys stay None (no motion)
|
||||
self.smargon.emit(
|
||||
SmargonCoordinate(chi_deg=targets.get("chi"), phi_deg=targets.get("phi"))
|
||||
)
|
||||
|
||||
@Slot(DAQStatusModel)
|
||||
def update_daq_status(self, s: DAQStatusModel):
|
||||
self.chi_enter.update_value(s.geom.smargon.chi_deg)
|
||||
self.phi_enter.update_value(s.geom.smargon.phi_deg)
|
||||
self.move_group.update_actual("chi", s.geom.smargon.chi_deg)
|
||||
self.move_group.update_actual("phi", s.geom.smargon.phi_deg)
|
||||
self._geom = s.geom
|
||||
|
||||
@Slot(Coordinate)
|
||||
|
||||
@@ -348,6 +348,12 @@ INPUT_BG = "rgba(255, 255, 255, 33%)"
|
||||
INPUT_INVALID_BG = "#e9c4cf" # red 20% over latte base
|
||||
INPUT_DISABLED_BG = "#e6e9ef" # latte mantle
|
||||
INPUT_DISABLED_INVALID_BG = "#ecdae2" # faint red wash
|
||||
# Motor batch-move states (MotorMoveGroup "movestate" dynamic property):
|
||||
# staged-but-not-sent target vs motor in motion — see motor_move_group.py.
|
||||
INPUT_PENDING_BG = "#f2cdba" # peach 25% over latte base
|
||||
INPUT_MOVING_BG = "#c3ddc3" # green 25% over latte base
|
||||
DARK_INPUT_PENDING_BG = "#473741" # warn (copper) 25% over bg
|
||||
DARK_INPUT_MOVING_BG = "#2f4e5d" # green 25% over bg
|
||||
|
||||
# -- Status bar flags (Catppuccin Latte) ------------------------------------
|
||||
STATUS_OK = "#40a02b" # closed / idle / owned / tell ok (green)
|
||||
@@ -715,6 +721,16 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str:
|
||||
background-color: $input_disabled_bg;
|
||||
}
|
||||
|
||||
/* Motor batch-move states (before the invalid rules on purpose: equal
|
||||
specificity, so out-of-range red must come last to win). */
|
||||
QLineEdit[movestate="pending"] {
|
||||
background-color: $input_pending_bg;
|
||||
}
|
||||
|
||||
QLineEdit[movestate="moving"] {
|
||||
background-color: $input_moving_bg;
|
||||
}
|
||||
|
||||
QLineEdit[invalid="true"] {
|
||||
background-color: $input_invalid_bg;
|
||||
}
|
||||
@@ -1380,6 +1396,14 @@ def _sunset_stylesheet() -> str:
|
||||
background-color: $dark_disabled;
|
||||
}
|
||||
|
||||
QLineEdit[movestate="pending"] {
|
||||
background-color: $dark_input_pending_bg;
|
||||
}
|
||||
|
||||
QLineEdit[movestate="moving"] {
|
||||
background-color: $dark_input_moving_bg;
|
||||
}
|
||||
|
||||
QLineEdit[invalid="true"] {
|
||||
background-color: $dark_error_bg;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Batch entry -> apply for motor value boxes.
|
||||
|
||||
Why: typing into a motor box used to fire the move on Enter, one axis at a
|
||||
time. Operators want to stage several targets, review them, then start all
|
||||
motors with one click. The group tracks a per-box state machine:
|
||||
|
||||
neutral (theme default) --user edit--> pending (orange)
|
||||
pending --Move clicked--> moving (green)
|
||||
moving --actual reaches target--> neutral
|
||||
|
||||
Colors are QSS dynamic properties ("movestate" on the box), styled per theme
|
||||
in styles.py — same pattern as NumberLineEdit's "invalid" property, so the
|
||||
existing out-of-range red still wins while typing.
|
||||
|
||||
Modular on purpose: any panel opts a NumberLineEdit in with add_box(); boxes
|
||||
not registered keep their old behavior.
|
||||
"""
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QPoint, Qt, Signal, Slot
|
||||
from PySide6.QtWidgets import QPushButton, QToolTip
|
||||
|
||||
from aare.gui.widgets.number_line_edit import NumberLineEdit
|
||||
|
||||
|
||||
class MotorMoveGroup(QObject):
|
||||
# {name: target} for every box that was pending when Move was clicked
|
||||
applied = Signal(dict)
|
||||
|
||||
def __init__(self, button_text: str = "Move", parent=None):
|
||||
super().__init__(parent)
|
||||
self.button = QPushButton(button_text)
|
||||
self.button.setEnabled(False)
|
||||
self.button.clicked.connect(self._apply)
|
||||
self._boxes: dict[str, NumberLineEdit] = {}
|
||||
self._tols: dict[str, float] = {}
|
||||
self._state: dict[str, str] = {} # "" (neutral) | "pending" | "moving"
|
||||
self._targets: dict[str, float] = {}
|
||||
|
||||
def add_box(self, name: str, box: NumberLineEdit, tol: float = 0.1):
|
||||
# tol is the "at target" window — hardware never lands exactly on the
|
||||
# setpoint, so this stays a per-box knob (encoder resolution differs
|
||||
# per motor).
|
||||
self._boxes[name] = box
|
||||
self._tols[name] = tol
|
||||
self._state[name] = ""
|
||||
box.textEdited.connect(lambda _text, n=name: self._on_edited(n))
|
||||
# Below-min can only be judged on Enter (typing "3" may become "30"),
|
||||
# and the validator swallows editingFinished for out-of-range text —
|
||||
# so catch the key directly.
|
||||
box.installEventFilter(self)
|
||||
|
||||
# -- panel-facing API ----------------------------------------------------
|
||||
@Slot(str, float)
|
||||
def update_actual(self, name: str, value: float):
|
||||
"""Feed the motor's actual position. Neutral boxes track it (same as
|
||||
the old direct update_value call); pending/moving boxes keep showing
|
||||
the user's target until the move completes."""
|
||||
box = self._boxes[name]
|
||||
state = self._state[name]
|
||||
if state == "moving" and abs(value - self._targets[name]) <= self._tols[name]:
|
||||
self._set_state(name, "")
|
||||
state = ""
|
||||
if state == "":
|
||||
box.update_value(value)
|
||||
|
||||
# -- internals -----------------------------------------------------------
|
||||
def _on_edited(self, name: str):
|
||||
box = self._boxes[name]
|
||||
try:
|
||||
value = float(box.text())
|
||||
except ValueError:
|
||||
# incomplete entry ("", "-", "1e"): stay/become pending, no tips
|
||||
self._set_state(name, "pending")
|
||||
return
|
||||
top = box.validator.top()
|
||||
if value > top:
|
||||
QToolTip.showText(
|
||||
box.mapToGlobal(QPoint(0, box.height())),
|
||||
f"Maximum value: {box.to_string(top)}",
|
||||
box,
|
||||
)
|
||||
# editing back to the current position cancels the pending move
|
||||
if abs(value - box.saved_value) <= self._tols[name]:
|
||||
self._set_state(name, "")
|
||||
else:
|
||||
self._set_state(name, "pending")
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() == QEvent.Type.KeyPress and event.key() in (
|
||||
Qt.Key.Key_Return,
|
||||
Qt.Key.Key_Enter,
|
||||
):
|
||||
for box in self._boxes.values():
|
||||
if obj is box:
|
||||
try:
|
||||
value = float(box.text())
|
||||
except ValueError:
|
||||
break
|
||||
bottom = box.validator.bottom()
|
||||
if value < bottom:
|
||||
QToolTip.showText(
|
||||
box.mapToGlobal(QPoint(0, box.height())),
|
||||
f"Too small — minimum value: {box.to_string(bottom)}",
|
||||
box,
|
||||
)
|
||||
break
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
@Slot()
|
||||
def _apply(self):
|
||||
targets = {}
|
||||
for name, box in self._boxes.items():
|
||||
# out-of-range text stays pending (red via the invalid property);
|
||||
# only valid targets are sent
|
||||
if self._state[name] == "pending" and box.validate(box.text()):
|
||||
targets[name] = box.value
|
||||
self._targets[name] = box.value
|
||||
self._set_state(name, "moving")
|
||||
if targets:
|
||||
self.applied.emit(targets)
|
||||
# ponytail: no timeout — a move that never reaches target leaves the
|
||||
# box green until the user re-edits it; add a watchdog if that bites.
|
||||
|
||||
def _set_state(self, name: str, state: str):
|
||||
box = self._boxes[name]
|
||||
if box.property("movestate") != state:
|
||||
box.setProperty("movestate", state)
|
||||
# property selectors only re-evaluate on repolish
|
||||
box.style().unpolish(box)
|
||||
box.style().polish(box)
|
||||
self._state[name] = state
|
||||
self.button.setEnabled(any(s == "pending" for s in self._state.values()))
|
||||
@@ -0,0 +1,67 @@
|
||||
"""MotorMoveGroup is motor protection UX: typing must never start a move —
|
||||
targets are staged (orange), sent only by the Move button (green), and the
|
||||
box returns to neutral when the motor actually arrives."""
|
||||
|
||||
import pytest
|
||||
|
||||
from aare.gui.widgets.motor_move_group import MotorMoveGroup
|
||||
from aare.gui.widgets.number_line_edit import NumberLineEdit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chi(qtbot):
|
||||
box = NumberLineEdit(-0.2, 40, decimals=1)
|
||||
qtbot.addWidget(box)
|
||||
group = MotorMoveGroup()
|
||||
group.add_box("chi", box)
|
||||
return group, box
|
||||
|
||||
|
||||
def _type(qtbot, box, text):
|
||||
box.clear()
|
||||
qtbot.keyClicks(box, text)
|
||||
|
||||
|
||||
def test_stage_apply_settle(chi, qtbot):
|
||||
group, box = chi
|
||||
sent = []
|
||||
group.applied.connect(sent.append)
|
||||
|
||||
_type(qtbot, box, "25.0")
|
||||
assert box.property("movestate") == "pending"
|
||||
assert group.button.isEnabled()
|
||||
assert not sent # typing (even Enter) must not move the motor
|
||||
|
||||
group.button.click()
|
||||
assert sent == [{"chi": 25.0}]
|
||||
assert box.property("movestate") == "moving"
|
||||
assert not group.button.isEnabled()
|
||||
|
||||
group.update_actual("chi", 10.0) # still travelling
|
||||
assert box.property("movestate") == "moving"
|
||||
assert box.value == 25.0 # box keeps showing the target
|
||||
|
||||
group.update_actual("chi", 25.05) # within tol -> arrived
|
||||
assert box.property("movestate") == ""
|
||||
group.update_actual("chi", 3.0) # neutral boxes track the actual again
|
||||
assert box.value == 3.0
|
||||
|
||||
|
||||
def test_edit_back_to_current_cancels_pending(chi, qtbot):
|
||||
group, box = chi
|
||||
_type(qtbot, box, "5.0")
|
||||
assert group.button.isEnabled()
|
||||
_type(qtbot, box, "0.0") # back to the actual position
|
||||
assert box.property("movestate") == ""
|
||||
assert not group.button.isEnabled()
|
||||
|
||||
|
||||
def test_over_max_is_invalid_and_never_sent(chi, qtbot):
|
||||
group, box = chi
|
||||
sent = []
|
||||
group.applied.connect(sent.append)
|
||||
_type(qtbot, box, "500")
|
||||
assert box.property("invalid") is True # red via existing validator path
|
||||
group.button.click()
|
||||
assert not sent # out-of-range target is never applied
|
||||
assert box.property("movestate") == "pending"
|
||||
Reference in New Issue
Block a user