Merge branch 'continue-removing-deps' into 'v3'
Removing dependencies from CSM, autofocus, and stage_measure See merge request openflexure/openflexure-microscope-server!452
This commit is contained in:
commit
150e69d235
7 changed files with 279 additions and 493 deletions
|
|
@ -10,20 +10,18 @@ See repository root for licensing information.
|
|||
import logging
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Literal, Mapping, Optional, Sequence
|
||||
from types import TracebackType
|
||||
from typing import Literal, Mapping, Optional, Self, Sequence
|
||||
|
||||
import numpy as np
|
||||
from fastapi import Depends
|
||||
from pydantic import BaseModel, computed_field, field_validator, model_validator
|
||||
|
||||
import labthings_fastapi as lt
|
||||
from labthings_fastapi.types.numpy import NDArray
|
||||
|
||||
from .camera import CameraDependency as CameraClient
|
||||
from .camera import RawCameraDependency as RawCamera
|
||||
from .stage import StageDependency as Stage
|
||||
from .camera import BaseCamera
|
||||
from .stage import BaseStage
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
MIN_TEST_IMAGE_COUNT = 3
|
||||
|
|
@ -220,24 +218,18 @@ class JPEGSharpnessMonitor:
|
|||
``data_dict`` and data with interpolated ``z`` positions can be retrieved with
|
||||
move_data.
|
||||
|
||||
A new JPEGSharpnessMonitor instance is created each time an action with the
|
||||
SharpnessMonitorDep as an argument is called.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, stage: Stage, camera: RawCamera, portal: lt.deps.BlockingPortal
|
||||
) -> None:
|
||||
def __init__(self, stage: BaseStage, camera: BaseCamera) -> None:
|
||||
"""Initialise a new JPEGSharpnessMonitor. The args are injected automatically.
|
||||
|
||||
:param stage: A direct_thing_client dependency for the the microscope stage.
|
||||
:param camera: A raw_thing_client depeendency for the camera. This is a raw
|
||||
dependency as the underlying class needs to be
|
||||
:param portal: The asyncio blocking portal for asynchronous task scheduling.
|
||||
"""
|
||||
self.camera = camera
|
||||
self.stage = stage
|
||||
self.portal = portal
|
||||
LOGGER.debug(f"Created sharpness monitor with {stage}, {camera}, {portal}")
|
||||
LOGGER.debug(f"Created sharpness monitor with {stage}, {camera}")
|
||||
self.stage_positions: list[Mapping[str, int]] = []
|
||||
self.stage_times: list[float] = []
|
||||
self.jpeg_times: list[float] = []
|
||||
|
|
@ -254,14 +246,23 @@ class JPEGSharpnessMonitor:
|
|||
if not self.running:
|
||||
break
|
||||
|
||||
@contextmanager
|
||||
def run(self) -> None:
|
||||
"""Context manager, during which we will monitor sharpness from the camera."""
|
||||
self.portal.start_task_soon(self.monitor_sharpness)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.running = False
|
||||
def __enter__(self) -> Self:
|
||||
"""Start context manager, during which sharpness from the camera is monitored."""
|
||||
# Use the cameras _thing_server_interface to get access to the server event loop
|
||||
# and start a task.
|
||||
self.camera._thing_server_interface.start_async_task_soon(
|
||||
self.monitor_sharpness
|
||||
)
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
_exc_type: type[BaseException],
|
||||
_exc_value: Optional[BaseException],
|
||||
_traceback: Optional[TracebackType],
|
||||
) -> None:
|
||||
"""Clean up after context manager is closed."""
|
||||
self.running = False
|
||||
|
||||
def focus_rel(self, dz: int, block_cancellation: int = False) -> tuple[int, int]:
|
||||
"""Move the stage by dz, monitoring the position over time.
|
||||
|
|
@ -339,9 +340,6 @@ class JPEGSharpnessMonitor:
|
|||
return SharpnessDataArrays(**data)
|
||||
|
||||
|
||||
SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()]
|
||||
|
||||
|
||||
class AutofocusThing(lt.Thing):
|
||||
"""The Thing concerned with combinations of z axis movements and the camera.
|
||||
|
||||
|
|
@ -350,10 +348,12 @@ class AutofocusThing(lt.Thing):
|
|||
field of view to assess focus (autofocus and testing the success of a z-stack)
|
||||
"""
|
||||
|
||||
_cam: BaseCamera = lt.thing_slot()
|
||||
_stage: BaseStage = lt.thing_slot()
|
||||
|
||||
@lt.action
|
||||
def fast_autofocus(
|
||||
self,
|
||||
sharpness_monitor: SharpnessMonitorDep,
|
||||
dz: int = 2000,
|
||||
start: Literal["centre", "base"] = "centre",
|
||||
) -> SharpnessDataArrays:
|
||||
|
|
@ -363,7 +363,7 @@ class AutofocusThing(lt.Thing):
|
|||
the position where the image was sharpest. We'll then move back down, and
|
||||
finally up to the sharpest point.
|
||||
"""
|
||||
with sharpness_monitor.run():
|
||||
with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor:
|
||||
# Move to (-dz / 2)
|
||||
if start == "centre":
|
||||
sharpness_monitor.focus_rel(-dz // 2)
|
||||
|
|
@ -384,7 +384,6 @@ class AutofocusThing(lt.Thing):
|
|||
@lt.action
|
||||
def z_move_and_measure_sharpness(
|
||||
self,
|
||||
sharpness_monitor: SharpnessMonitorDep,
|
||||
dz: Sequence[int],
|
||||
wait: float = 0,
|
||||
) -> SharpnessDataArrays:
|
||||
|
|
@ -400,7 +399,7 @@ class AutofocusThing(lt.Thing):
|
|||
If ``wait`` is specified, we will wait for that many seconds
|
||||
between moves.
|
||||
"""
|
||||
with sharpness_monitor.run():
|
||||
with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor:
|
||||
for move_index, current_dz in enumerate(dz):
|
||||
if move_index > 0 and wait > 0:
|
||||
time.sleep(wait)
|
||||
|
|
@ -410,8 +409,6 @@ class AutofocusThing(lt.Thing):
|
|||
@lt.action
|
||||
def looping_autofocus(
|
||||
self,
|
||||
stage: Stage,
|
||||
sharpness_monitor: SharpnessMonitorDep,
|
||||
dz: int = 2000,
|
||||
start: Literal["centre", "base"] = "centre",
|
||||
) -> tuple[list[float], list[float]]:
|
||||
|
|
@ -425,12 +422,12 @@ class AutofocusThing(lt.Thing):
|
|||
attempt = 0
|
||||
backlash = 200
|
||||
|
||||
with sharpness_monitor.run():
|
||||
with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor:
|
||||
while attempt < 10:
|
||||
attempt += 1
|
||||
if start == "centre":
|
||||
stage.move_relative(x=0, y=0, z=-(backlash + dz / 2))
|
||||
stage.move_relative(x=0, y=0, z=backlash)
|
||||
self._stage.move_relative(x=0, y=0, z=-(backlash + dz / 2))
|
||||
self._stage.move_relative(x=0, y=0, z=backlash)
|
||||
|
||||
# Always start centrally for future runs
|
||||
start = "centre"
|
||||
|
|
@ -445,8 +442,8 @@ class AutofocusThing(lt.Thing):
|
|||
target_max = np.max(heights) - dz / 5
|
||||
|
||||
# move to the peak
|
||||
stage.move_absolute(z=peak_height - backlash)
|
||||
stage.move_absolute(z=peak_height)
|
||||
self._stage.move_absolute(z=peak_height - backlash)
|
||||
self._stage.move_absolute(z=peak_height)
|
||||
|
||||
if target_min < peak_height < target_max:
|
||||
# If it is within the target range then return
|
||||
|
|
@ -487,28 +484,26 @@ class AutofocusThing(lt.Thing):
|
|||
images_dir: str,
|
||||
autofocus_dz: int,
|
||||
save_resolution: tuple[int, int],
|
||||
logger: lt.deps.InvocationLogger,
|
||||
) -> StackParams:
|
||||
"""Set up the parameters used for all stacks in a scan.
|
||||
|
||||
:param images_dir: the folder to save all images
|
||||
:param autofocus_dz: the range to autofocus over if a stack fails
|
||||
:param save_resolution: The resolution to save the captures to disk with
|
||||
:param logger: A logger passed from the calling Thing
|
||||
|
||||
:returns: A StackParams object with the required parameters.
|
||||
"""
|
||||
# Coerce min_images_to_test parameter
|
||||
min_images_to_test = self.stack_min_images_to_test
|
||||
if min_images_to_test < MIN_TEST_IMAGE_COUNT:
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
f"Cannot test only {min_images_to_test} image(s) as this will fail. "
|
||||
"Setting min images to test to lowest possible value of"
|
||||
f"{MIN_TEST_IMAGE_COUNT}."
|
||||
)
|
||||
min_images_to_test = MIN_TEST_IMAGE_COUNT
|
||||
elif min_images_to_test > MAX_TEST_IMAGE_COUNT:
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
f"Testing {min_images_to_test} images will cause defocus. "
|
||||
"Setting min images to test to highest possible value of "
|
||||
f"{MAX_TEST_IMAGE_COUNT}."
|
||||
|
|
@ -516,7 +511,7 @@ class AutofocusThing(lt.Thing):
|
|||
min_images_to_test = MAX_TEST_IMAGE_COUNT
|
||||
elif min_images_to_test % 2 == 0:
|
||||
min_images_to_test += 1
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
"Minimum number of images to test should be odd, setting to "
|
||||
f"{min_images_to_test}."
|
||||
)
|
||||
|
|
@ -527,19 +522,19 @@ class AutofocusThing(lt.Thing):
|
|||
# min_images_to_save
|
||||
images_to_save = self.stack_images_to_save
|
||||
if images_to_save <= 0:
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
"At least 1 images must be saved. Setting images to save to 1."
|
||||
)
|
||||
images_to_save = 1
|
||||
elif images_to_save > min_images_to_test:
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
f"Cannot save {images_to_save} images as this above the minimum "
|
||||
f"number to test. Setting images to save to {MAX_TEST_IMAGE_COUNT}."
|
||||
)
|
||||
images_to_save = min_images_to_test
|
||||
elif images_to_save % 2 == 0:
|
||||
images_to_save += 1
|
||||
logger.warning(
|
||||
self.logger.warning(
|
||||
f"Images to save should be odd, setting to {images_to_save}."
|
||||
)
|
||||
# Set the Thing property to the coerced value
|
||||
|
|
@ -557,9 +552,6 @@ class AutofocusThing(lt.Thing):
|
|||
@lt.action
|
||||
def run_smart_stack(
|
||||
self,
|
||||
cam: CameraClient,
|
||||
stage: Stage,
|
||||
sharpness_monitor: SharpnessMonitorDep,
|
||||
stack_parameters: StackParams,
|
||||
save_on_failure: bool = False,
|
||||
check_turning_points: bool = True,
|
||||
|
|
@ -572,11 +564,6 @@ class AutofocusThing(lt.Thing):
|
|||
The sharpest image, and optionally images around the sharpest, will be saved
|
||||
to the images_dir with their coordinates in the filename.
|
||||
|
||||
|
||||
:param cam: Camera Dependency supplied by LabThings dependency injection
|
||||
:param stage: Stage Dependency supplied by LabThings dependency injection
|
||||
:param sharpness_monitor: Sharpness Monitor Dependency (for focus detection)
|
||||
supplied by LabThings dependency injection
|
||||
:param stack_parameters: A StackParams object containing the required
|
||||
parameters to run a stack.
|
||||
:param save_on_failure: Whether to save an image even if no focus was found.
|
||||
|
|
@ -595,8 +582,6 @@ class AutofocusThing(lt.Thing):
|
|||
success, captures, sharpest_id = self.z_stack(
|
||||
stack_parameters=stack_parameters,
|
||||
check_turning_points=check_turning_points,
|
||||
cam=cam,
|
||||
stage=stage,
|
||||
)
|
||||
|
||||
if success:
|
||||
|
|
@ -609,12 +594,7 @@ class AutofocusThing(lt.Thing):
|
|||
initial_z_pos = captures[0].position["z"]
|
||||
# If a stack is not successful, move to the start and autofocus
|
||||
try:
|
||||
self.reset_stack(
|
||||
initial_z_pos,
|
||||
stack_parameters.autofocus_dz,
|
||||
stage,
|
||||
sharpness_monitor,
|
||||
)
|
||||
self.reset_stack(initial_z_pos, stack_parameters.autofocus_dz)
|
||||
except NoFocusFoundError:
|
||||
break
|
||||
|
||||
|
|
@ -626,7 +606,6 @@ class AutofocusThing(lt.Thing):
|
|||
sharpest_id=sharpest_id,
|
||||
captures=captures,
|
||||
stack_parameters=stack_parameters,
|
||||
cam=cam,
|
||||
)
|
||||
|
||||
return success, _get_capture_by_id(captures, sharpest_id).position["z"]
|
||||
|
|
@ -635,8 +614,6 @@ class AutofocusThing(lt.Thing):
|
|||
self,
|
||||
initial_z_pos: list[int],
|
||||
autofocus_dz: int,
|
||||
stage: Stage,
|
||||
sharpness_monitor: SharpnessMonitorDep,
|
||||
) -> None:
|
||||
"""Return to the initial z position and run a looping autofocus.
|
||||
|
||||
|
|
@ -646,19 +623,14 @@ class AutofocusThing(lt.Thing):
|
|||
``stage`` and ``sharpness_monitor`` are Thing dependencies passed through
|
||||
from the calling action.
|
||||
"""
|
||||
stage.move_absolute(z=initial_z_pos)
|
||||
self.looping_autofocus(
|
||||
stage=stage,
|
||||
sharpness_monitor=sharpness_monitor,
|
||||
dz=autofocus_dz,
|
||||
)
|
||||
self._stage.move_absolute(z=initial_z_pos)
|
||||
self.looping_autofocus(dz=autofocus_dz)
|
||||
|
||||
def save_stack(
|
||||
self,
|
||||
sharpest_id: int,
|
||||
captures: list[list],
|
||||
stack_parameters: StackParams,
|
||||
cam: CameraClient,
|
||||
) -> int:
|
||||
"""Save the required captures to disk.
|
||||
|
||||
|
|
@ -669,28 +641,24 @@ class AutofocusThing(lt.Thing):
|
|||
:param captures: a list of captures, including file name, image data and
|
||||
metadata
|
||||
:param stack_parameters: a StackParams object holding stack parameters
|
||||
:param cam: is a Thing dependency passed through from the calling action
|
||||
|
||||
"""
|
||||
sharpest_index = _get_capture_index_by_id(captures, sharpest_id)
|
||||
slice_to_save = stack_parameters.slice_to_save(sharpest_index)
|
||||
|
||||
# Loop through the range, saving each capture to disk
|
||||
for capture in captures[slice_to_save]:
|
||||
cam.save_from_memory(
|
||||
self._cam.save_from_memory(
|
||||
jpeg_path=os.path.join(stack_parameters.images_dir, capture.filename),
|
||||
save_resolution=stack_parameters.save_resolution,
|
||||
buffer_id=capture.buffer_id,
|
||||
)
|
||||
cam.clear_buffers()
|
||||
self._cam.clear_buffers()
|
||||
return sharpest_index
|
||||
|
||||
def z_stack(
|
||||
self,
|
||||
stack_parameters: StackParams,
|
||||
check_turning_points: bool,
|
||||
cam: CameraClient,
|
||||
stage: Stage,
|
||||
) -> tuple[bool, list[CaptureInfo], int]:
|
||||
"""Capture a series of images checking that sharpest image central.
|
||||
|
||||
|
|
@ -703,8 +671,6 @@ class AutofocusThing(lt.Thing):
|
|||
:param check_turning_points: Whether to check the number of turning points in
|
||||
the sharpnesses of the images in the stack is exactly 1. (May fail with
|
||||
thick samples)
|
||||
:param cam: Camera Dependency to be passed through from the calling action
|
||||
:param stage: Stage Dependency to be passed through from the calling action
|
||||
|
||||
:returns: A tuple of
|
||||
|
||||
|
|
@ -714,14 +680,14 @@ class AutofocusThing(lt.Thing):
|
|||
"""
|
||||
# Move down by the height of the z stack, plus an overshoot
|
||||
# Better to start too low and take too many images than too high and need to refocus
|
||||
stage.move_relative(
|
||||
self._stage.move_relative(
|
||||
z=-(
|
||||
stack_parameters.steps_undershoot
|
||||
+ stack_parameters.backlash_correction
|
||||
+ stack_parameters.stack_z_range / 2
|
||||
)
|
||||
)
|
||||
stage.move_relative(z=stack_parameters.backlash_correction)
|
||||
self._stage.move_relative(z=stack_parameters.backlash_correction)
|
||||
|
||||
captures = []
|
||||
# Always check for focus using the the last `min_images_to_test` in the
|
||||
|
|
@ -735,11 +701,7 @@ class AutofocusThing(lt.Thing):
|
|||
|
||||
# Append a new image to the stack
|
||||
captures.append(
|
||||
self.capture_stack_image(
|
||||
cam,
|
||||
stage,
|
||||
buffer_max=stack_parameters.min_images_to_test,
|
||||
)
|
||||
self.capture_stack_image(buffer_max=stack_parameters.min_images_to_test)
|
||||
)
|
||||
|
||||
# If the number of images is enough to test, test them
|
||||
|
|
@ -754,33 +716,26 @@ class AutofocusThing(lt.Thing):
|
|||
if result == "restart":
|
||||
return False, captures, capture_id
|
||||
# If reached here the result was "continue"
|
||||
stage.move_relative(z=stack_parameters.stack_dz)
|
||||
self._stage.move_relative(z=stack_parameters.stack_dz)
|
||||
return False, captures, capture_id
|
||||
|
||||
def capture_stack_image(
|
||||
self,
|
||||
cam: CameraClient,
|
||||
stage: Stage,
|
||||
buffer_max: int,
|
||||
) -> CaptureInfo:
|
||||
def capture_stack_image(self, buffer_max: int) -> CaptureInfo:
|
||||
"""Capture another image and return the capture information.
|
||||
|
||||
The capture is stored by the camera Thing, and can be saved by ID.
|
||||
|
||||
:param cam: Camera Dependency to be passed through from the calling action
|
||||
:param stage: Stage Dependency to be passed through from the calling action
|
||||
:param buffer_max: The maximum number of images to tell the camera to keep in memory
|
||||
for saving once the stack is complete
|
||||
|
||||
:returns: A CaptureInfo object containing the capture information including its
|
||||
camera buffer_id needed for saving.
|
||||
"""
|
||||
stage_location = stage.position
|
||||
buffer_id = cam.capture_to_memory(buffer_max=buffer_max)
|
||||
stage_location = self._stage.position
|
||||
buffer_id = self._cam.capture_to_memory(buffer_max=buffer_max)
|
||||
return CaptureInfo(
|
||||
buffer_id=buffer_id,
|
||||
position=stage_location,
|
||||
sharpness=cam.grab_jpeg_size(stream_name="lores"),
|
||||
sharpness=self._cam.grab_jpeg_size(stream_name="lores"),
|
||||
)
|
||||
|
||||
# Silence too many returns in this situation as refactoring to reduce returns is
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ from camera_stage_mapping.camera_stage_tracker import Tracker
|
|||
from camera_stage_mapping.exceptions import MappingError
|
||||
from labthings_fastapi.types.numpy import DenumpifyingDict
|
||||
|
||||
from .camera import CameraDependency as CameraClient
|
||||
from .stage import StageDependency as Stage
|
||||
from .camera import BaseCamera
|
||||
from .stage import BaseStage
|
||||
|
||||
CoordinateType = Tuple[float, float, float]
|
||||
XYCoordinateType = Tuple[float, float]
|
||||
|
|
@ -62,13 +62,13 @@ class RecordedMove:
|
|||
for calibrating the stage as it allows measuring how long moves take.
|
||||
"""
|
||||
|
||||
def __init__(self, stage: Stage) -> None:
|
||||
def __init__(self, stage: BaseStage) -> None:
|
||||
"""Set the stage client used for for movement.
|
||||
|
||||
:param stage: the stage client to be used. ``stage.move_to_xyz_position`` will
|
||||
be called whenever the instance is called.
|
||||
"""
|
||||
self._stage: Stage = stage
|
||||
self._stage = stage
|
||||
self._current_position: Optional[CoordinateType] = None
|
||||
self._history: List[Tuple[float, Optional[CoordinateType]]] = []
|
||||
|
||||
|
|
@ -118,58 +118,53 @@ class CameraStageMapper(lt.Thing):
|
|||
override the ``get_xyz_position()`` and ``move_to_xyz_position()`` methods.
|
||||
"""
|
||||
|
||||
_cam: BaseCamera = lt.thing_slot()
|
||||
_stage: BaseStage = lt.thing_slot()
|
||||
|
||||
@lt.action
|
||||
def calibrate_1d(
|
||||
self,
|
||||
camera: CameraClient,
|
||||
stage: Stage,
|
||||
logger: lt.deps.InvocationLogger,
|
||||
direction: Tuple[float, float, float],
|
||||
) -> DenumpifyingDict:
|
||||
def calibrate_1d(self, direction: Tuple[float, float, float]) -> DenumpifyingDict:
|
||||
"""Move a microscope's stage in 1D, and figure out the relationship with the camera."""
|
||||
# Record positions and times for stage calibration
|
||||
recorded_move = RecordedMove(stage)
|
||||
recorded_move = RecordedMove(self._stage)
|
||||
tracker = Tracker(
|
||||
camera.capture_downsampled_array,
|
||||
stage.get_xyz_position,
|
||||
settle=camera.settle,
|
||||
self._cam.capture_downsampled_array,
|
||||
self._stage.get_xyz_position,
|
||||
settle=self._cam.settle,
|
||||
)
|
||||
direction_array: np.ndarray = np.array(direction)
|
||||
|
||||
starting_position = stage.position
|
||||
starting_position = self._stage.position
|
||||
try:
|
||||
result: dict = calibrate_backlash_1d(
|
||||
tracker, recorded_move, direction_array, logger=logger
|
||||
tracker, recorded_move, direction_array, logger=self.logger
|
||||
)
|
||||
except lt.exceptions.InvocationCancelledError as e:
|
||||
logger.info("User cancelled the camera stage mapping calibration")
|
||||
logger.info("Returning to starting position")
|
||||
stage.move_absolute(**starting_position, block_cancellation=True)
|
||||
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)
|
||||
raise e
|
||||
except MappingError as e:
|
||||
logger.info("Returning to starting position due to failed calibration")
|
||||
stage.move_absolute(**starting_position, block_cancellation=True)
|
||||
self.logger.info("Returning to starting position due to failed calibration")
|
||||
self._stage.move_absolute(**starting_position, block_cancellation=True)
|
||||
raise e
|
||||
result["move_history"] = recorded_move.history
|
||||
result["image_resolution"] = camera.capture_downsampled_array().shape[:2]
|
||||
result["image_resolution"] = self._cam.capture_downsampled_array().shape[:2]
|
||||
return result
|
||||
|
||||
@lt.action
|
||||
def calibrate_xy(
|
||||
self, camera: CameraClient, stage: Stage, logger: lt.deps.InvocationLogger
|
||||
) -> DenumpifyingDict:
|
||||
def calibrate_xy(self) -> DenumpifyingDict:
|
||||
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera.
|
||||
|
||||
This performs two 1d calibrations in x and y, then combines their results.
|
||||
"""
|
||||
downsampling_factor = camera.downsampled_array_factor
|
||||
downsampling_factor = self._cam.downsampled_array_factor
|
||||
# Calibrate y-axis first as it is more likely to fail.
|
||||
# The x-y difference is due to the camera aspect ratio, not the stage hardware.
|
||||
logger.info("Calibrating Y axis:")
|
||||
cal_y: dict = self.calibrate_1d(camera, stage, logger, (0, 1, 0))
|
||||
logger.info("Calibrating X axis:")
|
||||
cal_x: dict = self.calibrate_1d(camera, stage, logger, (1, 0, 0))
|
||||
logger.info("Calibration complete, updating metadata.")
|
||||
self.logger.info("Calibrating Y axis:")
|
||||
cal_y: dict = self.calibrate_1d((0, 1, 0))
|
||||
self.logger.info("Calibrating X axis:")
|
||||
cal_x: dict = self.calibrate_1d((1, 0, 0))
|
||||
self.logger.info("Calibration complete, updating metadata.")
|
||||
|
||||
# Combine X and Y calibrations to make a 2D calibration
|
||||
cal_xy: dict = image_to_stage_displacement_from_1d([cal_x, cal_y])
|
||||
|
|
@ -185,7 +180,7 @@ class CameraStageMapper(lt.Thing):
|
|||
f"[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],"
|
||||
f"[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]"
|
||||
)
|
||||
logger.info(f"CSM matrix is {csm_as_string}.")
|
||||
self.logger.info(f"CSM matrix is {csm_as_string}.")
|
||||
|
||||
data: Dict[str, dict] = {
|
||||
"camera_stage_mapping_calibration": cal_xy,
|
||||
|
|
@ -201,9 +196,7 @@ class CameraStageMapper(lt.Thing):
|
|||
return data
|
||||
|
||||
@lt.property
|
||||
def image_to_stage_displacement_matrix(
|
||||
self,
|
||||
) -> Optional[List[List[float]]]: # 2x2 integer array
|
||||
def image_to_stage_displacement_matrix(self) -> Optional[List[List[float]]]:
|
||||
"""A 2x2 matrix that converts displacement in image coordinates to stage coordinates.
|
||||
|
||||
Note that this matrix is defined using "matrix coordinates", i.e. image coordinates
|
||||
|
|
@ -253,12 +246,7 @@ class CameraStageMapper(lt.Thing):
|
|||
raise CSMUncalibratedError() # noqa: RSE102
|
||||
|
||||
@lt.action
|
||||
def move_in_image_coordinates(
|
||||
self,
|
||||
stage: Stage,
|
||||
x: float,
|
||||
y: float,
|
||||
) -> None:
|
||||
def move_in_image_coordinates(self, x: float, y: float) -> None:
|
||||
"""Move by a given number of pixels on the camera.
|
||||
|
||||
NB x and y here refer to what is usually understood to be the horizontal and
|
||||
|
|
@ -271,7 +259,7 @@ 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))
|
||||
self._stage.move_relative(**self.convert_image_to_stage_coordinates(x=x, y=y))
|
||||
|
||||
@lt.action
|
||||
def convert_image_to_stage_coordinates(
|
||||
|
|
|
|||
|
|
@ -378,7 +378,6 @@ class SmartScanThing(lt.Thing):
|
|||
images_dir=self._ongoing_scan.images_dir,
|
||||
autofocus_dz=self.autofocus_dz,
|
||||
save_resolution=self._scan_data.save_resolution,
|
||||
logger=self._scan_logger,
|
||||
)
|
||||
self._preview_stitcher = stitching.PreviewStitcher(
|
||||
self._ongoing_scan.images_dir,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ this is 10% of the expected motion is the measured axis.
|
|||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
from typing import Any, Literal, Optional, overload
|
||||
|
||||
|
|
@ -25,14 +24,9 @@ from camera_stage_mapping import fft_image_tracking
|
|||
|
||||
# Things
|
||||
from .autofocus import AutofocusThing
|
||||
from .camera import CameraDependency as CamDep
|
||||
from .camera import BaseCamera
|
||||
from .camera_stage_mapping import CameraStageMapper
|
||||
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")
|
||||
from .stage import BaseStage
|
||||
|
||||
## Size of movement in percentage of field of view
|
||||
SMALL_STEP = 20
|
||||
|
|
@ -111,20 +105,6 @@ class RomDataTracker:
|
|||
return {axis: int(turning_loc), other_axis: other_coord, "z": int(turning_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.
|
||||
|
||||
|
|
@ -136,6 +116,11 @@ class ParasiticMotionError(Exception):
|
|||
class RangeofMotionThing(lt.Thing):
|
||||
"""A class used to measure the range of motion of the stage in X and Y."""
|
||||
|
||||
_autofocus: AutofocusThing = lt.thing_slot()
|
||||
_cam: BaseCamera = lt.thing_slot()
|
||||
_csm: CameraStageMapper = lt.thing_slot()
|
||||
_stage: BaseStage = lt.thing_slot()
|
||||
|
||||
calibrated_range: Optional[list[int, int]] = lt.setting(default=None, readonly=True)
|
||||
|
||||
def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None:
|
||||
|
|
@ -146,21 +131,9 @@ class RangeofMotionThing(lt.Thing):
|
|||
self._rom_data = RomDataTracker()
|
||||
|
||||
@lt.action
|
||||
def perform_rom_test(
|
||||
self,
|
||||
autofocus: AutofocusDep,
|
||||
stage: StageDep,
|
||||
cam: CamDep,
|
||||
csm: CSMDep,
|
||||
logger: lt.deps.InvocationLogger,
|
||||
) -> dict[str, Any]:
|
||||
def perform_rom_test(self) -> 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)
|
||||
|
|
@ -168,17 +141,14 @@ class RangeofMotionThing(lt.Thing):
|
|||
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(
|
||||
self.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)
|
||||
self._set_stream_resolution()
|
||||
|
||||
# The total range for the two axes under test
|
||||
step_range = []
|
||||
|
|
@ -193,84 +163,61 @@ class RangeofMotionThing(lt.Thing):
|
|||
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
|
||||
)
|
||||
self._move_until_edge(axis=axis, direction=axis_dir)
|
||||
# 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.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,
|
||||
"CSM Matrix": self._csm.image_to_stage_displacement_matrix,
|
||||
"Step Range": step_range,
|
||||
}
|
||||
|
||||
@lt.action
|
||||
def perform_recentre(
|
||||
self,
|
||||
autofocus: AutofocusDep,
|
||||
stage: StageDep,
|
||||
cam: CamDep,
|
||||
csm: CSMDep,
|
||||
logger: lt.deps.InvocationLogger,
|
||||
) -> None:
|
||||
"""Measures the curvature of the motion to centre 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.
|
||||
"""
|
||||
def perform_recentre(self) -> None:
|
||||
"""Measures the curvature of the motion to centre x and y axes."""
|
||||
got_lock = self._lock.acquire(blocking=False)
|
||||
if not got_lock:
|
||||
raise RuntimeError("Trying to run recentre when a test is already running.")
|
||||
|
||||
try:
|
||||
rom_deps = RomDeps(
|
||||
autofocus=autofocus, stage=stage, csm=csm, cam=cam, logger=logger
|
||||
)
|
||||
logger.info("Recentring the stage.")
|
||||
self.logger.info("Recentring the stage.")
|
||||
|
||||
self._set_stream_resolution(cam)
|
||||
self._set_stream_resolution()
|
||||
|
||||
# Strictly typed definitions to iterate over for MyPys sake.
|
||||
axes: tuple[Literal["x"], Literal["y"]] = ("x", "y")
|
||||
|
||||
for axis in axes:
|
||||
self._recentre_axis(axis, rom_deps)
|
||||
self._recentre_axis(axis)
|
||||
|
||||
centre = rom_deps.stage.position
|
||||
centre = self._stage.position
|
||||
centre_str = f"({centre['x']}, {centre['y']})"
|
||||
rom_deps.logger.info(f"Centre is estimated at {centre_str}.")
|
||||
self.logger.info(f"Centre is estimated at {centre_str}.")
|
||||
# Set the central position to (0,0,0)
|
||||
rom_deps.stage.set_zero_position()
|
||||
rom_deps.logger.info("Position reset to (0, 0, 0).")
|
||||
self._stage.set_zero_position()
|
||||
self.logger.info("Position reset to (0, 0, 0).")
|
||||
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
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
|
||||
def _set_stream_resolution(self) -> None:
|
||||
"""Set the self._stream_resolution attribute by reading camera."""
|
||||
stream_shape = self._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,
|
||||
self, axis: Literal["x", "y"], direction: Literal[1, -1]
|
||||
) -> None:
|
||||
"""Move in one direction until movement per step decreases significantly.
|
||||
|
||||
|
|
@ -278,93 +225,72 @@ class RangeofMotionThing(lt.Thing):
|
|||
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._autofocus.looping_autofocus(dz=1000)
|
||||
starting_position = self._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"
|
||||
)
|
||||
self.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._moves_for_z_prediction(
|
||||
axis=axis,
|
||||
direction=direction,
|
||||
rom_deps=rom_deps,
|
||||
)
|
||||
self.logger.info("Moving the stage in 5 medium sized steps.")
|
||||
self._moves_for_z_prediction(axis=axis, direction=direction)
|
||||
|
||||
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
|
||||
)
|
||||
self._big_z_corrected_movement(axis=axis, direction=direction)
|
||||
# Autofocus and record position
|
||||
rom_deps.autofocus.looping_autofocus(dz=800)
|
||||
self._rom_data.stage_coords.append(rom_deps.stage.position)
|
||||
self._autofocus.looping_autofocus(dz=800)
|
||||
self._rom_data.stage_coords.append(self._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,
|
||||
)
|
||||
still_moving = self._stage_still_moves(axis=axis, direction=direction)
|
||||
|
||||
# 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,
|
||||
)
|
||||
self.logger.info("Moving stage back until motion is detect")
|
||||
self._move_back_until_motion_detected(axis=axis, direction=direction)
|
||||
|
||||
# Replace final position.
|
||||
self._rom_data.stage_coords[-1] = rom_deps.stage.position
|
||||
self._rom_data.stage_coords[-1] = self._stage.position
|
||||
|
||||
finally:
|
||||
rom_deps.stage.move_absolute(**starting_position, block_cancellation=True)
|
||||
self._stage.move_absolute(**starting_position, block_cancellation=True)
|
||||
|
||||
def _recentre_axis(self, axis: Literal["x", "y"], rom_deps: RomDeps) -> None:
|
||||
def _recentre_axis(self, axis: Literal["x", "y"]) -> None:
|
||||
"""Recentre a single axis.
|
||||
|
||||
:param axis: The axis to recentre
|
||||
:param rom_deps: All dependencies that were passed to the calling Action.
|
||||
"""
|
||||
rom_deps.logger.info(f"Finding centre in {axis}.")
|
||||
self.logger.info(f"Finding centre in {axis}.")
|
||||
# A new tracker for this axis.
|
||||
self._rom_data = RomDataTracker()
|
||||
# Direction is in image coords, initial assumption is that centre is at (0,0).
|
||||
direction = self._img_dir_from_stage_coords(
|
||||
{"x": 0, "y": 0, "z": 0}, axis, rom_deps
|
||||
)
|
||||
direction = self._img_dir_from_stage_coords({"x": 0, "y": 0, "z": 0}, axis)
|
||||
|
||||
i = 0
|
||||
while True:
|
||||
i += 1
|
||||
# Find z then make a number of moves (autofocussing and logging position)
|
||||
# 5 moves initially (2 subsequently).
|
||||
rom_deps.autofocus.looping_autofocus(dz=1000)
|
||||
self._rom_data.stage_coords.append(rom_deps.stage.position)
|
||||
self._autofocus.looping_autofocus(dz=1000)
|
||||
self._rom_data.stage_coords.append(self._stage.position)
|
||||
self._moves_for_z_prediction(
|
||||
axis=axis,
|
||||
direction=direction,
|
||||
rom_deps=rom_deps,
|
||||
n_moves=5 if i == 1 else 2,
|
||||
)
|
||||
|
||||
centred, direction = self._recentre_decision(axis, rom_deps)
|
||||
centred, direction = self._recentre_decision(axis)
|
||||
if centred:
|
||||
break
|
||||
|
||||
|
|
@ -373,10 +299,10 @@ class RangeofMotionThing(lt.Thing):
|
|||
raise RuntimeError(f"Couldn't find centre of {axis}-axis")
|
||||
|
||||
# Make a big z-corrected move towards estimate of centre.
|
||||
self._big_z_corrected_movement(axis, direction, rom_deps)
|
||||
self._big_z_corrected_movement(axis, direction)
|
||||
|
||||
def _recentre_decision(
|
||||
self, axis: Literal["x", "y"], rom_deps: RomDeps
|
||||
self, axis: Literal["x", "y"]
|
||||
) -> tuple[bool, Literal[1, -1]]:
|
||||
"""Decide what to do next during recentreing.
|
||||
|
||||
|
|
@ -388,26 +314,25 @@ class RangeofMotionThing(lt.Thing):
|
|||
* If larger, return that it is not centred, and the direction to move in.
|
||||
|
||||
:param axis: The axis to recentre
|
||||
:param rom_deps: All dependencies that were passed to the calling Action.
|
||||
:return: A tuple. The first value is True for "the stage is now centred" and
|
||||
False for "the stage is not centred". The second value is the direction to
|
||||
move in, this only has meaning if the first value is False.
|
||||
"""
|
||||
estimate = self._rom_data.find_turning_point(axis=axis)
|
||||
img_perc = self._distance_in_img_percentage(estimate, axis, rom_deps)
|
||||
img_perc = self._distance_in_img_percentage(estimate, axis)
|
||||
|
||||
# if the distance is less than 1 big step away then move to it and exit
|
||||
if abs(img_perc) < BIG_STEP:
|
||||
rom_deps.logger.info(f"Estimated centre of {axis}-axis is {estimate[axis]}")
|
||||
rom_deps.stage.move_absolute(**estimate)
|
||||
self.logger.info(f"Estimated centre of {axis}-axis is {estimate[axis]}")
|
||||
self._stage.move_absolute(**estimate)
|
||||
# Note the second return, the direction, is meaningless here.
|
||||
return True, 1
|
||||
rom_deps.logger.info(
|
||||
self.logger.info(
|
||||
f"Estimated centre {abs(img_perc):.0f}% of a field of view away, "
|
||||
"that is too far to move in one move."
|
||||
)
|
||||
# Else calculate the desired direction in image coordinates.
|
||||
direction = self._img_dir_from_stage_coords(estimate, axis, rom_deps)
|
||||
direction = self._img_dir_from_stage_coords(estimate, axis)
|
||||
return False, direction
|
||||
|
||||
def _img_percentage_to_img_coords(
|
||||
|
|
@ -428,30 +353,28 @@ class RangeofMotionThing(lt.Thing):
|
|||
return (fov_perc / 100) * self._stream_resolution[img_index]
|
||||
|
||||
def _img_dir_from_stage_coords(
|
||||
self, target: dict[str, int], axis: Literal["x", "y"], rom_deps: RomDeps
|
||||
self, target: dict[str, int], axis: Literal["x", "y"]
|
||||
) -> Literal[1, -1]:
|
||||
"""For a target location in stage coords, return the direction in image coordinates.
|
||||
|
||||
:param target: The target poisiton in stage coordinates
|
||||
: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.
|
||||
:return: Direction to move in image coordinates.
|
||||
"""
|
||||
target_im_coords = rom_deps.csm.convert_stage_to_image_coordinates(**target)
|
||||
current_loc = rom_deps.stage.position
|
||||
current_loc_im_coords = rom_deps.csm.convert_stage_to_image_coordinates(
|
||||
target_im_coords = self._csm.convert_stage_to_image_coordinates(**target)
|
||||
current_loc = self._stage.position
|
||||
current_loc_im_coords = self._csm.convert_stage_to_image_coordinates(
|
||||
**current_loc
|
||||
)
|
||||
return -1 if target_im_coords[axis] < current_loc_im_coords[axis] else 1
|
||||
|
||||
def _distance_in_img_percentage(
|
||||
self, target: dict[str, int], axis: Literal["x", "y"], rom_deps: RomDeps
|
||||
self, target: dict[str, int], axis: Literal["x", "y"]
|
||||
) -> float:
|
||||
"""For a target location in stage coords return the distance in percentage of FOV.
|
||||
|
||||
:param target: The target poisiton in stage coordinates
|
||||
: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.
|
||||
:return: Percentage of field of view the stage should move by.
|
||||
"""
|
||||
if self._stream_resolution is None:
|
||||
|
|
@ -459,9 +382,9 @@ class RangeofMotionThing(lt.Thing):
|
|||
"Stream resolution must be set before converting coords to percentage"
|
||||
)
|
||||
|
||||
current_loc = rom_deps.stage.position
|
||||
current_loc = self._stage.position
|
||||
move_stage = {key: target[key] - current_loc[key] for key in target}
|
||||
move_img = rom_deps.csm.convert_stage_to_image_coordinates(**move_stage)
|
||||
move_img = self._csm.convert_stage_to_image_coordinates(**move_stage)
|
||||
|
||||
img_index = 0 if axis == "x" else 1
|
||||
return (move_img[axis] / self._stream_resolution[img_index]) * 100
|
||||
|
|
@ -488,11 +411,7 @@ class RangeofMotionThing(lt.Thing):
|
|||
return {"x": 0, "y": distance * direction}
|
||||
|
||||
def _moves_for_z_prediction(
|
||||
self,
|
||||
axis: Literal["x", "y"],
|
||||
direction: Literal[1, -1],
|
||||
rom_deps: RomDeps,
|
||||
n_moves: int = 5,
|
||||
self, axis: Literal["x", "y"], direction: Literal[1, -1], n_moves: int = 5
|
||||
) -> None:
|
||||
"""Perform medium sized moves with autofocus for z feed-forward.
|
||||
|
||||
|
|
@ -502,7 +421,6 @@ class RangeofMotionThing(lt.Thing):
|
|||
|
||||
: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
|
||||
:param n_moves: Number of moves to make. Default is 5 which is enough for an
|
||||
initial z estimate.
|
||||
"""
|
||||
|
|
@ -510,9 +428,9 @@ class RangeofMotionThing(lt.Thing):
|
|||
fov_perc=MEDIUM_STEP, axis=axis, direction=direction
|
||||
)
|
||||
for _loop in range(n_moves):
|
||||
offset = self._move_and_measure(movement=movement, rom_deps=rom_deps)
|
||||
offset = self._move_and_measure(movement=movement)
|
||||
|
||||
self._rom_data.record_movement(rom_deps.stage.position, offset)
|
||||
self._rom_data.record_movement(self._stage.position, offset)
|
||||
if _parasitic_motion_detected(movement, offset):
|
||||
raise ParasiticMotionError(
|
||||
"Parasitic motion detected during images to calculate z-curvature. "
|
||||
|
|
@ -521,7 +439,7 @@ class RangeofMotionThing(lt.Thing):
|
|||
)
|
||||
|
||||
def _big_z_corrected_movement(
|
||||
self, axis: Literal["x", "y"], direction: Literal[1, -1], rom_deps: RomDeps
|
||||
self, axis: Literal["x", "y"], direction: Literal[1, -1]
|
||||
) -> None:
|
||||
"""Take one big move with feed-forward z-correction.
|
||||
|
||||
|
|
@ -529,27 +447,25 @@ class RangeofMotionThing(lt.Thing):
|
|||
|
||||
: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_movement = rom_deps.csm.convert_image_to_stage_coordinates(**movement)
|
||||
stage_movement = self._csm.convert_image_to_stage_coordinates(**movement)
|
||||
|
||||
z_disp = self._rom_data.predict_z_displacement(
|
||||
axis=axis,
|
||||
stage_movement=stage_movement,
|
||||
stage_position=rom_deps.stage.position,
|
||||
stage_position=self._stage.position,
|
||||
)
|
||||
rom_deps.stage.move_relative(z=z_disp)
|
||||
rom_deps.csm.move_in_image_coordinates(**movement)
|
||||
self._stage.move_relative(z=z_disp)
|
||||
self._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.
|
||||
|
||||
|
|
@ -562,7 +478,6 @@ class RangeofMotionThing(lt.Thing):
|
|||
|
||||
: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
|
||||
|
|
@ -572,7 +487,6 @@ class RangeofMotionThing(lt.Thing):
|
|||
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
|
||||
|
|
@ -580,13 +494,13 @@ class RangeofMotionThing(lt.Thing):
|
|||
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)
|
||||
self.logger.info(f"Offset measured as {offset[axis]}")
|
||||
self._rom_data.record_movement(self._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.")
|
||||
self.logger.info("Edge has been found.")
|
||||
return False
|
||||
# If we reached here the stage is still moving fine
|
||||
return True
|
||||
|
|
@ -595,7 +509,6 @@ class RangeofMotionThing(lt.Thing):
|
|||
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.
|
||||
|
||||
|
|
@ -605,7 +518,6 @@ class RangeofMotionThing(lt.Thing):
|
|||
: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
|
||||
|
|
@ -617,17 +529,17 @@ class RangeofMotionThing(lt.Thing):
|
|||
|
||||
for i in range(max_moves):
|
||||
# Increment movement
|
||||
rom_deps.logger.info(f"Testing with step size {movement[axis]}")
|
||||
self.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
|
||||
movement=movement, perform_autofocus=run_autofocus
|
||||
)
|
||||
|
||||
rom_deps.logger.info(f"Offset measured as {abs(offset[axis])}")
|
||||
self.logger.info(f"Offset measured as {abs(offset[axis])}")
|
||||
if abs(offset[axis]) > motion_minimum:
|
||||
rom_deps.logger.info("Motion detected.")
|
||||
self.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.")
|
||||
|
|
@ -635,7 +547,6 @@ class RangeofMotionThing(lt.Thing):
|
|||
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,
|
||||
|
|
@ -643,7 +554,6 @@ class RangeofMotionThing(lt.Thing):
|
|||
"""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
|
||||
|
|
@ -659,13 +569,13 @@ class RangeofMotionThing(lt.Thing):
|
|||
)
|
||||
|
||||
# Take image before move
|
||||
before_img = rom_deps.cam.grab_as_array()
|
||||
before_img = self._cam.grab_as_array()
|
||||
# Move and autofocus if required
|
||||
rom_deps.csm.move_in_image_coordinates(**movement)
|
||||
self._csm.move_in_image_coordinates(**movement)
|
||||
if perform_autofocus:
|
||||
rom_deps.autofocus.looping_autofocus(dz=800)
|
||||
self._autofocus.looping_autofocus(dz=800)
|
||||
# Take image and calculate offset
|
||||
offset = self._offset_from(before_img, rom_deps=rom_deps)
|
||||
offset = self._offset_from(before_img)
|
||||
|
||||
if max_autofocus_repeats > 0:
|
||||
axis = _axis_from_movement_dict(movement)
|
||||
|
|
@ -673,12 +583,12 @@ class RangeofMotionThing(lt.Thing):
|
|||
parasitic_motion = _parasitic_motion_detected(movement, offset)
|
||||
while abs(offset[axis]) < abs_min_offset or parasitic_motion:
|
||||
af_repeats += 1
|
||||
rom_deps.logger.info(
|
||||
self.logger.info(
|
||||
f"Motion not detected. Refocusing to check. Attempt {af_repeats}/3."
|
||||
)
|
||||
rom_deps.autofocus.looping_autofocus(dz=800)
|
||||
self._autofocus.looping_autofocus(dz=800)
|
||||
# Re-take image and calculate offset
|
||||
offset = self._offset_from(before_img, rom_deps=rom_deps)
|
||||
offset = self._offset_from(before_img)
|
||||
parasitic_motion = _parasitic_motion_detected(movement, offset)
|
||||
if af_repeats >= max_autofocus_repeats:
|
||||
# break if the maximum number of tries are exceeded.
|
||||
|
|
@ -686,22 +596,19 @@ class RangeofMotionThing(lt.Thing):
|
|||
|
||||
return offset
|
||||
|
||||
def _offset_from(
|
||||
self, before_img: np.ndarray, rom_deps: RomDeps
|
||||
) -> dict[str, float]:
|
||||
def _offset_from(self, before_img: np.ndarray) -> 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()
|
||||
is_sample, _bg_message = self._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()
|
||||
after_img = self._cam.grab_as_array()
|
||||
offset = fft_image_tracking.displacement_between_images(
|
||||
image_0=before_img,
|
||||
image_1=after_img,
|
||||
|
|
|
|||
|
|
@ -48,22 +48,23 @@ def fake_sharpness_data(
|
|||
def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes, mocker):
|
||||
"""Test the high level looping autofocus algorithm."""
|
||||
dz = 2000
|
||||
autofocus_thing = create_thing_without_server(AutofocusThing, mock_all_slots=True)
|
||||
|
||||
# Make a mock stage where move_absolute abs and relative updates the position counter.
|
||||
stage = mocker.Mock()
|
||||
stage.position = {"x": 0, "y": 0, "z": start_z}
|
||||
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z}
|
||||
|
||||
def set_pos(**kwargs: int) -> None:
|
||||
"""Move absolute should update position. So make a side effect for the mock."""
|
||||
for axis, value in kwargs.items():
|
||||
stage.position[axis] = value
|
||||
autofocus_thing._stage.position[axis] = value
|
||||
|
||||
def adjust_pos(**kwargs: int) -> None:
|
||||
"""Move relative should update position. So make a side effect for the mock."""
|
||||
for axis, value in kwargs.items():
|
||||
stage.position[axis] += value
|
||||
autofocus_thing._stage.position[axis] += value
|
||||
|
||||
stage.move_absolute.side_effect = set_pos
|
||||
stage.move_relative.side_effect = adjust_pos
|
||||
autofocus_thing._stage.move_absolute.side_effect = set_pos
|
||||
autofocus_thing._stage.move_relative.side_effect = adjust_pos
|
||||
|
||||
# Make a mock sharpness monitor that can generate sharpness data.
|
||||
sharpness_monitor = mocker.MagicMock()
|
||||
|
|
@ -73,27 +74,25 @@ def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes,
|
|||
"""Generate sharpnesses based on parameterised input, and mock stage position."""
|
||||
return fake_sharpness_data(
|
||||
dz=dz,
|
||||
start_z=stage.position["z"],
|
||||
start_z=autofocus_thing._stage.position["z"],
|
||||
max_loc=max_loc,
|
||||
)
|
||||
|
||||
sharpness_monitor.move_data.side_effect = return_sharpness
|
||||
|
||||
autofocus_thing = create_thing_without_server(AutofocusThing)
|
||||
# Mock the context manager call so out MagicMock sharpness monitor is returned.
|
||||
mock_context = mocker.patch(
|
||||
"openflexure_microscope_server.things.autofocus.JPEGSharpnessMonitor"
|
||||
)
|
||||
mock_context.return_value.__enter__.return_value = sharpness_monitor
|
||||
mock_context.return_value.__exit__.return_value = None
|
||||
|
||||
if passes:
|
||||
autofocus_thing.looping_autofocus(
|
||||
stage=stage,
|
||||
sharpness_monitor=sharpness_monitor,
|
||||
dz=dz,
|
||||
start="centre" if centre else "base",
|
||||
)
|
||||
autofocus_thing.looping_autofocus(dz=dz, start="centre" if centre else "base")
|
||||
else:
|
||||
with pytest.raises(NoFocusFoundError):
|
||||
autofocus_thing.looping_autofocus(
|
||||
stage=stage,
|
||||
sharpness_monitor=sharpness_monitor,
|
||||
dz=dz,
|
||||
start="centre" if centre else "base",
|
||||
dz=dz, start="centre" if centre else "base"
|
||||
)
|
||||
assert sharpness_monitor.focus_rel.call_count == attempts_expected
|
||||
assert abs(max_loc - stage.position["z"]) < dz / 40
|
||||
assert abs(max_loc - autofocus_thing._stage.position["z"]) < dz / 40
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ from openflexure_microscope_server.things.autofocus import (
|
|||
_get_peak_turning_point,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger("mock-invocation_logger")
|
||||
RANDOM_GENERATOR = np.random.default_rng()
|
||||
|
||||
|
||||
|
|
@ -265,7 +264,7 @@ def test_retrieval_of_captures(start):
|
|||
@pytest.fixture
|
||||
def autofocus_thing():
|
||||
"""Return an autofocus thing connected to a server."""
|
||||
return create_thing_without_server(AutofocusThing)
|
||||
return create_thing_without_server(AutofocusThing, mock_all_slots=True)
|
||||
|
||||
|
||||
def test_create_stack(autofocus_thing, caplog):
|
||||
|
|
@ -274,10 +273,7 @@ def test_create_stack(autofocus_thing, caplog):
|
|||
initial_images_to_save = autofocus_thing.stack_images_to_save
|
||||
with caplog.at_level(logging.INFO):
|
||||
stack_params = autofocus_thing.create_stack_params(
|
||||
autofocus_dz=2000,
|
||||
images_dir="/this/is/fake",
|
||||
save_resolution=(1640, 1232),
|
||||
logger=LOGGER,
|
||||
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
|
||||
)
|
||||
|
||||
assert len(caplog.records) == 0
|
||||
|
|
@ -304,10 +300,7 @@ def test_coercing_stack_test_ims(
|
|||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
stack_params = autofocus_thing.create_stack_params(
|
||||
autofocus_dz=2000,
|
||||
images_dir="/this/is/fake",
|
||||
save_resolution=(1640, 1232),
|
||||
logger=LOGGER,
|
||||
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
|
||||
)
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
|
|
@ -335,10 +328,7 @@ def test_coercing_stack_save_ims(
|
|||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
stack_params = autofocus_thing.create_stack_params(
|
||||
autofocus_dz=2000,
|
||||
images_dir="/this/is/fake",
|
||||
save_resolution=(1640, 1232),
|
||||
logger=LOGGER,
|
||||
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
|
||||
)
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
|
|
@ -352,14 +342,8 @@ def test_coercing_stack_save_ims(
|
|||
@pytest.mark.parametrize("pass_on", [1, 2, 3, 4])
|
||||
def test_run_smart_stack(pass_on, autofocus_thing, mocker):
|
||||
"""Test Running smart stack with the stack passing on different attempts."""
|
||||
cam = mocker.Mock()
|
||||
stage = mocker.Mock()
|
||||
sharpness_monitor = mocker.MagicMock()
|
||||
stack_params = autofocus_thing.create_stack_params(
|
||||
autofocus_dz=2000,
|
||||
images_dir="/this/is/fake",
|
||||
save_resolution=(1640, 1232),
|
||||
logger=LOGGER,
|
||||
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
|
||||
)
|
||||
assert stack_params.max_attempts == 3
|
||||
|
||||
|
|
@ -386,9 +370,6 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker):
|
|||
|
||||
# Run it
|
||||
success, final_z = autofocus_thing.run_smart_stack(
|
||||
cam=cam,
|
||||
stage=stage,
|
||||
sharpness_monitor=sharpness_monitor,
|
||||
stack_parameters=stack_params,
|
||||
save_on_failure=False,
|
||||
check_turning_points=True,
|
||||
|
|
@ -403,16 +384,16 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker):
|
|||
n_stacks = min(pass_on, stack_params.max_attempts)
|
||||
assert autofocus_thing.z_stack.call_count == n_stacks
|
||||
# Move absolute should be 1 less time that the number of times z_stack_run
|
||||
assert stage.move_absolute.call_count == n_stacks - 1
|
||||
assert autofocus_thing._stage.move_absolute.call_count == n_stacks - 1
|
||||
# As should looping autofocus
|
||||
assert autofocus_thing.looping_autofocus.call_count == n_stacks - 1
|
||||
|
||||
# Check rest stack is moving to the first image in the stack.
|
||||
if n_stacks > 1:
|
||||
assert stage.move_absolute.call_args.kwargs["z"] == -99
|
||||
assert autofocus_thing._stage.move_absolute.call_args.kwargs["z"] == -99
|
||||
|
||||
# Mock called to save image
|
||||
assert cam.save_from_memory.call_count == (1 if success else 0)
|
||||
assert autofocus_thing._cam.save_from_memory.call_count == (1 if success else 0)
|
||||
|
||||
|
||||
def setup_and_run_z_stack(check_returns, check_turning_points, autofocus_thing, mocker):
|
||||
|
|
@ -423,15 +404,10 @@ def setup_and_run_z_stack(check_returns, check_turning_points, autofocus_thing,
|
|||
results). If it a tuple (or anything else), it is set as a return value.
|
||||
"""
|
||||
stack_params = autofocus_thing.create_stack_params(
|
||||
autofocus_dz=2000,
|
||||
images_dir="/this/is/fake",
|
||||
save_resolution=(1640, 1232),
|
||||
logger=LOGGER,
|
||||
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
|
||||
)
|
||||
stack_params.settling_time = 0 # Don't settle or tests take forever.
|
||||
|
||||
stage = mocker.Mock()
|
||||
cam = mocker.Mock()
|
||||
autofocus_thing.capture_stack_image = mocker.Mock()
|
||||
if isinstance(check_returns, list):
|
||||
autofocus_thing.check_stack_result = mocker.Mock(side_effect=check_returns)
|
||||
|
|
@ -440,8 +416,6 @@ def setup_and_run_z_stack(check_returns, check_turning_points, autofocus_thing,
|
|||
return autofocus_thing.z_stack(
|
||||
stack_parameters=stack_params,
|
||||
check_turning_points=check_turning_points,
|
||||
cam=cam,
|
||||
stage=stage,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -506,20 +480,16 @@ def test_z_stack_return(autofocus_thing, mocker):
|
|||
assert ret[0]
|
||||
|
||||
|
||||
def test_capture_stack_image(autofocus_thing, mocker):
|
||||
def test_capture_stack_image(autofocus_thing):
|
||||
"""Check that capture stack image calls the expected functions and returns the expected data."""
|
||||
stage = mocker.Mock()
|
||||
stage.position = {"x": 123, "y": 456, "z": 789}
|
||||
cam = mocker.Mock()
|
||||
cam.capture_to_memory.return_value = "fake_buffer_id"
|
||||
cam.grab_jpeg_size.return_value = 54321
|
||||
autofocus_thing._stage.position = {"x": 123, "y": 456, "z": 789}
|
||||
autofocus_thing._cam.capture_to_memory.return_value = "fake_buffer_id"
|
||||
autofocus_thing._cam.grab_jpeg_size.return_value = 54321
|
||||
buffer_max = 11
|
||||
|
||||
info = autofocus_thing.capture_stack_image(
|
||||
cam=cam, stage=stage, buffer_max=buffer_max
|
||||
)
|
||||
assert cam.capture_to_memory.call_count == 1
|
||||
assert cam.grab_jpeg_size.call_count == 1
|
||||
info = autofocus_thing.capture_stack_image(buffer_max=buffer_max)
|
||||
assert autofocus_thing._cam.capture_to_memory.call_count == 1
|
||||
assert autofocus_thing._cam.grab_jpeg_size.call_count == 1
|
||||
assert info.buffer_id == "fake_buffer_id"
|
||||
assert info.position == {"x": 123, "y": 456, "z": 789}
|
||||
assert info.sharpness == 54321
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"""File contains unit tests for stage_measure."""
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from copy import copy
|
||||
|
||||
|
|
@ -143,23 +142,21 @@ def test_parasitic_detect(par_fraction, 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."""
|
||||
rom_thing = create_thing_without_server(stage_measure.RangeofMotionThing)
|
||||
rom_thing = create_thing_without_server(
|
||||
stage_measure.RangeofMotionThing, mock_all_slots=True
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Stream resolution must be set"):
|
||||
rom_thing._img_percentage_to_img_coords(20, "x")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rom_thing(example_rom_data) -> stage_measure.RangeofMotionThing:
|
||||
def rom_thing(example_rom_data, csm_matrix) -> stage_measure.RangeofMotionThing:
|
||||
"""Yield a RangeofMotionThing already populated with some example rom_data."""
|
||||
rom_thing = create_thing_without_server(stage_measure.RangeofMotionThing)
|
||||
rom_thing = create_thing_without_server(
|
||||
stage_measure.RangeofMotionThing, mock_all_slots=True
|
||||
)
|
||||
rom_thing._stream_resolution = [800, 600]
|
||||
rom_thing._rom_data = example_rom_data
|
||||
return 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, **_kwargs: float) -> dict[str, int]:
|
||||
"""Convert image coordinates to stage coordinates."""
|
||||
|
|
@ -169,22 +166,13 @@ def mock_rom_deps(csm_matrix, mocker) -> stage_measure.RomDeps:
|
|||
"""Convert stage coordinates to image coordinates."""
|
||||
return csm_stage_to_img(csm_matrix, x=x, y=y)
|
||||
|
||||
mock_cam = mocker.Mock()
|
||||
mock_cam.image_is_sample.return_value = (True, "Mocked not measured.")
|
||||
rom_thing._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
|
||||
mock_csm.convert_stage_to_image_coordinates.side_effect = un_apply_csm
|
||||
|
||||
return stage_measure.RomDeps(
|
||||
autofocus=mocker.Mock(),
|
||||
stage=mocker.Mock(),
|
||||
cam=mock_cam,
|
||||
csm=mock_csm,
|
||||
logger=LOGGER,
|
||||
)
|
||||
rom_thing._csm.image_to_stage_displacement_matrix = csm_matrix
|
||||
rom_thing._csm.convert_image_to_stage_coordinates.side_effect = apply_csm
|
||||
rom_thing._csm.convert_stage_to_image_coordinates.side_effect = un_apply_csm
|
||||
return rom_thing
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -197,22 +185,18 @@ def mock_rom_deps(csm_matrix, mocker) -> stage_measure.RomDeps:
|
|||
({"x": 0, "y": 5000, "z": 0}, {"x": 0, "y": 0, "z": 0}, "y", 1),
|
||||
],
|
||||
)
|
||||
def test_img_dir_from_stage_coords(
|
||||
pos, target_pos, axis, expected_dir, rom_thing, mock_rom_deps
|
||||
):
|
||||
def test_img_dir_from_stage_coords(pos, target_pos, axis, expected_dir, rom_thing):
|
||||
"""Check image direction is correctly generated."""
|
||||
mock_rom_deps.stage.position = pos
|
||||
direction = rom_thing._img_dir_from_stage_coords(target_pos, axis, mock_rom_deps)
|
||||
rom_thing._stage.position = pos
|
||||
direction = rom_thing._img_dir_from_stage_coords(target_pos, axis)
|
||||
assert direction == expected_dir
|
||||
|
||||
|
||||
def test_distance_in_img_percentage_err(rom_thing, mock_rom_deps):
|
||||
def test_distance_in_img_percentage_err(rom_thing):
|
||||
"""Check that _distance_in_img_percentage throws error if stream res is not set."""
|
||||
rom_thing._stream_resolution = None
|
||||
with pytest.raises(RuntimeError):
|
||||
rom_thing._distance_in_img_percentage(
|
||||
{"x": 0, "y": 0, "z": 0}, "x", mock_rom_deps
|
||||
)
|
||||
rom_thing._distance_in_img_percentage({"x": 0, "y": 0, "z": 0}, "x")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -225,16 +209,14 @@ def test_distance_in_img_percentage_err(rom_thing, mock_rom_deps):
|
|||
({"x": 0, "y": 5000, "z": 0}, {"x": 0, "y": 0, "z": 0}, "y", 462.131),
|
||||
],
|
||||
)
|
||||
def test_distance_in_img_percentage(
|
||||
pos, target_pos, axis, expected_perc, rom_thing, mock_rom_deps
|
||||
):
|
||||
def test_distance_in_img_percentage(pos, target_pos, axis, expected_perc, rom_thing):
|
||||
"""Check _distance_in_img_percentage calculates expected values based on mock CSM."""
|
||||
mock_rom_deps.stage.position = pos
|
||||
img_perc = rom_thing._distance_in_img_percentage(target_pos, axis, mock_rom_deps)
|
||||
rom_thing._stage.position = pos
|
||||
img_perc = rom_thing._distance_in_img_percentage(target_pos, axis)
|
||||
assert round(img_perc, 3) == expected_perc
|
||||
|
||||
|
||||
def test_offset_from(rom_thing, mock_rom_deps, mocker):
|
||||
def test_offset_from(rom_thing, mocker):
|
||||
"""Check the calls and returns for RangeofMotionThing._offset_from."""
|
||||
# Set up mock for the FFT displacement
|
||||
disp_between_route = (
|
||||
|
|
@ -247,14 +229,14 @@ def test_offset_from(rom_thing, mock_rom_deps, mocker):
|
|||
)
|
||||
|
||||
# Run it
|
||||
offset = rom_thing._offset_from(before_img="MOCK_IMAGE", rom_deps=mock_rom_deps)
|
||||
offset = rom_thing._offset_from(before_img="MOCK_IMAGE")
|
||||
|
||||
# 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
|
||||
assert rom_thing._cam.grab_as_array.call_count == 1
|
||||
mock_after_image = rom_thing._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
|
||||
|
|
@ -264,7 +246,7 @@ def test_offset_from(rom_thing, mock_rom_deps, mocker):
|
|||
|
||||
|
||||
@pytest.mark.parametrize("perform_autofocus", [True, False])
|
||||
def test_move_and_measure(perform_autofocus, rom_thing, mock_rom_deps, mocker):
|
||||
def test_move_and_measure(perform_autofocus, rom_thing, 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
|
||||
|
|
@ -273,16 +255,16 @@ def test_move_and_measure(perform_autofocus, rom_thing, mock_rom_deps, mocker):
|
|||
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
|
||||
movement=movement, perform_autofocus=perform_autofocus
|
||||
)
|
||||
|
||||
# Check exactly 1 move
|
||||
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1
|
||||
assert rom_thing._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
|
||||
assert rom_thing._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
|
||||
assert rom_thing._autofocus.looping_autofocus.call_count == expected_af_count
|
||||
# And check final return
|
||||
assert offset == mock_offset_value
|
||||
|
||||
|
|
@ -300,7 +282,7 @@ def test_move_and_measure(perform_autofocus, rom_thing, mock_rom_deps, mocker):
|
|||
],
|
||||
)
|
||||
def test_move_and_measure_with_refocus(
|
||||
x_offsets, n_offset_measures, expected_return, rom_thing, mock_rom_deps, mocker
|
||||
x_offsets, n_offset_measures, expected_return, rom_thing, 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)
|
||||
|
|
@ -310,21 +292,20 @@ def test_move_and_measure_with_refocus(
|
|||
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
|
||||
assert rom_thing._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
|
||||
assert rom_thing._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
|
||||
assert rom_thing._autofocus.looping_autofocus.call_count == expected_af_count
|
||||
# And check final return
|
||||
assert offset == expected_return
|
||||
|
||||
|
|
@ -339,7 +320,6 @@ def test_move_and_measure_with_bad_refocus_args(rom_thing, mocker):
|
|||
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,
|
||||
)
|
||||
|
|
@ -348,7 +328,6 @@ def test_move_and_measure_with_bad_refocus_args(rom_thing, mocker):
|
|||
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,
|
||||
|
|
@ -357,7 +336,7 @@ def test_move_and_measure_with_bad_refocus_args(rom_thing, mocker):
|
|||
assert offset_from_mock.call_count == 0
|
||||
|
||||
|
||||
def test_move_back_until_motion_detected(rom_thing, mock_rom_deps, mocker):
|
||||
def test_move_back_until_motion_detected(rom_thing, 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
|
||||
|
|
@ -368,7 +347,7 @@ def test_move_back_until_motion_detected(rom_thing, mock_rom_deps, mocker):
|
|||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Cannot detect motion again"):
|
||||
rom_thing._move_back_until_motion_detected("y", -1, rom_deps=mock_rom_deps)
|
||||
rom_thing._move_back_until_motion_detected("y", -1)
|
||||
|
||||
max_tries = int(1.5 * stage_measure.BIG_STEP / stage_measure.SMALL_STEP)
|
||||
expected_step = 800 * stage_measure.SMALL_STEP / 100
|
||||
|
|
@ -386,7 +365,7 @@ def test_move_back_until_motion_detected(rom_thing, mock_rom_deps, mocker):
|
|||
)
|
||||
|
||||
# Other axis and direction this time
|
||||
rom_thing._move_back_until_motion_detected("x", 1, rom_deps=mock_rom_deps)
|
||||
rom_thing._move_back_until_motion_detected("x", 1)
|
||||
|
||||
# Should only be called 3 times
|
||||
assert mock_move_n_meas.call_count == 3
|
||||
|
|
@ -417,7 +396,6 @@ def test_stage_still_moves(
|
|||
expected_to_detect_motion,
|
||||
offset_calls,
|
||||
rom_thing,
|
||||
mock_rom_deps,
|
||||
mocker,
|
||||
):
|
||||
"""Test _stage_still_moves correctly detects stage movement."""
|
||||
|
|
@ -435,38 +413,34 @@ def test_stage_still_moves(
|
|||
rom_thing, "_offset_from", side_effect=gen_offsets()
|
||||
)
|
||||
|
||||
still_moves = rom_thing._stage_still_moves(
|
||||
axis="x",
|
||||
direction=1,
|
||||
rom_deps=mock_rom_deps,
|
||||
)
|
||||
still_moves = rom_thing._stage_still_moves(axis="x", direction=1)
|
||||
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):
|
||||
def test_big_z_corrected_movement(rom_thing):
|
||||
"""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._stage.position = {"x": 5000, "y": 30, "z": 500}
|
||||
|
||||
rom_thing._big_z_corrected_movement("x", direction=1, rom_deps=mock_rom_deps)
|
||||
rom_thing._big_z_corrected_movement("x", direction=1)
|
||||
|
||||
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 rom_thing._stage.move_relative.call_count == 1
|
||||
move_kwargs = rom_thing._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"] == 160
|
||||
|
||||
# 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 rom_thing._csm.move_in_image_coordinates.call_count == 1
|
||||
lat_mov_kwargs = rom_thing._csm.move_in_image_coordinates.call_args.kwargs
|
||||
assert lat_mov_kwargs == expected_movement
|
||||
|
||||
|
||||
def test_moves_for_z_prediction(rom_thing, mock_rom_deps, mocker):
|
||||
def test_moves_for_z_prediction(rom_thing, 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
|
||||
|
|
@ -474,7 +448,7 @@ def test_moves_for_z_prediction(rom_thing, mock_rom_deps, mocker):
|
|||
mocker.patch.object(
|
||||
rom_thing, "_offset_from", side_effect=increasing_xy_dict_generator()
|
||||
)
|
||||
type(mock_rom_deps.stage).position = mocker.PropertyMock(
|
||||
type(rom_thing._stage).position = mocker.PropertyMock(
|
||||
side_effect=increasing_xyz_dict_generator()
|
||||
)
|
||||
|
||||
|
|
@ -484,7 +458,7 @@ def test_moves_for_z_prediction(rom_thing, mock_rom_deps, mocker):
|
|||
rom_thing._rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
# Run it!
|
||||
rom_thing._moves_for_z_prediction("x", direction=-1, rom_deps=mock_rom_deps)
|
||||
rom_thing._moves_for_z_prediction("x", direction=-1)
|
||||
|
||||
# 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)]
|
||||
|
|
@ -493,16 +467,16 @@ def test_moves_for_z_prediction(rom_thing, mock_rom_deps, mocker):
|
|||
]
|
||||
|
||||
# Check that the csm movement function is called 5 times
|
||||
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 5
|
||||
assert rom_thing._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:
|
||||
for arg_list in rom_thing._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):
|
||||
def test_move_until_edge_error(rom_thing, 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(
|
||||
type(rom_thing._stage).position = mocker.PropertyMock(
|
||||
return_value=mock_position_dict
|
||||
)
|
||||
mocker.patch.object(
|
||||
|
|
@ -514,20 +488,20 @@ def test_move_until_edge_error(rom_thing, mock_rom_deps, 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, rom_deps=mock_rom_deps)
|
||||
rom_thing._move_until_edge("y", direction=-1)
|
||||
# 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
|
||||
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)
|
||||
assert abs_move_kwargs == expected_abs_move_kwargs
|
||||
|
||||
|
||||
def test_move_until_edge(rom_thing, mock_rom_deps, mocker):
|
||||
def test_move_until_edge(rom_thing, 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"}
|
||||
rom_thing._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."""
|
||||
|
|
@ -538,13 +512,13 @@ def test_move_until_edge(rom_thing, mock_rom_deps, mocker):
|
|||
"""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}"
|
||||
rom_thing._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"
|
||||
rom_thing._stage.position = "mock-final-pos"
|
||||
|
||||
# Mock the main movement functions
|
||||
mock_init_moves = mocker.patch.object(
|
||||
|
|
@ -568,7 +542,7 @@ def test_move_until_edge(rom_thing, mock_rom_deps, mocker):
|
|||
rom_thing._rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
# Run function
|
||||
rom_thing._move_until_edge("y", direction=-1, rom_deps=mock_rom_deps)
|
||||
rom_thing._move_until_edge("y", direction=-1)
|
||||
|
||||
# Check the call counts are as expected
|
||||
# One call of initial moves
|
||||
|
|
@ -600,19 +574,19 @@ def test_move_until_edge(rom_thing, mock_rom_deps, mocker):
|
|||
]
|
||||
|
||||
|
||||
def test_perform_rom_actions_locked(rom_thing, mock_rom_deps):
|
||||
def test_perform_rom_actions_locked(rom_thing):
|
||||
"""Check the error if running one of the calibration actions while 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))
|
||||
rom_thing.perform_rom_test()
|
||||
err_msg = "Trying to run recentre when a test is already running."
|
||||
with pytest.raises(RuntimeError, match=err_msg):
|
||||
rom_thing.perform_recentre(**dataclasses.asdict(mock_rom_deps))
|
||||
rom_thing.perform_recentre()
|
||||
|
||||
|
||||
def test_perform_rom_test_(rom_thing, mock_rom_deps, mocker):
|
||||
def test_perform_rom_test_(rom_thing, mocker):
|
||||
"""Check that perform Rom Test runs the expected high level algorithm."""
|
||||
|
||||
def check_and_modify_rom_data(axis: str, direction: int, **_kwargs):
|
||||
|
|
@ -629,9 +603,9 @@ def test_perform_rom_test_(rom_thing, mock_rom_deps, mocker):
|
|||
)
|
||||
|
||||
mocker.patch.object(
|
||||
mock_rom_deps.cam, "grab_as_array", return_value=np.zeros([123, 456, 3])
|
||||
rom_thing._cam, "grab_as_array", return_value=np.zeros([123, 456, 3])
|
||||
)
|
||||
final_dict = rom_thing.perform_rom_test(**dataclasses.asdict(mock_rom_deps))
|
||||
final_dict = rom_thing.perform_rom_test()
|
||||
|
||||
# This should take less than 1 sec
|
||||
assert final_dict["Time"] <= 1
|
||||
|
|
@ -659,7 +633,7 @@ def test_perform_rom_test_(rom_thing, mock_rom_deps, mocker):
|
|||
assert mock_move_until_edge.call_args_list[3].kwargs["direction"] == -1
|
||||
|
||||
|
||||
def test_perform_recenter(rom_thing, mock_rom_deps, mocker, caplog):
|
||||
def test_perform_recenter(rom_thing, mocker, caplog):
|
||||
"""Check that performing the recentre runs through expected operations."""
|
||||
|
||||
def check_lock(*_args, **_kwargs):
|
||||
|
|
@ -673,14 +647,10 @@ def test_perform_recenter(rom_thing, mock_rom_deps, mocker, caplog):
|
|||
|
||||
# Set a mock stage position, as we patch _recentre_axis this should be logged
|
||||
# as the centre.
|
||||
mock_rom_deps.stage.position = {"x": 4321, "y": 1234, "z": 0}
|
||||
|
||||
# Unpack the dict and save because it the dictionary is a copy so we can't check
|
||||
# mocks from `mock_rom_deps`
|
||||
mock_deps_dict = dataclasses.asdict(mock_rom_deps)
|
||||
rom_thing._stage.position = {"x": 4321, "y": 1234, "z": 0}
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
rom_thing.perform_recentre(**mock_deps_dict)
|
||||
rom_thing.perform_recentre()
|
||||
|
||||
assert len(caplog.messages) == 3
|
||||
assert caplog.messages[0] == "Recentring the stage."
|
||||
|
|
@ -699,17 +669,17 @@ def test_perform_recenter(rom_thing, mock_rom_deps, mocker, caplog):
|
|||
assert mock_recentre_axis.call_args_list[1].args[0] == "y"
|
||||
|
||||
# check that the position set to zero once
|
||||
assert mock_deps_dict["stage"].set_zero_position.call_count == 1
|
||||
assert rom_thing._stage.set_zero_position.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("true_on", [1, 4, 10, 11])
|
||||
def test_recentre_axis(true_on, rom_thing, mock_rom_deps, mocker):
|
||||
def test_recentre_axis(true_on, rom_thing, mocker):
|
||||
"""Test the high level algorithm of recentring an axis.
|
||||
|
||||
This doesn't include deciding if we are centred or choosing the direction to move.
|
||||
"""
|
||||
## Start such that movement starts negative
|
||||
mock_rom_deps.stage.position = {"x": 10000, "y": 10000, "z": 0}
|
||||
rom_thing._stage.position = {"x": 10000, "y": 10000, "z": 0}
|
||||
mock_moves = mocker.patch.object(rom_thing, "_moves_for_z_prediction")
|
||||
|
||||
# Mock _recentre_decision so it returns (False, 1) a number of times then finally
|
||||
|
|
@ -721,9 +691,9 @@ def test_recentre_axis(true_on, rom_thing, mock_rom_deps, mocker):
|
|||
|
||||
if true_on > 10:
|
||||
with pytest.raises(RuntimeError, match="Couldn't find centre"):
|
||||
rom_thing._recentre_axis("x", mock_rom_deps)
|
||||
rom_thing._recentre_axis("x")
|
||||
else:
|
||||
rom_thing._recentre_axis("x", mock_rom_deps)
|
||||
rom_thing._recentre_axis("x")
|
||||
|
||||
# _recentre_decision is called until True or exits after 10 attempts
|
||||
assert mock_recentre_decision.call_count == min(true_on, 10)
|
||||
|
|
@ -748,9 +718,7 @@ def test_recentre_axis(true_on, rom_thing, mock_rom_deps, mocker):
|
|||
(-199, -1, (True, 1)), # Always return 1 when c
|
||||
],
|
||||
)
|
||||
def test_recentre_decision(
|
||||
dist, direction, expected_decision, rom_thing, mock_rom_deps, mocker
|
||||
):
|
||||
def test_recentre_decision(dist, direction, expected_decision, rom_thing, mocker):
|
||||
"""What the algorithm decides to do in different situations."""
|
||||
# Mock find turning point just because there is no _rom_data
|
||||
mocker.patch.object(rom_thing._rom_data, "find_turning_point")
|
||||
|
|
@ -760,8 +728,8 @@ def test_recentre_decision(
|
|||
mocker.patch.object(rom_thing, "_distance_in_img_percentage", return_value=dist)
|
||||
mocker.patch.object(rom_thing, "_img_dir_from_stage_coords", return_value=direction)
|
||||
|
||||
assert rom_thing._recentre_decision("x", mock_rom_deps) == expected_decision
|
||||
assert rom_thing._recentre_decision("x") == expected_decision
|
||||
|
||||
# Check that we make an absolute move (to the centre) before returning centred.
|
||||
centred = expected_decision[0]
|
||||
mock_rom_deps.stage.move_absolute.call_count == 1 if centred else 0
|
||||
rom_thing._stage.move_absolute.call_count == 1 if centred else 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue