feat(LamNI): auto-retry lsamrot moves after a Galil safety-thread trip
CI for csaxs_bec / test (push) Successful in 1m51s

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 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-27 09:38:54 +02:00
co-authored by Claude Sonnet 5
parent 83f9a6bb59
commit 0b4a25c050
2 changed files with 198 additions and 8 deletions
+71 -8
View File
@@ -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
@@ -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