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:
Julian Stirling 2025-12-17 11:57:07 +00:00
commit 150e69d235
7 changed files with 279 additions and 493 deletions

View file

@ -10,20 +10,18 @@ See repository root for licensing information.
import logging import logging
import os import os
import time import time
from contextlib import contextmanager
from dataclasses import dataclass 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 import numpy as np
from fastapi import Depends
from pydantic import BaseModel, computed_field, field_validator, model_validator from pydantic import BaseModel, computed_field, field_validator, model_validator
import labthings_fastapi as lt import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
from .camera import CameraDependency as CameraClient from .camera import BaseCamera
from .camera import RawCameraDependency as RawCamera from .stage import BaseStage
from .stage import StageDependency as Stage
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
MIN_TEST_IMAGE_COUNT = 3 MIN_TEST_IMAGE_COUNT = 3
@ -220,24 +218,18 @@ class JPEGSharpnessMonitor:
``data_dict`` and data with interpolated ``z`` positions can be retrieved with ``data_dict`` and data with interpolated ``z`` positions can be retrieved with
move_data. move_data.
A new JPEGSharpnessMonitor instance is created each time an action with the
SharpnessMonitorDep as an argument is called.
""" """
def __init__( def __init__(self, stage: BaseStage, camera: BaseCamera) -> None:
self, stage: Stage, camera: RawCamera, portal: lt.deps.BlockingPortal
) -> None:
"""Initialise a new JPEGSharpnessMonitor. The args are injected automatically. """Initialise a new JPEGSharpnessMonitor. The args are injected automatically.
:param stage: A direct_thing_client dependency for the the microscope stage. :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 :param camera: A raw_thing_client depeendency for the camera. This is a raw
dependency as the underlying class needs to be dependency as the underlying class needs to be
:param portal: The asyncio blocking portal for asynchronous task scheduling.
""" """
self.camera = camera self.camera = camera
self.stage = stage self.stage = stage
self.portal = portal LOGGER.debug(f"Created sharpness monitor with {stage}, {camera}")
LOGGER.debug(f"Created sharpness monitor with {stage}, {camera}, {portal}")
self.stage_positions: list[Mapping[str, int]] = [] self.stage_positions: list[Mapping[str, int]] = []
self.stage_times: list[float] = [] self.stage_times: list[float] = []
self.jpeg_times: list[float] = [] self.jpeg_times: list[float] = []
@ -254,14 +246,23 @@ class JPEGSharpnessMonitor:
if not self.running: if not self.running:
break break
@contextmanager def __enter__(self) -> Self:
def run(self) -> None: """Start context manager, during which sharpness from the camera is monitored."""
"""Context manager, during which we will monitor sharpness from the camera.""" # Use the cameras _thing_server_interface to get access to the server event loop
self.portal.start_task_soon(self.monitor_sharpness) # and start a task.
try: self.camera._thing_server_interface.start_async_task_soon(
yield self.monitor_sharpness
finally: )
self.running = False 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]: def focus_rel(self, dz: int, block_cancellation: int = False) -> tuple[int, int]:
"""Move the stage by dz, monitoring the position over time. """Move the stage by dz, monitoring the position over time.
@ -339,9 +340,6 @@ class JPEGSharpnessMonitor:
return SharpnessDataArrays(**data) return SharpnessDataArrays(**data)
SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()]
class AutofocusThing(lt.Thing): class AutofocusThing(lt.Thing):
"""The Thing concerned with combinations of z axis movements and the camera. """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) 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 @lt.action
def fast_autofocus( def fast_autofocus(
self, self,
sharpness_monitor: SharpnessMonitorDep,
dz: int = 2000, dz: int = 2000,
start: Literal["centre", "base"] = "centre", start: Literal["centre", "base"] = "centre",
) -> SharpnessDataArrays: ) -> SharpnessDataArrays:
@ -363,7 +363,7 @@ class AutofocusThing(lt.Thing):
the position where the image was sharpest. We'll then move back down, and the position where the image was sharpest. We'll then move back down, and
finally up to the sharpest point. finally up to the sharpest point.
""" """
with sharpness_monitor.run(): with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor:
# Move to (-dz / 2) # Move to (-dz / 2)
if start == "centre": if start == "centre":
sharpness_monitor.focus_rel(-dz // 2) sharpness_monitor.focus_rel(-dz // 2)
@ -384,7 +384,6 @@ class AutofocusThing(lt.Thing):
@lt.action @lt.action
def z_move_and_measure_sharpness( def z_move_and_measure_sharpness(
self, self,
sharpness_monitor: SharpnessMonitorDep,
dz: Sequence[int], dz: Sequence[int],
wait: float = 0, wait: float = 0,
) -> SharpnessDataArrays: ) -> SharpnessDataArrays:
@ -400,7 +399,7 @@ class AutofocusThing(lt.Thing):
If ``wait`` is specified, we will wait for that many seconds If ``wait`` is specified, we will wait for that many seconds
between moves. between moves.
""" """
with sharpness_monitor.run(): with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor:
for move_index, current_dz in enumerate(dz): for move_index, current_dz in enumerate(dz):
if move_index > 0 and wait > 0: if move_index > 0 and wait > 0:
time.sleep(wait) time.sleep(wait)
@ -410,8 +409,6 @@ class AutofocusThing(lt.Thing):
@lt.action @lt.action
def looping_autofocus( def looping_autofocus(
self, self,
stage: Stage,
sharpness_monitor: SharpnessMonitorDep,
dz: int = 2000, dz: int = 2000,
start: Literal["centre", "base"] = "centre", start: Literal["centre", "base"] = "centre",
) -> tuple[list[float], list[float]]: ) -> tuple[list[float], list[float]]:
@ -425,12 +422,12 @@ class AutofocusThing(lt.Thing):
attempt = 0 attempt = 0
backlash = 200 backlash = 200
with sharpness_monitor.run(): with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor:
while attempt < 10: while attempt < 10:
attempt += 1 attempt += 1
if start == "centre": if start == "centre":
stage.move_relative(x=0, y=0, z=-(backlash + dz / 2)) self._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)
# Always start centrally for future runs # Always start centrally for future runs
start = "centre" start = "centre"
@ -445,8 +442,8 @@ class AutofocusThing(lt.Thing):
target_max = np.max(heights) - dz / 5 target_max = np.max(heights) - dz / 5
# move to the peak # move to the peak
stage.move_absolute(z=peak_height - backlash) self._stage.move_absolute(z=peak_height - backlash)
stage.move_absolute(z=peak_height) self._stage.move_absolute(z=peak_height)
if target_min < peak_height < target_max: if target_min < peak_height < target_max:
# If it is within the target range then return # If it is within the target range then return
@ -487,28 +484,26 @@ class AutofocusThing(lt.Thing):
images_dir: str, images_dir: str,
autofocus_dz: int, autofocus_dz: int,
save_resolution: tuple[int, int], save_resolution: tuple[int, int],
logger: lt.deps.InvocationLogger,
) -> StackParams: ) -> StackParams:
"""Set up the parameters used for all stacks in a scan. """Set up the parameters used for all stacks in a scan.
:param images_dir: the folder to save all images :param images_dir: the folder to save all images
:param autofocus_dz: the range to autofocus over if a stack fails :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 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. :returns: A StackParams object with the required parameters.
""" """
# Coerce min_images_to_test parameter # Coerce min_images_to_test parameter
min_images_to_test = self.stack_min_images_to_test min_images_to_test = self.stack_min_images_to_test
if min_images_to_test < MIN_TEST_IMAGE_COUNT: 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. " f"Cannot test only {min_images_to_test} image(s) as this will fail. "
"Setting min images to test to lowest possible value of" "Setting min images to test to lowest possible value of"
f"{MIN_TEST_IMAGE_COUNT}." f"{MIN_TEST_IMAGE_COUNT}."
) )
min_images_to_test = MIN_TEST_IMAGE_COUNT min_images_to_test = MIN_TEST_IMAGE_COUNT
elif min_images_to_test > MAX_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. " f"Testing {min_images_to_test} images will cause defocus. "
"Setting min images to test to highest possible value of " "Setting min images to test to highest possible value of "
f"{MAX_TEST_IMAGE_COUNT}." f"{MAX_TEST_IMAGE_COUNT}."
@ -516,7 +511,7 @@ class AutofocusThing(lt.Thing):
min_images_to_test = MAX_TEST_IMAGE_COUNT min_images_to_test = MAX_TEST_IMAGE_COUNT
elif min_images_to_test % 2 == 0: elif min_images_to_test % 2 == 0:
min_images_to_test += 1 min_images_to_test += 1
logger.warning( self.logger.warning(
"Minimum number of images to test should be odd, setting to " "Minimum number of images to test should be odd, setting to "
f"{min_images_to_test}." f"{min_images_to_test}."
) )
@ -527,19 +522,19 @@ class AutofocusThing(lt.Thing):
# min_images_to_save # min_images_to_save
images_to_save = self.stack_images_to_save images_to_save = self.stack_images_to_save
if images_to_save <= 0: if images_to_save <= 0:
logger.warning( self.logger.warning(
"At least 1 images must be saved. Setting images to save to 1." "At least 1 images must be saved. Setting images to save to 1."
) )
images_to_save = 1 images_to_save = 1
elif images_to_save > min_images_to_test: 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"Cannot save {images_to_save} images as this above the minimum "
f"number to test. Setting images to save to {MAX_TEST_IMAGE_COUNT}." f"number to test. Setting images to save to {MAX_TEST_IMAGE_COUNT}."
) )
images_to_save = min_images_to_test images_to_save = min_images_to_test
elif images_to_save % 2 == 0: elif images_to_save % 2 == 0:
images_to_save += 1 images_to_save += 1
logger.warning( self.logger.warning(
f"Images to save should be odd, setting to {images_to_save}." f"Images to save should be odd, setting to {images_to_save}."
) )
# Set the Thing property to the coerced value # Set the Thing property to the coerced value
@ -557,9 +552,6 @@ class AutofocusThing(lt.Thing):
@lt.action @lt.action
def run_smart_stack( def run_smart_stack(
self, self,
cam: CameraClient,
stage: Stage,
sharpness_monitor: SharpnessMonitorDep,
stack_parameters: StackParams, stack_parameters: StackParams,
save_on_failure: bool = False, save_on_failure: bool = False,
check_turning_points: bool = True, 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 The sharpest image, and optionally images around the sharpest, will be saved
to the images_dir with their coordinates in the filename. 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 :param stack_parameters: A StackParams object containing the required
parameters to run a stack. parameters to run a stack.
:param save_on_failure: Whether to save an image even if no focus was found. :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( success, captures, sharpest_id = self.z_stack(
stack_parameters=stack_parameters, stack_parameters=stack_parameters,
check_turning_points=check_turning_points, check_turning_points=check_turning_points,
cam=cam,
stage=stage,
) )
if success: if success:
@ -609,12 +594,7 @@ class AutofocusThing(lt.Thing):
initial_z_pos = captures[0].position["z"] initial_z_pos = captures[0].position["z"]
# If a stack is not successful, move to the start and autofocus # If a stack is not successful, move to the start and autofocus
try: try:
self.reset_stack( self.reset_stack(initial_z_pos, stack_parameters.autofocus_dz)
initial_z_pos,
stack_parameters.autofocus_dz,
stage,
sharpness_monitor,
)
except NoFocusFoundError: except NoFocusFoundError:
break break
@ -626,7 +606,6 @@ class AutofocusThing(lt.Thing):
sharpest_id=sharpest_id, sharpest_id=sharpest_id,
captures=captures, captures=captures,
stack_parameters=stack_parameters, stack_parameters=stack_parameters,
cam=cam,
) )
return success, _get_capture_by_id(captures, sharpest_id).position["z"] return success, _get_capture_by_id(captures, sharpest_id).position["z"]
@ -635,8 +614,6 @@ class AutofocusThing(lt.Thing):
self, self,
initial_z_pos: list[int], initial_z_pos: list[int],
autofocus_dz: int, autofocus_dz: int,
stage: Stage,
sharpness_monitor: SharpnessMonitorDep,
) -> None: ) -> None:
"""Return to the initial z position and run a looping autofocus. """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 ``stage`` and ``sharpness_monitor`` are Thing dependencies passed through
from the calling action. from the calling action.
""" """
stage.move_absolute(z=initial_z_pos) self._stage.move_absolute(z=initial_z_pos)
self.looping_autofocus( self.looping_autofocus(dz=autofocus_dz)
stage=stage,
sharpness_monitor=sharpness_monitor,
dz=autofocus_dz,
)
def save_stack( def save_stack(
self, self,
sharpest_id: int, sharpest_id: int,
captures: list[list], captures: list[list],
stack_parameters: StackParams, stack_parameters: StackParams,
cam: CameraClient,
) -> int: ) -> int:
"""Save the required captures to disk. """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 :param captures: a list of captures, including file name, image data and
metadata metadata
:param stack_parameters: a StackParams object holding stack parameters :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) sharpest_index = _get_capture_index_by_id(captures, sharpest_id)
slice_to_save = stack_parameters.slice_to_save(sharpest_index) slice_to_save = stack_parameters.slice_to_save(sharpest_index)
# Loop through the range, saving each capture to disk # Loop through the range, saving each capture to disk
for capture in captures[slice_to_save]: 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), jpeg_path=os.path.join(stack_parameters.images_dir, capture.filename),
save_resolution=stack_parameters.save_resolution, save_resolution=stack_parameters.save_resolution,
buffer_id=capture.buffer_id, buffer_id=capture.buffer_id,
) )
cam.clear_buffers() self._cam.clear_buffers()
return sharpest_index return sharpest_index
def z_stack( def z_stack(
self, self,
stack_parameters: StackParams, stack_parameters: StackParams,
check_turning_points: bool, check_turning_points: bool,
cam: CameraClient,
stage: Stage,
) -> tuple[bool, list[CaptureInfo], int]: ) -> tuple[bool, list[CaptureInfo], int]:
"""Capture a series of images checking that sharpest image central. """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 :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 the sharpnesses of the images in the stack is exactly 1. (May fail with
thick samples) 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 :returns: A tuple of
@ -714,14 +680,14 @@ class AutofocusThing(lt.Thing):
""" """
# Move down by the height of the z stack, plus an overshoot # 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 # 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=-( z=-(
stack_parameters.steps_undershoot stack_parameters.steps_undershoot
+ stack_parameters.backlash_correction + stack_parameters.backlash_correction
+ stack_parameters.stack_z_range / 2 + stack_parameters.stack_z_range / 2
) )
) )
stage.move_relative(z=stack_parameters.backlash_correction) self._stage.move_relative(z=stack_parameters.backlash_correction)
captures = [] captures = []
# Always check for focus using the the last `min_images_to_test` in the # 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 # Append a new image to the stack
captures.append( captures.append(
self.capture_stack_image( self.capture_stack_image(buffer_max=stack_parameters.min_images_to_test)
cam,
stage,
buffer_max=stack_parameters.min_images_to_test,
)
) )
# If the number of images is enough to test, test them # If the number of images is enough to test, test them
@ -754,33 +716,26 @@ class AutofocusThing(lt.Thing):
if result == "restart": if result == "restart":
return False, captures, capture_id return False, captures, capture_id
# If reached here the result was "continue" # 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 return False, captures, capture_id
def capture_stack_image( def capture_stack_image(self, buffer_max: int) -> CaptureInfo:
self,
cam: CameraClient,
stage: Stage,
buffer_max: int,
) -> CaptureInfo:
"""Capture another image and return the capture information. """Capture another image and return the capture information.
The capture is stored by the camera Thing, and can be saved by ID. 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 :param buffer_max: The maximum number of images to tell the camera to keep in memory
for saving once the stack is complete for saving once the stack is complete
:returns: A CaptureInfo object containing the capture information including its :returns: A CaptureInfo object containing the capture information including its
camera buffer_id needed for saving. camera buffer_id needed for saving.
""" """
stage_location = stage.position stage_location = self._stage.position
buffer_id = cam.capture_to_memory(buffer_max=buffer_max) buffer_id = self._cam.capture_to_memory(buffer_max=buffer_max)
return CaptureInfo( return CaptureInfo(
buffer_id=buffer_id, buffer_id=buffer_id,
position=stage_location, 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 # Silence too many returns in this situation as refactoring to reduce returns is

View file

@ -33,8 +33,8 @@ from camera_stage_mapping.camera_stage_tracker import Tracker
from camera_stage_mapping.exceptions import MappingError from camera_stage_mapping.exceptions import MappingError
from labthings_fastapi.types.numpy import DenumpifyingDict from labthings_fastapi.types.numpy import DenumpifyingDict
from .camera import CameraDependency as CameraClient from .camera import BaseCamera
from .stage import StageDependency as Stage from .stage import BaseStage
CoordinateType = Tuple[float, float, float] CoordinateType = Tuple[float, float, float]
XYCoordinateType = Tuple[float, float] XYCoordinateType = Tuple[float, float]
@ -62,13 +62,13 @@ class RecordedMove:
for calibrating the stage as it allows measuring how long moves take. 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. """Set the stage client used for for movement.
:param stage: the stage client to be used. ``stage.move_to_xyz_position`` will :param stage: the stage client to be used. ``stage.move_to_xyz_position`` will
be called whenever the instance is called. be called whenever the instance is called.
""" """
self._stage: Stage = stage self._stage = stage
self._current_position: Optional[CoordinateType] = None self._current_position: Optional[CoordinateType] = None
self._history: List[Tuple[float, Optional[CoordinateType]]] = [] 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. override the ``get_xyz_position()`` and ``move_to_xyz_position()`` methods.
""" """
_cam: BaseCamera = lt.thing_slot()
_stage: BaseStage = lt.thing_slot()
@lt.action @lt.action
def calibrate_1d( def calibrate_1d(self, direction: Tuple[float, float, float]) -> DenumpifyingDict:
self,
camera: CameraClient,
stage: Stage,
logger: lt.deps.InvocationLogger,
direction: Tuple[float, float, float],
) -> DenumpifyingDict:
"""Move a microscope's stage in 1D, and figure out the relationship with the camera.""" """Move a microscope's stage in 1D, and figure out the relationship with the camera."""
# Record positions and times for stage calibration # Record positions and times for stage calibration
recorded_move = RecordedMove(stage) recorded_move = RecordedMove(self._stage)
tracker = Tracker( tracker = Tracker(
camera.capture_downsampled_array, self._cam.capture_downsampled_array,
stage.get_xyz_position, self._stage.get_xyz_position,
settle=camera.settle, settle=self._cam.settle,
) )
direction_array: np.ndarray = np.array(direction) direction_array: np.ndarray = np.array(direction)
starting_position = stage.position starting_position = self._stage.position
try: try:
result: dict = calibrate_backlash_1d( 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: except lt.exceptions.InvocationCancelledError as e:
logger.info("User cancelled the camera stage mapping calibration") self.logger.info("User cancelled the camera stage mapping calibration")
logger.info("Returning to starting position") self.logger.info("Returning to starting position")
stage.move_absolute(**starting_position, block_cancellation=True) self._stage.move_absolute(**starting_position, block_cancellation=True)
raise e raise e
except MappingError as e: except MappingError as e:
logger.info("Returning to starting position due to failed calibration") self.logger.info("Returning to starting position due to failed calibration")
stage.move_absolute(**starting_position, block_cancellation=True) self._stage.move_absolute(**starting_position, block_cancellation=True)
raise e raise e
result["move_history"] = recorded_move.history 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 return result
@lt.action @lt.action
def calibrate_xy( def calibrate_xy(self) -> DenumpifyingDict:
self, camera: CameraClient, stage: Stage, logger: lt.deps.InvocationLogger
) -> DenumpifyingDict:
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera. """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. 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. # 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. # The x-y difference is due to the camera aspect ratio, not the stage hardware.
logger.info("Calibrating Y axis:") self.logger.info("Calibrating Y axis:")
cal_y: dict = self.calibrate_1d(camera, stage, logger, (0, 1, 0)) cal_y: dict = self.calibrate_1d((0, 1, 0))
logger.info("Calibrating X axis:") self.logger.info("Calibrating X axis:")
cal_x: dict = self.calibrate_1d(camera, stage, logger, (1, 0, 0)) cal_x: dict = self.calibrate_1d((1, 0, 0))
logger.info("Calibration complete, updating metadata.") self.logger.info("Calibration complete, updating metadata.")
# Combine X and Y calibrations to make a 2D calibration # Combine X and Y calibrations to make a 2D calibration
cal_xy: dict = image_to_stage_displacement_from_1d([cal_x, cal_y]) 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[0][0], 2)}, {round(csm_matrix[0][1], 2)},],"
f"[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][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] = { data: Dict[str, dict] = {
"camera_stage_mapping_calibration": cal_xy, "camera_stage_mapping_calibration": cal_xy,
@ -201,9 +196,7 @@ class CameraStageMapper(lt.Thing):
return data return data
@lt.property @lt.property
def image_to_stage_displacement_matrix( def image_to_stage_displacement_matrix(self) -> Optional[List[List[float]]]:
self,
) -> Optional[List[List[float]]]: # 2x2 integer array
"""A 2x2 matrix that converts displacement in image coordinates to stage coordinates. """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 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 raise CSMUncalibratedError() # noqa: RSE102
@lt.action @lt.action
def move_in_image_coordinates( def move_in_image_coordinates(self, x: float, y: float) -> None:
self,
stage: Stage,
x: float,
y: float,
) -> None:
"""Move by a given number of pixels on the camera. """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 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. an image usually helps resolve any ambiguity.
""" """
self.assert_calibrated() 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 @lt.action
def convert_image_to_stage_coordinates( def convert_image_to_stage_coordinates(

View file

@ -378,7 +378,6 @@ class SmartScanThing(lt.Thing):
images_dir=self._ongoing_scan.images_dir, images_dir=self._ongoing_scan.images_dir,
autofocus_dz=self.autofocus_dz, autofocus_dz=self.autofocus_dz,
save_resolution=self._scan_data.save_resolution, save_resolution=self._scan_data.save_resolution,
logger=self._scan_logger,
) )
self._preview_stitcher = stitching.PreviewStitcher( self._preview_stitcher = stitching.PreviewStitcher(
self._ongoing_scan.images_dir, self._ongoing_scan.images_dir,

View file

@ -14,7 +14,6 @@ this is 10% of the expected motion is the measured axis.
""" """
import time import time
from dataclasses import dataclass
from threading import Lock from threading import Lock
from typing import Any, Literal, Optional, overload from typing import Any, Literal, Optional, overload
@ -25,14 +24,9 @@ from camera_stage_mapping import fft_image_tracking
# Things # Things
from .autofocus import AutofocusThing from .autofocus import AutofocusThing
from .camera import CameraDependency as CamDep from .camera import BaseCamera
from .camera_stage_mapping import CameraStageMapper from .camera_stage_mapping import CameraStageMapper
from .stage import StageDependency as StageDep from .stage import BaseStage
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 ## Size of movement in percentage of field of view
SMALL_STEP = 20 SMALL_STEP = 20
@ -111,20 +105,6 @@ class RomDataTracker:
return {axis: int(turning_loc), other_axis: other_coord, "z": int(turning_z)} 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): class ParasiticMotionError(Exception):
"""Custom exception raised when parasitic motion is detected. """Custom exception raised when parasitic motion is detected.
@ -136,6 +116,11 @@ class ParasiticMotionError(Exception):
class RangeofMotionThing(lt.Thing): class RangeofMotionThing(lt.Thing):
"""A class used to measure the range of motion of the stage in X and Y.""" """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) calibrated_range: Optional[list[int, int]] = lt.setting(default=None, readonly=True)
def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None: def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None:
@ -146,21 +131,9 @@ class RangeofMotionThing(lt.Thing):
self._rom_data = RomDataTracker() self._rom_data = RomDataTracker()
@lt.action @lt.action
def perform_rom_test( def perform_rom_test(self) -> dict[str, Any]:
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. """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. :return: Results dictionary separated into keys of each axis and direction.
""" """
got_lock = self._lock.acquire(blocking=False) 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.") raise RuntimeError("Trying to run ROM test when a test is already running.")
try: try:
rom_deps = RomDeps( self.logger.info(
autofocus=autofocus, stage=stage, csm=csm, cam=cam, logger=logger
)
logger.info(
"Using the stage to measure the Range of Motion. " "Using the stage to measure the Range of Motion. "
"Please ensure you are using a sample that covers the whole range of " "Please ensure you are using a sample that covers the whole range of "
"motion. This should be approximately 12 x 12 mm." "motion. This should be approximately 12 x 12 mm."
) )
start_time = time.time() start_time = time.time()
self._set_stream_resolution(cam) self._set_stream_resolution()
# The total range for the two axes under test # The total range for the two axes under test
step_range = [] step_range = []
@ -193,84 +163,61 @@ class RangeofMotionThing(lt.Thing):
for axis_dir in directions: for axis_dir in directions:
# Create a new tracker at start of measurement. # Create a new tracker at start of measurement.
self._rom_data = RomDataTracker() self._rom_data = RomDataTracker()
self._move_until_edge( self._move_until_edge(axis=axis, direction=axis_dir)
axis=axis, direction=axis_dir, rom_deps=rom_deps
)
# Append final position from tracker # Append final position from tracker
axis_limits.append(self._rom_data.final_position[axis]) axis_limits.append(self._rom_data.final_position[axis])
# Calculate step range. # Calculate step range.
step_range.append(abs(axis_limits[0] - axis_limits[1])) step_range.append(abs(axis_limits[0] - axis_limits[1]))
total_time = time.time() - start_time 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 self.calibrated_range = step_range
finally: finally:
self._lock.release() self._lock.release()
return { return {
"Time": total_time, "Time": total_time,
"CSM Matrix": csm.image_to_stage_displacement_matrix, "CSM Matrix": self._csm.image_to_stage_displacement_matrix,
"Step Range": step_range, "Step Range": step_range,
} }
@lt.action @lt.action
def perform_recentre( def perform_recentre(self) -> None:
self, """Measures the curvature of the motion to centre x and y axes."""
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.
"""
got_lock = self._lock.acquire(blocking=False) got_lock = self._lock.acquire(blocking=False)
if not got_lock: if not got_lock:
raise RuntimeError("Trying to run recentre when a test is already running.") raise RuntimeError("Trying to run recentre when a test is already running.")
try: try:
rom_deps = RomDeps( self.logger.info("Recentring the stage.")
autofocus=autofocus, stage=stage, csm=csm, cam=cam, logger=logger
)
logger.info("Recentring the stage.")
self._set_stream_resolution(cam) self._set_stream_resolution()
# Strictly typed definitions to iterate over for MyPys sake. # Strictly typed definitions to iterate over for MyPys sake.
axes: tuple[Literal["x"], Literal["y"]] = ("x", "y") axes: tuple[Literal["x"], Literal["y"]] = ("x", "y")
for axis in axes: 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']})" 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) # Set the central position to (0,0,0)
rom_deps.stage.set_zero_position() self._stage.set_zero_position()
rom_deps.logger.info("Position reset to (0, 0, 0).") self.logger.info("Position reset to (0, 0, 0).")
finally: finally:
self._lock.release() self._lock.release()
def _set_stream_resolution(self, cam: CamDep) -> None: def _set_stream_resolution(self) -> None:
"""Set the self._stream_resolution attribute by reading camera. """Set the self._stream_resolution attribute by reading camera."""
stream_shape = self._cam.grab_as_array().shape
:param cam: The camera dependency.
"""
stream_shape = cam.grab_as_array().shape
# Swap axes as numpy is [y, x] # Swap axes as numpy is [y, x]
self._stream_resolution = (stream_shape[1], stream_shape[0]) self._stream_resolution = (stream_shape[1], stream_shape[0])
def _move_until_edge( def _move_until_edge(
self, self, axis: Literal["x", "y"], direction: Literal[1, -1]
axis: Literal["x", "y"],
direction: Literal[1, -1],
rom_deps: RomDeps,
) -> None: ) -> None:
"""Move in one direction until movement per step decreases significantly. """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 will be some movement as there is no hard stop, but it will reduce
significantly. 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 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. :param direction: The direction which is being measured. This must be 1 or -1.
:return: Results dictionary containing stage positions, :return: Results dictionary containing stage positions,
correlations and the final position. correlations and the final position.
""" """
# Start by performing an autofocus and recording starting position # Start by performing an autofocus and recording starting position
rom_deps.autofocus.looping_autofocus(dz=1000) self._autofocus.looping_autofocus(dz=1000)
starting_position = rom_deps.stage.position starting_position = self._stage.position
self._rom_data.stage_coords.append(starting_position) self._rom_data.stage_coords.append(starting_position)
try: try:
dir_str = "positive" if direction == 1 else "negative" dir_str = "positive" if direction == 1 else "negative"
rom_deps.logger.info( self.logger.info(f"Beginning the {axis}-axis in the {dir_str} direction")
f"Beginning the {axis}-axis in the {dir_str} direction"
)
rom_deps.logger.info("Moving the stage in 5 medium sized steps.") self.logger.info("Moving the stage in 5 medium sized steps.")
self._moves_for_z_prediction( self._moves_for_z_prediction(axis=axis, direction=direction)
axis=axis,
direction=direction,
rom_deps=rom_deps,
)
still_moving = True still_moving = True
# Loop taking 1 big step followed by 3 small steps to check if the # 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. # stage is moving as expected or has reached end of range of motion.
# Stop once end of range of motion is detected. # Stop once end of range of motion is detected.
while still_moving: while still_moving:
self._big_z_corrected_movement( self._big_z_corrected_movement(axis=axis, direction=direction)
axis=axis, direction=direction, rom_deps=rom_deps
)
# Autofocus and record position # Autofocus and record position
rom_deps.autofocus.looping_autofocus(dz=800) self._autofocus.looping_autofocus(dz=800)
self._rom_data.stage_coords.append(rom_deps.stage.position) self._rom_data.stage_coords.append(self._stage.position)
# Perform small moves to check stage is still moving. # Perform small moves to check stage is still moving.
still_moving = self._stage_still_moves( still_moving = self._stage_still_moves(axis=axis, direction=direction)
axis=axis,
direction=direction,
rom_deps=rom_deps,
)
# Stage may have crashed during a large move. Move stage back until motion # Stage may have crashed during a large move. Move stage back until motion
# is detected # is detected
rom_deps.logger.info("Moving stage back until motion is detect") self.logger.info("Moving stage back until motion is detect")
self._move_back_until_motion_detected( self._move_back_until_motion_detected(axis=axis, direction=direction)
axis=axis,
direction=direction,
rom_deps=rom_deps,
)
# Replace final position. # Replace final position.
self._rom_data.stage_coords[-1] = rom_deps.stage.position self._rom_data.stage_coords[-1] = self._stage.position
finally: 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. """Recentre a single axis.
:param axis: The axis to recentre :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. # A new tracker for this axis.
self._rom_data = RomDataTracker() self._rom_data = RomDataTracker()
# Direction is in image coords, initial assumption is that centre is at (0,0). # Direction is in image coords, initial assumption is that centre is at (0,0).
direction = self._img_dir_from_stage_coords( direction = self._img_dir_from_stage_coords({"x": 0, "y": 0, "z": 0}, axis)
{"x": 0, "y": 0, "z": 0}, axis, rom_deps
)
i = 0 i = 0
while True: while True:
i += 1 i += 1
# Find z then make a number of moves (autofocussing and logging position) # Find z then make a number of moves (autofocussing and logging position)
# 5 moves initially (2 subsequently). # 5 moves initially (2 subsequently).
rom_deps.autofocus.looping_autofocus(dz=1000) self._autofocus.looping_autofocus(dz=1000)
self._rom_data.stage_coords.append(rom_deps.stage.position) self._rom_data.stage_coords.append(self._stage.position)
self._moves_for_z_prediction( self._moves_for_z_prediction(
axis=axis, axis=axis,
direction=direction, direction=direction,
rom_deps=rom_deps,
n_moves=5 if i == 1 else 2, n_moves=5 if i == 1 else 2,
) )
centred, direction = self._recentre_decision(axis, rom_deps) centred, direction = self._recentre_decision(axis)
if centred: if centred:
break break
@ -373,10 +299,10 @@ class RangeofMotionThing(lt.Thing):
raise RuntimeError(f"Couldn't find centre of {axis}-axis") raise RuntimeError(f"Couldn't find centre of {axis}-axis")
# Make a big z-corrected move towards estimate of centre. # 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( def _recentre_decision(
self, axis: Literal["x", "y"], rom_deps: RomDeps self, axis: Literal["x", "y"]
) -> tuple[bool, Literal[1, -1]]: ) -> tuple[bool, Literal[1, -1]]:
"""Decide what to do next during recentreing. """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. * If larger, return that it is not centred, and the direction to move in.
:param axis: The axis to recentre :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 :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 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. move in, this only has meaning if the first value is False.
""" """
estimate = self._rom_data.find_turning_point(axis=axis) 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 the distance is less than 1 big step away then move to it and exit
if abs(img_perc) < BIG_STEP: if abs(img_perc) < BIG_STEP:
rom_deps.logger.info(f"Estimated centre of {axis}-axis is {estimate[axis]}") self.logger.info(f"Estimated centre of {axis}-axis is {estimate[axis]}")
rom_deps.stage.move_absolute(**estimate) self._stage.move_absolute(**estimate)
# Note the second return, the direction, is meaningless here. # Note the second return, the direction, is meaningless here.
return True, 1 return True, 1
rom_deps.logger.info( self.logger.info(
f"Estimated centre {abs(img_perc):.0f}% of a field of view away, " f"Estimated centre {abs(img_perc):.0f}% of a field of view away, "
"that is too far to move in one move." "that is too far to move in one move."
) )
# Else calculate the desired direction in image coordinates. # 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 return False, direction
def _img_percentage_to_img_coords( def _img_percentage_to_img_coords(
@ -428,30 +353,28 @@ class RangeofMotionThing(lt.Thing):
return (fov_perc / 100) * self._stream_resolution[img_index] return (fov_perc / 100) * self._stream_resolution[img_index]
def _img_dir_from_stage_coords( 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]: ) -> Literal[1, -1]:
"""For a target location in stage coords, return the direction in image coordinates. """For a target location in stage coords, return the direction in image coordinates.
:param target: The target poisiton in stage coordinates :param target: The target poisiton in stage coordinates
:param axis: The axis which is being measured. This must be 'x' or 'y'. :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. :return: Direction to move in image coordinates.
""" """
target_im_coords = rom_deps.csm.convert_stage_to_image_coordinates(**target) target_im_coords = self._csm.convert_stage_to_image_coordinates(**target)
current_loc = rom_deps.stage.position current_loc = self._stage.position
current_loc_im_coords = rom_deps.csm.convert_stage_to_image_coordinates( current_loc_im_coords = self._csm.convert_stage_to_image_coordinates(
**current_loc **current_loc
) )
return -1 if target_im_coords[axis] < current_loc_im_coords[axis] else 1 return -1 if target_im_coords[axis] < current_loc_im_coords[axis] else 1
def _distance_in_img_percentage( 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: ) -> float:
"""For a target location in stage coords return the distance in percentage of FOV. """For a target location in stage coords return the distance in percentage of FOV.
:param target: The target poisiton in stage coordinates :param target: The target poisiton in stage coordinates
:param axis: The axis which is being measured. This must be 'x' or 'y'. :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. :return: Percentage of field of view the stage should move by.
""" """
if self._stream_resolution is None: if self._stream_resolution is None:
@ -459,9 +382,9 @@ class RangeofMotionThing(lt.Thing):
"Stream resolution must be set before converting coords to percentage" "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_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 img_index = 0 if axis == "x" else 1
return (move_img[axis] / self._stream_resolution[img_index]) * 100 return (move_img[axis] / self._stream_resolution[img_index]) * 100
@ -488,11 +411,7 @@ class RangeofMotionThing(lt.Thing):
return {"x": 0, "y": distance * direction} return {"x": 0, "y": distance * direction}
def _moves_for_z_prediction( def _moves_for_z_prediction(
self, self, axis: Literal["x", "y"], direction: Literal[1, -1], n_moves: int = 5
axis: Literal["x", "y"],
direction: Literal[1, -1],
rom_deps: RomDeps,
n_moves: int = 5,
) -> None: ) -> None:
"""Perform medium sized moves with autofocus for z feed-forward. """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 direction: The direction the stage moves.
:param axis: The axis which is being measured. This must be 'x' or 'y'. :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 :param n_moves: Number of moves to make. Default is 5 which is enough for an
initial z estimate. initial z estimate.
""" """
@ -510,9 +428,9 @@ class RangeofMotionThing(lt.Thing):
fov_perc=MEDIUM_STEP, axis=axis, direction=direction fov_perc=MEDIUM_STEP, axis=axis, direction=direction
) )
for _loop in range(n_moves): 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): if _parasitic_motion_detected(movement, offset):
raise ParasiticMotionError( raise ParasiticMotionError(
"Parasitic motion detected during images to calculate z-curvature. " "Parasitic motion detected during images to calculate z-curvature. "
@ -521,7 +439,7 @@ class RangeofMotionThing(lt.Thing):
) )
def _big_z_corrected_movement( 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: ) -> None:
"""Take one big move with feed-forward z-correction. """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 axis: The axis to move in.
:param direction: The direction 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( movement = self._movement_in_img_coords(
fov_perc=BIG_STEP, axis=axis, direction=direction fov_perc=BIG_STEP, axis=axis, direction=direction
) )
# Convert to stage coordinates # 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( z_disp = self._rom_data.predict_z_displacement(
axis=axis, axis=axis,
stage_movement=stage_movement, stage_movement=stage_movement,
stage_position=rom_deps.stage.position, stage_position=self._stage.position,
) )
rom_deps.stage.move_relative(z=z_disp) self._stage.move_relative(z=z_disp)
rom_deps.csm.move_in_image_coordinates(**movement) self._csm.move_in_image_coordinates(**movement)
def _stage_still_moves( def _stage_still_moves(
self, self,
axis: Literal["x", "y"], axis: Literal["x", "y"],
direction: Literal[1, -1], direction: Literal[1, -1],
rom_deps: RomDeps,
) -> bool: ) -> bool:
"""Carry out 3 small moves in a given direction and axis. """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 axis: The axis which is being measured. This must be 'x' or 'y'.
:param direction: The direction the stage moves. :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( movement = self._movement_in_img_coords(
fov_perc=SMALL_STEP, axis=axis, direction=direction fov_perc=SMALL_STEP, axis=axis, direction=direction
@ -572,7 +487,6 @@ class RangeofMotionThing(lt.Thing):
for _loop in range(3): for _loop in range(3):
offset = self._move_and_measure( offset = self._move_and_measure(
movement=movement, movement=movement,
rom_deps=rom_deps,
# Don't autofocus initially # Don't autofocus initially
perform_autofocus=False, perform_autofocus=False,
# But retry focus 3 times if detected motion is too small or parasitic # 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, abs_min_offset=abs_min_offset,
) )
parasitic_motion = _parasitic_motion_detected(movement, offset) parasitic_motion = _parasitic_motion_detected(movement, offset)
rom_deps.logger.info(f"Offset measured as {offset[axis]}") self.logger.info(f"Offset measured as {offset[axis]}")
self._rom_data.record_movement(rom_deps.stage.position, offset) self._rom_data.record_movement(self._stage.position, offset)
if abs(offset[axis]) < abs_min_offset or parasitic_motion: if abs(offset[axis]) < abs_min_offset or parasitic_motion:
# If the offset is below the abs min offset, or significant # If the offset is below the abs min offset, or significant
# parasitic motion is detected then the edge has been found. # 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 return False
# If we reached here the stage is still moving fine # If we reached here the stage is still moving fine
return True return True
@ -595,7 +509,6 @@ class RangeofMotionThing(lt.Thing):
self, self,
axis: Literal["x", "y"], axis: Literal["x", "y"],
direction: Literal[1, -1], direction: Literal[1, -1],
rom_deps: RomDeps,
) -> None: ) -> None:
"""Move the stage against the direction of test unil motion is detected. """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 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, :param direction: The direction in which the stage was moving during the test,
this method will move in the opposite direction. 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( movement = self._movement_in_img_coords(
fov_perc=SMALL_STEP, axis=axis, direction=-direction fov_perc=SMALL_STEP, axis=axis, direction=-direction
@ -617,17 +529,17 @@ class RangeofMotionThing(lt.Thing):
for i in range(max_moves): for i in range(max_moves):
# Increment movement # 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 an autofocus every 3 steps
run_autofocus = i % 3 == 2 run_autofocus = i % 3 == 2
offset = self._move_and_measure( 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: if abs(offset[axis]) > motion_minimum:
rom_deps.logger.info("Motion detected.") self.logger.info("Motion detected.")
return return
# If this point is reached then motion is never detected # If this point is reached then motion is never detected
raise RuntimeError("Cannot detect motion again after reaching end of range.") raise RuntimeError("Cannot detect motion again after reaching end of range.")
@ -635,7 +547,6 @@ class RangeofMotionThing(lt.Thing):
def _move_and_measure( def _move_and_measure(
self, self,
movement: dict[str, float], movement: dict[str, float],
rom_deps: RomDeps,
perform_autofocus: bool = True, perform_autofocus: bool = True,
max_autofocus_repeats: int = 0, max_autofocus_repeats: int = 0,
abs_min_offset: float = 0.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. """Move the stage and measure the offset between the two positions.
:param movement: A dictionary containing the distance to move in image coords. :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. :param perform_autofocus: Set to False to disable autofocus after move.
Default is True Default is True
:param max_autofocus_repeats: The number of times to repeat the focus if the :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 # Take image before move
before_img = rom_deps.cam.grab_as_array() before_img = self._cam.grab_as_array()
# Move and autofocus if required # Move and autofocus if required
rom_deps.csm.move_in_image_coordinates(**movement) self._csm.move_in_image_coordinates(**movement)
if perform_autofocus: if perform_autofocus:
rom_deps.autofocus.looping_autofocus(dz=800) self._autofocus.looping_autofocus(dz=800)
# Take image and calculate offset # 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: if max_autofocus_repeats > 0:
axis = _axis_from_movement_dict(movement) axis = _axis_from_movement_dict(movement)
@ -673,12 +583,12 @@ class RangeofMotionThing(lt.Thing):
parasitic_motion = _parasitic_motion_detected(movement, offset) parasitic_motion = _parasitic_motion_detected(movement, offset)
while abs(offset[axis]) < abs_min_offset or parasitic_motion: while abs(offset[axis]) < abs_min_offset or parasitic_motion:
af_repeats += 1 af_repeats += 1
rom_deps.logger.info( self.logger.info(
f"Motion not detected. Refocusing to check. Attempt {af_repeats}/3." 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 # 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) parasitic_motion = _parasitic_motion_detected(movement, offset)
if af_repeats >= max_autofocus_repeats: if af_repeats >= max_autofocus_repeats:
# break if the maximum number of tries are exceeded. # break if the maximum number of tries are exceeded.
@ -686,22 +596,19 @@ class RangeofMotionThing(lt.Thing):
return offset return offset
def _offset_from( def _offset_from(self, before_img: np.ndarray) -> dict[str, float]:
self, before_img: np.ndarray, rom_deps: RomDeps
) -> dict[str, float]:
"""Take an image and calculate the offset from an input image. """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 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 :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: if not is_sample:
raise RuntimeError( raise RuntimeError(
"No sample detected. Sample must be densely featured and cover the " "No sample detected. Sample must be densely featured and cover the "
"whole range of motion." "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( offset = fft_image_tracking.displacement_between_images(
image_0=before_img, image_0=before_img,
image_1=after_img, image_1=after_img,

View file

@ -48,22 +48,23 @@ def fake_sharpness_data(
def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes, mocker): def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes, mocker):
"""Test the high level looping autofocus algorithm.""" """Test the high level looping autofocus algorithm."""
dz = 2000 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. # Make a mock stage where move_absolute abs and relative updates the position counter.
stage = mocker.Mock() autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z}
stage.position = {"x": 0, "y": 0, "z": start_z}
def set_pos(**kwargs: int) -> None: def set_pos(**kwargs: int) -> None:
"""Move absolute should update position. So make a side effect for the mock.""" """Move absolute should update position. So make a side effect for the mock."""
for axis, value in kwargs.items(): for axis, value in kwargs.items():
stage.position[axis] = value autofocus_thing._stage.position[axis] = value
def adjust_pos(**kwargs: int) -> None: def adjust_pos(**kwargs: int) -> None:
"""Move relative should update position. So make a side effect for the mock.""" """Move relative should update position. So make a side effect for the mock."""
for axis, value in kwargs.items(): for axis, value in kwargs.items():
stage.position[axis] += value autofocus_thing._stage.position[axis] += value
stage.move_absolute.side_effect = set_pos autofocus_thing._stage.move_absolute.side_effect = set_pos
stage.move_relative.side_effect = adjust_pos autofocus_thing._stage.move_relative.side_effect = adjust_pos
# Make a mock sharpness monitor that can generate sharpness data. # Make a mock sharpness monitor that can generate sharpness data.
sharpness_monitor = mocker.MagicMock() 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.""" """Generate sharpnesses based on parameterised input, and mock stage position."""
return fake_sharpness_data( return fake_sharpness_data(
dz=dz, dz=dz,
start_z=stage.position["z"], start_z=autofocus_thing._stage.position["z"],
max_loc=max_loc, max_loc=max_loc,
) )
sharpness_monitor.move_data.side_effect = return_sharpness 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: if passes:
autofocus_thing.looping_autofocus( autofocus_thing.looping_autofocus(dz=dz, start="centre" if centre else "base")
stage=stage,
sharpness_monitor=sharpness_monitor,
dz=dz,
start="centre" if centre else "base",
)
else: else:
with pytest.raises(NoFocusFoundError): with pytest.raises(NoFocusFoundError):
autofocus_thing.looping_autofocus( autofocus_thing.looping_autofocus(
stage=stage, dz=dz, start="centre" if centre else "base"
sharpness_monitor=sharpness_monitor,
dz=dz,
start="centre" if centre else "base",
) )
assert sharpness_monitor.focus_rel.call_count == attempts_expected 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

View file

@ -26,7 +26,6 @@ from openflexure_microscope_server.things.autofocus import (
_get_peak_turning_point, _get_peak_turning_point,
) )
LOGGER = logging.getLogger("mock-invocation_logger")
RANDOM_GENERATOR = np.random.default_rng() RANDOM_GENERATOR = np.random.default_rng()
@ -265,7 +264,7 @@ def test_retrieval_of_captures(start):
@pytest.fixture @pytest.fixture
def autofocus_thing(): def autofocus_thing():
"""Return an autofocus thing connected to a server.""" """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): 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 initial_images_to_save = autofocus_thing.stack_images_to_save
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
stack_params = autofocus_thing.create_stack_params( stack_params = autofocus_thing.create_stack_params(
autofocus_dz=2000, autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
images_dir="/this/is/fake",
save_resolution=(1640, 1232),
logger=LOGGER,
) )
assert len(caplog.records) == 0 assert len(caplog.records) == 0
@ -304,10 +300,7 @@ def test_coercing_stack_test_ims(
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
stack_params = autofocus_thing.create_stack_params( stack_params = autofocus_thing.create_stack_params(
autofocus_dz=2000, autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
images_dir="/this/is/fake",
save_resolution=(1640, 1232),
logger=LOGGER,
) )
assert len(caplog.records) == 1 assert len(caplog.records) == 1
@ -335,10 +328,7 @@ def test_coercing_stack_save_ims(
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
stack_params = autofocus_thing.create_stack_params( stack_params = autofocus_thing.create_stack_params(
autofocus_dz=2000, autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
images_dir="/this/is/fake",
save_resolution=(1640, 1232),
logger=LOGGER,
) )
assert len(caplog.records) == 1 assert len(caplog.records) == 1
@ -352,14 +342,8 @@ def test_coercing_stack_save_ims(
@pytest.mark.parametrize("pass_on", [1, 2, 3, 4]) @pytest.mark.parametrize("pass_on", [1, 2, 3, 4])
def test_run_smart_stack(pass_on, autofocus_thing, mocker): def test_run_smart_stack(pass_on, autofocus_thing, mocker):
"""Test Running smart stack with the stack passing on different attempts.""" """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( stack_params = autofocus_thing.create_stack_params(
autofocus_dz=2000, autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
images_dir="/this/is/fake",
save_resolution=(1640, 1232),
logger=LOGGER,
) )
assert stack_params.max_attempts == 3 assert stack_params.max_attempts == 3
@ -386,9 +370,6 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker):
# Run it # Run it
success, final_z = autofocus_thing.run_smart_stack( success, final_z = autofocus_thing.run_smart_stack(
cam=cam,
stage=stage,
sharpness_monitor=sharpness_monitor,
stack_parameters=stack_params, stack_parameters=stack_params,
save_on_failure=False, save_on_failure=False,
check_turning_points=True, 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) n_stacks = min(pass_on, stack_params.max_attempts)
assert autofocus_thing.z_stack.call_count == n_stacks assert autofocus_thing.z_stack.call_count == n_stacks
# Move absolute should be 1 less time that the number of times z_stack_run # 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 # As should looping autofocus
assert autofocus_thing.looping_autofocus.call_count == n_stacks - 1 assert autofocus_thing.looping_autofocus.call_count == n_stacks - 1
# Check rest stack is moving to the first image in the stack. # Check rest stack is moving to the first image in the stack.
if n_stacks > 1: 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 # 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): 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. results). If it a tuple (or anything else), it is set as a return value.
""" """
stack_params = autofocus_thing.create_stack_params( stack_params = autofocus_thing.create_stack_params(
autofocus_dz=2000, autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
images_dir="/this/is/fake",
save_resolution=(1640, 1232),
logger=LOGGER,
) )
stack_params.settling_time = 0 # Don't settle or tests take forever. 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() autofocus_thing.capture_stack_image = mocker.Mock()
if isinstance(check_returns, list): if isinstance(check_returns, list):
autofocus_thing.check_stack_result = mocker.Mock(side_effect=check_returns) 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( return autofocus_thing.z_stack(
stack_parameters=stack_params, stack_parameters=stack_params,
check_turning_points=check_turning_points, 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] 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.""" """Check that capture stack image calls the expected functions and returns the expected data."""
stage = mocker.Mock() autofocus_thing._stage.position = {"x": 123, "y": 456, "z": 789}
stage.position = {"x": 123, "y": 456, "z": 789} autofocus_thing._cam.capture_to_memory.return_value = "fake_buffer_id"
cam = mocker.Mock() autofocus_thing._cam.grab_jpeg_size.return_value = 54321
cam.capture_to_memory.return_value = "fake_buffer_id"
cam.grab_jpeg_size.return_value = 54321
buffer_max = 11 buffer_max = 11
info = autofocus_thing.capture_stack_image( info = autofocus_thing.capture_stack_image(buffer_max=buffer_max)
cam=cam, stage=stage, 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 cam.capture_to_memory.call_count == 1
assert cam.grab_jpeg_size.call_count == 1
assert info.buffer_id == "fake_buffer_id" assert info.buffer_id == "fake_buffer_id"
assert info.position == {"x": 123, "y": 456, "z": 789} assert info.position == {"x": 123, "y": 456, "z": 789}
assert info.sharpness == 54321 assert info.sharpness == 54321

View file

@ -1,6 +1,5 @@
"""File contains unit tests for stage_measure.""" """File contains unit tests for stage_measure."""
import dataclasses
import logging import logging
from copy import copy 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(): def test_error_if_no_stream_res_set_when_requesting_img_coords():
"""Check a RuntimeError thrown when requesting image coordinates if resolution unset.""" """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"): with pytest.raises(RuntimeError, match="Stream resolution must be set"):
rom_thing._img_percentage_to_img_coords(20, "x") rom_thing._img_percentage_to_img_coords(20, "x")
@pytest.fixture @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.""" """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._stream_resolution = [800, 600]
rom_thing._rom_data = example_rom_data 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]: def apply_csm(x: float, y: float, **_kwargs: float) -> dict[str, int]:
"""Convert image coordinates to stage coordinates.""" """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.""" """Convert stage coordinates to image coordinates."""
return csm_stage_to_img(csm_matrix, x=x, y=y) return csm_stage_to_img(csm_matrix, x=x, y=y)
mock_cam = mocker.Mock() rom_thing._cam.image_is_sample.return_value = (True, "Mocked not measured.")
mock_cam.image_is_sample.return_value = (True, "Mocked not measured.")
mock_csm = mocker.Mock()
# Set up mock csm to return a CSM matrix # Set up mock csm to return a CSM matrix
mock_csm.image_to_stage_displacement_matrix = csm_matrix rom_thing._csm.image_to_stage_displacement_matrix = csm_matrix
mock_csm.convert_image_to_stage_coordinates.side_effect = apply_csm rom_thing._csm.convert_image_to_stage_coordinates.side_effect = apply_csm
mock_csm.convert_stage_to_image_coordinates.side_effect = un_apply_csm rom_thing._csm.convert_stage_to_image_coordinates.side_effect = un_apply_csm
return rom_thing
return stage_measure.RomDeps(
autofocus=mocker.Mock(),
stage=mocker.Mock(),
cam=mock_cam,
csm=mock_csm,
logger=LOGGER,
)
@pytest.mark.parametrize( @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), ({"x": 0, "y": 5000, "z": 0}, {"x": 0, "y": 0, "z": 0}, "y", 1),
], ],
) )
def test_img_dir_from_stage_coords( def test_img_dir_from_stage_coords(pos, target_pos, axis, expected_dir, rom_thing):
pos, target_pos, axis, expected_dir, rom_thing, mock_rom_deps
):
"""Check image direction is correctly generated.""" """Check image direction is correctly generated."""
mock_rom_deps.stage.position = pos rom_thing._stage.position = pos
direction = rom_thing._img_dir_from_stage_coords(target_pos, axis, mock_rom_deps) direction = rom_thing._img_dir_from_stage_coords(target_pos, axis)
assert direction == expected_dir 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.""" """Check that _distance_in_img_percentage throws error if stream res is not set."""
rom_thing._stream_resolution = None rom_thing._stream_resolution = None
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
rom_thing._distance_in_img_percentage( rom_thing._distance_in_img_percentage({"x": 0, "y": 0, "z": 0}, "x")
{"x": 0, "y": 0, "z": 0}, "x", mock_rom_deps
)
@pytest.mark.parametrize( @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), ({"x": 0, "y": 5000, "z": 0}, {"x": 0, "y": 0, "z": 0}, "y", 462.131),
], ],
) )
def test_distance_in_img_percentage( def test_distance_in_img_percentage(pos, target_pos, axis, expected_perc, rom_thing):
pos, target_pos, axis, expected_perc, rom_thing, mock_rom_deps
):
"""Check _distance_in_img_percentage calculates expected values based on mock CSM.""" """Check _distance_in_img_percentage calculates expected values based on mock CSM."""
mock_rom_deps.stage.position = pos rom_thing._stage.position = pos
img_perc = rom_thing._distance_in_img_percentage(target_pos, axis, mock_rom_deps) img_perc = rom_thing._distance_in_img_percentage(target_pos, axis)
assert round(img_perc, 3) == expected_perc 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.""" """Check the calls and returns for RangeofMotionThing._offset_from."""
# Set up mock for the FFT displacement # Set up mock for the FFT displacement
disp_between_route = ( disp_between_route = (
@ -247,14 +229,14 @@ def test_offset_from(rom_thing, mock_rom_deps, mocker):
) )
# Run it # 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 # Check the offset is a dictionary with the correct values for the axes
assert offset["x"] == 456 assert offset["x"] == 456
assert offset["y"] == 123 assert offset["y"] == 123
# Check 1 image was taken # Check 1 image was taken
assert mock_rom_deps.cam.grab_as_array.call_count == 1 assert rom_thing._cam.grab_as_array.call_count == 1
mock_after_image = mock_rom_deps.cam.grab_as_array.return_value mock_after_image = rom_thing._cam.grab_as_array.return_value
# Check the FFT displacement was called once # Check the FFT displacement was called once
assert mock_disp_between.call_count == 1 assert mock_disp_between.call_count == 1
displacement_kwargs = mock_disp_between.call_args.kwargs 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]) @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. """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 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) mocker.patch.object(rom_thing, "_offset_from", return_value=mock_offset_value)
movement = {"x": 100, "y": 0} movement = {"x": 100, "y": 0}
offset = rom_thing._move_and_measure( 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 # 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 # 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 # Check autofocus call count
expected_af_count = 1 if perform_autofocus else 0 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 # And check final return
assert offset == mock_offset_value 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( 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.""" """Test _move_and_measure with final refocus if offset is too small."""
return_dicts = tuple({"x": x, "y": 0} for x in x_offsets) 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} movement = {"x": 10, "y": 0}
offset = rom_thing._move_and_measure( offset = rom_thing._move_and_measure(
movement=movement, movement=movement,
rom_deps=mock_rom_deps,
perform_autofocus=False, perform_autofocus=False,
max_autofocus_repeats=3, max_autofocus_repeats=3,
abs_min_offset=5, abs_min_offset=5,
) )
# Check exactly 1 move # 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 # Check expected _offset_from calls
assert offset_from_mock.call_count == n_offset_measures assert offset_from_mock.call_count == n_offset_measures
# The kwargs of the call should movement dict # 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 # Check autofocus call count is 1 less than number of offset measures as no autofocus
# is performed before the first one # is performed before the first one
expected_af_count = n_offset_measures - 1 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 # And check final return
assert offset == expected_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"): with pytest.raises(ValueError, match="abs_min_offset must be positive"):
rom_thing._move_and_measure( rom_thing._move_and_measure(
movement={"x": 10, "y": 0}, movement={"x": 10, "y": 0},
rom_deps=mock_rom_deps,
perform_autofocus=False, perform_autofocus=False,
max_autofocus_repeats=3, 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"): with pytest.raises(ValueError, match="abs_min_offset must be positive"):
rom_thing._move_and_measure( rom_thing._move_and_measure(
movement={"x": 10, "y": 0}, movement={"x": 10, "y": 0},
rom_deps=mock_rom_deps,
perform_autofocus=False, perform_autofocus=False,
max_autofocus_repeats=3, max_autofocus_repeats=3,
abs_min_offset=-123.456, 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 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. """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 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"): 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) max_tries = int(1.5 * stage_measure.BIG_STEP / stage_measure.SMALL_STEP)
expected_step = 800 * stage_measure.SMALL_STEP / 100 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 # 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 # Should only be called 3 times
assert mock_move_n_meas.call_count == 3 assert mock_move_n_meas.call_count == 3
@ -417,7 +396,6 @@ def test_stage_still_moves(
expected_to_detect_motion, expected_to_detect_motion,
offset_calls, offset_calls,
rom_thing, rom_thing,
mock_rom_deps,
mocker, mocker,
): ):
"""Test _stage_still_moves correctly detects stage movement.""" """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() rom_thing, "_offset_from", side_effect=gen_offsets()
) )
still_moves = rom_thing._stage_still_moves( still_moves = rom_thing._stage_still_moves(axis="x", direction=1)
axis="x",
direction=1,
rom_deps=mock_rom_deps,
)
assert still_moves is expected_to_detect_motion assert still_moves is expected_to_detect_motion
assert mock_offset_from.call_count == offset_calls 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.""" """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} expected_movement = {"x": 800 * stage_measure.BIG_STEP / 100, "y": 0}
# Check there is one z move in steps # Check there is one z move in steps
assert mock_rom_deps.stage.move_relative.call_count == 1 assert rom_thing._stage.move_relative.call_count == 1
move_kwargs = mock_rom_deps.stage.move_relative.call_args.kwargs move_kwargs = rom_thing._stage.move_relative.call_args.kwargs
assert "x" not in move_kwargs assert "x" not in move_kwargs
assert "y" not in move_kwargs assert "y" not in move_kwargs
assert "z" in move_kwargs assert "z" in move_kwargs
assert move_kwargs["z"] == 160 assert move_kwargs["z"] == 160
# And one move in image coordinates # And one move in image coordinates
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1 assert rom_thing._csm.move_in_image_coordinates.call_count == 1
lat_mov_kwargs = mock_rom_deps.csm.move_in_image_coordinates.call_args.kwargs lat_mov_kwargs = rom_thing._csm.move_in_image_coordinates.call_args.kwargs
assert lat_mov_kwargs == expected_movement 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.""" """Check the initial moves are of the correct size and are recorded."""
# Mock the _offset_from and stage.position to return generated dictionaries that # 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 # 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( mocker.patch.object(
rom_thing, "_offset_from", side_effect=increasing_xy_dict_generator() 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() 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() rom_thing._rom_data = stage_measure.RomDataTracker()
# Run it! # 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. # 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.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 # 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 # 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 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.""" """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} 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 return_value=mock_position_dict
) )
mocker.patch.object( 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: # Error should be raised even though it is in the Try:
with pytest.raises(RuntimeError, match="Mock"): 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 # However the "finally" should have executed returning to the starting position
assert mock_rom_deps.stage.move_absolute.call_count == 1 assert rom_thing._stage.move_absolute.call_count == 1
abs_move_kwargs = mock_rom_deps.stage.move_absolute.call_args.kwargs abs_move_kwargs = rom_thing._stage.move_absolute.call_args.kwargs
expected_abs_move_kwargs = dict(**mock_position_dict, block_cancellation=True) expected_abs_move_kwargs = dict(**mock_position_dict, block_cancellation=True)
assert abs_move_kwargs == expected_abs_move_kwargs 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.""" """Check move until edge runs the correct movement sequence."""
# Remove the mock RomData before starting # Remove the mock RomData before starting
rom_thing._rom_data = stage_measure.RomDataTracker() 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): def add_fake_initial_positions(*_args, **_kwargs):
"""Rather than run initial moves just add some fake data.""" """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.""" """With big moves update the stage position."""
big_move_count = 1 big_move_count = 1
while True: 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 yield
big_move_count += 1 big_move_count += 1
def set_final_pos(*_args, **_kwargs): def set_final_pos(*_args, **_kwargs):
"""When move_back_until_motion_detected is run set a final stage position.""" """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 the main movement functions
mock_init_moves = mocker.patch.object( 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() rom_thing._rom_data = stage_measure.RomDataTracker()
# Run function # 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 # Check the call counts are as expected
# One call of initial moves # 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.""" """Check the error if running one of the calibration actions while locked."""
# Not an RLock so no need to thread. # Not an RLock so no need to thread.
rom_thing._lock.acquire() rom_thing._lock.acquire()
err_msg = "Trying to run ROM test when a test is already running." err_msg = "Trying to run ROM test when a test is already running."
with pytest.raises(RuntimeError, match=err_msg): 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." err_msg = "Trying to run recentre when a test is already running."
with pytest.raises(RuntimeError, match=err_msg): 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.""" """Check that perform Rom Test runs the expected high level algorithm."""
def check_and_modify_rom_data(axis: str, direction: int, **_kwargs): 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( 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 # This should take less than 1 sec
assert final_dict["Time"] <= 1 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 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.""" """Check that performing the recentre runs through expected operations."""
def check_lock(*_args, **_kwargs): 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 # Set a mock stage position, as we patch _recentre_axis this should be logged
# as the centre. # as the centre.
mock_rom_deps.stage.position = {"x": 4321, "y": 1234, "z": 0} rom_thing._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)
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
rom_thing.perform_recentre(**mock_deps_dict) rom_thing.perform_recentre()
assert len(caplog.messages) == 3 assert len(caplog.messages) == 3
assert caplog.messages[0] == "Recentring the stage." 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" assert mock_recentre_axis.call_args_list[1].args[0] == "y"
# check that the position set to zero once # 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]) @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. """Test the high level algorithm of recentring an axis.
This doesn't include deciding if we are centred or choosing the direction to move. This doesn't include deciding if we are centred or choosing the direction to move.
""" """
## Start such that movement starts negative ## 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_moves = mocker.patch.object(rom_thing, "_moves_for_z_prediction")
# Mock _recentre_decision so it returns (False, 1) a number of times then finally # 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: if true_on > 10:
with pytest.raises(RuntimeError, match="Couldn't find centre"): with pytest.raises(RuntimeError, match="Couldn't find centre"):
rom_thing._recentre_axis("x", mock_rom_deps) rom_thing._recentre_axis("x")
else: 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 # _recentre_decision is called until True or exits after 10 attempts
assert mock_recentre_decision.call_count == min(true_on, 10) 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 (-199, -1, (True, 1)), # Always return 1 when c
], ],
) )
def test_recentre_decision( def test_recentre_decision(dist, direction, expected_decision, rom_thing, mocker):
dist, direction, expected_decision, rom_thing, mock_rom_deps, mocker
):
"""What the algorithm decides to do in different situations.""" """What the algorithm decides to do in different situations."""
# Mock find turning point just because there is no _rom_data # Mock find turning point just because there is no _rom_data
mocker.patch.object(rom_thing._rom_data, "find_turning_point") 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, "_distance_in_img_percentage", return_value=dist)
mocker.patch.object(rom_thing, "_img_dir_from_stage_coords", return_value=direction) 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. # Check that we make an absolute move (to the centre) before returning centred.
centred = expected_decision[0] 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