Source code for openflexure_microscope.stage.sanga

import logging
import time
from collections.abc import Iterable
from types import GeneratorType
from typing import Optional, Tuple, Union

import numpy as np
from sangaboard import Sangaboard
from typing_extensions import Literal

from openflexure_microscope.stage.base import BaseStage
from openflexure_microscope.utilities import axes_to_array


def _displacement_to_array(
    displacement: int, axis: Literal["x", "y", "z"]
) -> np.ndarray:
    # Create the displacement array
    return np.array(
        [
            displacement if axis == "x" else 0,
            displacement if axis == "y" else 0,
            displacement if axis == "z" else 0,
        ]
    )


[docs]class SangaStage(BaseStage): """ Sangaboard v0.2 and v0.3 powered Stage object Args: port (str): Serial port on which to open communication Attributes: board (:py:class:`openflexure_microscope.stage.sangaboard.Sangaboard`): Parent Sangaboard object. _backlash (list): 3-element (element-per-axis) list of backlash compensation in steps. """ def __init__(self, port=None, **kwargs): """Class managing serial communications with the motors for an Openflexure stage""" BaseStage.__init__(self) self.port = port self.board = Sangaboard(port, **kwargs) # Initialise backlash storage, used by property setter/getter self._backlash = None self.settle_time = 0.2 # Default move settle time self._position_on_enter = None @property def state(self): """The general state dictionary of the board.""" return {"position": self.position_map} @property def configuration(self): return { "port": self.port, "board": self.board.board, "firmware": self.board.firmware, } @property def n_axes(self): """The number of axes this stage has.""" return 3 @property def position(self) -> Tuple[int, int, int]: return self.board.position @property def backlash(self) -> np.ndarray: """The distance used for backlash compensation. Software backlash compensation is enabled by setting this property to a value other than `None`. The value can either be an array-like object (list, tuple, or numpy array) with one element for each axis, or a single integer if all axes are the same. The property will always return an array with the same length as the number of axes. The backlash compensation algorithm is fairly basic - it ensures that we always approach a point from the same direction. For each axis that's moving, the direction of motion is compared with ``backlash``. If the direction is opposite, then the stage will overshoot by the amount in ``-backlash[i]`` and then move back by ``backlash[i]``. This is computed per-axis, so if some axes are moving in the same direction as ``backlash``, they won't do two moves. """ if isinstance(self._backlash, np.ndarray): return self._backlash elif isinstance(self._backlash, list): return np.array(self._backlash) elif isinstance(self._backlash, int): return np.array([self._backlash] * self.n_axes) else: return np.array([0] * self.n_axes) @backlash.setter def backlash(self, blsh): logging.debug("Setting backlash to %s", (blsh)) if blsh is None: self._backlash = None elif isinstance(blsh, Iterable): assert len(blsh) == self.n_axes self._backlash = np.array(blsh) else: self._backlash = np.array([int(blsh)] * self.n_axes, dtype=np.int)
[docs] def update_settings(self, config: dict): """Update settings from a config dictionary""" # Set backlash. Expects a dictionary with axis labels if "backlash" in config: # Construct backlash array backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0]) self.backlash = np.array(backlash) if "settle_time" in config: self.settle_time = config.get("settle_time")
[docs] def read_settings(self) -> dict: """Return the current settings as a dictionary""" if self.backlash is not None: blsh = self.backlash.tolist() else: blsh = None config = { "backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}, "settle_time": self.settle_time, } return config
[docs] def move_rel( self, displacement: Union[int, Tuple[int, int, int], np.ndarray], axis: Optional[Literal["x", "y", "z"]] = None, backlash: bool = True, ): """Make a relative move, optionally correcting for backlash. displacement: integer or array/list of 3 integers axis: None (for 3-axis moves) or one of 'x','y','z' backlash: (default: True) whether to correct for backlash. Backlash Correction: This backlash correction strategy ensures we're always approaching the end point from the same direction, while minimising the amount of extra motion. It's a good option if you're scanning in a line, for example, as it will kick in when moving to the start of the line, but not for each point on the line. For each axis where we're moving in the *opposite* direction to self.backlash, we deliberately overshoot: """ with self.lock: logging.debug("Moving sangaboard by %s", displacement) # If we specify an axis name and a displacement int, convert to a displacement tuple if axis: # Displacement MUST be an integer if axis name is specified if not isinstance(displacement, int): raise TypeError( "Displacement must be an integer when axis is specified" ) # Axis name MUST be x, y, or z if axis not in ("x", "y", "z"): raise ValueError("axis must be one of x, y, or z") # Calculate displacement array displacement_array: np.ndarray = _displacement_to_array( displacement, axis ) elif isinstance(displacement, np.ndarray): displacement_array = displacement elif isinstance(displacement, (list, tuple, GeneratorType)): # Convert our displacement tuple/generator into a numpy array displacement_array = np.array(list(displacement)) else: raise TypeError(f"Unsupported displacement type {type(displacement)}") # Handle simple case, no backlash if not backlash or self.backlash is None: return self.board.move_rel(displacement_array) # Handle move with backlash correction # Calculate main movement initial_move: np.ndarray = np.copy(displacement_array) initial_move -= np.where( self.backlash * displacement_array < 0, self.backlash, np.zeros(self.n_axes, dtype=self.backlash.dtype), ) # Make the main movement self.board.move_rel(initial_move) # Handle backlash if required if np.any(displacement_array - initial_move != 0): # If backlash correction has kicked in and made us overshoot, move # to the correct end position (i.e. the move we were asked to make) self.board.move_rel(displacement_array - initial_move) # Settle outside of the stage lock so that another move request # can just take over before settling time.sleep(self.settle_time)
[docs] def move_abs(self, final: Union[Tuple[int, int, int], np.ndarray], **kwargs): """Make an absolute move to a position """ with self.lock: logging.debug("Moving sangaboard to %s", final) self.board.move_abs(final, **kwargs) # Settle outside of the stage lock so that another move request # can just take over before settling time.sleep(self.settle_time)
[docs] def zero_position(self): """Set the current position to zero""" with self.lock: self.board.zero_position()
[docs] def close(self): """Cleanly close communication with the stage""" if hasattr(self, "board"): self.board.close()
# Methods specific to Sangaboard
[docs] def release_motors(self): """De-energise the stepper motor coils""" self.board.release_motors()
def __enter__(self): """When we use this in a with statement, remember where we started.""" self._position_on_enter = self.position return self def __exit__(self, type_, value, traceback): """The end of the with statement. Reset position if it went wrong. NB the instrument is closed when the object is deleted, so we don't need to worry about that here. """ if type_ is not None: print( "An exception occurred inside a with block, resetting position \ to its value at the start of the with block" ) try: time.sleep(0.5) self.move_abs(self._position_on_enter) except Exception as e: # pylint: disable=W0703 print( "A further exception occurred when resetting position: {}".format(e) ) print("Move completed, raising exception...") raise value # Propagate the exception
[docs]class SangaDeltaStage(SangaStage): def __init__( self, port: Optional[str] = None, flex_h: int = 80, flex_a: int = 50, flex_b: int = 50, camera_angle: float = 0, **kwargs, ): self.flex_h: int = flex_h self.flex_a: int = flex_a self.flex_b: int = flex_b # Set up camera rotation relative to stage camera_theta: float = (camera_angle / 180) * np.pi self.R_camera: np.ndarray = np.array( [ [np.cos(camera_theta), -np.sin(camera_theta), 0], [np.sin(camera_theta), np.cos(camera_theta), 0], [0, 0, 1], ] ) logging.debug(self.R_camera) # Transformation matrix converting delta into cartesian x_fac: float = -1 * np.multiply( np.divide(2, np.sqrt(3)), np.divide(self.flex_b, self.flex_h) ) y_fac: float = -1 * np.divide(self.flex_b, self.flex_h) z_fac: float = np.multiply(np.divide(1, 3), np.divide(self.flex_b, self.flex_a)) self.Tvd: np.ndarray = np.array( [ [-x_fac, x_fac, 0], [0.5 * y_fac, 0.5 * y_fac, -y_fac], [z_fac, z_fac, z_fac], ] ) logging.debug(self.Tvd) self.Tdv: np.ndarray = np.linalg.inv(self.Tvd) logging.debug(self.Tdv) SangaStage.__init__(self, port=port, **kwargs) @property def raw_position(self) -> Tuple[int, int, int]: return self.board.position @property def position(self): # TODO: Account for camera rotation position: np.ndarray = np.dot(self.Tvd, self.raw_position) position: np.ndarray = np.dot(np.linalg.inv(self.R_camera), position) return [int(p) for p in position]
[docs] def move_rel( self, displacement: Union[int, Tuple[int, int, int], np.ndarray], axis: Optional[Literal["x", "y", "z"]] = None, backlash: bool = True, ): # If we specify an axis name and a displacement int, convert to a displacement tuple if axis: # Displacement MUST be an integer if axis name is specified if not isinstance(displacement, int): raise TypeError( "Displacement must be an integer when axis is specified" ) # Axis name MUST be x, y, or z if axis not in ("x", "y", "z"): raise ValueError("axis must be one of x, y, or z") # Calculate displacement array cartesian_displacement_array: np.ndarray = _displacement_to_array( displacement, axis ) elif isinstance(displacement, np.ndarray): cartesian_displacement_array = displacement elif isinstance(displacement, (list, tuple, GeneratorType)): # Convert our displacement tuple/generator into a numpy array cartesian_displacement_array = np.array(list(displacement)) else: raise TypeError(f"Unsupported displacement type {type(displacement)}") # Transform into camera coordinates camera_displacement_array: np.ndarray = np.dot( self.R_camera, cartesian_displacement_array ) # Transform into delta coordinates delta_displacement_array: np.ndarray = np.dot( self.Tdv, camera_displacement_array ) logging.debug("Delta displacement: %s", (delta_displacement_array)) # Do the move SangaStage.move_rel( self, delta_displacement_array, axis=None, backlash=backlash )
[docs] def move_abs(self, final: Union[Tuple[int, int, int], np.ndarray], **kwargs): # Transform into camera coordinates camera_final_array: np.ndarray = np.dot(self.R_camera, final) # Transform into delta coordinates delta_final_array: np.ndarray = np.dot(self.Tdv, camera_final_array) logging.debug("Delta final: %s", (final)) # Do the move SangaStage.move_abs(self, delta_final_array, **kwargs)