python-microscope / microscope

Python library for control of microscope devices, supporting hardware triggers and distribution of devices over the network for performance and flexibility.
https://www.python-microscope.org
GNU General Public License v3.0
66 stars 38 forks source link

Draft abc.SLM feedback wanted #302

Open juliomateoslangerak opened 3 weeks ago

juliomateoslangerak commented 3 weeks ago

I implemented a draft of an abc.SLM in here. Very much like abc.DM

Code below. Any comments or feedback?

class SpatialLightModulator(TriggerTargetMixin, Device, metaclass=abc.ABCMeta):
    """Base class for Spatial Light Modulators (SLM).

    This class is very similar to the Deformable Mirrors abc. We are trying
    to keep nomenclature consistent. The main differences are that the shape
    of the patterns are different and that we need to provide wavelengths
    along the patterns.

    Similarly to deformable mirrors, there is no method to reset
    or clear a deformable mirror. For the sake of uniformity, it is better for
    python-microscope users to pass the pattern they want, probably a
    pattern that flattens the SLM.

    The private properties `_patterns` and `_pattern_idx` are
    initialized to `None` to support the queueing of patterns and
    software triggering.
    """

    @abc.abstractmethod
    def __init__(self, **kwargs) -> None:
        super().__init__(**kwargs)
        self._patterns: typing.Optional[numpy.ndarray] = None
        self._pattern_idx: int = -1
        self._wavelengths: typing.Optional[typing.List[int]] = None

    @abc.abstractmethod
    def _get_shape(self) -> typing.Tuple[int, int]:
        """Get the shape of the SLM in pixels as a (width, height) tuple."""
        raise NotImplementedError

    def get_shape(self) -> typing.Tuple[int, int]:
        """Return a tuple of `(width, height)` corresponding to the shape of the SLM"""
        return self._get_shape()

    def _validate_patterns(self, patterns: numpy.ndarray, wavelengths: typing.Union[list[int], int]) -> None:
        """Validate the shape of a series of patterns.

        Only validates the shape of the patterns, not if the values
        are actually in the [0 1] range.  If some hardware is unable
        to handle values outside their defined range (most will simply
        clip them), then it's the responsibility of the subclass to do
        the clipping before sending the values.

        """
        if 2 > patterns.ndim > 3:
            raise ValueError(
                "PATTERNS has %d dimensions (must be 2 or 3)" % patterns.ndim
            )

        if patterns.ndim == 3:
            if not isinstance(wavelengths, list) or len(wavelengths) != patterns.shape[0]:
                raise ValueError(
                    "The length of the wavelengths list %d does not match the number of patterns to load %d"
                    % (len(wavelengths), patterns.shape[0],)
                )
        elif not isinstance(wavelengths, int):
            raise ValueError(
                "The wavelength should be an integer when loading a single pattern"
            )

        if (patterns.shape[-2], patterns.shape[-1]) != self.get_shape():
            raise ValueError(
                "PATTERNS shape %s does not match the SLM's shape %s"
                % ((patterns.shape[-2], patterns.shape[-1],), self.get_shape(),)
            )

    @abc.abstractmethod
    def _do_apply_pattern(self, pattern: numpy.ndarray, wavelength: int) -> None:
        raise NotImplementedError()

    def apply_pattern(self, pattern: numpy.ndarray, wavelength: int) -> None:
        """Apply this pattern.

        Args:
            pattern: A 'XY' ndarray with the phases to be loaded into the SLM. The phases have to be
            in the range [0, 1]. 0=0pi and 1=2pi
            wavelength: The wavelength to which the SLM has to be calibrated for that pattern

        Raises:
            microscope.IncompatibleStateError: if device trigger type is
                not set to software.

        """
        if self.trigger_type is not microscope.TriggerType.SOFTWARE:
            # An alternative to error is to change the trigger type,
            # apply the pattern, then restore the trigger type, but
            # that would clear the queue on the device.  It's better
            # to have the user specifically do it.  See issue #61.
            raise microscope.IncompatibleStateError(
                "apply_pattern requires software trigger type"
            )
        self._validate_patterns(pattern, [wavelength])
        self._do_apply_pattern(pattern, wavelength)

    def queue_patterns(self, patterns: numpy.ndarray, wavelengths: typing.List[int]) -> None:
        """Send a set of patterns to the SLM.

        Args:
            patterns: An `NXY` elements array of phase values in the range
            [0, 1]. 0=0pi and 1=2pi. N is the number of phases to add the queue
            wavelengths: A list of wavelengths (in nm) of length N

        A convenience fallback is provided for software triggering is provided.

        """
        self._validate_patterns(patterns, wavelengths)
        self._patterns = patterns
        self._wavelengths = wavelengths
        self._pattern_idx = -1  # none is applied yet
        # TODO: What is the function to run the patterns in the queue? enable?

    def _do_trigger(self) -> None:
        """Convenience fallback.

        This only provides a convenience fallback for devices that
        don't support queuing multiple patterns and software trigger,
        i.e., devices that take only one pattern at a time.  This is
        not the case of most devices.

        Devices that support queuing patterns, should override this
        method.

        .. todo::

            Instead of a convenience fallback, we should have a
            separate mixin for this.

        """
        if self._patterns is None:
            raise microscope.DeviceError("no pattern queued to apply")
        self._pattern_idx += 1
        self.apply_pattern(self._patterns[self._pattern_idx, :], self._wavelengths[self._pattern_idx])

    def trigger(self) -> None:
        """Apply the next pattern in the queue."""
        # This is just a passthrough to the TriggerTargetMixin class
        # and only exists for the docstring.
        return super().trigger()