Add stage backlash correction

This commit is contained in:
Julian Stirling 2026-02-24 16:47:52 +00:00
parent 35819aa826
commit c2b784e606
6 changed files with 244 additions and 33 deletions

View file

@ -141,11 +141,15 @@ class CameraStageMapper(lt.Thing):
except lt.exceptions.InvocationCancelledError as e: except lt.exceptions.InvocationCancelledError as e:
self.logger.info("User cancelled the camera stage mapping calibration") self.logger.info("User cancelled the camera stage mapping calibration")
self.logger.info("Returning to starting position") self.logger.info("Returning to starting position")
self._stage.move_absolute(**starting_position, block_cancellation=True) self._stage.move_absolute(
**starting_position, block_cancellation=True, backlash_compensation=None
)
raise e raise e
except MappingError as e: except MappingError as e:
self.logger.info("Returning to starting position due to failed calibration") self.logger.info("Returning to starting position due to failed calibration")
self._stage.move_absolute(**starting_position, block_cancellation=True) self._stage.move_absolute(
**starting_position, block_cancellation=True, backlash_compensation=None
)
raise e raise e
result["move_history"] = recorded_move.history result["move_history"] = recorded_move.history
result["image_resolution"] = self._cam.capture_downsampled_array().shape[:2] result["image_resolution"] = self._cam.capture_downsampled_array().shape[:2]
@ -264,6 +268,7 @@ class CameraStageMapper(lt.Thing):
self._stage.move_relative( self._stage.move_relative(
**self.convert_image_to_stage_coordinates(x=x, y=y), **self.convert_image_to_stage_coordinates(x=x, y=y),
block_cancellation=False, block_cancellation=False,
backlash_compensation=None,
) )
@lt.action @lt.action

View file

@ -481,7 +481,9 @@ class SmartScanThing(lt.Thing):
if self._scan_data is not None: if self._scan_data is not None:
self._stage.move_absolute( self._stage.move_absolute(
**self.scan_data.starting_position, block_cancellation=True **self.scan_data.starting_position,
block_cancellation=True,
backlash_compensation=None,
) )
@property @property

View file

