diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 40b98ec4..ac553d85 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -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: diff --git a/src/aare/gui/panels/data_collection_settings.py b/src/aare/gui/panels/data_collection_settings.py index fc833c2c..a0f01ef7 100644 --- a/src/aare/gui/panels/data_collection_settings.py +++ b/src/aare/gui/panels/data_collection_settings.py @@ -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) diff --git a/src/aare/gui/panels/monochromator_panel.py b/src/aare/gui/panels/monochromator_panel.py index 28f787e5..06924ac4 100644 --- a/src/aare/gui/panels/monochromator_panel.py +++ b/src/aare/gui/panels/monochromator_panel.py @@ -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 diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index ed27a993..e7568ac1 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -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): diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index 19b6fd6d..edf19e83 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -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 diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 03454282..b87bf46a 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -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 diff --git a/src/aare/gui/widgets/motor_move_group.py b/src/aare/gui/widgets/motor_move_group.py index 3315d4b0..64663277 100644 --- a/src/aare/gui/widgets/motor_move_group.py +++ b/src/aare/gui/widgets/motor_move_group.py @@ -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) diff --git a/tests/unit/gui/test_busy_overlay.py b/tests/unit/gui/test_busy_overlay.py index cf188c2d..978c1d54 100644 --- a/tests/unit/gui/test_busy_overlay.py +++ b/tests/unit/gui/test_busy_overlay.py @@ -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" diff --git a/tests/unit/gui/test_camera_image.py b/tests/unit/gui/test_camera_image.py index be91b41a..94f4e0a5 100644 --- a/tests/unit/gui/test_camera_image.py +++ b/tests/unit/gui/test_camera_image.py @@ -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 diff --git a/tests/unit/gui/test_daq_worker_energy_changing.py b/tests/unit/gui/test_daq_worker_energy_changing.py new file mode 100644 index 00000000..44e9dae9 --- /dev/null +++ b/tests/unit/gui/test_daq_worker_energy_changing.py @@ -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] diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index d821e942..6f11cbb2 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -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") == "" diff --git a/tests/unit/gui/test_monochromator_panel.py b/tests/unit/gui/test_monochromator_panel.py index 3785eaeb..a465b44f 100644 --- a/tests/unit/gui/test_monochromator_panel.py +++ b/tests/unit/gui/test_monochromator_panel.py @@ -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() diff --git a/tests/unit/gui/test_motor_move_group.py b/tests/unit/gui/test_motor_move_group.py index fac30cf6..a864602a 100644 --- a/tests/unit/gui/test_motor_move_group.py +++ b/tests/unit/gui/test_motor_move_group.py @@ -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)