From 4d95cf755f059825efa75e11fce50f192a33a626 Mon Sep 17 00:00:00 2001 From: Mirko Holler Date: Thu, 29 Oct 2026 12:42:36 +0100 Subject: [PATCH] fix(aerotech): move settles only after holding the tolerance for 0.1 s After a hard stop (5 mm/s, 500 mm/s^2) X rings at ~10 Hz with ~20 um. The single position error check caught a zero crossing (-0.19 um), the move was declared settled 3.5 um off the target and failed (premove of a scan aborted). Now position error and distance to the target must stay within the tolerance for in_position_hold (0.1 s) without interruption. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M8zoDyJxbBLmjZCEPUo5FM --- csaxs_bec/devices/aerotech/a3200.py | 31 ++++++++++++++++++++++++----- tests/tests_devices/test_a3200.py | 12 +++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/csaxs_bec/devices/aerotech/a3200.py b/csaxs_bec/devices/aerotech/a3200.py index a7b1e7ba..b3eb69b8 100644 --- a/csaxs_bec/devices/aerotech/a3200.py +++ b/csaxs_bec/devices/aerotech/a3200.py @@ -524,6 +524,9 @@ class A3200Motor(Device, PositionerBase): position error to settle within tolerance before the position is compared with the target. The controller's In Position status is not used: its InPositionDistance can be tighter than the axis settles (X: 10 nm). + in_position_hold: position error and distance to the target must stay within + tolerance this long (s) before the move counts as settled. Default 0.1 s: after + a hard stop X rings at ~10 Hz, a single reading can be a zero crossing. acceleration: ramp rate for moves in user units/s^2; None keeps the controller's ramp settings (DefaultRampRate). max_scan_velocity: highest velocity a continuous scan may set on this axis, in user @@ -580,6 +583,7 @@ class A3200Motor(Device, PositionerBase): egu="mm", tolerance=0.001, in_position_timeout=2.0, + in_position_hold=0.1, require_homed=True, acceleration=None, max_scan_velocity=None, @@ -598,6 +602,7 @@ class A3200Motor(Device, PositionerBase): self.sign = sign self.tolerance = tolerance self.in_position_timeout = in_position_timeout + self.in_position_hold = in_position_hold self.require_homed = require_homed self.pso_input = pso_input self.pso_counts_divider = pso_counts_divider @@ -696,13 +701,29 @@ class A3200Motor(Device, PositionerBase): logger.warning(f"{self.name} stopped with faults {faults}.") self._done_moving(success=not faults) return - # the profile is done, the axis may still be settling + # the profile is done, the axis may still be settling: after a hard stop X rings + # at ~10 Hz with ~20 um, so a single small position error can be a zero crossing. + # Settled = position error and distance to the target within the tolerance for + # in_position_hold without interruption. deadline = time.time() + self.in_position_timeout - while abs(self.controller.position_error(self.axis_Id)) > self.tolerance: - if time.time() > deadline: + settled_since = None + while True: + now = time.time() + in_tolerance = ( + abs(self.controller.position_error(self.axis_Id)) <= self.tolerance + and abs(self.readback.get() - position) <= self.tolerance + ) + if not in_tolerance: + settled_since = None + elif settled_since is None: + settled_since = now + if settled_since is not None and now - settled_since >= self.in_position_hold: + break + if now > deadline: logger.warning( - f"{self.name}: position error not within {self.tolerance}" - f" {self.egu} {self.in_position_timeout} s after the end of the move." + f"{self.name}: not settled within {self.tolerance} {self.egu} for" + f" {self.in_position_hold} s, {self.in_position_timeout} s after the end" + " of the move." ) break time.sleep(0.02) diff --git a/tests/tests_devices/test_a3200.py b/tests/tests_devices/test_a3200.py index a5f8c28a..e69a744f 100644 --- a/tests/tests_devices/test_a3200.py +++ b/tests/tests_devices/test_a3200.py @@ -377,6 +377,18 @@ def test_move_waits_for_position_error_within_tolerance(): assert motor.controller.position_error("X") == 0.0 +def test_move_does_not_settle_on_a_zero_crossing(): + # ringing after the stop: one reading within tolerance, then out again, then settled + motor = _sim_motor(velocity=100.0, tolerance=0.001, in_position_hold=0.1) + errors = iter([0.0] + [0.01] * 5) + motor.controller.position_error = lambda axis: next(errors, 0.0) + start = time.time() + status = motor.move(1.0, wait=True) + # 5 readings out of tolerance (~0.1 s), then held for 0.1 s + assert time.time() - start >= 0.2 + assert status.success + + def test_move_ignores_in_position_tighter_than_tolerance(): # settled within tolerance, but outside the controller's InPositionDistance motor = _sim_motor(velocity=100.0, tolerance=0.001)