WIP: show busy state in GUI while energy change is in flight #216

Closed
duan_j wants to merge 1 commits from show-busy-when-changing-energy into main
13 changed files with 179 additions and 1 deletions
+6
View File
@@ -1013,8 +1013,13 @@ class MainWindow(QMainWindow):
# Energy row inside Exp. Config. — not staff-gated like the
# Beamline setup copy; the server enforces write permission anyway.
self.data_collection.change_energy.connect(self.daq.change_energy)
# Both Set Energy rows follow the one in-flight request, whichever
# row clicked. The mono panel exists for staff only (locked banner
# otherwise), so its half sits in the staff block below.
self.daq.energy_changing.connect(self.data_collection.set_energy_changing)
if self._decoded_token.staff:
self.daq.energy_changing.connect(self.monochromator_panel.set_energy_changing)
self.monochromator_panel.mono_pitch_scan.connect(self.daq.mono_pitch_scan)
self.monochromator_panel.move_beam_to_box.connect(self.daq.steer_beam)
self.monochromator_panel.change_energy.connect(self.daq.change_energy)
@@ -1204,6 +1209,7 @@ class MainWindow(QMainWindow):
self.daq.update.connect(self.portrait_sample_camera.update_daq_status)
for camera in (self.sample_camera, self.compact_sample_camera, self.portrait_sample_camera):
self.daq.auto_centering.connect(camera.set_auto_centering)
self.daq.energy_changing.connect(camera.set_energy_changing)
self.daq.update.connect(self.tell_samples.update_daq_status)
self.daq.update.connect(self.ref_tools_panel.update_daq_status)
if self.prediction_thread is not None:
@@ -216,6 +216,12 @@ class DataCollectionSettings(QFrame):
def _emit_change_energy(self):
self.change_energy.emit(float(self.energy_spin.value()) * 1000.0)
@Slot(bool)
def set_energy_changing(self, changing: bool):
# Button text is the loud cue; the spin's movestate is the subtle one.
self.change_energy_button.setText("Changing…" if changing else "Change Energy")
self._energy_state.set_changing(changing)
@Slot()
def switch_to_raster(self):
self._tab_bar.setCurrentIndex(0)
@@ -126,6 +126,12 @@ class MonochromatorPanel(QWidget):
def _emit_change_energy(self):
self.change_energy.emit(float(self.energy_spin.value()) * 1000.0)
@Slot(bool)
def set_energy_changing(self, changing: bool):
# Button text is the loud cue; the spin's movestate is the subtle one.
self.change_energy_button.setText("Changing…" if changing else "Change Energy")
self._energy_state.set_changing(changing)
@Slot(DAQStatusModel)
def update_daq_status(self, status: DAQStatusModel):
energy = status.diffraction.energy_keV
+14 -1
View File
@@ -88,6 +88,10 @@ class DAQWorker(QObject):
# True while a loop centering runs — /status alone cannot tell it from
# a manual alignment move (see build_busy_overlay_style).
auto_centering = Signal(bool)
# True while our own PUT /beamline/change_energy is in flight. The server
# never takes the hw lock for it, so /status.busy stays False the whole
# move — the reply edge is the only "done" signal the GUI has.
energy_changing = Signal(bool)
gui_sessions_loaded = Signal(list)
gui_close_requested = Signal(int, int, str)
recovery_action_completed = Signal(str)
@@ -781,6 +785,7 @@ class DAQWorker(QObject):
request.setRawHeader(b"Content-Type", b"application/json")
reply = self._net_manager.put(request, QByteArray(body.encode("utf-8")))
reply.finished.connect(lambda: self.handle_req_response(reply))
return reply
def generic_delete(self, url: str):
"""
@@ -832,7 +837,15 @@ class DAQWorker(QObject):
@Slot(float)
def change_energy(self, value: float):
self.generic_put(f"beamline/change_energy?value={value:.3f}")
# Endpoint blocks server-side until the mono lands (same trick as
# center_loop): finished fires on success AND error, so the flag
# always clears. ponytail: only the clicking GUI knows — other GUIs
# need the server to take the hw lock (see server change_energy).
reply = self.generic_put(f"beamline/change_energy?value={value:.3f}")
if reply is None:
return
self.energy_changing.emit(True)
reply.finished.connect(lambda: self.energy_changing.emit(False))
@Slot(int)
def front_light(self, v: int):
+7
View File
@@ -182,6 +182,7 @@ def build_busy_overlay_style(
tell_state: TellStateModel | None,
session_state: SessionsStateEnum | None = None,
auto_centering: bool = False,
energy_changing: bool = False,
) -> BusyOverlayStyle | None:
if session_state in {
SessionsStateEnum.OwnedByElse,
@@ -198,6 +199,12 @@ def build_busy_overlay_style(
if auto_centering:
return _animated("AUTO CENTERING", BUSY_PSI_RED, BUSY_PSI_RED_BORDER, BUSY_PSI_RED_DOT)
# Energy change is GUI-tracked (request in flight), not in /status.busy —
# the server skips the hw lock for it, so without this the curtain the
# mono pitch scan gets would be missing for the longer energy move.
if energy_changing:
return _animated("CHANGING ENERGY", BUSY_PSI_RED, BUSY_PSI_RED_BORDER, BUSY_PSI_RED_DOT)
if not is_busy:
return None
+7
View File
@@ -129,6 +129,7 @@ class SampleCameraImageLabel(QGraphicsView):
self._grid_update_min_interval_s = 1.0 / 25.0
self._tell_state = None
self._auto_centering = False
self._energy_changing = False
self._busy_overlay_style: BusyOverlayStyle | None = None
self._geom = geom
@@ -300,6 +301,11 @@ class SampleCameraImageLabel(QGraphicsView):
# /status tick (500 ms), well within the seconds a centering takes.
self._auto_centering = active
@Slot(bool)
def set_energy_changing(self, active: bool) -> None:
# Same deal as set_auto_centering: stored, overlay rebuilt next tick.
self._energy_changing = active
def _busy_overlay_text(self) -> str:
tell_state = self._tell_state
if tell_state is None:
@@ -817,6 +823,7 @@ class SampleCameraImageLabel(QGraphicsView):
tell_state=s.tell_state,
session_state=self._session_state,
auto_centering=self._auto_centering,
energy_changing=self._energy_changing,
)
if new_busy_style != self._busy_overlay_style:
self._busy_overlay_style = new_busy_style
+10
View File
@@ -75,6 +75,16 @@ class SpinMoveState(QObject):
self._spin.setValue(value)
self._syncing = False
@Slot(bool)
def set_changing(self, changing: bool):
"""Server-side edge from the in-flight request. True forces moving
(the twin Set Energy row that did not click has no other way to
know) and locks the spin so a second request cannot race the first;
False returns to neutral even when the mono never lands within tol,
which used to leave the row green forever."""
self._spin.setReadOnly(changing)
self._set_state("moving" if changing else "")
def _set_state(self, state: str):
if self._spin.property("movestate") != state:
self._spin.setProperty("movestate", state)
+7
View File
@@ -75,3 +75,10 @@ def test_robot_cooling_is_the_only_blue_busy_state():
for activity in ("mounting", "unmounting", "drying", "unknown"):
assert _style(activity).badge_bg == BUSY_PSI_RED
assert _style("unknown").text == "BEAMLINE BUSY"
def test_energy_changing_flag_shows_its_own_badge():
# GUI-tracked (request in flight), not in /status.busy: the server skips
# the hw lock for energy changes, so the flag alone must raise the badge.
style = build_busy_overlay_style(is_busy=False, tell_state=None, energy_changing=True)
assert style is not None and style.text == "CHANGING ENERGY"
+16
View File
@@ -297,3 +297,19 @@ def test_unknown_session_paints_the_viewing_mode_badge(camera):
assert camera._busy_overlay_style is None
camera.grab()
assert camera._session_badge_rect is not None
def test_energy_changing_raises_the_curtain_on_next_tick(camera, daq_status_factory):
# Stored on the edge, applied on the next /status tick (same as
# set_auto_centering) — so the tick is what must show the badge.
from aarecommon.models.models import SessionsStateEnum
# own session, else the viewing-mode badge wins over every busy badge
owned = daq_status_factory(session_state=SessionsStateEnum.OwnedByYou)
camera.set_energy_changing(True)
camera.update_daq_status(owned)
assert camera._busy_overlay_style is not None
assert camera._busy_overlay_style.text == "CHANGING ENERGY"
camera.set_energy_changing(False)
camera.update_daq_status(owned)
assert camera._busy_overlay_style is None
@@ -0,0 +1,40 @@
"""change_energy must expose its in-flight window: the server never takes
the hw lock for it, so the PUT reply edge is the GUI's only "done" signal."""
from unittest.mock import MagicMock
from aare.gui.threads.daq_worker import DAQWorker
def _worker(qapp, base_url):
w = DAQWorker(base_url=base_url, token="test-token")
w._timer.stop() # no background polling during tests
return w
def test_offline_worker_never_flags_changing(qapp):
w = _worker(qapp, None)
seen = []
w.energy_changing.connect(seen.append)
w.change_energy(12000.0) # offline: PUT is only logged, no reply
assert seen == []
def test_reply_edge_brackets_the_change(qapp):
w = _worker(qapp, "http://daq.test")
w._net_manager = MagicMock()
reply = w._net_manager.put.return_value
seen = []
w.energy_changing.connect(seen.append)
w.change_energy(12000.0)
assert seen == [True]
assert (
"beamline/change_energy?value=12000.000"
in w._net_manager.put.call_args[0][0].url().toString()
)
# finished fires on success AND error — every connected slot runs
for (slot,), _kw in reply.finished.connect.call_args_list:
slot()
assert seen == [True, False]
@@ -448,3 +448,14 @@ def test_energy_spin_motor_move_semantics(settings_panel, daq_status_factory):
settings_panel._energy_state.update_actual(12.3995)
assert box.property("movestate") == ""
def test_energy_changing_edge_drives_button_and_spin(settings_panel, daq_status_factory):
# Same edge as the Beamline setup row (full walk tested there).
settings_panel.update_daq_status(daq_status_factory())
settings_panel.set_energy_changing(True)
assert settings_panel.change_energy_button.text() == "Changing…"
assert settings_panel.energy_spin.property("movestate") == "moving"
settings_panel.set_energy_changing(False)
assert settings_panel.change_energy_button.text() == "Change Energy"
assert settings_panel.energy_spin.property("movestate") == ""
@@ -87,3 +87,21 @@ def test_energy_spin_motor_move_semantics(qtbot, daq_status_factory):
panel.update_daq_status(status)
assert box.property("movestate") == ""
assert panel.energy_spin.value() == pytest.approx(12.3995, abs=1e-3)
def test_energy_changing_edge_drives_button_and_spin(qtbot, daq_status_factory):
# The in-flight edge from DAQWorker, not the click, is what shows busy:
# the twin row in Exp. Config. gets the same edge without ever clicking.
panel = MonochromatorPanel()
qtbot.addWidget(panel)
panel.update_daq_status(daq_status_factory())
panel.set_energy_changing(True)
assert panel.change_energy_button.text() == "Changing…"
assert panel.energy_spin.property("movestate") == "moving"
assert panel.energy_spin.isReadOnly()
panel.set_energy_changing(False)
assert panel.change_energy_button.text() == "Change Energy"
assert panel.energy_spin.property("movestate") == ""
assert not panel.energy_spin.isReadOnly()
+31
View File
@@ -138,3 +138,34 @@ def test_spin_move_state_colors_actually_render(qtbot):
state.update_actual(12.398) # arrived within tol
assert value_area_color() == neutral
def test_spin_set_changing_forces_moving_and_clears_without_arrival(qtbot):
# The reply edge must (a) mark the twin row moving without a click and
# lock it, (b) return to neutral even when the mono misses the tol window
# — the old arrival-only exit left the row green forever.
from PySide6.QtWidgets import QDoubleSpinBox, QPushButton
from aare.gui.widgets.motor_move_group import SpinMoveState
spin = QDoubleSpinBox()
spin.setDecimals(3) # eV resolution, as the real Set Energy rows
spin.setRange(4.0, 20.0)
button = QPushButton()
qtbot.addWidget(spin)
qtbot.addWidget(button)
state = SpinMoveState(spin, button, tol=0.001)
state.update_actual(12.0)
state.set_changing(True)
assert spin.property("movestate") == "moving"
assert spin.isReadOnly()
assert not button.isEnabled()
state.update_actual(12.5) # mid-move readback must not leak into the spin
assert spin.value() == 12.0
state.set_changing(False)
assert spin.property("movestate") == ""
assert not spin.isReadOnly()
state.update_actual(12.995) # landed 5 eV off target: neutral row tracks it
assert spin.value() == pytest.approx(12.995)