WIP: show busy state in GUI while energy change is in flight #216
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user