feat(LamNI): auto-retry lsamrot moves after a Galil safety-thread trip
CI for csaxs_bec / test (push) Successful in 1m51s
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:
@@ -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
|
||||
Reference in New Issue
Block a user