From 35819aa826586dabb94ab31c7d971ca63d1a7416 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 23 Feb 2026 17:47:33 +0000 Subject: [PATCH] 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.