From 6f3cf00b3642b66563713c175ebcdea565ce629f Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 23 Feb 2026 13:03:58 +0000 Subject: [PATCH 1/7] Add a base stage method for update_position, splitting out hardware specifics --- .../things/stage/__init__.py | 11 +++++++++-- .../things/stage/dummy.py | 17 ++++++++++------- .../things/stage/sangaboard.py | 7 +++++-- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 2c06eaf3..5a350ce1 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -133,9 +133,16 @@ 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.""" + self._hardware_update_position() + + 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 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) From 35819aa826586dabb94ab31c7d971ca63d1a7416 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 23 Feb 2026 17:47:33 +0000 Subject: [PATCH 2/7] Track the backlash state of stages --- .../things/stage/__init__.py | 24 +++++++++++++ tests/unit_tests/test_stage.py | 35 ++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 5a350ce1..4a41bd42 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -101,6 +101,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, -1) # This must be the last thing the function does in case it is caught in a try. if ( @@ -124,6 +126,15 @@ 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}) + """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 is z=200. If the previous move was more than +200 in z then no + correction is needed. If the last movement was negative then a move ov -200, + followed by +200 will wipe out backlash. + """ + moving: bool = lt.property(default=False, readonly=True) """Whether the stage is in motion.""" @@ -134,7 +145,20 @@ class BaseStage(lt.Thing): def update_position(self) -> None: """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 is should be 2 for a move equal to backlash steps. + # moving as this will move the state from -1 (fully one direction) to + # 1, fully the other. + delta = ( + 2 * (self.position[axis] - pos_before[axis]) / self.backlash_steps[axis] + ) + # apply value and clamp to within range from -1 to 1 + self._backlash_state[axis] = max( + -1, min(1, self._backlash_state[axis] + delta) + ) def _hardware_update_position(self) -> None: """Read position from the stage and set internal attribute _hardware_position. diff --git a/tests/unit_tests/test_stage.py b/tests/unit_tests/test_stage.py index 727f4c4d..e2d04448 100644 --- a/tests/unit_tests/test_stage.py +++ b/tests/unit_tests/test_stage.py @@ -44,7 +44,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 +309,39 @@ 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": -1, "y": -1, "z": -1} + 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, "y": -1, "z": -1} + # Move 50 more steps to halfway between centre and "engaged" + dummy_stage.move_relative(x=50) + assert dummy_stage._backlash_state == {"x": 0.5, "y": -1, "z": -1} + # Move back 50 steps to the centre + dummy_stage.move_relative(x=-50) + assert dummy_stage._backlash_state == {"x": 0, "y": -1, "z": -1} + # Move 500 steps, now fully engaged + dummy_stage.move_relative(x=500) + assert dummy_stage._backlash_state == {"x": 1, "y": -1, "z": -1} + # Move back 100 steps in the centre of backash range again. + dummy_stage.move_relative(x=-100) + assert dummy_stage._backlash_state == {"x": 0, "y": -1, "z": -1} + # Move forward a load to re-engage + dummy_stage.move_relative(x=500) + assert dummy_stage._backlash_state == {"x": 1, "y": -1, "z": -1} + # 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": -1, "y": -1, "z": -1} + # Further motion changes nothing in state + dummy_stage.move_relative(x=-200) + assert dummy_stage._backlash_state == {"x": -1, "y": -1, "z": -1} + + def test_thing_description_equivalence(dummy_stage, mocker): """Stop extra actions getting added to child classes without explicit approval. From c2b784e606fe362a3b88f27a87add936eb2513e1 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 24 Feb 2026 16:47:52 +0000 Subject: [PATCH 3/7] Add stage backlash correction --- .../things/camera_stage_mapping.py | 9 +- .../things/smart_scan.py | 4 +- .../things/stage/__init__.py | 148 +++++++++++++++--- .../things/stage_measure.py | 8 +- tests/unit_tests/test_stage.py | 104 ++++++++++-- tests/unit_tests/test_stage_measure.py | 4 +- 6 files changed, 244 insertions(+), 33 deletions(-) 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..56cf7e0b 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -481,7 +481,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 4a41bd42..e949531d 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,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: """A base class for jog operations.""" @@ -102,7 +118,7 @@ class BaseStage(lt.Thing): 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, -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. if ( @@ -149,15 +165,13 @@ class BaseStage(lt.Thing): pos_before = dict(self.position) self._hardware_update_position() for axis in self._axis_names: - # The state delta is should be 2 for a move equal to backlash steps. - # moving as this will move the state from -1 (fully one direction) to + # The state delta is 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 = ( - 2 * (self.position[axis] - pos_before[axis]) / self.backlash_steps[axis] - ) - # apply value and clamp to within range from -1 to 1 + 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( - -1, min(1, self._backlash_state[axis] + delta) + 0, min(1, self._backlash_state[axis] + delta) ) def _hardware_update_position(self) -> None: @@ -223,12 +237,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 @@ -242,12 +269,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, @@ -262,6 +302,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 {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: """Start a relative move.""" raise NotImplementedError( 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_stage.py b/tests/unit_tests/test_stage.py index e2d04448..b448ea84 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, @@ -313,33 +315,117 @@ 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": -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} # Move 100 steps to centre of x backlash range 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" 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 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 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. 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 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 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 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): diff --git a/tests/unit_tests/test_stage_measure.py b/tests/unit_tests/test_stage_measure.py index 5c9e0690..cbb91ed2 100644 --- a/tests/unit_tests/test_stage_measure.py +++ b/tests/unit_tests/test_stage_measure.py @@ -490,7 +490,9 @@ def test_move_until_edge_error(rom_thing, mocker): # 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 From c12814f0ebd40d23f1edacd5ce73140c801d092c Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 24 Feb 2026 17:17:28 +0000 Subject: [PATCH 4/7] Add a load more backlash corrected move test cases. --- tests/unit_tests/test_stage.py | 160 ++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_stage.py b/tests/unit_tests/test_stage.py index b448ea84..ccd22c17 100644 --- a/tests/unit_tests/test_stage.py +++ b/tests/unit_tests/test_stage.py @@ -362,7 +362,9 @@ class BacklashMoveTestCase: if self.relative: return self.movement - return {ax: self.movement[ax] - self.position[ax] for ax in self.position} + 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.""" @@ -372,7 +374,7 @@ class BacklashMoveTestCase: BACKLASH_TEST_MOVEMENTS = [ - # Simple test case moving 1 step in x against backlash direction + # 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}, @@ -383,6 +385,160 @@ BACKLASH_TEST_MOVEMENTS = [ # 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}], + ), ] From 0527990282ec0752ee6f8b8a3cd962d6b4d01d06 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 24 Feb 2026 17:45:21 +0000 Subject: [PATCH 5/7] Use backlash compensation in smart scan and autofocus --- .../things/autofocus.py | 25 +++++++++++-------- .../things/smart_scan.py | 3 ++- .../things/stage/__init__.py | 4 ++- tests/unit_tests/test_autofocus.py | 2 ++ 4 files changed, 21 insertions(+), 13 deletions(-) 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/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 56cf7e0b..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) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index e949531d..2a6a776e 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -142,7 +142,9 @@ 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}) + 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. 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 From 9ab7e50773033ec5c354c7745aef26f4cb078492 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 25 Feb 2026 20:27:45 +0000 Subject: [PATCH 6/7] Apply suggestions from code review of branch backlash-in-stage Co-authored-by: Joe Knapper --- .../things/stage/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 2a6a776e..a80d0d79 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -148,8 +148,8 @@ class BaseStage(lt.Thing): """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 is z=200. If the previous move was more than +200 in z then no - correction is needed. If the last movement was negative then a move ov -200, + 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. """ @@ -167,7 +167,7 @@ class BaseStage(lt.Thing): pos_before = dict(self.position) self._hardware_update_position() for axis in self._axis_names: - # The state delta is should be 1 for a move equal to backlash steps. + # 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] @@ -339,7 +339,7 @@ class BaseStage(lt.Thing): check_axes = ("z",) case _: raise ValueError( - f"Unknown backlash compensation method {self._axis_names}" + f"Unknown backlash compensation method {backlash_compensation}" ) # Check the backlash state at the end of the move, no need to clip to range of From d4fbcb6d61af9964cf3257f27ae8aad5c5483ed2 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 2 Mar 2026 11:07:10 +0000 Subject: [PATCH 7/7] Apply suggestions from code review of branch backlash-in-stage Co-authored-by: Joe Knapper --- src/openflexure_microscope_server/things/stage/__init__.py | 4 ++++ tests/unit_tests/test_stage.py | 2 +- tests/unit_tests/test_stage_measure.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index a80d0d79..3298e2ac 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -36,6 +36,10 @@ 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. diff --git a/tests/unit_tests/test_stage.py b/tests/unit_tests/test_stage.py index ccd22c17..f922ef65 100644 --- a/tests/unit_tests/test_stage.py +++ b/tests/unit_tests/test_stage.py @@ -554,7 +554,7 @@ def test_backlash_compensated_moves(test_case, dummy_stage, mocker): dummy_stage._hardware_position = dummy_stage._apply_axis_direction( test_case.position ) - # double check position is set + # check position is set assert dummy_stage.position == test_case.position dummy_stage._backlash_state = test_case.backlash_state diff --git a/tests/unit_tests/test_stage_measure.py b/tests/unit_tests/test_stage_measure.py index cbb91ed2..d4b5c5a8 100644 --- a/tests/unit_tests/test_stage_measure.py +++ b/tests/unit_tests/test_stage_measure.py @@ -487,7 +487,7 @@ 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(