@ -11,6 +11,7 @@ As the object will be used as a context manager create the hardware connection i
from __future__ import annotations from __future__ import annotations
import enum
import queue import queue
import threading import threading
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
@ -32,6 +33,21 @@ class RedefinedBaseMovementError(RuntimeError):
""" """
class BacklashCompensation(enum.Enum):
"""The axes to apply backlash compensation to.
* MOVEMENT_AXES - Only the axes that are moving.
* ALL_AXES - All axes
* XY_ONLY - Only the x and y axes.
* Z_ONLY - The z-axis only.
"""
MOVEMENT_AXES = enum.auto()
ALL_AXES = enum.auto()
XY_ONLY = enum.auto()
Z_ONLY = enum.auto()
class JogCommand: class JogCommand:
"""A base class for jog operations.""" """A base class for jog operations."""
@ -102,7 +118,7 @@ class BaseStage(lt.Thing):
self._jog_thread: Optional[threading.Thread] = None self._jog_thread: Optional[threading.Thread] = None
self._hardware_position: Mapping[str, int] = dict.fromkeys(self._axis_names, 0) self._hardware_position: Mapping[str, int] = dict.fromkeys(self._axis_names, 0)
# Backlash state is a dict as we mutate it during moves. # Backlash state is a dict as we mutate it during moves.
self._backlash_state: dict[str, float] = dict.fromkeys(self._axis_names, -1) self._backlash_state: dict[str, float] = dict.fromkeys(self._axis_names, 0)
# This must be the last thing the function does in case it is caught in a try. # This must be the last thing the function does in case it is caught in a try.
if ( if (
@ -149,15 +165,13 @@ class BaseStage(lt.Thing):
pos_before = dict(self.position) pos_before = dict(self.position)
self._hardware_update_position() self._hardware_update_position()
for axis in self._axis_names: for axis in self._axis_names:
# The state delta is should be 2 for a move equal to backlash steps. # The state delta is should be 1 for a move equal to backlash steps.
# moving as this will move the state from -1 (fully one direction) to # moving as this will move the state from 0 (fully disengaged) to
# 1, fully the other. # 1, fully the other.
delta = ( delta = (self.position[axis] - pos_before[axis]) / self.backlash_steps[axis]
2 * (self.position[axis] - pos_before[axis]) / self.backlash_steps[axis] # apply value and clamp to within range from 0 to 1
)
# apply value and clamp to within range from -1 to 1
self._backlash_state[axis] = max( self._backlash_state[axis] = max(
-1, min(1, self._backlash_state[axis] + delta) 0, min(1, self._backlash_state[axis] + delta)
) )
def _hardware_update_position(self) -> None: def _hardware_update_position(self) -> None:
@ -223,12 +237,25 @@ class BaseStage(lt.Thing):
self.axis_inverted = direction self.axis_inverted = direction
@lt.action @lt.action
def move_relative(self, block_cancellation: bool = False, **kwargs: int) -> None: def move_relative(
self,
block_cancellation: bool = False,
backlash_compensation: Optional[BacklashCompensation] = None,
**kwargs: int,
) -> None:
"""Make a relative move. Keyword arguments should be axis names.""" """Make a relative move. Keyword arguments should be axis names."""
self._hardware_move_relative( if backlash_compensation is not None:
block_cancellation=block_cancellation, self._move_with_backlash_correction(
**self._apply_axis_direction(kwargs), block_cancellation=block_cancellation,
) relative=True,
backlash_compensation=backlash_compensation,
**kwargs,
)
else:
self._hardware_move_relative(
block_cancellation=block_cancellation,
**self._apply_axis_direction(kwargs),
)
def _hardware_move_relative( def _hardware_move_relative(
self, block_cancellation: bool = False, **kwargs: int self, block_cancellation: bool = False, **kwargs: int
@ -242,12 +269,25 @@ class BaseStage(lt.Thing):
) )
@lt.action @lt.action
def move_absolute(self, block_cancellation: bool = False, **kwargs: int) -> None: def move_absolute(
self,
block_cancellation: bool = False,
backlash_compensation: Optional[BacklashCompensation] = None,
**kwargs: int,
) -> None:
"""Make an absolute move. Keyword arguments should be axis names.""" """Make an absolute move. Keyword arguments should be axis names."""
self._hardware_move_absolute( if backlash_compensation is not None:
block_cancellation=block_cancellation, self._move_with_backlash_correction(
**self._apply_axis_direction(kwargs), block_cancellation=block_cancellation,
) relative=False,
backlash_compensation=backlash_compensation,
**kwargs,
)
else:
self._hardware_move_absolute(
block_cancellation=block_cancellation,
**self._apply_axis_direction(kwargs),
)
def _hardware_move_absolute( def _hardware_move_absolute(
self, self,
@ -262,6 +302,78 @@ class BaseStage(lt.Thing):
"StageThings must define their own _hardware_move_absolute method" "StageThings must define their own _hardware_move_absolute method"
) )
def _move_with_backlash_correction(
self,
block_cancellation: bool,
relative: bool,
backlash_compensation: BacklashCompensation,
**kwargs: int,
) -> None:
"""Make a movement with backlash correction.
:param block_cancellation: True to prevent the move being cancelled.
:param relative: True if the kwargs are relative moves. False if they are
absolute.
:param backlash_compensation: A BacklashCompensation which sets which axes to
apply backalsh compensation to.
:param kwargs: A mapping of axis name to integer for the movement.
"""
# Calculate relative movement
if relative:
move = {ax: kwargs.get(ax, 0) for ax in self._axis_names}
else:
move = {ax: kwargs.get(ax, pos) - pos for ax, pos in self.position.items()}
# Depending on the backlash method decide which axes to check
check_axes: tuple[str, ...]
match backlash_compensation:
case BacklashCompensation.MOVEMENT_AXES:
check_axes = tuple(ax for ax, move in move.items() if move != 0)
case BacklashCompensation.ALL_AXES:
check_axes = self._axis_names
case BacklashCompensation.XY_ONLY:
check_axes = ("x", "y")
case BacklashCompensation.Z_ONLY:
check_axes = ("z",)
case _:
raise ValueError(
f"Unknown backlash compensation method {self._axis_names}"
)
# Check the backlash state at the end of the move, no need to clip to range of
# 0-1
final_state = {
ax: self._backlash_state[ax] + move[ax] / self.backlash_steps[ax]
for ax in check_axes
}
# If the state is 1 or greater the motors are engaged in the preferred
# direction, if not a correction move is needed.
correction = {
ax: self.backlash_steps[ax]
for ax, state in final_state.items()
if state < 1
}
if correction:
# If there is a correction to apply move in two goes
first_move = {
ax: move[ax] - correction.get(ax, 0) for ax in self._axis_names
}
self._hardware_move_relative(
block_cancellation=block_cancellation,
**self._apply_axis_direction(first_move),
)
self._hardware_move_relative(
block_cancellation=block_cancellation,
**self._apply_axis_direction(correction),
)
else:
# Else just complete the relative move
self._hardware_move_relative(
block_cancellation=block_cancellation,
**self._apply_axis_direction(move),
)
def _hardware_start_move_relative(self, displacement: Sequence[int]) -> None: def _hardware_start_move_relative(self, displacement: Sequence[int]) -> None:
"""Start a relative move.""" """Start a relative move."""
raise NotImplementedError( raise NotImplementedError(

View file

@ -267,7 +267,9 @@ class RangeofMotionThing(lt.Thing):
self._rom_data.stage_coords[-1] = self._stage.position self._rom_data.stage_coords[-1] = self._stage.position
finally: finally:
self._stage.move_absolute(**starting_position, block_cancellation=True) self._stage.move_absolute(
**starting_position, block_cancellation=True, backlash_compensation=None
)
def _recentre_axis(self, axis: Literal["x", "y"]) -> None: def _recentre_axis(self, axis: Literal["x", "y"]) -> None:
"""Recentre a single axis. """Recentre a single axis.
@ -327,7 +329,9 @@ class RangeofMotionThing(lt.Thing):
# if the distance is less than 1 big step away then move to it and exit # if the distance is less than 1 big step away then move to it and exit
if abs(img_perc) < BIG_STEP: if abs(img_perc) < BIG_STEP:
self.logger.info(f"Estimated centre of {axis}-axis is {estimate[axis]}") self.logger.info(f"Estimated centre of {axis}-axis is {estimate[axis]}")
self._stage.move_absolute(**estimate, block_cancellation=False) self._stage.move_absolute(
**estimate, block_cancellation=False, backlash_compensation=None
)
# Note the second return, the direction, is meaningless here. # Note the second return, the direction, is meaningless here.
return True, 1 return True, 1
self.logger.info( self.logger.info(

View file

@ -4,6 +4,7 @@ import itertools
import logging import logging
import threading import threading
import time import time
from dataclasses import dataclass
import pytest import pytest
from hypothesis import HealthCheck, given, settings from hypothesis import HealthCheck, given, settings
@ -14,6 +15,7 @@ from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
from openflexure_microscope_server.things.stage import ( from openflexure_microscope_server.things.stage import (
BacklashCompensation,
BaseStage, BaseStage,
JogCommand, JogCommand,
JogQueue, JogQueue,
@ -313,33 +315,117 @@ def test_backlash_state(dummy_stage):
"""Test that the backlash state updates as the stage moves.""" """Test that the backlash state updates as the stage moves."""
# Use with to start the movement thread. # Use with to start the movement thread.
with dummy_stage: with dummy_stage:
assert dummy_stage._backlash_state == {"x": -1, "y": -1, "z": -1} assert dummy_stage._backlash_state == {"x": 0, "y": 0, "z": 0}
assert dummy_stage.backlash_steps == {"x": 200, "y": 200, "z": 200} assert dummy_stage.backlash_steps == {"x": 200, "y": 200, "z": 200}
# Move 100 steps to centre of x backlash range # Move 100 steps to centre of x backlash range
dummy_stage.move_relative(x=100) dummy_stage.move_relative(x=100)
assert dummy_stage._backlash_state == {"x": 0, "y": -1, "z": -1} assert dummy_stage._backlash_state == {"x": 0.5, "y": 0, "z": 0}
# Move 50 more steps to halfway between centre and "engaged" # Move 50 more steps to halfway between centre and "engaged"
dummy_stage.move_relative(x=50) dummy_stage.move_relative(x=50)
assert dummy_stage._backlash_state == {"x": 0.5, "y": -1, "z": -1} assert dummy_stage._backlash_state == {"x": 0.75, "y": 0, "z": 0}
# Move back 50 steps to the centre # Move back 50 steps to the centre
dummy_stage.move_relative(x=-50) dummy_stage.move_relative(x=-50)
assert dummy_stage._backlash_state == {"x": 0, "y": -1, "z": -1} assert dummy_stage._backlash_state == {"x": 0.5, "y": 0, "z": 0}
# Move 500 steps, now fully engaged # Move 500 steps, now fully engaged
dummy_stage.move_relative(x=500) dummy_stage.move_relative(x=500)
assert dummy_stage._backlash_state == {"x": 1, "y": -1, "z": -1} assert dummy_stage._backlash_state == {"x": 1, "y": 0, "z": 0}
# Move back 100 steps in the centre of backash range again. # Move back 100 steps in the centre of backash range again.
dummy_stage.move_relative(x=-100) dummy_stage.move_relative(x=-100)
assert dummy_stage._backlash_state == {"x": 0, "y": -1, "z": -1} assert dummy_stage._backlash_state == {"x": 0.5, "y": 0, "z": 0}
# Move forward a load to re-engage # Move forward a load to re-engage
dummy_stage.move_relative(x=500) dummy_stage.move_relative(x=500)
assert dummy_stage._backlash_state == {"x": 1, "y": -1, "z": -1} assert dummy_stage._backlash_state == {"x": 1, "y": 0, "z": 0}
# Move back 200 to be fully at the far side of the backlash range # Move back 200 to be fully at the far side of the backlash range
dummy_stage.move_relative(x=-200) dummy_stage.move_relative(x=-200)
assert dummy_stage._backlash_state == {"x": -1, "y": -1, "z": -1} assert dummy_stage._backlash_state == {"x": 0, "y": 0, "z": 0}
# Further motion changes nothing in state # Further motion changes nothing in state
dummy_stage.move_relative(x=-200) dummy_stage.move_relative(x=-200)
assert dummy_stage._backlash_state == {"x": -1, "y": -1, "z": -1} assert dummy_stage._backlash_state == {"x": 0, "y": 0, "z": 0}
@dataclass()
class BacklashMoveTestCase:
"""Structured information for each test of backlash compensation."""
position: dict[str, float]
backlash_state: dict[str, float]
backlash_steps: dict[str, int]
movement: dict[str, int]
relative: bool
backlash_compensation: BacklashCompensation
result: list[dict[str, int]]
@property
def rel_move(self):
"""The relative movement."""
if self.relative:
return self.movement
return {ax: self.movement[ax] - self.position[ax] for ax in self.position}
def validate(self):
"""Check that the total movement in the result is the relative movement requested."""
total = {ax: sum(move[ax] for move in self.result) for ax in self.position}
expected = self.rel_move
assert total == expected
BACKLASH_TEST_MOVEMENTS = [
# Simple test case moving 1 step in x against backlash direction
BacklashMoveTestCase(
position={"x": 0, "y": 0, "z": 0},
backlash_state={"x": 0, "y": 0, "z": 0},
backlash_steps={"x": 200, "y": 200, "z": 200},
movement={"x": -1, "y": 0, "z": 0},
relative=True,
backlash_compensation=BacklashCompensation.MOVEMENT_AXES,
# There are 2 moves as it is against the direction
result=[{"x": -201, "y": 0, "z": 0}, {"x": 200, "y": 0, "z": 0}],
),
]
@pytest.mark.parametrize("test_case", BACKLASH_TEST_MOVEMENTS)
def test_backlash_compensated_moves(test_case, dummy_stage, mocker):
"""Test that backlash correction is applied correctly.
See each test case for an explanation.
"""
test_case.validate()
mock_hw_move_rel = mocker.patch.object(dummy_stage, "_hardware_move_relative")
# Set position
dummy_stage._hardware_position = dummy_stage._apply_axis_direction(
test_case.position
)
# double check position is set
assert dummy_stage.position == test_case.position
dummy_stage._backlash_state = test_case.backlash_state
dummy_stage.backlash_steps = test_case.backlash_steps
if test_case.relative:
dummy_stage.move_relative(
**test_case.movement,
block_cancellation=False,
backlash_compensation=test_case.backlash_compensation,
)
else:
dummy_stage.move_absolute(
**test_case.movement,
block_cancellation=False,
backlash_compensation=test_case.backlash_compensation,
)
assert mock_hw_move_rel.call_count == len(test_case.result)
for res, call in zip(
test_case.result, mock_hw_move_rel.call_args_list, strict=True
):
applied = call.kwargs
# Remove the block_cancellation argument and add in any axes that are 0
applied.pop("block_cancellation")
applied = {ax: applied.get(ax, 0) for ax in res}
# Check the expected result was applied for each move.
assert applied == dummy_stage._apply_axis_direction(res)
def test_thing_description_equivalence(dummy_stage, mocker): def test_thing_description_equivalence(dummy_stage, mocker):

View file

@ -490,7 +490,9 @@ def test_move_until_edge_error(rom_thing, mocker):
# However the "finally" should have executed returning to the starting position # However the "finally" should have executed returning to the starting position
assert rom_thing._stage.move_absolute.call_count == 1 assert rom_thing._stage.move_absolute.call_count == 1
abs_move_kwargs = rom_thing._stage.move_absolute.call_args.kwargs abs_move_kwargs = rom_thing._stage.move_absolute.call_args.kwargs
expected_abs_move_kwargs = dict(**mock_position_dict, block_cancellation=True) expected_abs_move_kwargs = dict(
**mock_position_dict, block_cancellation=True, backlash_compensation=None
)
assert abs_move_kwargs == expected_abs_move_kwargs assert abs_move_kwargs == expected_abs_move_kwargs