diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 0ee8d32b..909ff229 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -22,7 +22,7 @@ import labthings_fastapi as lt from labthings_fastapi.types.numpy import NDArray from .camera import BaseCamera, CaptureParams -from .stage import BaseStage +from .stage import BacklashCompensation, BaseStage LOGGER = logging.getLogger(__name__) MIN_TEST_IMAGE_COUNT = 3 @@ -458,14 +458,17 @@ class AutofocusThing(lt.Thing): up to 10 times. """ attempt = 0 - backlash = 200 with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor: while attempt < 10: attempt += 1 if start == "centre": - self._stage.move_relative(x=0, y=0, z=-int(backlash + dz / 2)) - self._stage.move_relative(x=0, y=0, z=backlash) + self._stage.move_relative( + x=0, + y=0, + z=-int(dz / 2), + backlash_compensation=BacklashCompensation.Z_ONLY, + ) # Always start centrally for future runs start = "centre" @@ -480,8 +483,10 @@ class AutofocusThing(lt.Thing): target_max = np.max(heights) - dz / 5 # move to the peak - self._stage.move_absolute(z=peak_height - backlash) - self._stage.move_absolute(z=peak_height) + self._stage.move_absolute( + z=peak_height, + backlash_compensation=BacklashCompensation.Z_ONLY, + ) if target_min < peak_height < target_max: # 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 self._stage.move_relative( z=-int( - stack_parameters.steps_undershoot - + stack_parameters.backlash_correction - + stack_parameters.stack_z_range / 2 - ) + stack_parameters.steps_undershoot + stack_parameters.stack_z_range / 2 + ), + backlash_compensation=BacklashCompensation.Z_ONLY, ) - self._stage.move_relative(z=stack_parameters.backlash_correction) captures: list[CaptureInfo] = [] # Always check for focus using the the last `min_images_to_test` in the diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index 7860f938..813c762f 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -141,11 +141,15 @@ class CameraStageMapper(lt.Thing): except lt.exceptions.InvocationCancelledError as e: self.logger.info("User cancelled the camera stage mapping calibration") 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 except MappingError as e: 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 result["move_history"] = recorded_move.history result["image_resolution"] = self._cam.capture_downsampled_array().shape[:2] @@ -264,6 +268,7 @@ class CameraStageMapper(lt.Thing): self._stage.move_relative( **self.convert_image_to_stage_coordinates(x=x, y=y), block_cancellation=False, + backlash_compensation=None, ) @lt.action diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 84143e25..d587de3c 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -37,7 +37,7 @@ from openflexure_microscope_server.utilities import coerce_thing_selector # Things from .camera import BaseCamera from .scan_workflows import ScanWorkflow -from .stage import BaseStage +from .stage import BacklashCompensation, BaseStage T = TypeVar("T") P = ParamSpec("P") @@ -311,6 +311,7 @@ class SmartScanThing(lt.Thing): x=next_point[0], y=next_point[1], z=z_estimate, + backlash_compensation=BacklashCompensation.XY_ONLY, ) return (next_point[0], next_point[1], z_estimate) @@ -481,7 +482,9 @@ class SmartScanThing(lt.Thing): if self._scan_data is not None: self._stage.move_absolute( - **self.scan_data.starting_position, block_cancellation=True + **self.scan_data.starting_position, + block_cancellation=True, + backlash_compensation=None, ) @property diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 2c06eaf3..3298e2ac 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -11,6 +11,7 @@ As the object will be used as a context manager create the hardware connection i from __future__ import annotations +import enum import queue import threading 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: """A base class for jog operations.""" @@ -101,6 +121,8 @@ class BaseStage(lt.Thing): self._jog_queue = JogQueue() self._jog_thread: Optional[threading.Thread] = None 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. if ( @@ -124,6 +146,17 @@ class BaseStage(lt.Thing): """Current position of the stage.""" 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) """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.""" 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( - "StageThings must define their own update_position method" + "StageThings must define their own _hardware_update_position method" ) @overload @@ -192,12 +243,25 @@ class BaseStage(lt.Thing): self.axis_inverted = direction @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.""" - self._hardware_move_relative( - block_cancellation=block_cancellation, - **self._apply_axis_direction(kwargs), - ) + if backlash_compensation is not None: + self._move_with_backlash_correction( + 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( self, block_cancellation: bool = False, **kwargs: int @@ -211,12 +275,25 @@ class BaseStage(lt.Thing): ) @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.""" - self._hardware_move_absolute( - block_cancellation=block_cancellation, - **self._apply_axis_direction(kwargs), - ) + if backlash_compensation is not None: + self._move_with_backlash_correction( + 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( self, @@ -231,6 +308,78 @@ class BaseStage(lt.Thing): "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: """Start a relative move.""" raise NotImplementedError( diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index 3b1aaf0a..4948d266 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -81,9 +81,12 @@ class DummyStage(BaseStage): ) """Used to convert coordinates between the program frame and the hardware frame.""" - def update_position(self) -> None: - """Read position from the stage and set the corresponding property.""" - pass + 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. + """ + self._hardware_position = self.instantaneous_position def _set_pos_during_move( self, displacement: Sequence[int], fraction_complete: float @@ -109,7 +112,7 @@ class DummyStage(BaseStage): # If there is a new movement. if movement_request is not None: # Set the hardware position from instantaneous before continuing. - self._hardware_position = self.instantaneous_position + self.update_position() if movement_request.displacement is None: # If it is a stop command, stop moving @@ -138,7 +141,7 @@ class DummyStage(BaseStage): # move is complete fraction_complete = 1.0 self._set_pos_during_move(displacement, fraction_complete) - self._hardware_position = self.instantaneous_position + self.update_position() self._movement_ongoing = False def _check_for_new_move_request(self) -> Optional[DummyStageMovement]: @@ -233,5 +236,5 @@ class DummyStage(BaseStage): stage. """ with self._hardware_lock: - self._hardware_position = dict.fromkeys(self.axis_names, 0) - self.instantaneous_position = self._hardware_position + self.instantaneous_position = dict.fromkeys(self.axis_names, 0) + self.update_position() diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 128586f3..a6c99eb9 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -80,8 +80,11 @@ class SangaboardThing(BaseStage): ) """Used to convert coordinates between the program frame and the hardware frame.""" - def update_position(self) -> None: - """Read position from the stage and set the corresponding property.""" + 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. + """ with self._hardware_lock: self._hardware_position = dict( zip(self.axis_names, self._sangaboard.position, strict=True) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 9162d6e9..411e57ed 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -267,7 +267,9 @@ class RangeofMotionThing(lt.Thing): self._rom_data.stage_coords[-1] = self._stage.position 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: """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 abs(img_perc) < BIG_STEP: 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. return True, 1 self.logger.info( diff --git a/tests/unit_tests/test_autofocus.py b/tests/unit_tests/test_autofocus.py index 1c3f6e08..3661cbc6 100644 --- a/tests/unit_tests/test_autofocus.py +++ b/tests/unit_tests/test_autofocus.py @@ -60,6 +60,8 @@ def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes, def adjust_pos(**kwargs: int) -> None: """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(): autofocus_thing._stage.position[axis] += value diff --git a/tests/unit_tests/test_stage.py b/tests/unit_tests/test_stage.py index 727f4c4d..f922ef65 100644 --- a/tests/unit_tests/test_stage.py +++ b/tests/unit_tests/test_stage.py @@ -4,6 +4,7 @@ import itertools import logging import threading import time +from dataclasses import dataclass import pytest 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.stage import ( + BacklashCompensation, BaseStage, JogCommand, JogQueue, @@ -44,7 +46,7 @@ def test_not_implemented_methods(): """Check all methods a child class should implement raise NotImplemented.""" stage = create_thing_without_server(BaseStage) methods_and_args = [ - (stage.update_position, ()), + (stage._hardware_update_position, ()), (stage._hardware_move_relative, ()), (stage._hardware_move_absolute, ()), (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): """Stop extra actions getting added to child classes without explicit approval. diff --git a/tests/unit_tests/test_stage_measure.py b/tests/unit_tests/test_stage_measure.py index 5c9e0690..d4b5c5a8 100644 --- a/tests/unit_tests/test_stage_measure.py +++ b/tests/unit_tests/test_stage_measure.py @@ -487,10 +487,12 @@ def test_move_until_edge_error(rom_thing, mocker): # Error should be raised even though it is in the Try: with pytest.raises(RuntimeError, match="Mock"): 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 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