Merge branch 'rom_test_only' into 'v3'
Adding automatic range of motion measurements See merge request openflexure/openflexure-microscope-server!335
This commit is contained in:
commit
94c9a0e7dd
7 changed files with 1244 additions and 4 deletions
|
|
@ -15,7 +15,8 @@
|
|||
"kwargs": {
|
||||
"scans_folder": "/var/openflexure/scans/"
|
||||
}
|
||||
}
|
||||
},
|
||||
"/stage_measure/":"openflexure_microscope_server.things.stage_measure:RangeofMotionThing"
|
||||
},
|
||||
"settings_folder": "/var/openflexure/settings/",
|
||||
"log_folder": "/var/openflexure/logs/"
|
||||
|
|
|
|||
|
|
@ -268,10 +268,18 @@ class CameraStageMapper(lt.Thing):
|
|||
an image usually helps resolve any ambiguity.
|
||||
"""
|
||||
self.assert_calibrated()
|
||||
stage.move_relative(**self.convert_image_to_stage_coordinates(x=x, y=y))
|
||||
|
||||
@lt.thing_action
|
||||
def convert_image_to_stage_coordinates(
|
||||
self, x: float, y: float
|
||||
) -> Mapping[str, int]:
|
||||
"""Convert image coordinates to stage coordinates."""
|
||||
self.assert_calibrated()
|
||||
relative_move: np.ndarray = np.dot(
|
||||
np.array([y, x]), np.array(self.image_to_stage_displacement_matrix)
|
||||
)
|
||||
stage.move_relative(x=relative_move[0], y=relative_move[1])
|
||||
return {"x": int(relative_move[0]), "y": int(relative_move[1])}
|
||||
|
||||
@lt.thing_property
|
||||
def thing_state(self) -> Mapping[str, Any]:
|
||||
|
|
|
|||
595
src/openflexure_microscope_server/things/stage_measure.py
Normal file
595
src/openflexure_microscope_server/things/stage_measure.py
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
"""File contains all the functions used to measure the range of motion.
|
||||
|
||||
The range of motion is measured by first taking 5 'medium' sized steps which gives
|
||||
enough positions to predict future z positions. Next, one 'big' step is taken followed
|
||||
by 3 'small' steps to test that the stage is still moving as expected and has not
|
||||
reached the edge. Once the edge has been found and to account for the possibility that
|
||||
the edge was reached during a "big" step, the stage is moved in a sequence of steps
|
||||
in the opposite direction until motion is detected. This is position is taken as the
|
||||
true final position.
|
||||
|
||||
Throughout the test, parasitic motion (motion in the axis not being measured)
|
||||
is tracked and an error is raised if it exceeds a minimum amount. Currently
|
||||
this is 10% of the expected motion is the measured axis.
|
||||
"""
|
||||
|
||||
from typing import Literal, Any, Optional, overload
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
|
||||
from scipy.optimize import curve_fit
|
||||
import numpy as np
|
||||
|
||||
from camera_stage_mapping import fft_image_tracking
|
||||
import labthings_fastapi as lt
|
||||
|
||||
from openflexure_microscope_server.utilities import quadratic
|
||||
|
||||
# Things
|
||||
from .autofocus import AutofocusThing
|
||||
from .camera_stage_mapping import CameraStageMapper
|
||||
from .camera import CameraDependency as CamDep
|
||||
from .stage import StageDependency as StageDep
|
||||
|
||||
CSMDep = lt.deps.direct_thing_client_dependency(
|
||||
CameraStageMapper, "/camera_stage_mapping/"
|
||||
)
|
||||
AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/")
|
||||
|
||||
## Size of movement in percentage of field of view
|
||||
SMALL_STEP = 20
|
||||
MEDIUM_STEP = 50
|
||||
BIG_STEP = 200
|
||||
|
||||
PARASITIC_MOTION_TOL = 0.1
|
||||
DETECT_MOTION_TOL = 0.65
|
||||
|
||||
|
||||
class RomDataTracker:
|
||||
"""Class for tracking range of motion data.
|
||||
|
||||
The attributes storing data are:
|
||||
|
||||
* ``stage_coords`` A list of the the stage coordinates at each measurement location
|
||||
* ``displacements`` A list of the displacement calculated after each movement
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Define useful data tracked throughout test."""
|
||||
self.stage_coords: list[dict[str, int]] = []
|
||||
self.offsets: list[dict[str, float]] = []
|
||||
|
||||
def record_movement(
|
||||
self, current_pos: dict[str, int], offset: dict[str, float]
|
||||
) -> None:
|
||||
"""Record the current position and the measured offset of the last move."""
|
||||
self.stage_coords.append(current_pos)
|
||||
self.offsets.append(offset)
|
||||
|
||||
@property
|
||||
def final_position(self) -> dict[str, int]:
|
||||
"""The last stage coordinate recorded."""
|
||||
return self.stage_coords[-1].copy()
|
||||
|
||||
def predict_z_displacement(
|
||||
self,
|
||||
axis: Literal["x", "y"],
|
||||
stage_movement: dict[str, int],
|
||||
stage_position: dict[str, int],
|
||||
) -> int:
|
||||
"""Predict the z-displacement needed for a given movement.
|
||||
|
||||
:param axis: The axis which is being measured. This must be 'x' or 'y'.
|
||||
:param stage_movement: The movement to be performed in stage coordinates.
|
||||
:param stage_position: The current stage position in stage coordinates.
|
||||
:return: The predicted relative z displacement needed to stay in focus.
|
||||
"""
|
||||
# x or y positions
|
||||
lateral_positions = [i[axis] for i in self.stage_coords]
|
||||
z_positions = [i["z"] for i in self.stage_coords]
|
||||
fit_params, *_others = curve_fit(quadratic, lateral_positions, z_positions)
|
||||
z_dest = quadratic(stage_position[axis] + stage_movement[axis], *fit_params)
|
||||
|
||||
return int(z_dest - stage_position["z"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class RomDeps:
|
||||
"""Grouped dependencies for the Range of motion Thing.
|
||||
|
||||
These are used to pass the dependencies from actions to other sub-functions.
|
||||
"""
|
||||
|
||||
autofocus: AutofocusDep
|
||||
stage: StageDep
|
||||
cam: CamDep
|
||||
csm: CSMDep
|
||||
logger: lt.deps.InvocationLogger
|
||||
|
||||
|
||||
class ParasiticMotionError(Exception):
|
||||
"""Custom exception raised when parasitic motion is detected.
|
||||
|
||||
Parasitic motion is when motion in the direction not being measured
|
||||
is too high.
|
||||
"""
|
||||
|
||||
|
||||
class RangeofMotionThing(lt.Thing):
|
||||
"""A class used to measure the range of motion of the stage in X and Y."""
|
||||
|
||||
calibrated_range = lt.ThingSetting(
|
||||
initial_value=None, model=Optional[list[int, int]], readonly=True
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialise and create the lock."""
|
||||
super().__init__()
|
||||
self._lock = Lock()
|
||||
self._stream_resolution: Optional[tuple[int, int]] = None
|
||||
self._rom_data = RomDataTracker()
|
||||
|
||||
@lt.thing_action
|
||||
def perform_rom_test(
|
||||
self,
|
||||
autofocus: AutofocusDep,
|
||||
stage: StageDep,
|
||||
cam: CamDep,
|
||||
csm: CSMDep,
|
||||
logger: lt.deps.InvocationLogger,
|
||||
) -> dict[str, Any]:
|
||||
"""Measures the range of motion of the stage across the x and y axes.
|
||||
|
||||
:param autofocus: A raw_thing_client dependency for autofocus.
|
||||
:param stage: A raw_thing_client depeendency for the microscope stage.
|
||||
:param cam: A raw_thing_client depeendency for the camera.
|
||||
:param csm: A raw_thing_client depeendency for camera stage mapping.
|
||||
:param logger: A raw_thing_client depeendency for the logger.
|
||||
:return: Results dictionary separated into keys of each axis and direction.
|
||||
"""
|
||||
got_lock = self._lock.acquire(blocking=False)
|
||||
if not got_lock:
|
||||
raise RuntimeError("Trying to run ROM test when a test is already running.")
|
||||
|
||||
try:
|
||||
rom_deps = RomDeps(
|
||||
autofocus=autofocus, stage=stage, csm=csm, cam=cam, logger=logger
|
||||
)
|
||||
logger.info(
|
||||
"Using the stage to measure the Range of Motion. "
|
||||
"Please ensure you are using a sample that covers the whole range of "
|
||||
"motion. This should be approximately 12 x 12 mm."
|
||||
)
|
||||
start_time = time.time()
|
||||
|
||||
self._set_stream_resolution(cam)
|
||||
|
||||
# The total range for the two axes under test
|
||||
step_range = []
|
||||
|
||||
# Strictly typed definitions to iterate over for MyPys sake.
|
||||
axes: tuple[Literal["x"], Literal["y"]] = ("x", "y")
|
||||
directions: tuple[Literal[1], Literal[-1]] = (1, -1)
|
||||
|
||||
for axis in axes:
|
||||
# The final position of the axis under test in the given direction
|
||||
axis_limits = []
|
||||
for axis_dir in directions:
|
||||
# Create a new tracker at start of measurement.
|
||||
self._rom_data = RomDataTracker()
|
||||
self._move_until_edge(
|
||||
axis=axis, direction=axis_dir, rom_deps=rom_deps
|
||||
)
|
||||
# Append final position from tracker
|
||||
axis_limits.append(self._rom_data.final_position[axis])
|
||||
# Calculate step range.
|
||||
step_range.append(abs(axis_limits[0] - axis_limits[1]))
|
||||
|
||||
total_time = time.time() - start_time
|
||||
logger.info(f"Range of motion is {step_range[0]} x {step_range[1]} steps")
|
||||
self.calibrated_range = step_range
|
||||
finally:
|
||||
self._lock.release()
|
||||
return {
|
||||
"Time": total_time,
|
||||
"CSM Matrix": csm.image_to_stage_displacement_matrix,
|
||||
"Step Range": step_range,
|
||||
}
|
||||
|
||||
def _set_stream_resolution(self, cam: CamDep) -> None:
|
||||
"""Set the self._stream_resolution attribute by reading camera.
|
||||
|
||||
:param cam: The camera dependency.
|
||||
"""
|
||||
stream_shape = cam.grab_as_array().shape
|
||||
# Swap axes as numpy is [y, x]
|
||||
self._stream_resolution = (stream_shape[1], stream_shape[0])
|
||||
|
||||
def _move_until_edge(
|
||||
self,
|
||||
axis: Literal["x", "y"],
|
||||
direction: Literal[1, -1],
|
||||
rom_deps: RomDeps,
|
||||
) -> None:
|
||||
"""Move in one direction until movement per step decreases significantly.
|
||||
|
||||
This should move until the edge of the stage. Once the edge is reached there
|
||||
will be some movement as there is no hard stop, but it will reduce
|
||||
significantly.
|
||||
|
||||
:param rom_deps: All dependencies that were passed to the calling Action
|
||||
:param axis: The axis which is being measured. This must be 'x' or 'y'.
|
||||
:param direction: The direction which is being measured. This must be 1 or -1.
|
||||
:return: Results dictionary containing stage positions,
|
||||
correlations and the final position.
|
||||
"""
|
||||
# Start by performing an autofocus and recording starting position
|
||||
rom_deps.autofocus.looping_autofocus(dz=1000)
|
||||
starting_position = rom_deps.stage.position
|
||||
self._rom_data.stage_coords.append(starting_position)
|
||||
|
||||
try:
|
||||
dir_str = "positive" if direction == 1 else "negative"
|
||||
rom_deps.logger.info(
|
||||
f"Beginning the {axis}-axis in the {dir_str} direction"
|
||||
)
|
||||
|
||||
rom_deps.logger.info("Moving the stage in 5 medium sized steps.")
|
||||
self._initial_moves_for_z_prediction(
|
||||
axis=axis,
|
||||
direction=direction,
|
||||
rom_deps=rom_deps,
|
||||
)
|
||||
|
||||
still_moving = True
|
||||
# Loop taking 1 big step followed by 3 small steps to check if the
|
||||
# stage is moving as expected or has reached end of range of motion.
|
||||
# Stop once end of range of motion is detected.
|
||||
while still_moving:
|
||||
self._big_z_corrected_movement(
|
||||
axis=axis, direction=direction, rom_deps=rom_deps
|
||||
)
|
||||
# Autofocus and record position
|
||||
rom_deps.autofocus.looping_autofocus(dz=800)
|
||||
self._rom_data.stage_coords.append(rom_deps.stage.position)
|
||||
|
||||
# Perform small moves to check stage is still moving.
|
||||
still_moving = self._stage_still_moves(
|
||||
axis=axis,
|
||||
direction=direction,
|
||||
rom_deps=rom_deps,
|
||||
)
|
||||
|
||||
# Stage may have crashed during a large move. Move stage back until motion
|
||||
# is detected
|
||||
rom_deps.logger.info("Moving stage back until motion is detect")
|
||||
self._move_back_until_motion_detected(
|
||||
axis=axis,
|
||||
direction=direction,
|
||||
rom_deps=rom_deps,
|
||||
)
|
||||
|
||||
# Replace final position.
|
||||
self._rom_data.stage_coords[-1] = rom_deps.stage.position
|
||||
|
||||
finally:
|
||||
rom_deps.stage.move_absolute(**starting_position, block_cancellation=True)
|
||||
|
||||
def _img_percentage_to_img_coords(
|
||||
self, fov_perc: int, axis: Literal["x", "y"]
|
||||
) -> float:
|
||||
"""For a given image percentage and axis return the distance in img coords.
|
||||
|
||||
:param fov_perc: The percentage of field of view the stage should move by.
|
||||
:param axis: The resolution of the stream from the camera.
|
||||
:return: Distance in image coordinates (pixels)
|
||||
"""
|
||||
if self._stream_resolution is None:
|
||||
raise RuntimeError(
|
||||
"Stream resolution must be set before generating movement in coords"
|
||||
)
|
||||
|
||||
img_index = 0 if axis == "x" else 1
|
||||
return (fov_perc / 100) * self._stream_resolution[img_index]
|
||||
|
||||
def _movement_in_img_coords(
|
||||
self,
|
||||
fov_perc: int,
|
||||
axis: Literal["x", "y"],
|
||||
direction: Literal[1, -1],
|
||||
) -> dict[str, float]:
|
||||
"""Return the dictionary for a move in image coordinates.
|
||||
|
||||
This dictionary can be passed directly to csm.move_in_image_coordinates
|
||||
|
||||
:param fov_perc: The percentage of field of view the stage should move by.
|
||||
:param axis: The resolution of the stream from the camera.
|
||||
:param direction: The direction the stage moves.
|
||||
|
||||
:return: The movement size in image coordinates
|
||||
"""
|
||||
distance = self._img_percentage_to_img_coords(fov_perc=fov_perc, axis=axis)
|
||||
if axis == "x":
|
||||
return {"x": distance * direction, "y": 0}
|
||||
return {"x": 0, "y": distance * direction}
|
||||
|
||||
def _initial_moves_for_z_prediction(
|
||||
self,
|
||||
axis: Literal["x", "y"],
|
||||
direction: Literal[1, -1],
|
||||
rom_deps: RomDeps,
|
||||
) -> None:
|
||||
"""Perform 5 medium sized moves with autofocus for z feed-forward.
|
||||
|
||||
z-feed forward allows prediction of the z-position as the stage moves. For the
|
||||
feed forward calculation to work an initial number of measurements must be
|
||||
taken. This method performs these initial measurements.
|
||||
|
||||
:param direction: The direction the stage moves.
|
||||
:param axis: The axis which is being measured. This must be 'x' or 'y'.
|
||||
:param rom_deps: All dependencies that were passed to the calling Action
|
||||
"""
|
||||
movement = self._movement_in_img_coords(
|
||||
fov_perc=MEDIUM_STEP, axis=axis, direction=direction
|
||||
)
|
||||
for _loop in range(5):
|
||||
offset = self._move_and_measure(movement=movement, rom_deps=rom_deps)
|
||||
rom_deps.logger.info(f"Offset measured as {offset[axis]}")
|
||||
|
||||
self._rom_data.record_movement(rom_deps.stage.position, offset)
|
||||
if _parasitic_motion_detected(movement, offset):
|
||||
raise ParasiticMotionError(
|
||||
"Parasitic motion detected during initial images to calculate "
|
||||
"z-curvature. This may indicate you have started at the end of the "
|
||||
"range of travel, or that camera stage mapping is poorly "
|
||||
"calibrated."
|
||||
)
|
||||
|
||||
def _big_z_corrected_movement(
|
||||
self, axis: Literal["x", "y"], direction: Literal[1, -1], rom_deps: RomDeps
|
||||
) -> None:
|
||||
"""Take one big move with feed-forward z-correction.
|
||||
|
||||
The size is defined by the BIG_STEP constant.
|
||||
|
||||
:param axis: The axis to move in.
|
||||
:param direction: The direction to move in.
|
||||
:param rom_deps: All dependencies that were passed to the calling Action
|
||||
"""
|
||||
movement = self._movement_in_img_coords(
|
||||
fov_perc=BIG_STEP, axis=axis, direction=direction
|
||||
)
|
||||
# Convert to stage coordinates
|
||||
stage_movemenet = rom_deps.csm.convert_image_to_stage_coordinates(**movement)
|
||||
|
||||
z_disp = self._rom_data.predict_z_displacement(
|
||||
axis=axis,
|
||||
stage_movement=stage_movemenet,
|
||||
stage_position=rom_deps.stage.position,
|
||||
)
|
||||
rom_deps.stage.move_relative(z=z_disp)
|
||||
rom_deps.csm.move_in_image_coordinates(**movement)
|
||||
|
||||
def _stage_still_moves(
|
||||
self,
|
||||
axis: Literal["x", "y"],
|
||||
direction: Literal[1, -1],
|
||||
rom_deps: RomDeps,
|
||||
) -> bool:
|
||||
"""Carry out 3 small moves in a given direction and axis.
|
||||
|
||||
An image is taken before and after each move to check the stage has moved as
|
||||
far as it should. If the offset (calculated by cross correlation) is less than
|
||||
expected, 3 attempts are made to refocus the image to ensure that image quality
|
||||
is not causing the offset to mistakenly be reported as a low value. If the
|
||||
calculated offset is still too low then this is taken as an indication that the
|
||||
edge has been found.
|
||||
|
||||
:param axis: The axis which is being measured. This must be 'x' or 'y'.
|
||||
:param direction: The direction the stage moves.
|
||||
:param rom_deps: All dependencies that were passed to the calling Action
|
||||
"""
|
||||
movement = self._movement_in_img_coords(
|
||||
fov_perc=SMALL_STEP, axis=axis, direction=direction
|
||||
)
|
||||
abs_min_offset = abs(movement[axis] * DETECT_MOTION_TOL)
|
||||
|
||||
for _loop in range(3):
|
||||
offset = self._move_and_measure(
|
||||
movement=movement,
|
||||
rom_deps=rom_deps,
|
||||
# Don't autofocus initially
|
||||
perform_autofocus=False,
|
||||
# But retry focus 3 times if detected motion is too small or parasitic
|
||||
max_autofocus_repeats=3,
|
||||
abs_min_offset=abs_min_offset,
|
||||
)
|
||||
parasitic_motion = _parasitic_motion_detected(movement, offset)
|
||||
rom_deps.logger.info(f"Offset measured as {offset[axis]}")
|
||||
self._rom_data.record_movement(rom_deps.stage.position, offset)
|
||||
|
||||
if abs(offset[axis]) < abs_min_offset or parasitic_motion:
|
||||
# If the offset is below the abs min offset, or significant
|
||||
# parasitic motion is detected then the edge has been found.
|
||||
rom_deps.logger.info("Edge has been found.")
|
||||
return False
|
||||
# If we reached here the stage is still moving fine
|
||||
return True
|
||||
|
||||
def _move_back_until_motion_detected(
|
||||
self,
|
||||
axis: Literal["x", "y"],
|
||||
direction: Literal[1, -1],
|
||||
rom_deps: RomDeps,
|
||||
) -> None:
|
||||
"""Move the stage against the direction of test unil motion is detected.
|
||||
|
||||
In the case that the stage has reached the end of the motion, this method moves
|
||||
the motor in the opposite direction until reliable motion is detected.
|
||||
|
||||
:param axis: The axis in which the stage is moving. This must be 'x' or 'y'.
|
||||
:param direction: The direction in which the stage was moving during the test,
|
||||
this method will move in the opposite direction.
|
||||
:param rom_deps: All dependencies that were passed to the calling Action
|
||||
"""
|
||||
movement = self._movement_in_img_coords(
|
||||
fov_perc=SMALL_STEP, axis=axis, direction=-direction
|
||||
)
|
||||
|
||||
max_moves = int(1.5 * BIG_STEP / SMALL_STEP)
|
||||
# minimum number of pixels for motion to be detected as reliable
|
||||
motion_minimum = abs(movement[axis] * DETECT_MOTION_TOL)
|
||||
|
||||
for i in range(max_moves):
|
||||
# Increment movement
|
||||
rom_deps.logger.info(f"Testing with step size {movement[axis]}")
|
||||
|
||||
# Run an autofocus every 3 steps
|
||||
run_autofocus = i % 3 == 2
|
||||
offset = self._move_and_measure(
|
||||
movement=movement, rom_deps=rom_deps, perform_autofocus=run_autofocus
|
||||
)
|
||||
|
||||
rom_deps.logger.info(f"Offset measured as {abs(offset[axis])}")
|
||||
if abs(offset[axis]) > motion_minimum:
|
||||
rom_deps.logger.info("Motion detected.")
|
||||
return
|
||||
# If this point is reached then motion is never detected
|
||||
raise RuntimeError("Cannot detect motion again after reaching end of range.")
|
||||
|
||||
def _move_and_measure(
|
||||
self,
|
||||
movement: dict[str, float],
|
||||
rom_deps: RomDeps,
|
||||
perform_autofocus: bool = True,
|
||||
max_autofocus_repeats: int = 0,
|
||||
abs_min_offset: float = 0.0,
|
||||
) -> dict[str, float]:
|
||||
"""Move the stage and measure the offset between the two positions.
|
||||
|
||||
:param movement: A dictionary containing the distance to move in image coords.
|
||||
:param rom_deps: All dependencies that were passed to the calling Action
|
||||
:param perform_autofocus: Set to False to disable autofocus after move.
|
||||
Default is True
|
||||
:param max_autofocus_repeats: The number of times to repeat the focus if the
|
||||
detected (on-axis) offset is below ``abs_min_offset``. This will only work
|
||||
if ``abs_min_offset`` is also set
|
||||
:param abs_min_offset: The absolute minimum (on-axis) offset, under which the
|
||||
autofocus is repeated.
|
||||
:return: The calculated offset from cross correlation.
|
||||
"""
|
||||
if max_autofocus_repeats > 0 and abs_min_offset <= 0:
|
||||
raise ValueError(
|
||||
"abs_min_offset must be positive if max_autofocus_repeats > 0."
|
||||
)
|
||||
|
||||
# Take image before move
|
||||
before_img = rom_deps.cam.grab_as_array()
|
||||
# Move and autofocus if required
|
||||
rom_deps.csm.move_in_image_coordinates(**movement)
|
||||
if perform_autofocus:
|
||||
rom_deps.autofocus.looping_autofocus(dz=800)
|
||||
# Take image and calculate offset
|
||||
offset = self._offset_from(before_img, rom_deps=rom_deps)
|
||||
|
||||
if max_autofocus_repeats > 0:
|
||||
axis = _axis_from_movement_dict(movement)
|
||||
af_repeats = 0
|
||||
parasitic_motion = _parasitic_motion_detected(movement, offset)
|
||||
while abs(offset[axis]) < abs_min_offset or parasitic_motion:
|
||||
af_repeats += 1
|
||||
rom_deps.logger.info(
|
||||
f"Motion not detected. Refocusing to check. Attempt {af_repeats}/3."
|
||||
)
|
||||
rom_deps.autofocus.looping_autofocus(dz=800)
|
||||
# Re-take image and calculate offset
|
||||
offset = self._offset_from(before_img, rom_deps=rom_deps)
|
||||
parasitic_motion = _parasitic_motion_detected(movement, offset)
|
||||
if af_repeats >= max_autofocus_repeats:
|
||||
# break if the maximum number of tries are exceeded.
|
||||
break
|
||||
|
||||
return offset
|
||||
|
||||
def _offset_from(
|
||||
self, before_img: np.ndarray, rom_deps: RomDeps
|
||||
) -> dict[str, float]:
|
||||
"""Take an image and calculate the offset from an input image.
|
||||
|
||||
:param before_img: The image the offset should be calculated with respect to.
|
||||
:param rom_deps: All dependencies that were passed to the calling Action
|
||||
:return: The calculated offset as a dictionary in pixels
|
||||
"""
|
||||
is_sample, _bg_message = rom_deps.cam.image_is_sample()
|
||||
if not is_sample:
|
||||
raise RuntimeError(
|
||||
"No sample detected. Sample must be densely featured and cover the "
|
||||
"whole range of motion."
|
||||
)
|
||||
after_img = rom_deps.cam.grab_as_array()
|
||||
offset = fft_image_tracking.displacement_between_images(
|
||||
image_0=before_img,
|
||||
image_1=after_img,
|
||||
sigma=10,
|
||||
fractional_threshold=0.1,
|
||||
pad=True,
|
||||
)
|
||||
return {"x": offset[1], "y": offset[0]}
|
||||
|
||||
|
||||
# The number of return arguments depends on if return_other is True or False
|
||||
# let MyPy know with overloads typed to Literal[False] and Literal[True].
|
||||
@overload
|
||||
def _axis_from_movement_dict(
|
||||
movement: dict[str, float], return_other: Literal[False]
|
||||
) -> str: ...
|
||||
@overload
|
||||
def _axis_from_movement_dict(
|
||||
movement: dict[str, float], return_other: Literal[True]
|
||||
) -> tuple[str, str]: ...
|
||||
|
||||
|
||||
# Overload the case where return_other is not specified, this is needed
|
||||
# because MyPy doesn't see that return other's default is `False` and use
|
||||
# the `Literal[False]` overload.
|
||||
@overload
|
||||
def _axis_from_movement_dict(movement: dict[str, float]) -> str: ...
|
||||
|
||||
|
||||
# Finally The function.
|
||||
def _axis_from_movement_dict(
|
||||
movement: dict[str, float], return_other: bool = False
|
||||
) -> str | tuple[str, str]:
|
||||
"""Return the axis that a given movement dictionary moves in.
|
||||
|
||||
For example: ``_axis_from_movement_dict({"x": 10, "y":0})`` will return ``x``.
|
||||
|
||||
:param movement: The movement dictionary.
|
||||
:param return_other: If True return a tuple with the first value being the movement
|
||||
axis and the second being the other axis. Default is False
|
||||
:return: The movement axis (and optionally the other axis) as strings.
|
||||
"""
|
||||
# Not exclusive or, this errors if both axes are zero or neither axes are zero.
|
||||
if not ((movement["x"] == 0) ^ (movement["y"] == 0)):
|
||||
raise ValueError("Either x or y movement should be zero, but not both.")
|
||||
|
||||
if return_other:
|
||||
return ("y", "x") if movement["x"] == 0 else ("x", "y")
|
||||
return "y" if movement["x"] == 0 else "x"
|
||||
|
||||
|
||||
def _parasitic_motion_detected(
|
||||
movement: dict[str, float], offset: dict[str, float]
|
||||
) -> bool:
|
||||
"""Compare desired movement to measured offset, report if parasitic motion was detected.
|
||||
|
||||
:param movement: The movement dictionary in image coordinates.
|
||||
:param offset: The offset calculated from correlation.
|
||||
:return: True if too much parasitic motion is detects. False if movement is as
|
||||
expected.
|
||||
"""
|
||||
movement_axis, other_axis = _axis_from_movement_dict(movement, return_other=True)
|
||||
parasitic_motion = abs(offset[other_axis])
|
||||
max_parasitic_motion = abs(movement[movement_axis] * PARASITIC_MOTION_TOL)
|
||||
return parasitic_motion > max_parasitic_motion
|
||||
|
|
@ -1,6 +1,15 @@
|
|||
"""Utility functions and classes."""
|
||||
|
||||
from typing import TypeVar, Callable, ParamSpec, Optional, Any, Concatenate, Self
|
||||
from typing import (
|
||||
TypeVar,
|
||||
Callable,
|
||||
ParamSpec,
|
||||
Optional,
|
||||
Any,
|
||||
Concatenate,
|
||||
Self,
|
||||
overload,
|
||||
)
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
|
@ -11,6 +20,7 @@ import tomllib
|
|||
from functools import wraps
|
||||
|
||||
from pydantic import BaseModel
|
||||
import numpy as np
|
||||
|
||||
T = TypeVar("T")
|
||||
P = ParamSpec("P")
|
||||
|
|
@ -327,3 +337,25 @@ def _get_version_from_toml(toml_path: str) -> str:
|
|||
except (IOError, ValueError, KeyError):
|
||||
LOGGER.error("Problem opening pyproject.toml")
|
||||
return "Undefined"
|
||||
|
||||
|
||||
# Let MyPy know that the output type matches x.
|
||||
@overload
|
||||
def quadratic(x: np.ndarray, a: float, b: float, c: float) -> np.ndarray: ...
|
||||
@overload
|
||||
def quadratic(x: float, a: float, b: float, c: float) -> float: ...
|
||||
|
||||
|
||||
def quadratic(
|
||||
x: float | np.ndarray, a: float, b: float, c: float
|
||||
) -> float | np.ndarray:
|
||||
"""Quadratic function. Used for predicting z.
|
||||
|
||||
:param x: The point or points at which to evaluate the quadratic. This can be a
|
||||
float or a numpy array. The return will be the same type.
|
||||
:param a: The coefficient of x^2.
|
||||
:param b: The coefficient of x.
|
||||
:param c: The constant coefficient.
|
||||
:return: The quadratic, evaluated at each point in ``x``
|
||||
"""
|
||||
return a * x**2 + b * x + c
|
||||
|
|
|
|||
|
|
@ -17,3 +17,7 @@ class MockCSMThing:
|
|||
"""
|
||||
|
||||
image_resolution = (123, 456)
|
||||
image_to_stage_displacement_matrix = [
|
||||
[0.03061156624485296, 1.8031242270940833],
|
||||
[1.773236372778601, 0.006660431608601435],
|
||||
]
|
||||
|
|
|
|||
|
|
@ -16,4 +16,4 @@ class MockStageThing:
|
|||
is not artificially inflated.
|
||||
"""
|
||||
|
||||
position = (111, 222, 333)
|
||||
position = {"x": 3635, "y": 10, "z": 617}
|
||||
|
|
|
|||
600
tests/test_stage_measure.py
Normal file
600
tests/test_stage_measure.py
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
"""File contains unit tests for stage_measure."""
|
||||
|
||||
from copy import copy
|
||||
import logging
|
||||
import dataclasses
|
||||
import tempfile
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import labthings_fastapi as lt
|
||||
|
||||
from openflexure_microscope_server.things import stage_measure
|
||||
|
||||
LOGGER = logging.getLogger("mock-invocation_logger")
|
||||
|
||||
|
||||
# Useful generators
|
||||
def increasing_xy_dict_generator(*_args, **_kwargs):
|
||||
"""Generate x-y dictionaries of incrementing sizes.
|
||||
|
||||
These don't simulate expected effects, but allow checking sequential reads of a
|
||||
function/property were used.
|
||||
"""
|
||||
i = 0
|
||||
while True:
|
||||
yield {"x": i, "y": i}
|
||||
i += 1
|
||||
|
||||
|
||||
def increasing_xyz_dict_generator(*_args, **_kwargs):
|
||||
"""Generate x-y-z dictionaries of incrementing sizes.
|
||||
|
||||
These don't simulate expected effects, but allow checking sequential reads of a
|
||||
function/property were used.
|
||||
"""
|
||||
i = 0
|
||||
while True:
|
||||
yield {"x": i, "y": i, "z": i}
|
||||
i += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def csm_matrix():
|
||||
"""Return an example CSM matrix."""
|
||||
return [
|
||||
[0.03061156624485296, -1.8031242270940833],
|
||||
[1.773236372778601, 0.006660431608601435],
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def example_rom_data():
|
||||
"""Return some example data in a RomDataTracker."""
|
||||
mock_positions = [
|
||||
{"x": 0, "y": 0, "z": 42},
|
||||
{"x": 727, "y": 2, "z": 154},
|
||||
{"x": 1454, "y": 4, "z": 228},
|
||||
{"x": 2181, "y": 6, "z": 351},
|
||||
{"x": 2908, "y": 8, "z": 509},
|
||||
{"x": 3635, "y": 10, "z": 617},
|
||||
]
|
||||
rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
# loop through mock positions recording them with an offset.
|
||||
for position in mock_positions:
|
||||
offset = {"x": 54.4, "y": 0}
|
||||
rom_data.record_movement(position, offset)
|
||||
return rom_data
|
||||
|
||||
|
||||
def test_predict_z(example_rom_data):
|
||||
"""Check that the prediction for the next z position is correct."""
|
||||
mock_z_diff = example_rom_data.predict_z_displacement(
|
||||
axis="x",
|
||||
stage_movement={"x": 5243, "y": 0},
|
||||
stage_position={"x": 3635, "y": 10, "z": 617},
|
||||
)
|
||||
expected_z_diff = 1343
|
||||
assert mock_z_diff == expected_z_diff
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("movement", "axis", "other_axis"),
|
||||
[
|
||||
({"x": 2908, "y": 0}, "x", "y"),
|
||||
({"x": -29, "y": 0}, "x", "y"),
|
||||
({"x": 0, "y": 123}, "y", "x"),
|
||||
({"x": 0, "y": -456}, "y", "x"),
|
||||
],
|
||||
)
|
||||
def test_axis_from_movement_dict(movement, axis, other_axis):
|
||||
"""Test that _axis_from_movement_dict identifies the correct axes."""
|
||||
assert stage_measure._axis_from_movement_dict(movement) == axis
|
||||
|
||||
ret_axes = stage_measure._axis_from_movement_dict(movement, return_other=True)
|
||||
assert ret_axes == (axis, other_axis)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("movement", [{"x": 0, "y": 0}, {"x": 10, "y": 10}])
|
||||
def test_error_on_axis_from_movement_dict(movement):
|
||||
"""Check _axis_from_movement_dict errors if both axes are zero, or both are non-zero."""
|
||||
with pytest.raises(ValueError, match="Either x or y movement should be zero"):
|
||||
assert stage_measure._axis_from_movement_dict(movement)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("par_fraction", "too_high"),
|
||||
[
|
||||
(-0.20, True),
|
||||
(-0.11, True),
|
||||
(-0.09, False),
|
||||
(-0.05, False),
|
||||
(0.00, False),
|
||||
(0.05, False),
|
||||
(0.09, False),
|
||||
(0.11, True),
|
||||
(0.20, True),
|
||||
],
|
||||
)
|
||||
def test_parasitic_detect(par_fraction, too_high):
|
||||
"""Check parasitic motion is detected if the fraction of parasitic motion is too high."""
|
||||
movement = {"x": 2908, "y": 0}
|
||||
|
||||
offset = copy(movement)
|
||||
offset["y"] = movement["x"] * par_fraction
|
||||
|
||||
detected = stage_measure._parasitic_motion_detected(
|
||||
movement=movement, offset=offset
|
||||
)
|
||||
assert detected == too_high
|
||||
|
||||
|
||||
def test_error_if_no_stream_res_set_when_requesting_img_coords():
|
||||
"""Check a RuntimeError thrown when requesting image coordinates if resolution unset."""
|
||||
with pytest.raises(RuntimeError, match="Stream resolution must be set"):
|
||||
stage_measure.RangeofMotionThing()._img_percentage_to_img_coords(20, "x")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rom_thing(example_rom_data) -> stage_measure.RangeofMotionThing:
|
||||
"""Yield a RangeofMotionThing already populated with some example rom_data."""
|
||||
rom_thing = stage_measure.RangeofMotionThing()
|
||||
rom_thing._stream_resolution = [800, 600]
|
||||
rom_thing._rom_data = example_rom_data
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
server = lt.ThingServer(settings_folder=tmpdir)
|
||||
server.add_thing(rom_thing, "/rom_thing/")
|
||||
with TestClient(server.app):
|
||||
yield rom_thing
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rom_deps(csm_matrix, mocker) -> stage_measure.RomDeps:
|
||||
"""Return a RomDeps object full of mocks, except the logger which is LOGGER."""
|
||||
|
||||
def apply_csm(x: float, y: float) -> dict[str, int]:
|
||||
"""Convert image coordinates to stage coordinates."""
|
||||
vec = np.dot(np.array([y, x]), np.array(csm_matrix))
|
||||
return {"x": int(vec[0]), "y": int(vec[1])}
|
||||
|
||||
mock_cam = mocker.Mock()
|
||||
mock_cam.image_is_sample.return_value = (True, "Mocked not measured.")
|
||||
|
||||
mock_csm = mocker.Mock()
|
||||
# Set up mock csm to return a CSM matrix
|
||||
mock_csm.image_to_stage_displacement_matrix = csm_matrix
|
||||
mock_csm.convert_image_to_stage_coordinates.side_effect = apply_csm
|
||||
|
||||
return stage_measure.RomDeps(
|
||||
autofocus=mocker.Mock(),
|
||||
stage=mocker.Mock(),
|
||||
cam=mock_cam,
|
||||
csm=mock_csm,
|
||||
logger=LOGGER,
|
||||
)
|
||||
|
||||
|
||||
def test_offset_from(rom_thing, mock_rom_deps, mocker):
|
||||
"""Check the calls and returns for RangeofMotionThing._offset_from."""
|
||||
# Set up mock for the FFT displacement
|
||||
disp_between_route = (
|
||||
"openflexure_microscope_server.things.stage_measure."
|
||||
"fft_image_tracking.displacement_between_images"
|
||||
)
|
||||
mock_disp_between = mocker.patch(
|
||||
disp_between_route,
|
||||
return_value=[123, 456],
|
||||
)
|
||||
|
||||
# Run it
|
||||
offset = rom_thing._offset_from(before_img="MOCK_IMAGE", rom_deps=mock_rom_deps)
|
||||
|
||||
# Check the offset is a dictionary with the correct values for the axes
|
||||
assert offset["x"] == 456
|
||||
assert offset["y"] == 123
|
||||
# Check 1 image was taken
|
||||
assert mock_rom_deps.cam.grab_as_array.call_count == 1
|
||||
mock_after_image = mock_rom_deps.cam.grab_as_array.return_value
|
||||
# Check the FFT displacement was called once
|
||||
assert mock_disp_between.call_count == 1
|
||||
displacement_kwargs = mock_disp_between.call_args.kwargs
|
||||
# Check the image inputs
|
||||
assert displacement_kwargs["image_0"] == "MOCK_IMAGE"
|
||||
assert displacement_kwargs["image_1"] == mock_after_image
|
||||
|
||||
|
||||
@pytest.mark.parametrize("perform_autofocus", [True, False])
|
||||
def test_move_and_measure(perform_autofocus, rom_thing, mock_rom_deps, mocker):
|
||||
"""Test _move_and_measure with and without initial autofocus checking call counts.
|
||||
|
||||
This doesn't test with the repeate autofocus if motion isn't detected
|
||||
"""
|
||||
mock_offset_value = {"x": 100, "y": 3}
|
||||
mocker.patch.object(rom_thing, "_offset_from", return_value=mock_offset_value)
|
||||
movement = {"x": 100, "y": 0}
|
||||
offset = rom_thing._move_and_measure(
|
||||
movement=movement, rom_deps=mock_rom_deps, perform_autofocus=perform_autofocus
|
||||
)
|
||||
|
||||
# Check exactly 1 move
|
||||
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1
|
||||
# The kwargs of the call should movement dict
|
||||
assert mock_rom_deps.csm.move_in_image_coordinates.call_args.kwargs == movement
|
||||
# Check autofocus call count
|
||||
expected_af_count = 1 if perform_autofocus else 0
|
||||
assert mock_rom_deps.autofocus.looping_autofocus.call_count == expected_af_count
|
||||
# And check final return
|
||||
assert offset == mock_offset_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("x_offsets", "n_offset_measures", "expected_return"),
|
||||
[
|
||||
([5.1, 0.2], 1, {"x": 5.1, "y": 0}),
|
||||
([-5.1, 0.2], 1, {"x": -5.1, "y": 0}),
|
||||
([0.1, 5.2, 0.3, 0.4, 0.5], 2, {"x": 5.2, "y": 0}),
|
||||
([0.1, 0.2, 5.3, 0.4, 0.5], 3, {"x": 5.3, "y": 0}),
|
||||
([0.1, 0.2, 0.3, 5.4, 0.5], 4, {"x": 5.4, "y": 0}),
|
||||
# n_offset_measures shouldn't go higher than 4 as max autofocus repeats is 3
|
||||
([0.1, 0.2, 0.3, 0.4, 5.5], 4, {"x": 0.4, "y": 0}),
|
||||
],
|
||||
)
|
||||
def test_move_and_measure_with_refocus(
|
||||
x_offsets, n_offset_measures, expected_return, rom_thing, mock_rom_deps, mocker
|
||||
):
|
||||
"""Test _move_and_measure with final refocus if offset is too small."""
|
||||
return_dicts = tuple({"x": x, "y": 0} for x in x_offsets)
|
||||
offset_from_mock = mocker.patch.object(
|
||||
rom_thing, "_offset_from", side_effect=return_dicts
|
||||
)
|
||||
movement = {"x": 10, "y": 0}
|
||||
offset = rom_thing._move_and_measure(
|
||||
movement=movement,
|
||||
rom_deps=mock_rom_deps,
|
||||
perform_autofocus=False,
|
||||
max_autofocus_repeats=3,
|
||||
abs_min_offset=5,
|
||||
)
|
||||
# Check exactly 1 move
|
||||
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1
|
||||
# Check expected _offset_from calls
|
||||
assert offset_from_mock.call_count == n_offset_measures
|
||||
# The kwargs of the call should movement dict
|
||||
assert mock_rom_deps.csm.move_in_image_coordinates.call_args.kwargs == movement
|
||||
# Check autofocus call count is 1 less than number of offset measures as no autofocus
|
||||
# is performed before the first one
|
||||
expected_af_count = n_offset_measures - 1
|
||||
assert mock_rom_deps.autofocus.looping_autofocus.call_count == expected_af_count
|
||||
# And check final return
|
||||
assert offset == expected_return
|
||||
|
||||
|
||||
def test_move_and_measure_with_bad_refocus_args(rom_thing, mocker):
|
||||
"""Check error if abs_min_offset is not positive when using it to determine if to autofocus."""
|
||||
offset_from_mock = mocker.patch.object(
|
||||
rom_thing, "_offset_from", return_value={"x": 0, "y": 0}
|
||||
)
|
||||
|
||||
# First check with abs_min_offset not set. The default should be zero.
|
||||
with pytest.raises(ValueError, match="abs_min_offset must be positive"):
|
||||
rom_thing._move_and_measure(
|
||||
movement={"x": 10, "y": 0},
|
||||
rom_deps=mock_rom_deps,
|
||||
perform_autofocus=False,
|
||||
max_autofocus_repeats=3,
|
||||
)
|
||||
# Then check with abs_min_offset negative, this might happen if the the expected
|
||||
# move calculation is not made absolute.
|
||||
with pytest.raises(ValueError, match="abs_min_offset must be positive"):
|
||||
rom_thing._move_and_measure(
|
||||
movement={"x": 10, "y": 0},
|
||||
rom_deps=mock_rom_deps,
|
||||
perform_autofocus=False,
|
||||
max_autofocus_repeats=3,
|
||||
abs_min_offset=-123.456,
|
||||
)
|
||||
# Should have never measured and offset. Just error straight away.
|
||||
assert offset_from_mock.call_count == 0
|
||||
|
||||
|
||||
def test_move_back_until_motion_detected(rom_thing, mock_rom_deps, mocker):
|
||||
"""Check that _move_back_until_motion_detected is making increasing negative moves.
|
||||
|
||||
The moves for this method should be in opposite direction to the direction
|
||||
specified as this is moving back after the stage reaches end of its movement.
|
||||
"""
|
||||
mock_move_n_meas = mocker.patch.object(
|
||||
rom_thing, "_move_and_measure", return_value={"x": 0, "y": 0}
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Cannot detect motion again"):
|
||||
rom_thing._move_back_until_motion_detected("y", -1, rom_deps=mock_rom_deps)
|
||||
|
||||
max_tries = int(1.5 * stage_measure.BIG_STEP / stage_measure.SMALL_STEP)
|
||||
expected_step = 800 * stage_measure.SMALL_STEP / 100
|
||||
assert mock_move_n_meas.call_count == max_tries
|
||||
for _i, call_args in enumerate(mock_move_n_meas.call_args_list):
|
||||
call_args.kwargs["movement"] = {"x": 0, "y": expected_step}
|
||||
call_args.kwargs["perform_autofocus"] = False
|
||||
|
||||
## Reset mock and change the side effect
|
||||
mock_move_n_meas.reset_mock()
|
||||
mock_move_n_meas.side_effect = (
|
||||
{"x": 0, "y": 0},
|
||||
{"x": 0, "y": 0},
|
||||
{"x": expected_step * 0.8, "y": 0},
|
||||
)
|
||||
|
||||
# Other axis and direction this time
|
||||
rom_thing._move_back_until_motion_detected("x", 1, rom_deps=mock_rom_deps)
|
||||
|
||||
# Should only be called 3 times
|
||||
assert mock_move_n_meas.call_count == 3
|
||||
for _i, call_args in enumerate(mock_move_n_meas.call_args_list):
|
||||
call_args.kwargs["movement"] = {"x": -expected_step, "y": 0}
|
||||
call_args.kwargs["perform_autofocus"] = False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("good_moves", "expected_to_detect_motion", "offset_calls"),
|
||||
[
|
||||
([0, 1, 2], True, 3), # First 3 pass all good
|
||||
([1, 2, 3], True, 4), # First is bad, will refocus, still complete
|
||||
([2, 3, 4], True, 5), # First 2 are bad, will refocus twice, still complete
|
||||
([3, 4, 5], True, 6), # First 3 are bad, will refocus 3 times, still complete
|
||||
([4, 5, 6], False, 4), # First 4 are bad, fails
|
||||
# Check second and 3rd measurement can fail after first fails 3 times
|
||||
([3, 5, 7], True, 8),
|
||||
([3, 6, 9], True, 10),
|
||||
([3, 7, 11], True, 12),
|
||||
# But they can't fail 4 times
|
||||
([3, 8, 9], False, 8),
|
||||
([3, 7, 12], False, 12),
|
||||
],
|
||||
)
|
||||
def test_stage_still_moves(
|
||||
good_moves,
|
||||
expected_to_detect_motion,
|
||||
offset_calls,
|
||||
rom_thing,
|
||||
mock_rom_deps,
|
||||
mocker,
|
||||
):
|
||||
"""Test _stage_still_moves correctly detects stage movement."""
|
||||
min_offset = 800 * stage_measure.SMALL_STEP / 100 * stage_measure.DETECT_MOTION_TOL
|
||||
|
||||
def gen_offsets(*_args, **_kwargs):
|
||||
"""Generate offset dictionaries with small moves unless count matches ``good_moves``."""
|
||||
i = 0
|
||||
while True:
|
||||
x = min_offset * 1.2 if i in good_moves else 0.1
|
||||
yield {"x": x, "y": 0}
|
||||
i += 1
|
||||
|
||||
mock_offset_from = mocker.patch.object(
|
||||
rom_thing, "_offset_from", side_effect=gen_offsets()
|
||||
)
|
||||
|
||||
still_moves = rom_thing._stage_still_moves(
|
||||
axis="x",
|
||||
direction=1,
|
||||
rom_deps=mock_rom_deps,
|
||||
)
|
||||
assert still_moves is expected_to_detect_motion
|
||||
assert mock_offset_from.call_count == offset_calls
|
||||
|
||||
|
||||
def test_big_z_corrected_movement(rom_thing, mock_rom_deps):
|
||||
"""Check big z corrected move moves in x/y and z the expected distances."""
|
||||
mock_rom_deps.stage.position = {"x": 5000, "y": 30, "z": 500}
|
||||
|
||||
rom_thing._big_z_corrected_movement("x", direction=1, rom_deps=mock_rom_deps)
|
||||
|
||||
expected_movement = {"x": 800 * stage_measure.BIG_STEP / 100, "y": 0}
|
||||
|
||||
# Check there is one z move in steps
|
||||
assert mock_rom_deps.stage.move_relative.call_count == 1
|
||||
move_kwargs = mock_rom_deps.stage.move_relative.call_args.kwargs
|
||||
assert "x" not in move_kwargs
|
||||
assert "y" not in move_kwargs
|
||||
assert "z" in move_kwargs
|
||||
assert move_kwargs["z"] == 1148
|
||||
|
||||
# And one move in image coordinates
|
||||
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1
|
||||
lat_mov_kwargs = mock_rom_deps.csm.move_in_image_coordinates.call_args.kwargs
|
||||
assert lat_mov_kwargs == expected_movement
|
||||
|
||||
|
||||
def test_initial_moves_for_z_prediction(rom_thing, mock_rom_deps, mocker):
|
||||
"""Check the initial moves are of the correct size and are recorded."""
|
||||
# Mock the _offset_from and stage.position to return generated dictionaries that
|
||||
# increment each time they are called. (All values 0 the first time, all values 1
|
||||
# the second time ...)
|
||||
mocker.patch.object(
|
||||
rom_thing, "_offset_from", side_effect=increasing_xy_dict_generator()
|
||||
)
|
||||
type(mock_rom_deps.stage).position = mocker.PropertyMock(
|
||||
side_effect=increasing_xyz_dict_generator()
|
||||
)
|
||||
|
||||
expected_movement = {"x": -800 * stage_measure.MEDIUM_STEP / 100, "y": 0}
|
||||
|
||||
# Remove the mock RomData before starting
|
||||
rom_thing._rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
# Run it!
|
||||
rom_thing._initial_moves_for_z_prediction("x", direction=-1, rom_deps=mock_rom_deps)
|
||||
|
||||
# Check that _rom_data now contains the 5 mocked returns in order.
|
||||
assert rom_thing._rom_data.offsets == [{"x": i, "y": i} for i in range(5)]
|
||||
assert rom_thing._rom_data.stage_coords == [
|
||||
{"x": i, "y": i, "z": i} for i in range(5)
|
||||
]
|
||||
|
||||
# Check that the csm movement function is called 5 times
|
||||
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 5
|
||||
# Each time with the expected movement
|
||||
for arg_list in mock_rom_deps.csm.move_in_image_coordinates.call_args_list:
|
||||
assert arg_list.kwargs == expected_movement
|
||||
|
||||
|
||||
def test_move_until_edge_error(rom_thing, mock_rom_deps, mocker):
|
||||
"""Check that if there is an error while moving to the edge the stage returns to start."""
|
||||
mock_position_dict = {"x": 123, "y": 456, "z": 789}
|
||||
type(mock_rom_deps.stage).position = mocker.PropertyMock(
|
||||
return_value=mock_position_dict
|
||||
)
|
||||
mocker.patch.object(
|
||||
rom_thing, "_initial_moves_for_z_prediction", side_effect=RuntimeError("Mock")
|
||||
)
|
||||
|
||||
# Remove the mock RomData before starting
|
||||
rom_thing._rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
# 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, rom_deps=mock_rom_deps)
|
||||
# However the "finally" should have executed returning to the starting position
|
||||
assert mock_rom_deps.stage.move_absolute.call_count == 1
|
||||
abs_move_kwargs = mock_rom_deps.stage.move_absolute.call_args.kwargs
|
||||
expected_abs_move_kwargs = dict(**mock_position_dict, block_cancellation=True)
|
||||
assert abs_move_kwargs == expected_abs_move_kwargs
|
||||
|
||||
|
||||
def test_move_until_edge(rom_thing, mock_rom_deps, mocker):
|
||||
"""Check move until edge runs the correct movement sequence."""
|
||||
# Remove the mock RomData before starting
|
||||
rom_thing._rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
mock_rom_deps.stage.position = {"x": "mock", "y": "starting", "z": "pos"}
|
||||
|
||||
def add_fake_initial_positions(*_args, **_kwargs):
|
||||
"""Rather than run initial moves just add some fake data."""
|
||||
for i in range(5):
|
||||
rom_thing._rom_data.record_movement(f"mock-init-pos{i + 1}", "mock-offset")
|
||||
|
||||
def update_stage_pos_on_big_move(*_args, **_kwargs):
|
||||
"""With big moves update the stage position."""
|
||||
big_move_count = 1
|
||||
while True:
|
||||
mock_rom_deps.stage.position = f"mocked-big-move-pos{big_move_count}"
|
||||
yield
|
||||
big_move_count += 1
|
||||
|
||||
def set_final_pos(*_args, **_kwargs):
|
||||
"""When move_back_until_motion_detected is run set a final stage position."""
|
||||
mock_rom_deps.stage.position = "mock-final-pos"
|
||||
|
||||
# Mock the main movement functions
|
||||
mock_init_moves = mocker.patch.object(
|
||||
rom_thing,
|
||||
"_initial_moves_for_z_prediction",
|
||||
side_effect=add_fake_initial_positions,
|
||||
)
|
||||
mock_big_moves = mocker.patch.object(
|
||||
rom_thing,
|
||||
"_big_z_corrected_movement",
|
||||
side_effect=update_stage_pos_on_big_move(),
|
||||
)
|
||||
mock_move_check = mocker.patch.object(
|
||||
rom_thing, "_stage_still_moves", side_effect=[True] * 9 + [False]
|
||||
)
|
||||
mock_move_back = mocker.patch.object(
|
||||
rom_thing, "_move_back_until_motion_detected", side_effect=set_final_pos
|
||||
)
|
||||
|
||||
# Remove the mock RomData before starting
|
||||
rom_thing._rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
# Run function
|
||||
rom_thing._move_until_edge("y", direction=-1, rom_deps=mock_rom_deps)
|
||||
|
||||
# Check the call counts are as expected
|
||||
# One call of initial moves
|
||||
assert mock_init_moves.call_count == 1
|
||||
# Big moves and the check the stage is moving are each called 10 times as the mock
|
||||
# for _stage_still_moves replies False on the 10th call.
|
||||
assert mock_big_moves.call_count == 10
|
||||
assert mock_move_check.call_count == 10
|
||||
# And one call of _move_back_until_motion_detected
|
||||
assert mock_move_back.call_count == 1
|
||||
|
||||
assert rom_thing._rom_data.stage_coords == [
|
||||
{"x": "mock", "y": "starting", "z": "pos"},
|
||||
"mock-init-pos1",
|
||||
"mock-init-pos2",
|
||||
"mock-init-pos3",
|
||||
"mock-init-pos4",
|
||||
"mock-init-pos5",
|
||||
"mocked-big-move-pos1",
|
||||
"mocked-big-move-pos2",
|
||||
"mocked-big-move-pos3",
|
||||
"mocked-big-move-pos4",
|
||||
"mocked-big-move-pos5",
|
||||
"mocked-big-move-pos6",
|
||||
"mocked-big-move-pos7",
|
||||
"mocked-big-move-pos8",
|
||||
"mocked-big-move-pos9",
|
||||
"mock-final-pos", # This overwrites the 10th big move position recording.
|
||||
]
|
||||
|
||||
|
||||
def test_perform_rom_test_locked(rom_thing, mock_rom_deps):
|
||||
"""Check the error if the Rom test is locked."""
|
||||
# Not an RLock so no need to thread.
|
||||
rom_thing._lock.acquire()
|
||||
err_msg = "Trying to run ROM test when a test is already running."
|
||||
with pytest.raises(RuntimeError, match=err_msg):
|
||||
rom_thing.perform_rom_test(**dataclasses.asdict(mock_rom_deps))
|
||||
|
||||
|
||||
def test_perform_rom_test_(rom_thing, mock_rom_deps, mocker):
|
||||
"""Check that perform Rom Test runs the expected high level algorithm."""
|
||||
|
||||
def check_and_modify_rom_data(axis: str, direction: int, **_kwargs):
|
||||
"""Check the rom_data is empty each time, and add a final position."""
|
||||
# check rom_data was cleared at the start of this run
|
||||
assert len(rom_thing._rom_data.stage_coords) == 0
|
||||
dist = 1111 if axis == "x" else 2222
|
||||
final_pos = {"x": 0, "y": 0}
|
||||
final_pos[axis] = dist * direction
|
||||
rom_thing._rom_data.stage_coords.append(final_pos)
|
||||
|
||||
mock_move_until_edge = mocker.patch.object(
|
||||
rom_thing, "_move_until_edge", side_effect=check_and_modify_rom_data
|
||||
)
|
||||
|
||||
mocker.patch.object(
|
||||
mock_rom_deps.cam, "grab_as_array", return_value=np.zeros([123, 456, 3])
|
||||
)
|
||||
final_dict = rom_thing.perform_rom_test(**dataclasses.asdict(mock_rom_deps))
|
||||
|
||||
# This should take less than 1 sec
|
||||
assert final_dict["Time"] <= 1
|
||||
# CSM should be 2x2 list
|
||||
assert isinstance(final_dict["CSM Matrix"], list)
|
||||
assert len(final_dict["CSM Matrix"]) == 2
|
||||
assert len(final_dict["CSM Matrix"][0]) == 2
|
||||
assert len(final_dict["CSM Matrix"][1]) == 2
|
||||
# And step range should be [2222, 4444]
|
||||
assert final_dict["Step Range"] == [2222, 4444]
|
||||
|
||||
# Check that the axes were called in the expected order.
|
||||
assert mock_move_until_edge.call_count == 4
|
||||
|
||||
assert mock_move_until_edge.call_args_list[0].kwargs["axis"] == "x"
|
||||
assert mock_move_until_edge.call_args_list[0].kwargs["direction"] == 1
|
||||
|
||||
assert mock_move_until_edge.call_args_list[1].kwargs["axis"] == "x"
|
||||
assert mock_move_until_edge.call_args_list[1].kwargs["direction"] == -1
|
||||
|
||||
assert mock_move_until_edge.call_args_list[2].kwargs["axis"] == "y"
|
||||
assert mock_move_until_edge.call_args_list[2].kwargs["direction"] == 1
|
||||
|
||||
assert mock_move_until_edge.call_args_list[3].kwargs["axis"] == "y"
|
||||
assert mock_move_until_edge.call_args_list[3].kwargs["direction"] == -1
|
||||
Loading…
Add table
Add a link
Reference in a new issue