Merge branch 'backlash-in-stage' into 'v3'

Backlash correction in base stage class

Closes #420

See merge request openflexure/openflexure-microscope-server!509
This commit is contained in:
Julian Stirling 2026-03-02 19:10:46 +00:00
commit dc2bc77dbb
10 changed files with 490 additions and 41 deletions

View file

@ -22,7 +22,7 @@ import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
from .camera import BaseCamera, CaptureParams from .camera import BaseCamera, CaptureParams
from .stage import BaseStage from .stage import BacklashCompensation, BaseStage
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
MIN_TEST_IMAGE_COUNT = 3 MIN_TEST_IMAGE_COUNT = 3
@ -458,14 +458,17 @@ class AutofocusThing(lt.Thing):
up to 10 times. up to 10 times.
""" """
attempt = 0 attempt = 0
backlash = 200
with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor: with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor:
while attempt < 10: while attempt < 10:
attempt += 1 attempt += 1
if start == "centre": if start == "centre":
self._stage.move_relative(x=0, y=0, z=-int(backlash + dz / 2)) self._stage.move_relative(
self._stage.move_relative(x=0, y=0, z=backlash) x=0,
y=0,
z=-int(dz / 2),
backlash_compensation=BacklashCompensation.Z_ONLY,
)
# Always start centrally for future runs # Always start centrally for future runs
start = "centre" start = "centre"
@ -480,8 +483,10 @@ class AutofocusThing(lt.Thing):
target_max = np.max(heights) - dz / 5 target_max = np.max(heights) - dz / 5
# move to the peak # move to the peak
self._stage.move_absolute(z=peak_height - backlash) self._stage.move_absolute(
self._stage.move_absolute(z=peak_height) z=peak_height,
backlash_compensation=BacklashCompensation.Z_ONLY,
)
if target_min < peak_height < target_max: if target_min < peak_height < target_max:
# If it is within the target range then return # If it is within the target range then return
@ -622,12 +627,10 @@ class AutofocusThing(lt.Thing):
# Better to start too low and take too many images than too high and need to refocus # Better to start too low and take too many images than too high and need to refocus
self._stage.move_relative( self._stage.move_relative(
z=-int( z=-int(
stack_parameters.steps_undershoot stack_parameters.steps_undershoot + stack_parameters.stack_z_range / 2
+ stack_parameters.backlash_correction ),
+ stack_parameters.stack_z_range / 2 backlash_compensation=BacklashCompensation.Z_ONLY,
)
) )
self._stage.move_relative(z=stack_parameters.backlash_correction)
captures: list[CaptureInfo] = [] captures: list[CaptureInfo] = []
# Always check for focus using the the last `min_images_to_test` in the # Always check for focus using the the last `min_images_to_test` in the

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

@ -37,7 +37,7 @@ from openflexure_microscope_server.utilities import coerce_thing_selector
# Things # Things
from .camera import BaseCamera from .camera import BaseCamera
from .scan_workflows import ScanWorkflow from .scan_workflows import ScanWorkflow
from .stage import BaseStage from .stage import BacklashCompensation, BaseStage
T = TypeVar("T") T = TypeVar("T")
P = ParamSpec("P") P = ParamSpec("P")
@ -311,6 +311,7 @@ class SmartScanThing(lt.Thing):
x=next_point[0], x=next_point[0],
y=next_point[1], y=next_point[1],
z=z_estimate, z=z_estimate,
backlash_compensation=BacklashCompensation.XY_ONLY,
) )
return (next_point[0], next_point[1], z_estimate) return (next_point[0], next_point[1], z_estimate)
@ -481,7 +482,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,25 @@ class RedefinedBaseMovementError(RuntimeError):
""" """
class BacklashCompensation(enum.Enum):
"""The axes to apply backlash compensation to.
This will perform a correction step - if necessary - on every
chosen axis, even if that axis position isn't set to move in
the calling movement
* 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."""
@ -101,6 +121,8 @@ class BaseStage(lt.Thing):
self._jog_queue = JogQueue() self._jog_queue = JogQueue()
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.
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 (
@ -124,6 +146,17 @@ class BaseStage(lt.Thing):
"""Current position of the stage.""" """Current position of the stage."""
return self._apply_axis_direction(self._hardware_position) return self._apply_axis_direction(self._hardware_position)
backlash_steps: dict[str, int] = lt.setting(
default={"x": 200, "y": 200, "z": 200}, readonly=True
)
"""The number of steps to elimate backlash. The sign sets the direction.
A positive number sets the direction of the second move in a backlash correction.
For example, consider z=200. If the previous move was more than +200 in z, no
correction is needed. If the last movement was negative then a move of -200,
followed by +200 will wipe out backlash.
"""
moving: bool = lt.property(default=False, readonly=True) moving: bool = lt.property(default=False, readonly=True)
"""Whether the stage is in motion.""" """Whether the stage is in motion."""
@ -133,9 +166,27 @@ class BaseStage(lt.Thing):
"""Used to convert coordinates between the program frame and the hardware frame.""" """Used to convert coordinates between the program frame and the hardware frame."""
def update_position(self) -> None: def update_position(self) -> None:
"""Read position from the stage and set the corresponding property.""" """Update the position property from the stage."""
# Copy the position before the move.
pos_before = dict(self.position)
self._hardware_update_position()
for axis in self._axis_names:
# The state delta should be 1 for a move equal to backlash steps.
# moving as this will move the state from 0 (fully disengaged) to
# 1, fully the other.
delta = (self.position[axis] - pos_before[axis]) / self.backlash_steps[axis]
# apply value and clamp to within range from 0 to 1
self._backlash_state[axis] = max(
0, min(1, self._backlash_state[axis] + delta)
)
def _hardware_update_position(self) -> None:
"""Read position from the stage and set internal attribute _hardware_position.
_hardware_position should only be set in this function.
"""
raise NotImplementedError( raise NotImplementedError(
"StageThings must define their own update_position method" "StageThings must define their own _hardware_update_position method"
) )
@overload @overload
@ -192,12 +243,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
@ -211,12 +275,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,
@ -231,6 +308,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 {backlash_compensation}"
)
# 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

