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:
commit
dc2bc77dbb
10 changed files with 490 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue