From 0b4a25c050f252b56832ddcbc09819959e76cb88 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 27 Jul 2026 09:38:54 +0200 Subject: [PATCH] feat(LamNI): auto-retry lsamrot moves after a Galil safety-thread trip A safety thread on the LAMNI Galil controller supervises air pressure and capacitive-sensor feedback for the rotation stage; a spurious trip drops allaxref and sets allaxrer/caperr, failing every subsequent lsamrot move until manually cleared with allaxrer=1/caperr=0/allaxref=1. LamniGalilMotor.move() now resets those latches and retries once, scoped to lsamrot only via LamniGalilController.reset_axis_errors() -- other axes on the shared controller keep today's behavior unchanged, and a persisting fault still surfaces as a failure after the single retry. Co-Authored-By: Claude Sonnet 5 --- csaxs_bec/devices/omny/galil/lgalil_ophyd.py | 79 +++++++++-- .../tests_devices/test_lgalil_lamni_retry.py | 127 ++++++++++++++++++ 2 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 tests/tests_devices/test_lgalil_lamni_retry.py diff --git a/csaxs_bec/devices/omny/galil/lgalil_ophyd.py b/csaxs_bec/devices/omny/galil/lgalil_ophyd.py index b86a86d..cffcace 100644 --- a/csaxs_bec/devices/omny/galil/lgalil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/lgalil_ophyd.py @@ -14,6 +14,7 @@ from csaxs_bec.devices.omny.galil.galil_ophyd import ( BECConfigError, GalilAxesReferenced, GalilController, + GalilError, GalilMotorIsMoving, GalilSetpointSignal, GalilSignalRO, @@ -120,6 +121,21 @@ class LamniGalilController(GalilController): print("\nDecoding caperr =", caperr) self.visualize_caperr(caperr) + def reset_axis_errors(self) -> None: + """Clear the LAMNI safety-thread error latches so a subsequent move is + allowed again. Does not address a genuine hardware fault -- if the + underlying condition persists, the next move will fail the same way. + """ + allaxrer = int(float(self.socket_put_and_receive("MGallaxrer"))) + caperr = int(float(self.socket_put_and_receive("MGcaperr"))) + logger.warning( + f"Resetting LAMNI Galil error latches (allaxrer={allaxrer}: " + f"{self.decode_allaxrer(allaxrer)}, caperr={caperr})." + ) + self.socket_put_confirmed("allaxrer=1") + self.socket_put_confirmed("caperr=0") + self.socket_put_confirmed("allaxref=1") + def decode_allaxrer(self, code: int) -> str: """Return human-readable meaning of allaxrer code.""" return self.allaxrer_table.get(code, "Unknown allaxrer code") @@ -342,16 +358,50 @@ class LamniGalilMotor(Device, PositionerBase): self._started_moving = False timeout = kwargs.pop("timeout", 100) status = super().move(position, timeout=timeout, **kwargs) - self.user_setpoint.put(position, wait=False) + + # lsamrot: same condition used in GalilSetpointSignal._socket_set to identify + # the LAMNI rotation axis on the shared LAMNI Galil controller. Only lsamrot + # gets the automatic error-latch reset + retry; other axes on this controller + # keep today's behavior unchanged. + is_lsamrot = self.axis_Id_numeric == 2 and self.controller.sock.host == "mpc2680.psi.ch" + + try: + self.user_setpoint.put(position, wait=False) + except GalilError as exc: + if not is_lsamrot: + raise + logger.warning( + f"{self.name}: move to {position} failed ({exc}); resetting Galil " + "error latches and retrying once." + ) + self.controller.reset_axis_errors() + try: + self.user_setpoint.put(position, wait=False) + except GalilError as exc2: + logger.error( + f"{self.name}: move to {position} failed again after resetting Galil " + f"error latches ({exc2}); this looks like a genuine hardware issue." + ) + raise def move_and_finish(): - while self.motor_is_moving.get(): - logger.info("motor is moving") - val = self.readback.read() - self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time()) - time.sleep(0.1) - val = self.readback.read() - success = np.isclose(val[self.name]["value"], position, atol=self.tolerance) + success = self._wait_for_move_done(position) + + if not success and is_lsamrot: + logger.warning( + f"{self.name}: move to {position} did not reach target; resetting " + "Galil error latches and retrying once." + ) + self.controller.reset_axis_errors() + try: + self.user_setpoint.put(position, wait=False) + except GalilError as exc: + logger.error( + f"{self.name}: retry after resetting Galil error latches failed: {exc}" + ) + success = False + else: + success = self._wait_for_move_done(position) if not success: print(" stop") @@ -368,6 +418,19 @@ class LamniGalilMotor(Device, PositionerBase): return status + def _wait_for_move_done(self, position) -> bool: + """Block until the physical motion started by the last setpoint write + stops, then check whether the readback settled within tolerance of + `position`. + """ + while self.motor_is_moving.get(): + logger.info("motor is moving") + val = self.readback.read() + self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time()) + time.sleep(0.1) + val = self.readback.read() + return np.isclose(val[self.name]["value"], position, atol=self.tolerance) + @property def axis_Id(self): return self._axis_Id_alpha diff --git a/tests/tests_devices/test_lgalil_lamni_retry.py b/tests/tests_devices/test_lgalil_lamni_retry.py new file mode 100644 index 0000000..56b0ef3 --- /dev/null +++ b/tests/tests_devices/test_lgalil_lamni_retry.py @@ -0,0 +1,127 @@ +"""Tests for the lsamrot-scoped auto-retry after a LAMNI Galil safety-thread trip. + +A safety thread on the LAMNI Galil controller supervises air pressure and +capacitive-sensor feedback for the small rotation stage (lsamrot). A glitch +can trip a latch, dropping the controller-global `allaxref` variable to 0 and +setting `allaxrer`/`caperr`; the known manual recovery is to send +`allaxrer=1`, `caperr=0`, `allaxref=1` and retry the move. These tests drive +that scenario against the simulated LAMNI Galil controller. +""" + +import pytest + +from csaxs_bec.devices.omny.galil.galil_ophyd import GalilError +from csaxs_bec.devices.omny.galil.lgalil_ophyd import LamniGalilController +from csaxs_bec.devices.sim.sim_lamni import SimLamniGalilMotor +from csaxs_bec.devices.sim.sim_socket import SimStateRegistry + +HOST = "mpc2680.psi.ch" +PORT = 8081 + + +@pytest.fixture(autouse=True) +def reset_sim_state(): + """Avoid cross-test leakage of shared (host, port)-keyed simulation state.""" + SimStateRegistry.reset() + yield + SimStateRegistry.reset() + + +def _make_axis(axis_id, name, device_manager): + LamniGalilController._reset_controller() + motor = SimLamniGalilMotor(axis_id, name=name, host=HOST, port=PORT, device_manager=device_manager) + motor.controller.on() + return motor + + +@pytest.fixture +def lsamrot(dm_with_devices): + motor = _make_axis("C", "lsamrot", dm_with_devices) + yield motor + motor.controller.off() + motor.controller._reset_controller() + + +@pytest.fixture +def lsamx(dm_with_devices): + motor = _make_axis("A", "lsamx", dm_with_devices) + yield motor + motor.controller.off() + motor.controller._reset_controller() + + +def _trip_safety_latch(motor): + """Provoke the same failure a real safety-thread trip causes: allaxref + drops to 0 (not referenced), with allaxrer/caperr set to a plausible + tripped code, exactly as the real Galil controller would report. + """ + motor.controller.socket_put_confirmed("allaxrer=6") + motor.controller.socket_put_confirmed("caperr=5") + motor.controller.socket_put_confirmed("allaxref=0") + + +def test_lsamrot_move_recovers_from_spurious_trip(lsamrot): + _trip_safety_latch(lsamrot) + + status = lsamrot.move(1.0, wait=True, timeout=10) + + assert status.success + assert lsamrot.readback.get() == pytest.approx(1.0, abs=lsamrot.tolerance) + assert lsamrot.controller.all_axes_referenced() + assert int(float(lsamrot.controller.socket_put_and_receive("MGcaperr"))) == 0 + assert int(float(lsamrot.controller.socket_put_and_receive("MGallaxrer"))) == 1 + + +def test_lsamrot_retry_budget_resets_for_each_new_target(lsamrot): + _trip_safety_latch(lsamrot) + status = lsamrot.move(1.0, wait=True, timeout=10) + assert status.success + + # a fresh fault on a new target must be recovered from again -- the retry + # budget is not exhausted by the previous move. + _trip_safety_latch(lsamrot) + status = lsamrot.move(2.0, wait=True, timeout=10) + assert status.success + assert lsamrot.readback.get() == pytest.approx(2.0, abs=lsamrot.tolerance) + + +def test_lsamrot_persistent_fault_raises_after_exactly_one_retry(lsamrot, monkeypatch): + """A genuine hardware fault must not be silently retried forever: if the + error latches don't actually clear, the move must fail after exactly one + retry, not loop. + """ + reset_calls = [] + + def fake_reset(self): + reset_calls.append(1) + # do NOT clear allaxref -- simulates a fault that persists through + # the reset sequence, as a real unresolved hardware issue would. + + monkeypatch.setattr(LamniGalilController, "reset_axis_errors", fake_reset) + + _trip_safety_latch(lsamrot) + + with pytest.raises(GalilError): + lsamrot.move(1.0, wait=True, timeout=10) + + assert len(reset_calls) == 1 + + +def test_non_lsamrot_axis_is_not_auto_recovered(lsamx, monkeypatch): + """The shared allaxrer/caperr/allaxref latches must only be auto-reset for + lsamrot; other axes on the same controller should fail immediately, same + as before this feature existed. + """ + reset_calls = [] + monkeypatch.setattr( + LamniGalilController, + "reset_axis_errors", + lambda self: reset_calls.append(1), + ) + + _trip_safety_latch(lsamx) + + with pytest.raises(GalilError): + lsamx.move(1.0, wait=True, timeout=10) + + assert len(reset_calls) == 0