@ -81,9 +81,12 @@ class DummyStage(BaseStage):
) )
"""Used to convert coordinates between the program frame and the hardware frame.""" """Used to convert coordinates between the program frame and the hardware frame."""
def update_position(self) -> None: def _hardware_update_position(self) -> None:
"""Read position from the stage and set the corresponding property.""" """Read position from the stage and set internal attribute _hardware_position.
pass
_hardware_position should only be set in this function.
"""
self._hardware_position = self.instantaneous_position
def _set_pos_during_move( def _set_pos_during_move(
self, displacement: Sequence[int], fraction_complete: float self, displacement: Sequence[int], fraction_complete: float
@ -109,7 +112,7 @@ class DummyStage(BaseStage):
# If there is a new movement. # If there is a new movement.
if movement_request is not None: if movement_request is not None:
# Set the hardware position from instantaneous before continuing. # Set the hardware position from instantaneous before continuing.
self._hardware_position = self.instantaneous_position self.update_position()
if movement_request.displacement is None: if movement_request.displacement is None:
# If it is a stop command, stop moving # If it is a stop command, stop moving
@ -138,7 +141,7 @@ class DummyStage(BaseStage):
# move is complete # move is complete
fraction_complete = 1.0 fraction_complete = 1.0
self._set_pos_during_move(displacement, fraction_complete) self._set_pos_during_move(displacement, fraction_complete)
self._hardware_position = self.instantaneous_position self.update_position()
self._movement_ongoing = False self._movement_ongoing = False
def _check_for_new_move_request(self) -> Optional[DummyStageMovement]: def _check_for_new_move_request(self) -> Optional[DummyStageMovement]:
@ -233,5 +236,5 @@ class DummyStage(BaseStage):
stage. stage.
""" """
with self._hardware_lock: with self._hardware_lock:
self._hardware_position = dict.fromkeys(self.axis_names, 0) self.instantaneous_position = dict.fromkeys(self.axis_names, 0)
self.instantaneous_position = self._hardware_position self.update_position()

View file

@ -80,8 +80,11 @@ class SangaboardThing(BaseStage):
) )
"""Used to convert coordinates between the program frame and the hardware frame.""" """Used to convert coordinates between the program frame and the hardware frame."""
def update_position(self) -> None: def _hardware_update_position(self) -> None:
"""Read position from the stage and set the corresponding property.""" """Read position from the stage and set internal attribute _hardware_position.
_hardware_position should only be set in this function.
"""
with self._hardware_lock: with self._hardware_lock:
self._hardware_position = dict( self._hardware_position = dict(
zip(self.axis_names, self._sangaboard.position, strict=True) zip(self.axis_names, self._sangaboard.position, strict=True)

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

@ -60,6 +60,8 @@ def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes,
def adjust_pos(**kwargs: int) -> None: def adjust_pos(**kwargs: int) -> None:
"""Move relative should update position. So make a side effect for the mock.""" """Move relative should update position. So make a side effect for the mock."""
# Remove backlash compensation from the dict if it exists.
kwargs.pop("backlash_compensation", None)
for axis, value in kwargs.items(): for axis, value in kwargs.items():
autofocus_thing._stage.position[axis] += value autofocus_thing._stage.position[axis] += value

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,
@ -44,7 +46,7 @@ def test_not_implemented_methods():
"""Check all methods a child class should implement raise NotImplemented.""" """Check all methods a child class should implement raise NotImplemented."""
stage = create_thing_without_server(BaseStage) stage = create_thing_without_server(BaseStage)
methods_and_args = [ methods_and_args = [
(stage.update_position, ()), (stage._hardware_update_position, ()),
(stage._hardware_move_relative, ()), (stage._hardware_move_relative, ()),
(stage._hardware_move_absolute, ()), (stage._hardware_move_absolute, ()),
(stage._hardware_start_move_relative, ((1, 2, 3),)), (stage._hardware_start_move_relative, ((1, 2, 3),)),
@ -309,6 +311,279 @@ def test_move_absolute(dummy_stage, path):
) )
def test_backlash_state(dummy_stage):
"""Test that the backlash state updates as the stage moves."""
# Use with to start the movement thread.
with dummy_stage:
assert dummy_stage._backlash_state == {"x": 0, "y": 0, "z": 0}
assert dummy_stage.backlash_steps == {"x": 200, "y": 200, "z": 200}
# Move 100 steps to centre of x backlash range
dummy_stage.move_relative(x=100)
assert dummy_stage._backlash_state == {"x": 0.5, "y": 0, "z": 0}
# Move 50 more steps to halfway between centre and "engaged"
dummy_stage.move_relative(x=50)
assert dummy_stage._backlash_state == {"x": 0.75, "y": 0, "z": 0}
# Move back 50 steps to the centre
dummy_stage.move_relative(x=-50)
assert dummy_stage._backlash_state == {"x": 0.5, "y": 0, "z": 0}
# Move 500 steps, now fully engaged
dummy_stage.move_relative(x=500)
assert dummy_stage._backlash_state == {"x": 1, "y": 0, "z": 0}
# Move back 100 steps in the centre of backash range again.
dummy_stage.move_relative(x=-100)
assert dummy_stage._backlash_state == {"x": 0.5, "y": 0, "z": 0}
# Move forward a load to re-engage
dummy_stage.move_relative(x=500)
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
dummy_stage.move_relative(x=-200)
assert dummy_stage._backlash_state == {"x": 0, "y": 0, "z": 0}
# Further motion changes nothing in state
dummy_stage.move_relative(x=-200)
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.get(ax, pos) - pos for ax, pos in self.position.items()
}
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 = [
# Moving 1 step in x against preferred movement 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}],
),
# Moving 1 step in the preferred movement 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 1 step doesn't move the backlash state to engaged.
result=[{"x": -199, "y": 0, "z": 0}, {"x": 200, "y": 0, "z": 0}],
),
# Moving 1 step in the preferred movement direction (already engaged)
BacklashMoveTestCase(
position={"x": 0, "y": 0, "z": 0},
backlash_state={"x": 1, "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,
# Already engaged
result=[{"x": 1, "y": 0, "z": 0}],
),
# Moving 100 steps in the preferred movement direction partially engaged
BacklashMoveTestCase(
position={"x": 0, "y": 0, "z": 0},
backlash_state={"x": 0.7, "y": 0, "z": 0},
backlash_steps={"x": 200, "y": 200, "z": 200},
movement={"x": 100, "y": 0, "z": 0},
relative=True,
backlash_compensation=BacklashCompensation.MOVEMENT_AXES,
# Already 70% to engaged 100 steps creates engagement
result=[{"x": 100, "y": 0, "z": 0}],
),
# Moving 100 steps in the preferred movement direction partially engaged (again)
BacklashMoveTestCase(
position={"x": 0, "y": 0, "z": 0},
backlash_state={"x": 0.3, "y": 0, "z": 0},
backlash_steps={"x": 200, "y": 200, "z": 200},
movement={"x": 100, "y": 0, "z": 0},
relative=True,
backlash_compensation=BacklashCompensation.MOVEMENT_AXES,
# Only 30% to engaged 100 steps doesn't engage, so 2 moves
result=[{"x": -100, "y": 0, "z": 0}, {"x": 200, "y": 0, "z": 0}],
),
## Absolute move versions of the same tests
# Moving 1 step in x against preferred movement direction
BacklashMoveTestCase(
position={"x": 123, "y": 456, "z": 789},
backlash_state={"x": 0, "y": 0, "z": 0},
backlash_steps={"x": 200, "y": 200, "z": 200},
movement={"x": 122},
relative=False,
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}],
),
# Moving 1 step in the preferred movement direction
BacklashMoveTestCase(
position={"x": 123, "y": 456, "z": 789},
backlash_state={"x": 0, "y": 0, "z": 0},
backlash_steps={"x": 200, "y": 200, "z": 200},
movement={"x": 124},
relative=False,
backlash_compensation=BacklashCompensation.MOVEMENT_AXES,
# There are 2 moves as 1 step doesn't move the backlash state to engaged.
result=[{"x": -199, "y": 0, "z": 0}, {"x": 200, "y": 0, "z": 0}],
),
# Moving 1 step in the preferred movement direction (already engaged)
BacklashMoveTestCase(
position={"x": 123, "y": 456, "z": 789},
backlash_state={"x": 1, "y": 0, "z": 0},
backlash_steps={"x": 200, "y": 200, "z": 200},
movement={"x": 124},
relative=False,
backlash_compensation=BacklashCompensation.MOVEMENT_AXES,
# Already engaged
result=[{"x": 1, "y": 0, "z": 0}],
),
# Moving 100 steps in the preferred movement direction partially engaged
BacklashMoveTestCase(
position={"x": 123, "y": 456, "z": 789},
backlash_state={"x": 0.7, "y": 0, "z": 0},
backlash_steps={"x": 200, "y": 200, "z": 200},
movement={"x": 223},
relative=False,
backlash_compensation=BacklashCompensation.MOVEMENT_AXES,
# Already 70% to engaged 100 steps creates engagement
result=[{"x": 100, "y": 0, "z": 0}],
),
# Moving 100 steps in the preferred movement direction partially engaged (again)
BacklashMoveTestCase(
position={"x": 123, "y": 456, "z": 789},
backlash_state={"x": 0.3, "y": 0, "z": 0},
backlash_steps={"x": 200, "y": 200, "z": 200},
movement={"x": 223},
relative=False,
backlash_compensation=BacklashCompensation.MOVEMENT_AXES,
# Only 30% to engaged 100 steps doesn't engage, so 2 moves
result=[{"x": -100, "y": 0, "z": 0}, {"x": 200, "y": 0, "z": 0}],
),
## Checking axes correct even when not moving if asked to
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": 0, "y": 0, "z": 0},
relative=True,
backlash_compensation=BacklashCompensation.ALL_AXES,
# All move forward and back
result=[{"x": -200, "y": -200, "z": -200}, {"x": 200, "y": 200, "z": 200}],
),
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": 0, "y": 0, "z": 0},
relative=True,
backlash_compensation=BacklashCompensation.XY_ONLY,
# x and y correct
result=[{"x": -200, "y": -200, "z": 0}, {"x": 200, "y": 200, "z": 0}],
),
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": 0, "y": 0, "z": 0},
relative=True,
backlash_compensation=BacklashCompensation.Z_ONLY,
# z corrects
result=[{"x": 0, "y": 0, "z": -200}, {"x": 0, "y": 0, "z": 200}],
),
## Checking reversed backlash directions (on y)
# In preferred direction
BacklashMoveTestCase(
position={"x": 0, "y": 0, "z": 0},
backlash_state={"x": 0, "y": 0, "z": 0},
backlash_steps={"x": 200, "y": -150, "z": 200},
movement={"x": 0, "y": -300, "z": 0},
relative=True,
backlash_compensation=BacklashCompensation.MOVEMENT_AXES,
# Only 1 move as preferred direction
result=[{"x": 0, "y": -300, "z": 0}],
),
# against preferred direction
BacklashMoveTestCase(
position={"x": 0, "y": 0, "z": 0},
backlash_state={"x": 0, "y": 0, "z": 0},
backlash_steps={"x": 200, "y": -150, "z": 200},
movement={"x": 0, "y": 300, "z": 0},
relative=True,
backlash_compensation=BacklashCompensation.MOVEMENT_AXES,
# 2 moves as against preferred direction
result=[{"x": 0, "y": 450, "z": 0}, {"x": 0, "y": -150, "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
)
# 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):
"""Stop extra actions getting added to child classes without explicit approval. """Stop extra actions getting added to child classes without explicit approval.

View file

@ -487,10 +487,12 @@ def test_move_until_edge_error(rom_thing, mocker):
# Error should be raised even though it is in the Try: # Error should be raised even though it is in the Try:
with pytest.raises(RuntimeError, match="Mock"): with pytest.raises(RuntimeError, match="Mock"):
rom_thing._move_until_edge("y", direction=-1) rom_thing._move_until_edge("y", direction=-1)
# 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